From cffcafb74a5e04b8492ab0f14d1b764fe626c5d7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 06:18:43 -0400 Subject: [PATCH 1/2] fix: separate stop hook checkpoint text --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- RELEASE-NOTES.md | 10 ++++++++ hooks/completion-claim-guard | 3 ++- tests/hook-contracts.sh | 41 +++++++++++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c23498b..adcd89c 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.0.3", + "version": "6.0.4", "source": "./", "author": { "name": "Jon Langevin", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 68ac3f3..28d9c57 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.0.3", + "version": "6.0.4", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index dd68318..e02e0ba 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.0.3", + "version": "6.0.4", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a829fca..0a05c33 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,15 @@ # Autonomous Dev Kit Release Notes +## v6.0.4 (2026-05-26) + +### Stop-hook feedback formatting + +- Added an explicit separator after the locked plan filename in the stop-hook + completion checkpoint so hosts that flatten hook feedback do not display + `plan.mdBefore stopping`. +- Added a hook-contract regression that flattens the checkpoint reason and + verifies the filename remains separated from the next sentence. + ## v6.0.3 (2026-05-26) ### Hook JSON reliability diff --git a/hooks/completion-claim-guard b/hooks/completion-claim-guard index 154ebaf..0436e1f 100755 --- a/hooks/completion-claim-guard +++ b/hooks/completion-claim-guard @@ -101,7 +101,8 @@ if [ -z "$failures" ]; then first_plan=$(printf '%s\n' "$locked_plans" | head -1) first_plan_name=$(basename "$first_plan") progress_file="${cwd_dir}/.autodev/state/phase-progress.jsonl" - block "Completion checkpoint — locked plan still in effect: ${first_plan_name} + checkpoint_prefix="Completion checkpoint — locked plan still in effect: ${first_plan_name} " + block "${checkpoint_prefix} Before stopping, decide whether this is: 1. A phase/task completion inside the locked design, or diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index 0b8f564..c9d2164 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -218,6 +218,46 @@ PLAN pass "completion-claim-guard: blocks phase completion and requests progress log" } +test_completion_continuation_block_keeps_heading_separator_when_flattened() { + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/tests" + cat >"$tmp/docs/plans/example.md" <<'PLAN' +# Example Plan + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 1 +**Out of scope:** +- (none) + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Example | Task 1 | feat/example | + +**Status:** Locked 2026-05-25T00:00:00Z + +### Task 1: Example +PLAN + cp tests/plan-scope-check.sh "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + bash hooks/scope-lock-apply "$tmp/docs/plans/example.md" >/dev/null + + local output flat_reason + output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","stop_hook_active":false,"last_assistant_message":"Task 1 complete."}')" + flat_reason="$(printf '%s' "$output" | jq -r '.reason' | tr -d '\r\n')" + + if ! printf '%s' "$flat_reason" | grep -q 'example.md Before stopping'; then + fail "completion-claim-guard: expected flattened checkpoint to keep separator before 'Before stopping', got: ${flat_reason}" + return + fi + pass "completion-claim-guard: flattened checkpoint keeps heading separator" +} + test_completion_allows_hard_blocker() { local tmp tmp="$(mktemp -d)" @@ -395,6 +435,7 @@ test_pretool_pr_review_json test_posttool_pr_created_json test_pre_compact_snapshot_json test_completion_continuation_block +test_completion_continuation_block_keeps_heading_separator_when_flattened test_completion_allows_hard_blocker test_pretool_allows_locked_plan_text_edit test_subagent_allows_non_manifest_plan_backport From 27240138577c2c830436cd7cd4b8bfa8103a9a10 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 06:23:17 -0400 Subject: [PATCH 2/2] fix: scope locked plans by session --- RELEASE-NOTES.md | 4 ++ hooks/completion-claim-guard | 30 +++++++++- hooks/pre-tool-scope-guard | 63 +++++++++++++++++++-- tests/hook-contracts.sh | 107 +++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0a05c33..afd9775 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -7,6 +7,10 @@ - Added an explicit separator after the locked plan filename in the stop-hook completion checkpoint so hosts that flatten hook feedback do not display `plan.mdBefore stopping`. +- Scoped stop-hook completion checks and pre-push lock verification to plans + locked by the current session when the host provides a transcript path, + avoiding cross-agent interference from unrelated locked plans in the same + workspace. - Added a hook-contract regression that flattens the checkpoint reason and verifies the filename remains separated from the next sentence. diff --git a/hooks/completion-claim-guard b/hooks/completion-claim-guard index 0436e1f..6e56e4e 100755 --- a/hooks/completion-claim-guard +++ b/hooks/completion-claim-guard @@ -33,6 +33,8 @@ stop_hook_active=$(printf '%s' "$hook_input" | jq -r '.stop_hook_active // false [ "$stop_hook_active" = "true" ] && exit 0 last_assistant_message=$(printf '%s' "$hook_input" | jq -r '.last_assistant_message // .assistant_message // empty' 2>/dev/null || true) transcript_path=$(printf '%s' "$hook_input" | jq -r '.transcript_path // empty' 2>/dev/null || true) +session_key="" +[ -n "$transcript_path" ] && session_key=$(basename "$transcript_path") if [ -z "$last_assistant_message" ] && [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then last_assistant_message=$(jq -rs ' map(select((.type // "") == "assistant")) | @@ -58,8 +60,32 @@ block() { plans_dir="${cwd_dir}/docs/plans" [ -d "$plans_dir" ] || exit 0 -locked_plans=$(grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' || true) +find_locked_plans() { + if [ -n "$session_key" ]; then + local state_file="${cwd_dir}/.claude/autodev-state/session-locks.jsonl" + [ -f "$state_file" ] || return 0 + jq -r --arg session "$session_key" \ + 'select(.ev == "session-lock" and .session == $session) | .pl' \ + "$state_file" 2>/dev/null \ + | awk 'NF && !seen[$0]++' \ + | while IFS= read -r plan; do + [ -n "$plan" ] || continue + case "$plan" in + /*) resolved="$plan" ;; + *) resolved="${cwd_dir}/${plan}" ;; + esac + [ -f "$resolved" ] || continue + grep -q '\*\*Status:\*\* Locked' "$resolved" 2>/dev/null || continue + printf '%s\n' "$resolved" + done + return 0 + fi + + grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ + | grep '\.md$' | grep -v '\.scope-lock' || true +} + +locked_plans=$(find_locked_plans) [ -z "$locked_plans" ] && exit 0 # no locked plans — nothing to guard diff --git a/hooks/pre-tool-scope-guard b/hooks/pre-tool-scope-guard index b9b7113..4cf2f57 100755 --- a/hooks/pre-tool-scope-guard +++ b/hooks/pre-tool-scope-guard @@ -36,6 +36,9 @@ hook_input=$(cat || true) tool_name=$(printf '%s' "$hook_input" | jq -r '.tool_name // empty' 2>/dev/null || true) cwd_dir=$(printf '%s' "$hook_input" | jq -r '.cwd // empty' 2>/dev/null || true) [ -z "$cwd_dir" ] && cwd_dir="${PWD}" +transcript_path=$(printf '%s' "$hook_input" | jq -r '.transcript_path // empty' 2>/dev/null || true) +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). block() { @@ -45,12 +48,58 @@ block() { exit 2 } -# Return the path of the first locked plan found under docs/plans/, or empty. -find_locked_plan() { +record_session_lock() { + local cmd="$1" + [ -n "$session_key" ] || return 0 + printf '%s' "$cmd" | grep -q 'scope-lock-apply' || return 0 + + local plan_arg="" + plan_arg=$(printf '%s' "$cmd" \ + | sed -nE 's/.*scope-lock-apply[[:space:]]+"?([^" ;]+)"?.*/\1/p' \ + | head -1 || true) + [ -n "$plan_arg" ] || return 0 + + local state_dir="${cwd_dir}/.claude/autodev-state" + mkdir -p "$state_dir" 2>/dev/null || return 0 + local state_file="${state_dir}/session-locks.jsonl" + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + jq -nc \ + --arg ts "$ts" \ + --arg session "$session_key" \ + --arg pl "$plan_arg" \ + '{ts:$ts,ev:"session-lock",session:$session,pl:$pl}' \ + >> "$state_file" 2>/dev/null || true +} + +# Return locked plans relevant to this session. If the host does not provide a +# transcript path, fall back to the legacy workspace-wide scan. +find_locked_plans() { local plans_dir="${cwd_dir}/docs/plans" [ -d "$plans_dir" ] || return 0 + + if [ -n "$session_key" ]; then + local state_file="${cwd_dir}/.claude/autodev-state/session-locks.jsonl" + [ -f "$state_file" ] || return 0 + jq -r --arg session "$session_key" \ + 'select(.ev == "session-lock" and .session == $session) | .pl' \ + "$state_file" 2>/dev/null \ + | awk 'NF && !seen[$0]++' \ + | while IFS= read -r plan; do + [ -n "$plan" ] || continue + case "$plan" in + /*) resolved="$plan" ;; + *) resolved="${cwd_dir}/${plan}" ;; + esac + [ -f "$resolved" ] || continue + grep -q '\*\*Status:\*\* Locked' "$resolved" 2>/dev/null || continue + printf '%s\n' "$resolved" + done + return 0 + fi + grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' | head -1 || true + | grep '\.md$' | grep -v '\.scope-lock' || true } # Run plan-scope-check --verify-lock; returns 0 if hash matches. @@ -88,6 +137,8 @@ case "$tool_name" in # ── Global opt-out (operator-level only, checked after self-bypass guard) ── [ "${SUPERPOWERS_HOOKS_DISABLE:-}" = "1" ] && exit 0 + record_session_lock "$cmd" + # ── 1. Force push (always blocked) ────────────────────────────────────── # Catches: --force, --force-with-lease, -f flag # These overwrite remote refs and lose commits for anyone who already pulled. @@ -106,12 +157,12 @@ case "$tool_name" in # ── 3. push / PR creation while locked plan hash mismatches ───────────── # Catches post-lock plan tampering before anything reaches the remote. if printf '%s' "$cmd" | grep -qE '(git push|gh pr create|gh pr merge)'; then - locked_plan=$(find_locked_plan) - if [ -n "$locked_plan" ]; then + while IFS= read -r locked_plan; do + [ -n "$locked_plan" ] || continue if ! verify_lock "$locked_plan"; then block "Locked plan hash mismatch: $(basename "$locked_plan") — the Scope Manifest has been modified since it was locked. This means either the manifest was edited without the amendment path or the scope-lock file was corrupted. Resolve via the scope-lock amendment path (recording-decisions → update manifest → re-run alignment-check) before pushing or creating PRs." fi - fi + done < <(find_locked_plans) fi # ── 4. Push/commit to default branch (blocked unless opt-out) ─────────── diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index c9d2164..c58c2de 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -258,6 +258,110 @@ PLAN pass "completion-claim-guard: flattened checkpoint keeps heading separator" } +test_pretool_records_session_lock_for_scope_lock_apply() { + local tmp transcript output state_file + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + + output="$(run_hook pre-tool-scope-guard '{"tool_name":"Bash","tool_input":{"command":"bash hooks/scope-lock-apply docs/plans/active.md"},"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "pre-tool-scope-guard: expected scope-lock recording to pass silently, got: ${output}" + return + fi + + state_file="$tmp/.claude/autodev-state/session-locks.jsonl" + if ! jq -e 'select(.ev == "session-lock" and .session == "session.jsonl" and .pl == "docs/plans/active.md")' "$state_file" >/dev/null; then + fail "pre-tool-scope-guard: expected session lock row in ${state_file}" + return + fi + pass "pre-tool-scope-guard: records scope-lock plan for current session" +} + +test_completion_ignores_unrelated_locked_plan_when_session_has_no_lock() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/tests" + cat >"$tmp/docs/plans/unrelated.md" <<'PLAN' +# Unrelated Plan + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 1 +**Out of scope:** +- (none) + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Unrelated | Task 1 | feat/unrelated | + +**Status:** Locked 2026-05-25T00:00:00Z + +### Task 1: Unrelated +PLAN + cp tests/plan-scope-check.sh "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + bash hooks/scope-lock-apply "$tmp/docs/plans/unrelated.md" >/dev/null + + output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false,"last_assistant_message":"Task complete."}')" + if [ -n "$output" ]; then + fail "completion-claim-guard: expected unrelated workspace lock to be ignored for session, got: ${output}" + return + fi + pass "completion-claim-guard: ignores unrelated locked plans when session has no lock" +} + +test_completion_uses_session_locked_plan_only() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/tests" + for name in active unrelated; do + cat >"$tmp/docs/plans/${name}.md" </dev/null + bash hooks/scope-lock-apply "$tmp/docs/plans/unrelated.md" >/dev/null + + run_hook pre-tool-scope-guard '{"tool_name":"Bash","tool_input":{"command":"bash hooks/scope-lock-apply docs/plans/active.md"},"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}' >/dev/null + output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false,"last_assistant_message":"Task complete."}')" + + if ! printf '%s' "$output" | jq -e '.decision == "block" and (.reason | contains("active.md")) and (.reason | contains("unrelated.md") | not)' >/dev/null; then + fail "completion-claim-guard: expected only the session locked plan to block, got: ${output}" + return + fi + pass "completion-claim-guard: uses only session locked plans" +} + test_completion_allows_hard_blocker() { local tmp tmp="$(mktemp -d)" @@ -436,6 +540,9 @@ test_posttool_pr_created_json test_pre_compact_snapshot_json test_completion_continuation_block test_completion_continuation_block_keeps_heading_separator_when_flattened +test_pretool_records_session_lock_for_scope_lock_apply +test_completion_ignores_unrelated_locked_plan_when_session_has_no_lock +test_completion_uses_session_locked_plan_only test_completion_allows_hard_blocker test_pretool_allows_locked_plan_text_edit test_subagent_allows_non_manifest_plan_backport