From c5a3afd0216e1996e7a3f9cbe0523da00d90f6ee Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 15:12:14 -0400 Subject: [PATCH] fix(hooks): block() must exit 0 with stdout JSON, not exit 2 (v6.1.3) Both Claude Code and Codex ignore stdout entirely when a hook exits with code 2 -- the blocking reason must be on stderr. The existing block() helper in hooks/pre-tool-scope-guard and hooks/subagent-scope-guard was emitting `{"decision":"block","reason":"..."}` on stdout and then exiting 2, which means: - Codex surfaced: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr" - Claude Code silently dropped the reason (the tool call was blocked but no reason text reached the agent or user) Fixed both hooks by switching to `exit 0` with stdout JSON (the documented decision-control path on both hosts) and mirroring the reason to stderr as belt + suspenders. This mirrors the pattern already in use in hooks/completion-claim-guard, which was correct. Also switched `jq -n` to `jq -nc` so the emitted JSON is compact and matches what existing grep-based hook tests look for. Added two new regression tests in tests/hook-contracts.sh that assert blocks exit 0, emit stdout JSON, AND mirror the reason to stderr. Both trigger easily-reproducible block paths: force-push (pre-tool) and manifest drift (subagent-stop). Version bump 6.1.2 -> 6.1.3 across all four manifests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- RELEASE-NOTES.md | 8 ++++ hooks/pre-tool-scope-guard | 18 ++++++-- hooks/subagent-scope-guard | 8 ++-- tests/hook-contracts.sh | 75 +++++++++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2631193..0eecb87 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "autodev", "description": "Autonomous development workflow skills for coding agents", - "version": "6.1.2", + "version": "6.1.3", "source": "./", "author": { "name": "Jon Langevin", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 174b50c..e2668eb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "autodev", "description": "Autonomous development workflow skills for coding agents: design, review, planning, execution, monitoring, and retrospectives", - "version": "6.1.2", + "version": "6.1.3", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 50b88ce..dbdd0a5 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "autodev", "displayName": "Autonomous Dev Kit", "description": "Autonomous development workflow skills for coding agents", - "version": "6.1.2", + "version": "6.1.3", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index ad9da2e..f88b083 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,13 @@ # Autonomous Dev Kit Release Notes +## v6.1.3 — 2026-05-27 + +PreToolUse / SubagentStop block contract fix for Codex compatibility. + +- `hooks/pre-tool-scope-guard` and `hooks/subagent-scope-guard`: the `block()` helper was emitting `{"decision":"block","reason":"..."}` on stdout and then `exit 2`. Both Claude Code and Codex ignore stdout JSON when a hook exits with code 2 — they require the reason on stderr. Codex enforces this strictly and surfaced the error: `PreToolUse hook exited with code 2 but did not write a blocking reason to stderr`. Claude Code silently dropped the reason. Fixed by switching to `exit 0` with stdout JSON (the documented decision-control path on both hosts) and mirroring the reason to stderr for any host that captures stderr regardless of exit code. Same pattern already used by `hooks/completion-claim-guard`. +- Switched `jq -n` → `jq -nc` in both hooks so the emitted JSON is compact (matches the format hosts and grep-based tests expect; trims a few bytes). +- Added two regression tests in `tests/hook-contracts.sh` that assert blocks exit 0, emit stdout JSON, AND mirror the reason to stderr. + ## v6.1.2 — 2026-05-27 SessionStart hook payload bloat fix. diff --git a/hooks/pre-tool-scope-guard b/hooks/pre-tool-scope-guard index f170efe..298a64d 100755 --- a/hooks/pre-tool-scope-guard +++ b/hooks/pre-tool-scope-guard @@ -40,12 +40,22 @@ transcript_path=$(printf '%s' "$hook_input" | jq -r '.transcript_path // empty' session_key="" [ -n "$transcript_path" ] && session_key=$(basename "$transcript_path") -# Output a block decision and exit 2 (the exit code Claude Code uses for blocks). +# Output a block decision via JSON on stdout, then exit 0. +# Both Claude Code and Codex parse `{"decision":"block","reason":"..."}` on +# stdout when the hook exits 0. We deliberately do NOT use `exit 2`: both +# hosts ignore stdout entirely on exit 2 and require the reason on stderr, +# so emitting JSON + exit 2 would surface "exited with code 2 but did not +# write a blocking reason to stderr" on Codex and silently drop the reason +# on Claude Code. Mirror the pattern used in hooks/completion-claim-guard. +# Also mirror the reason to stderr so hosts/logs that capture stderr still +# see the human-readable text. block() { local reason="$1" - printf '{"decision":"block","reason":%s}\n' \ - "$(printf '%s' "$reason" | jq -Rs .)" - exit 2 + if command -v jq >/dev/null 2>&1; then + jq -nc --arg reason "$reason" '{decision:"block",reason:$reason}' + fi + printf '%s\n' "$reason" >&2 + exit 0 } # Recognized helper script names that update session-lock state. Pattern-matched diff --git a/hooks/subagent-scope-guard b/hooks/subagent-scope-guard index 5143423..2cc736c 100755 --- a/hooks/subagent-scope-guard +++ b/hooks/subagent-scope-guard @@ -34,9 +34,11 @@ stop_hook_active=$(printf '%s' "$hook_input" | jq -r '.stop_hook_active // false block() { local reason="$1" - printf '{"decision":"block","reason":%s}\n' \ - "$(printf '%s' "$reason" | jq -Rs .)" - exit 2 + if command -v jq >/dev/null 2>&1; then + jq -nc --arg reason "$reason" '{decision:"block",reason:$reason}' + fi + printf '%s\n' "$reason" >&2 + exit 0 } # ── Detect uncommitted or recently committed protected-file changes ─────────── diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index da65a42..1591a9a 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -92,6 +92,79 @@ test_session_start_json() { assert_hook_context_json "session-start" "SessionStart" "$output" } +test_pre_tool_scope_guard_block_exits_zero_with_stderr_reason() { + # When pre-tool-scope-guard blocks a Bash command, it must: + # (1) exit 0 -- both Claude Code and Codex ignore stdout JSON on exit 2 + # (2) emit {"decision":"block","reason":"..."} on stdout (Claude Code path) + # (3) mirror the reason on stderr (Codex path / any host that reads stderr) + # Regression for Codex error: "PreToolUse hook exited with code 2 but did + # not write a blocking reason to stderr." + local tmp stdout_file stderr_file status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + stdout_file="$tmp/out" + stderr_file="$tmp/err" + # force-push trigger: always blocked, no setup required + set +e + printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"},"cwd":"'"$tmp"'"}' \ + | hooks/pre-tool-scope-guard >"$stdout_file" 2>"$stderr_file" + status=$? + set -e + if [ "$status" != "0" ]; then + fail "pre-tool-scope-guard: block must exit 0, got ${status}. stderr: $(cat "$stderr_file")" + return + fi + if ! grep -q '"decision":"block"' "$stdout_file"; then + fail "pre-tool-scope-guard: block must emit JSON on stdout, got: $(cat "$stdout_file")" + return + fi + if ! grep -qi 'force push' "$stderr_file"; then + fail "pre-tool-scope-guard: block must mirror reason to stderr, got: $(cat "$stderr_file")" + return + fi + pass "pre-tool-scope-guard: block emits exit 0 + stdout JSON + stderr text (Codex compat)" +} + +test_subagent_scope_guard_block_exits_zero_with_stderr_reason() { + # Same contract for the SubagentStop hook. + local tmp transcript stdout_file stderr_file status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" "$tmp/tests" + cp "$REPO_ROOT/tests/plan-scope-check.sh" "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + jq -nc --arg s "session.jsonl" --arg pl "docs/plans/active.md" \ + '{ev:"session-lock",session:$s,pl:$pl}' \ + > "$tmp/.claude/autodev-state/session-locks.jsonl" + # Force drift so verify-lock fails and block() fires. + awk '/^\*\*Tasks:\*\* 1/ {print; print "**Drift:** yes"; next} {print}' \ + "$tmp/docs/plans/active.md" > "$tmp/docs/plans/active.md.tmp" \ + && mv "$tmp/docs/plans/active.md.tmp" "$tmp/docs/plans/active.md" + stdout_file="$tmp/out" + stderr_file="$tmp/err" + set +e + printf '%s' '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false}' \ + | hooks/subagent-scope-guard >"$stdout_file" 2>"$stderr_file" + status=$? + set -e + if [ "$status" != "0" ]; then + fail "subagent-scope-guard: block must exit 0, got ${status}. stderr: $(cat "$stderr_file")" + return + fi + if ! grep -q '"decision":"block"' "$stdout_file"; then + fail "subagent-scope-guard: block must emit JSON on stdout, got: $(cat "$stdout_file")" + return + fi + if ! grep -qi 'manifest' "$stderr_file"; then + fail "subagent-scope-guard: block must mirror reason to stderr, got: $(cat "$stderr_file")" + return + fi + pass "subagent-scope-guard: block emits exit 0 + stdout JSON + stderr text (Codex compat)" +} + test_wrapper_suppresses_unavailable_c_utf8_locale_noise() { local tmp stdout_file stderr_file stderr_text stdout_text tmp="$(mktemp -d)" @@ -1404,6 +1477,8 @@ test_pretool_allows_locked_plan_text_edit test_subagent_allows_non_manifest_plan_backport test_subagent_scope_guard_ignores_unattributed_workspace_lock test_subagent_scope_guard_blocks_attributed_drift +test_pre_tool_scope_guard_block_exits_zero_with_stderr_reason +test_subagent_scope_guard_block_exits_zero_with_stderr_reason test_scope_lock_claim_writes_session_attribution test_scope_lock_claim_writes_are_idempotent test_scope_lock_claim_rejects_unlocked_plan