diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-19 21:04:43 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-19 21:04:43 -0500 |
| commit | 94e54f69453dea0feda4722347ba3c60c9822c2b (patch) | |
| tree | 33ecdef82afc8819dd8ef1dc1b587de046719550 | |
| parent | 33c6d7b8f497ba103d3a949e7f3cadc3d2e100ae (diff) | |
| download | rulesets-94e54f69453dea0feda4722347ba3c60c9822c2b.tar.gz rulesets-94e54f69453dea0feda4722347ba3c60c9822c2b.zip | |
feat(hooks): soft-nudge pending inbox handoffs at task boundaries
The "check inbox/ at every task boundary" rule was prose-only in protocols.org, so it held only as well as the agent remembered it and a handoff could pass a turn unseen. inbox-boundary-check.sh is a Stop hook that backs the rule: when inbox-status -q reports pending items it blocks the yield once and injects the count, so the agent processes them before returning.
It soft-nudges rather than hard-blocks. On the harness re-entry (stop_hook_active: true) the hook steps aside and lets the turn end, so a mid-task pause to ask a question, or an item the agent genuinely can't process, never wedges the session. A hard block would instead push inbox processing ahead of that clarifying question.
The hook self-skips where it doesn't apply: no inbox/ dir, no inbox-status, or a clean inbox each exit 0 silently, so one global hook covers every project with no config. It prefers the project-local .ai/scripts/inbox-status and falls back to one on PATH. It's wired into the Stop array ahead of ai-wrap-teardown in both the tracked settings.json (which the live ~/.claude/settings.json symlinks to) and the documented snippet. bats covers pending, clean, the re-entry step-aside, no-inbox, and the absent-status degrade.
| -rw-r--r-- | .ai/protocols.org | 2 | ||||
| -rw-r--r-- | .claude/settings.json | 4 | ||||
| -rw-r--r-- | claude-templates/.ai/protocols.org | 2 | ||||
| -rw-r--r-- | hooks/README.md | 1 | ||||
| -rwxr-xr-x | hooks/inbox-boundary-check.sh | 52 | ||||
| -rw-r--r-- | hooks/settings-snippet.json | 1 | ||||
| -rw-r--r-- | scripts/tests/inbox-boundary-check-hook.bats | 83 |
7 files changed, 145 insertions, 0 deletions
diff --git a/.ai/protocols.org b/.ai/protocols.org index 5cd69d4..2297026 100644 --- a/.ai/protocols.org +++ b/.ai/protocols.org @@ -205,6 +205,8 @@ Check =inbox/= at every task boundary (after finishing a unit of work, before re Exit 1 means handoffs are pending — process them per =inbox.org= process mode. For each accepted handoff, the act-vs-file rule: *act now* when it's clear, bounded, low-risk, in-scope, and cheaper than deferring — just do it, no asking; *file* otherwise — ask first, with filing as option 1 and "do it now" as option 2; *ask* if unsure. Exception: a proposal to change a shared asset (template workflow, rule, skill, synced script) or a substantive convention never silently acts now — it goes through the inbox engine's skeptical review and its approval (or park) step. Always reply to a handoff's sender (confirm on accept, the why on reject). Full process, the reply discipline, and the opt-in background-monitor =/loop= recipe live in =inbox.org= monitor mode. +A machine-global =Stop= hook (=inbox-boundary-check.sh=) backs this rule so it isn't prose-only. When handoffs are pending it blocks the turn once and injects the count, so a task boundary can't pass with items unseen. It soft-nudges rather than hard-blocks: on the harness re-entry it steps aside, so a mid-task pause to ask "what's next" is never hijacked into inbox processing. The rule above still governs what to do with the items; the hook only makes sure you look. + ** Recursive Reads — Honor =.aiignore= Before a naive recursive read or glob of a project tree (file inventories, "what's in this repo", broad greps), skip the noise: dependency trees (=node_modules/=, =.venv/=), build output (=dist/=, =build/=, =coverage/=), language caches (=__pycache__/=, =.pytest_cache/=, =*.pyc=), editor/OS cruft, and generated token/OAuth artifacts. These waste tokens and skew project summaries even when gitignored — a recursive read sees the disk, not git. diff --git a/.claude/settings.json b/.claude/settings.json index 1006916..a486e89 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -55,6 +55,10 @@ "hooks": [ { "type": "command", + "command": "~/.claude/hooks/inbox-boundary-check.sh" + }, + { + "type": "command", "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org index 5cd69d4..2297026 100644 --- a/claude-templates/.ai/protocols.org +++ b/claude-templates/.ai/protocols.org @@ -205,6 +205,8 @@ Check =inbox/= at every task boundary (after finishing a unit of work, before re Exit 1 means handoffs are pending — process them per =inbox.org= process mode. For each accepted handoff, the act-vs-file rule: *act now* when it's clear, bounded, low-risk, in-scope, and cheaper than deferring — just do it, no asking; *file* otherwise — ask first, with filing as option 1 and "do it now" as option 2; *ask* if unsure. Exception: a proposal to change a shared asset (template workflow, rule, skill, synced script) or a substantive convention never silently acts now — it goes through the inbox engine's skeptical review and its approval (or park) step. Always reply to a handoff's sender (confirm on accept, the why on reject). Full process, the reply discipline, and the opt-in background-monitor =/loop= recipe live in =inbox.org= monitor mode. +A machine-global =Stop= hook (=inbox-boundary-check.sh=) backs this rule so it isn't prose-only. When handoffs are pending it blocks the turn once and injects the count, so a task boundary can't pass with items unseen. It soft-nudges rather than hard-blocks: on the harness re-entry it steps aside, so a mid-task pause to ask "what's next" is never hijacked into inbox processing. The rule above still governs what to do with the items; the hook only makes sure you look. + ** Recursive Reads — Honor =.aiignore= Before a naive recursive read or glob of a project tree (file inventories, "what's in this repo", broad greps), skip the noise: dependency trees (=node_modules/=, =.venv/=), build output (=dist/=, =build/=, =coverage/=), language caches (=__pycache__/=, =.pytest_cache/=, =*.pyc=), editor/OS cruft, and generated token/OAuth artifacts. These waste tokens and skew project summaries even when gitignored — a recursive read sees the disk, not git. diff --git a/hooks/README.md b/hooks/README.md index 71b3613..e83469e 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -10,6 +10,7 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to | `git-commit-confirm.py` | `PreToolUse(Bash)` | Silent-unless-suspicious gate on `git commit`. Only prompts when the message contains AI-attribution patterns, the message can't be parsed (editor would open), no files are staged, or the git author is unusable. Clean commits pass through without a modal. Parses both HEREDOC and `-m`/`--message` forms. | | `gh-pr-create-confirm.py` | `PreToolUse(Bash)` | Gates `gh pr create` behind a confirmation modal showing title, base←head, reviewers, labels, assignees, milestone, draft flag, and body (HEREDOC or quoted). | | `destructive-bash-confirm.py` | `PreToolUse(Bash)` | Gates destructive commands (`git push --force`, `git reset --hard`, `git clean -f`, `git branch -D`, `rm -rf`) with a modal showing the command, local context (branch, uncommitted file counts, targeted paths), and a warning banner. Elevates severity when force-pushing protected branches or targeting root/home/wildcard paths. | +| `inbox-boundary-check.sh` | `Stop` | Soft-nudges the agent to process pending `inbox/` handoffs before yielding. Blocks the stop once (injects a reason with the pending count) when `inbox-status -q` reports pending items; steps aside on the harness re-entry (`stop_hook_active`) so a mid-task pause or an unprocessable item never wedges. No-ops in any project without an `inbox/` or without `inbox-status`. | Shared library (not a hook): `_common.py` — `read_payload()`, `respond_ask()`, `scan_attribution()`. Installed as a sibling symlink so the two Python hooks can `from _common import …` at runtime. diff --git a/hooks/inbox-boundary-check.sh b/hooks/inbox-boundary-check.sh new file mode 100755 index 0000000..916c000 --- /dev/null +++ b/hooks/inbox-boundary-check.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Stop hook: soft-nudge the agent to process pending inbox/ handoffs before it +# yields control back to Craig. +# +# The "check inbox/ at every task boundary" rule (protocols.org, Inbox +# Monitoring Cadence) is otherwise prose-only, so it holds only as well as the +# agent remembers it. A Stop is the agent finishing a turn and about to yield, +# which is the harness's closest event to the rule's own trigger ("after +# finishing a unit of work, before reporting back"). When handoffs are pending +# this hook blocks the yield once and injects a reason, so the agent processes +# them instead of returning with items unseen. +# +# Soft-nudge, not hard-block: on the harness re-entry (stop_hook_active: true) +# the hook steps aside and lets the turn end. An item the agent genuinely can't +# process, or a mid-task pause to ask Craig a question (also a Stop), never +# wedges the session. +# +# Self-skips everywhere it doesn't apply: no inbox/ dir, no inbox-status, or a +# clean inbox each exit 0 silently. One hook, every project, no config. +# +# Wire in ~/.claude/settings.json (see hooks/settings-snippet.json), Stop array. +set -u + +payload="$(cat)" + +# Re-entry after our own nudge: don't nudge twice, let the turn end. +active="$(printf '%s' "$payload" | jq -r '.stop_hook_active // false' 2>/dev/null)" +[ "$active" = "true" ] && exit 0 + +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" +[ -z "$cwd" ] && cwd="$PWD" +cd "$cwd" 2>/dev/null || exit 0 + +# Nothing to enforce without an inbox to watch. +[ -d inbox ] || exit 0 + +# Prefer the project-local inbox-status; fall back to one on PATH. Absent both, +# degrade to a no-op rather than block on a check we can't run. +status_bin=".ai/scripts/inbox-status" +[ -x "$status_bin" ] || status_bin="$(command -v inbox-status 2>/dev/null)" || exit 0 +[ -n "$status_bin" ] || exit 0 + +# inbox-status -q: exit 1 = pending, 0 = clean, 2 = no inbox. Only 1 nudges. +out="$("$status_bin" -q 2>/dev/null)" +[ $? -eq 1 ] || exit 0 + +count="$(printf '%s' "$out" | grep -oE '[0-9]+' | head -1)" +[ -n "$count" ] || count="Some" + +reason="$count pending inbox handoff(s) in $(basename "$cwd"). Process per inbox.org before yielding." +printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs .)" +exit 0 diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json index 0f0e784..26cbc18 100644 --- a/hooks/settings-snippet.json +++ b/hooks/settings-snippet.json @@ -34,6 +34,7 @@ "Stop": [ { "hooks": [ + { "type": "command", "command": "~/.claude/hooks/inbox-boundary-check.sh" }, { "type": "command", "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] } diff --git a/scripts/tests/inbox-boundary-check-hook.bats b/scripts/tests/inbox-boundary-check-hook.bats new file mode 100644 index 0000000..58668a5 --- /dev/null +++ b/scripts/tests/inbox-boundary-check-hook.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# hooks/inbox-boundary-check.sh — Stop hook that soft-nudges the agent to +# process pending inbox/ handoffs before yielding. Blocks the stop ONCE (emits +# a block decision + reason) when inbox-status reports pending items; on the +# harness re-entry (stop_hook_active: true) it steps aside so an unprocessable +# item or a mid-task pause never wedges. Self-skips where there's no inbox/ or +# no inbox-status. The real inbox-status is copied into the test project so the +# hook runs against real code, not a stub. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + SCRIPT="$REPO_ROOT/hooks/inbox-boundary-check.sh" + INBOX_STATUS="$REPO_ROOT/claude-templates/.ai/scripts/inbox-status" + TMPDIR_T="$(mktemp -d)" + CWD="$TMPDIR_T/proj" + mkdir -p "$CWD/.ai/scripts" + cp "$INBOX_STATUS" "$CWD/.ai/scripts/inbox-status" + chmod +x "$CWD/.ai/scripts/inbox-status" +} + +teardown() { + rm -rf "$TMPDIR_T" +} + +# Feed Stop-hook JSON on stdin. $1 = stop_hook_active (default false). +run_hook() { + local active="${1:-false}" + printf '{"cwd":"%s","hook_event_name":"Stop","stop_hook_active":%s}' \ + "$CWD" "$active" | bash "$SCRIPT" +} + +@test "pending handoffs block the stop with a count in the reason" { + mkdir -p "$CWD/inbox" + printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org" + printf 'y\n' >"$CWD/inbox/2026-07-19-from-work-other.org" + run run_hook + [ "$status" -eq 0 ] + # Valid JSON with a block decision. + echo "$output" | jq -e '.decision == "block"' + echo "$output" | jq -e '.reason | test("2 pending")' + echo "$output" | jq -e '.reason | test("inbox.org")' +} + +@test "a clean inbox (only artifacts) emits nothing" { + mkdir -p "$CWD/inbox" + touch "$CWD/inbox/.gitkeep" + printf 'done\n' >"$CWD/inbox/PROCESSED-2026-07-19-old.org" + run run_hook + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "soft-nudge: stop_hook_active=true steps aside even with pending items" { + mkdir -p "$CWD/inbox" + printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org" + run run_hook true + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "no inbox/ directory is a silent no-op" { + run run_hook + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "inbox-status absent degrades to a silent no-op" { + mkdir -p "$CWD/inbox" + printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org" + rm -f "$CWD/.ai/scripts/inbox-status" + # Also ensure nothing named inbox-status is on PATH for this run. + run env PATH="/usr/bin:/bin" bash -c ' + printf "{\"cwd\":\"'"$CWD"'\",\"hook_event_name\":\"Stop\"}" | bash "'"$SCRIPT"'"' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "the emitted reason names the project for context" { + mkdir -p "$CWD/inbox" + printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org" + run run_hook + echo "$output" | jq -e '.reason | test("proj")' +} |
