From c1c53b22797e9e9a9ab453a42809e6f50e5ccee9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 10:58:19 -0400 Subject: [PATCH 1/2] fix: complete stale scope locks --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- README.md | 2 +- RELEASE-NOTES.md | 15 ++ decisions/0001-complete-scope-locks.md | 42 +++++ hooks/completion-claim-guard | 2 +- hooks/pre-compact-snapshot | 35 +++- hooks/prompt-strict-interpretation | 28 ++- hooks/scope-lock-complete | 107 ++++++++++++ skills/scope-lock/SKILL.md | 23 ++- tests/hook-contracts.sh | 228 +++++++++++++++++++++++++ 12 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 decisions/0001-complete-scope-locks.md create mode 100755 hooks/scope-lock-complete diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index adcd89c..90a4798 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.4", + "version": "6.0.5", "source": "./", "author": { "name": "Jon Langevin", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 28d9c57..feedf87 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.4", + "version": "6.0.5", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index e02e0ba..802de35 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.4", + "version": "6.0.5", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/README.md b/README.md index e41ed5c..9ac1d02 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Per-skill host-conditional audit: [tests/cross-llm-coverage.md](tests/cross-llm- 7. **alignment-check** - Activates after adversarial review of plan passes. Narrowly structural: every design requirement maps to a plan task; every plan task traces to a design requirement; the Scope Manifest is well-formed (forward + reverse + manifest trace via `tests/plan-scope-check.sh`). -8. **scope-lock** - Activates immediately after `alignment-check` PASS. Stamps the plan with `Locked `, computes the manifest's sha256 into `.scope-lock`, commits both. From this point until completion (or an explicit user-approved amendment), the task list, PR count, and feature scope are immutable. Design backports that do not change the manifest are allowed; manifest changes go through ADR + alignment. The lock hash covers only the `## Scope Manifest` block, so explanatory design/task notes can evolve without invalidating scope. `subagent-driven-development` re-checks the lock between tasks; `finishing-a-development-branch` re-checks before any PR is created. +8. **scope-lock** - Activates immediately after `alignment-check` PASS. Stamps the plan with `Locked `, computes the manifest's sha256 into `.scope-lock`, commits both. From this point until completion (or an explicit user-approved amendment), the task list, PR count, and feature scope are immutable. Design backports that do not change the manifest are allowed; manifest changes go through ADR + alignment. The lock hash covers only the `## Scope Manifest` block, so explanatory design/task notes can evolve without invalidating scope. `subagent-driven-development` re-checks the lock between tasks; `finishing-a-development-branch` re-checks before any PR is created. When the design is fully complete, `hooks/scope-lock-complete --evidence ""` marks it `Complete`, removes the lock file, and prunes session reminder traces. 9. **subagent-driven-development** or **executing-plans** - Activates with a locked plan. Dispatches fresh subagent per task with two-stage review (spec compliance, then code quality). Between tasks, re-runs the scope-lock check; on lock drift, stops the line and surfaces the discrepancy. Phase/task completions are logged in compressed JSONL to `.autodev/state/phase-progress.jsonl` when a locked plan continues. diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index afd9775..b30cb92 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,20 @@ # Autonomous Dev Kit Release Notes +## v6.0.5 (2026-05-26) + +### Scope-lock completion cleanup + +- Added `hooks/scope-lock-complete` so completed locked plans can be marked + `Complete `, have their `.scope-lock` file removed, and prune session + lock/snapshot traces. +- Scoped strict prompt reminders to session-owned locked plans when the host + provides a transcript path, preventing unrelated historical locks from + attaching to new autonomous work. +- Changed PreCompact snapshots to include only active locked plans, reducing + oversized hook JSON in repositories with many old plan documents. +- Added hook-contract regressions for completion cleanup, session-scoped prompt + reminders, and locked-only PreCompact snapshots. + ## v6.0.4 (2026-05-26) ### Stop-hook feedback formatting diff --git a/decisions/0001-complete-scope-locks.md b/decisions/0001-complete-scope-locks.md new file mode 100644 index 0000000..f7f7c69 --- /dev/null +++ b/decisions/0001-complete-scope-locks.md @@ -0,0 +1,42 @@ +# 0001. Complete scope locks explicitly + +**Status:** Accepted +**Date:** 2026-05-26 +**Decision-makers:** Jon Langevin, Codex +**Related:** `hooks/scope-lock-complete`, `skills/scope-lock/SKILL.md`, `tests/hook-contracts.sh` + +## Context + +Scope locks prevent autonomous agents from silently changing a plan after +alignment. The previous lifecycle had a creation path but no completion path, +so old locked plans continued to trigger prompt, stop, and pre-compact +reminders in later unrelated sessions. + +## Decision + +We will complete locks explicitly with `hooks/scope-lock-complete`. The helper +verifies the lock when possible, changes the plan status to `Complete`, removes +the `.scope-lock` file, prunes session reminder traces, and records compact +completion evidence. + +**Alternatives considered and rejected:** + +- **Ignore old locks through active-context only** — hook state is repo-local + and must not depend on one workspace-specific state file. +- **Teach every hook to infer completion from PR history** — too expensive and + ambiguous; completion needs an explicit operator/agent action. + +## Consequences + +**Positive:** + +- Completed designs no longer nag unrelated sessions. +- Lock cleanup has one testable command instead of manual state edits. + +**Negative:** + +- Agents must remember one more lifecycle command before claiming a full + locked design is complete. + +**Reversibility:** Low cost. Revert the helper and tests; existing completed +plans remain ordinary markdown with no live lock file. diff --git a/hooks/completion-claim-guard b/hooks/completion-claim-guard index 6e56e4e..38eec56 100755 --- a/hooks/completion-claim-guard +++ b/hooks/completion-claim-guard @@ -139,7 +139,7 @@ If this is only a phase/task completion: - Continue automatically into the next phase/task from the Scope Manifest. Only stop if one of these is true: - - The entire locked design is complete and verified. + - The entire locked design is complete and verified; run hooks/scope-lock-complete --evidence \"\" to mark it complete and remove lock traces. - There is a hard blocker that requires human input. - The next action is a production deploy or destructive production change without already-recorded human approval. diff --git a/hooks/pre-compact-snapshot b/hooks/pre-compact-snapshot index 13364d4..ba3093f 100755 --- a/hooks/pre-compact-snapshot +++ b/hooks/pre-compact-snapshot @@ -25,12 +25,40 @@ hook_input=$(cat || 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") # ── Find locked plans and collect their state ───────────────────────────────── plans_dir="${cwd_dir}/docs/plans" state_section="" if [ -d "$plans_dir" ]; then + locked_plan_stream() { + 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' | sort || true + } + while IFS= read -r plan; do [ -z "$plan" ] && continue plan_name=$(basename "$plan") @@ -61,13 +89,11 @@ if [ -d "$plans_dir" ]; then pending_entries="${pending_entries:-}${entry} " fi - done < <(find "$plans_dir" -maxdepth 1 -name '*.md' \ - ! -name '*-design.md' ! -name 'README.md' 2>/dev/null \ - | grep -v '\.scope-lock' | sort || true) + done < <(locked_plan_stream) fi if [ -z "$state_section" ]; then - # No plans found — nothing to snapshot. Exit silently. + # No active locked plans found — nothing to snapshot. Exit silently. exit 0 fi @@ -75,7 +101,6 @@ fi STATE_DIR="${cwd_dir}/.claude/autodev-state" mkdir -p "$STATE_DIR" 2>/dev/null || true STATE_FILE="${STATE_DIR}/in-progress.jsonl" -LOCK_FILE="${STATE_DIR}/.in-progress.lock" ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") diff --git a/hooks/prompt-strict-interpretation b/hooks/prompt-strict-interpretation index e3b4359..b178571 100755 --- a/hooks/prompt-strict-interpretation +++ b/hooks/prompt-strict-interpretation @@ -26,6 +26,9 @@ prompt=$(printf '%s' "$hook_input" | jq -r '.prompt // empty' 2>/dev/null || tru 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") # ── Check for trigger phrases ──────────────────────────────────────────────── # These are phrases that agents have used as license to rescope on a locked plan. @@ -92,8 +95,29 @@ locked_plan="" locked_plan_name="" if [ -d "$plans_dir" ]; then - locked_plan=$(grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' | head -1 || true) + if [ -n "$session_key" ]; then + state_file="${cwd_dir}/.claude/autodev-state/session-locks.jsonl" + if [ -f "$state_file" ]; then + locked_plan=$(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 \ + | head -1 || true) + fi + else + locked_plan=$(grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ + | grep '\.md$' | grep -v '\.scope-lock' | head -1 || true) + fi fi # No locked plan — the invariant only applies under a lock. diff --git a/hooks/scope-lock-complete b/hooks/scope-lock-complete new file mode 100755 index 0000000..b7b585d --- /dev/null +++ b/hooks/scope-lock-complete @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# hooks/scope-lock-complete +# Marks a locked autodev plan complete and removes active lock traces. +# +# Usage: +# hooks/scope-lock-complete docs/plans/example.md --evidence "tests pass" +# +# Global opt-out: set SUPERPOWERS_HOOKS_DISABLE=1 + +set -euo pipefail + +[ "${SUPERPOWERS_HOOKS_DISABLE:-}" = "1" ] && exit 0 + +plan="${1:-}" +[ -n "$plan" ] || { + printf 'scope-lock-complete: missing plan path\n' >&2 + exit 2 +} +shift || true + +evidence="" +while [ "$#" -gt 0 ]; do + case "$1" in + --evidence) + shift + evidence="${1:-}" + ;; + *) + printf 'scope-lock-complete: unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac + shift || true +done + +case "$plan" in + /*) plan_abs="$plan" ;; + *) plan_abs="${PWD}/${plan}" ;; +esac + +[ -f "$plan_abs" ] || { + printf 'scope-lock-complete: plan not found: %s\n' "$plan_abs" >&2 + exit 2 +} + +if ! grep -q '\*\*Status:\*\* Locked' "$plan_abs" 2>/dev/null; then + printf 'scope-lock-complete: plan is not locked: %s\n' "$plan_abs" >&2 + exit 2 +fi + +plan_dir=$(cd "$(dirname "$plan_abs")" && pwd) +repo_root=$(cd "${plan_dir}/../.." && pwd) +plan_name=$(basename "$plan_abs") +plan_rel="docs/plans/${plan_name}" +lock_file="${plan_abs}.scope-lock" +checker="${repo_root}/tests/plan-scope-check.sh" + +if [ -x "$checker" ] && [ -f "$lock_file" ]; then + bash "$checker" --verify-lock "$plan_abs" >/dev/null +fi + +ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +tmp_file=$(mktemp) +awk -v ts="$ts" ' + !done && /^\*\*Status:\*\* Locked/ { + print "**Status:** Complete " ts + done = 1 + next + } + { print } +' "$plan_abs" > "$tmp_file" +mv "$tmp_file" "$plan_abs" +rm -f "$lock_file" + +prune_jsonl() { + local file="$1" + [ -f "$file" ] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + local tmp + tmp=$(mktemp) + jq -c \ + --arg name "$plan_name" \ + --arg rel "$plan_rel" \ + --arg abs "$plan_abs" \ + 'select((.pl? // "") != $name and (.pl? // "") != $rel and (.pl? // "") != $abs)' \ + "$file" > "$tmp" 2>/dev/null || { + rm -f "$tmp" + return 0 + } + mv "$tmp" "$file" +} + +prune_jsonl "${repo_root}/.claude/autodev-state/session-locks.jsonl" +prune_jsonl "${repo_root}/.claude/autodev-state/in-progress.jsonl" + +progress_dir="${repo_root}/.autodev/state" +mkdir -p "$progress_dir" 2>/dev/null || true +if command -v jq >/dev/null 2>&1; then + jq -nc \ + --arg ts "$ts" \ + --arg pl "$plan_name" \ + --arg e "$evidence" \ + '{ts:$ts,ev:"plan",pl:$pl,st:"complete"} + (if $e != "" then {e:$e} else {} end)' \ + >> "${progress_dir}/phase-progress.jsonl" 2>/dev/null || true +fi + +printf 'Completed %s and removed %s\n' "$plan_rel" "$(basename "$lock_file")" diff --git a/skills/scope-lock/SKILL.md b/skills/scope-lock/SKILL.md index 7323086..33e70a4 100644 --- a/skills/scope-lock/SKILL.md +++ b/skills/scope-lock/SKILL.md @@ -162,6 +162,25 @@ Cheap manifest edits = no lock at all. - **Silently dropping a task because it turned out to be hard.** That's the amendment path's job. A unilateral skip is a contract breach. - **"Demo" framing.** Once the manifest is locked, there is no demo mode. Either you ship the contract or you go through the amendment path. "Let me just get something working" is exactly the rationalization this skill blocks. +## Completing a Locked Plan + +When the whole locked design is genuinely complete and verified, close the lock +instead of leaving stale reminders in the workspace: + +```bash +hooks/scope-lock-complete docs/plans/.md --evidence "" +``` + +The helper verifies the current manifest hash when the checker is available, +changes the plan status from `Locked` to `Complete `, removes +`.scope-lock`, prunes matching `.claude/autodev-state/session-locks.jsonl` +and lock snapshot rows, and appends a compact completion row to +`.autodev/state/phase-progress.jsonl`. + +Do not manually edit `.scope-lock` files or leave a completed design in +`Locked` state. Stale locks cause future prompt/stop/pre-compact hooks to +re-attach old plans to unrelated work. + ## Integration **Called by:** @@ -180,8 +199,10 @@ Cheap manifest edits = no lock at all. - `git log --oneline ..HEAD` — actual commits to compare against the manifest. **Writes:** -- `docs/plans/.md` — the `**Status:**` line, on lock or reduce. +- `docs/plans/.md` — the `**Status:**` line, on lock, reduce, or complete. - `docs/plans/.md.scope-lock` — the manifest hash file. +- `.claude/autodev-state/*.jsonl` — session lock traces, pruned on completion. +- `.autodev/state/phase-progress.jsonl` — compact completion row. - (via `recording-decisions`) `decisions/NNNN-scope-amendment-.md`. ## Why a separate skill diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index c58c2de..efac960 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -125,6 +125,85 @@ test_prompt_strict_no_output_without_trigger() { pass "prompt-strict-interpretation: no output without trigger" } +test_prompt_strict_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" + 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 + bash hooks/scope-lock-apply "$tmp/docs/plans/unrelated.md" >/dev/null + + output="$(run_hook prompt-strict-interpretation '{"prompt":"continue autonomously","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "prompt-strict-interpretation: expected unrelated workspace lock to be ignored for session, got: ${output}" + return + fi + pass "prompt-strict-interpretation: ignores unrelated workspace lock when session has no lock" +} + +test_prompt_strict_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/.claude/autodev-state" + for name in aa-unrelated zz-active; do + cat >"$tmp/docs/plans/${name}.md" </dev/null + done + jq -nc --arg session "session.jsonl" --arg pl "docs/plans/zz-active.md" \ + '{ev:"session-lock",session:$session,pl:$pl}' \ + > "$tmp/.claude/autodev-state/session-locks.jsonl" + + output="$(run_hook prompt-strict-interpretation '{"prompt":"continue autonomously","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if ! printf '%s' "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("zz-active.md") and (contains("aa-unrelated.md") | not)' >/dev/null; then + fail "prompt-strict-interpretation: expected only session locked plan reminder, got: ${output}" + return + fi + pass "prompt-strict-interpretation: uses only session locked plan" +} + test_pretool_pr_review_json() { local output output="$(run_hook pretool-pr-review-reminder '{"tool_name":"Bash","tool_input":{"command":"gh pr create --title test --body test"},"cwd":"'"$REPO_ROOT"'"}')" @@ -176,6 +255,150 @@ PLAN pass "pre-compact-snapshot: writes compact lock state row" } +test_wrapper_suppresses_pre_compact_locale_noise() { + local tmp stdout_file stderr_file stderr_text stdout_text + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + stdout_file="$tmp/stdout.json" + stderr_file="$tmp/stderr.txt" + mkdir -p "$tmp/docs/plans" + 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 + bash hooks/scope-lock-apply "$tmp/docs/plans/example.md" >/dev/null + + run_hook_wrapper pre-compact-snapshot '{"cwd":"'"$tmp"'"}' "$stdout_file" "$stderr_file" + stdout_text="$(cat "$stdout_file")" + stderr_text="$(cat "$stderr_file")" + + if [ -n "$stderr_text" ]; then + fail "run-hook.cmd pre-compact-snapshot: expected no stderr for unsupported C.UTF-8 locale, got: ${stderr_text}" + return + fi + assert_hook_context_json "run-hook.cmd pre-compact-snapshot" "PreCompact" "$stdout_text" +} + +test_pre_compact_snapshot_only_locked_plans() { + local tmp output state_file + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + for name in locked draft; do + status="Draft" + [ "$name" = "locked" ] && status="Locked 2026-05-25T00:00:00Z" + cat >"$tmp/docs/plans/${name}.md" </dev/null + + output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'"}')" + if ! printf '%s' "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("locked.md") and (contains("draft.md") | not)' >/dev/null; then + fail "pre-compact-snapshot: expected only locked plans in snapshot, got: ${output}" + return + fi + + state_file="$tmp/.claude/autodev-state/in-progress.jsonl" + if jq -e 'select(.pl == "draft.md")' "$state_file" >/dev/null; then + fail "pre-compact-snapshot: expected no draft plan row in ${state_file}" + return + fi + pass "pre-compact-snapshot: snapshots only locked plans" +} + +test_scope_lock_complete_marks_complete_and_prunes_state() { + local tmp transcript state_file compact_output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/tests" "$tmp/.claude/autodev-state" "$tmp/.autodev/state" + cp tests/plan-scope-check.sh "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + 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 + bash hooks/scope-lock-apply "$tmp/docs/plans/example.md" >/dev/null + jq -nc --arg session "session.jsonl" --arg pl "docs/plans/example.md" \ + '{ev:"session-lock",session:$session,pl:$pl}' \ + > "$tmp/.claude/autodev-state/session-locks.jsonl" + jq -nc '{ev:"lock",pl:"example.md",st:"Locked 2026-05-25T00:00:00Z",h:"abc"}' \ + > "$tmp/.claude/autodev-state/in-progress.jsonl" + + hooks/scope-lock-complete "$tmp/docs/plans/example.md" --evidence "tests pass" >/dev/null + + if ! grep -q '\*\*Status:\*\* Complete ' "$tmp/docs/plans/example.md"; then + fail "scope-lock-complete: expected plan status to be Complete" + return + fi + if [ -e "$tmp/docs/plans/example.md.scope-lock" ]; then + fail "scope-lock-complete: expected scope-lock file to be removed" + return + fi + state_file="$tmp/.claude/autodev-state/session-locks.jsonl" + if [ -s "$state_file" ] && jq -e 'select(.pl == "docs/plans/example.md")' "$state_file" >/dev/null; then + fail "scope-lock-complete: expected session lock trace to be pruned" + return + fi + compact_output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$compact_output" ]; then + fail "scope-lock-complete: expected completed plan to produce no pre-compact lock snapshot, got: ${compact_output}" + return + fi + pass "scope-lock-complete: marks complete and prunes lock traces" +} + test_completion_continuation_block() { local tmp tmp="$(mktemp -d)" @@ -535,9 +758,14 @@ test_session_start_json test_wrapper_suppresses_unavailable_c_utf8_locale_noise test_prompt_strict_json test_prompt_strict_no_output_without_trigger +test_prompt_strict_ignores_unrelated_locked_plan_when_session_has_no_lock +test_prompt_strict_uses_session_locked_plan_only test_pretool_pr_review_json test_posttool_pr_created_json test_pre_compact_snapshot_json +test_wrapper_suppresses_pre_compact_locale_noise +test_pre_compact_snapshot_only_locked_plans +test_scope_lock_complete_marks_complete_and_prunes_state test_completion_continuation_block test_completion_continuation_block_keeps_heading_separator_when_flattened test_pretool_records_session_lock_for_scope_lock_apply From 0a517485c3661f66f1ce38c5e7e3cd0c8617e559 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 11:18:54 -0400 Subject: [PATCH 2/2] fix: harden scope lock completion cleanup --- README.md | 2 +- RELEASE-NOTES.md | 2 + hooks/completion-claim-guard | 33 ++- hooks/pre-compact-snapshot | 59 ++-- hooks/prompt-strict-interpretation | 59 ++-- hooks/scope-lock-complete | 176 +++++++++--- skills/adversarial-design-review/SKILL.md | 21 +- skills/scope-lock/SKILL.md | 12 +- tests/hook-contracts.sh | 315 ++++++++++++++++++++-- 9 files changed, 571 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 9ac1d02..67a3656 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Per-skill host-conditional audit: [tests/cross-llm-coverage.md](tests/cross-llm- 7. **alignment-check** - Activates after adversarial review of plan passes. Narrowly structural: every design requirement maps to a plan task; every plan task traces to a design requirement; the Scope Manifest is well-formed (forward + reverse + manifest trace via `tests/plan-scope-check.sh`). -8. **scope-lock** - Activates immediately after `alignment-check` PASS. Stamps the plan with `Locked `, computes the manifest's sha256 into `.scope-lock`, commits both. From this point until completion (or an explicit user-approved amendment), the task list, PR count, and feature scope are immutable. Design backports that do not change the manifest are allowed; manifest changes go through ADR + alignment. The lock hash covers only the `## Scope Manifest` block, so explanatory design/task notes can evolve without invalidating scope. `subagent-driven-development` re-checks the lock between tasks; `finishing-a-development-branch` re-checks before any PR is created. When the design is fully complete, `hooks/scope-lock-complete --evidence ""` marks it `Complete`, removes the lock file, and prunes session reminder traces. +8. **scope-lock** - Activates immediately after `alignment-check` PASS. Stamps the plan with `Locked `, computes the manifest's sha256 into `.scope-lock`, commits both. From this point until completion (or an explicit user-approved amendment), the task list, PR count, and feature scope are immutable. Design backports that do not change the manifest are allowed; manifest changes go through ADR + alignment. The lock hash covers only the `## Scope Manifest` block, so explanatory design/task notes can evolve without invalidating scope. `subagent-driven-development` re-checks the lock between tasks; `finishing-a-development-branch` re-checks before any PR is created. When the design is fully complete, `bash "${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-complete" --evidence ""` marks it `Complete`, removes the lock file, and prunes session reminder traces. 9. **subagent-driven-development** or **executing-plans** - Activates with a locked plan. Dispatches fresh subagent per task with two-stage review (spec compliance, then code quality). Between tasks, re-runs the scope-lock check; on lock drift, stops the line and surfaces the discrepancy. Phase/task completions are logged in compressed JSONL to `.autodev/state/phase-progress.jsonl` when a locked plan continues. diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index b30cb92..27df6f3 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -12,6 +12,8 @@ attaching to new autonomous work. - Changed PreCompact snapshots to include only active locked plans, reducing oversized hook JSON in repositories with many old plan documents. +- Clarified that adversarial design/plan review should dispatch a subagent with + the full adversarial prompt whenever the host exposes subagent support. - Added hook-contract regressions for completion cleanup, session-scoped prompt reminders, and locked-only PreCompact snapshots. diff --git a/hooks/completion-claim-guard b/hooks/completion-claim-guard index 38eec56..b82f1d9 100755 --- a/hooks/completion-claim-guard +++ b/hooks/completion-claim-guard @@ -61,11 +61,23 @@ plans_dir="${cwd_dir}/docs/plans" [ -d "$plans_dir" ] || exit 0 find_locked_plans() { - if [ -n "$session_key" ]; then + workspace_locked_plans() { + find "$plans_dir" -maxdepth 1 -name '*.md' \ + ! -name '*-design.md' ! -name 'README.md' 2>/dev/null \ + | grep -v '\.scope-lock' \ + | sort \ + | while IFS= read -r plan; do + [ -n "$plan" ] || continue + grep -q '\*\*Status:\*\* Locked' "$plan" 2>/dev/null || continue + printf '%s\n' "$plan" + done + } + + session_locked_plans() { 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' \ + 'select(.ev == "session-lock" and .session == $session) | .pl // empty' \ "$state_file" 2>/dev/null \ | awk 'NF && !seen[$0]++' \ | while IFS= read -r plan; do @@ -78,11 +90,22 @@ find_locked_plans() { grep -q '\*\*Status:\*\* Locked' "$resolved" 2>/dev/null || continue printf '%s\n' "$resolved" done + } + + if [ -n "$session_key" ]; then + session_plans=$(session_locked_plans) + if [ -n "$session_plans" ]; then + printf '%s\n' "$session_plans" + return 0 + fi + workspace_plans=$(workspace_locked_plans) + if [ "$(printf '%s\n' "$workspace_plans" | awk 'NF {count++} END {print count+0}')" -eq 1 ]; then + printf '%s\n' "$workspace_plans" + fi return 0 fi - grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' || true + workspace_locked_plans } locked_plans=$(find_locked_plans) @@ -139,7 +162,7 @@ If this is only a phase/task completion: - Continue automatically into the next phase/task from the Scope Manifest. Only stop if one of these is true: - - The entire locked design is complete and verified; run hooks/scope-lock-complete --evidence \"\" to mark it complete and remove lock traces. + - The entire locked design is complete and verified; run bash \"\${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-complete\" --evidence \"\" to mark it complete and remove lock traces. - There is a hard blocker that requires human input. - The next action is a production deploy or destructive production change without already-recorded human approval. diff --git a/hooks/pre-compact-snapshot b/hooks/pre-compact-snapshot index ba3093f..063c8e7 100755 --- a/hooks/pre-compact-snapshot +++ b/hooks/pre-compact-snapshot @@ -34,29 +34,52 @@ plans_dir="${cwd_dir}/docs/plans" state_section="" if [ -d "$plans_dir" ]; then + workspace_locked_plans() { + find "$plans_dir" -maxdepth 1 -name '*.md' \ + ! -name '*-design.md' ! -name 'README.md' 2>/dev/null \ + | grep -v '\.scope-lock' \ + | sort \ + | while IFS= read -r plan; do + [ -n "$plan" ] || continue + grep -q '\*\*Status:\*\* Locked' "$plan" 2>/dev/null || continue + printf '%s\n' "$plan" + done + } + + session_locked_plans() { + 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 // empty' \ + "$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 + } + locked_plan_stream() { 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 + session_plans=$(session_locked_plans) + if [ -n "$session_plans" ]; then + printf '%s\n' "$session_plans" + return 0 + fi + workspace_plans=$(workspace_locked_plans) + if [ "$(printf '%s\n' "$workspace_plans" | awk 'NF {count++} END {print count+0}')" -eq 1 ]; then + printf '%s\n' "$workspace_plans" + fi return 0 fi - grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' | sort || true + workspace_locked_plans } while IFS= read -r plan; do diff --git a/hooks/prompt-strict-interpretation b/hooks/prompt-strict-interpretation index b178571..10b0e4c 100755 --- a/hooks/prompt-strict-interpretation +++ b/hooks/prompt-strict-interpretation @@ -95,28 +95,49 @@ locked_plan="" locked_plan_name="" if [ -d "$plans_dir" ]; then + workspace_locked_plans() { + find "$plans_dir" -maxdepth 1 -name '*.md' \ + ! -name '*-design.md' ! -name 'README.md' 2>/dev/null \ + | grep -v '\.scope-lock' \ + | sort \ + | while IFS= read -r plan; do + [ -n "$plan" ] || continue + grep -q '\*\*Status:\*\* Locked' "$plan" 2>/dev/null || continue + printf '%s\n' "$plan" + done + } + + session_locked_plans() { + 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 // empty' \ + "$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 + } + if [ -n "$session_key" ]; then - state_file="${cwd_dir}/.claude/autodev-state/session-locks.jsonl" - if [ -f "$state_file" ]; then - locked_plan=$(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 \ - | head -1 || true) + session_plans=$(session_locked_plans) + if [ -n "$session_plans" ]; then + locked_plan=$(printf '%s\n' "$session_plans" | head -1) + else + workspace_plans=$(workspace_locked_plans) + if [ "$(printf '%s\n' "$workspace_plans" | awk 'NF {count++} END {print count+0}')" -eq 1 ]; then + locked_plan=$(printf '%s\n' "$workspace_plans" | head -1) + fi fi else - locked_plan=$(grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' | head -1 || true) + locked_plan=$(workspace_locked_plans | head -1 || true) fi fi diff --git a/hooks/scope-lock-complete b/hooks/scope-lock-complete index b7b585d..6dcf213 100755 --- a/hooks/scope-lock-complete +++ b/hooks/scope-lock-complete @@ -10,6 +10,10 @@ set -euo pipefail [ "${SUPERPOWERS_HOOKS_DISABLE:-}" = "1" ] && exit 0 +command -v jq >/dev/null 2>&1 || { + printf 'scope-lock-complete: jq is required for state cleanup\n' >&2 + exit 2 +} plan="${1:-}" [ -n "$plan" ] || { @@ -24,6 +28,10 @@ while [ "$#" -gt 0 ]; do --evidence) shift evidence="${1:-}" + if [ -z "$evidence" ] || [ "${evidence#--}" != "$evidence" ]; then + printf 'scope-lock-complete: --evidence requires a value\n' >&2 + exit 2 + fi ;; *) printf 'scope-lock-complete: unknown argument: %s\n' "$1" >&2 @@ -33,10 +41,24 @@ while [ "$#" -gt 0 ]; do shift || true done -case "$plan" in - /*) plan_abs="$plan" ;; - *) plan_abs="${PWD}/${plan}" ;; -esac +canonical_path_from_base() { + local base="$1" + local ref="$2" + local candidate + case "$ref" in + /*) candidate="$ref" ;; + */*) candidate="${base}/${ref}" ;; + *) candidate="${base}/docs/plans/${ref}" ;; + esac + local dir + dir=$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P) || return 1 + printf '%s/%s\n' "$dir" "$(basename "$candidate")" +} + +plan_abs=$(canonical_path_from_base "$PWD" "$plan") || { + printf 'scope-lock-complete: unable to resolve plan path: %s\n' "$plan" >&2 + exit 2 +} [ -f "$plan_abs" ] || { printf 'scope-lock-complete: plan not found: %s\n' "$plan_abs" >&2 @@ -53,14 +75,95 @@ repo_root=$(cd "${plan_dir}/../.." && pwd) plan_name=$(basename "$plan_abs") plan_rel="docs/plans/${plan_name}" lock_file="${plan_abs}.scope-lock" -checker="${repo_root}/tests/plan-scope-check.sh" -if [ -x "$checker" ] && [ -f "$lock_file" ]; then - bash "$checker" --verify-lock "$plan_abs" >/dev/null +if [ ! -f "$lock_file" ]; then + printf 'scope-lock-complete: lock file missing: %s\n' "$lock_file" >&2 + exit 2 fi +extract_manifest() { + awk ' + /^## Scope Manifest[[:space:]]*$/ { in_section = 1; print; next } + in_section && /^## / { in_section = 0 } + in_section && /^### Task [0-9]+[A-Za-z]*([: ]|$)/ { in_section = 0 } + in_section { print } + ' "$1" +} + +sha256_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + printf 'scope-lock-complete: sha256sum or shasum is required\n' >&2 + return 3 + fi +} + +verify_lock_hash() { + local expected actual + expected=$(awk 'NF && !/^#/ {print; exit}' "$lock_file") + if [ -z "$expected" ]; then + printf 'scope-lock-complete: lock file has no hash: %s\n' "$lock_file" >&2 + return 1 + fi + if ! extract_manifest "$plan_abs" | grep -q .; then + printf 'scope-lock-complete: no Scope Manifest section found in %s\n' "$plan_rel" >&2 + return 1 + fi + actual=$(extract_manifest "$plan_abs" | sha256_stdin) + if [ "$expected" != "$actual" ]; then + printf 'scope-lock-complete: manifest hash mismatch for %s (lock=%s, current=%s)\n' \ + "$plan_rel" "${expected:0:12}..." "${actual:0:12}..." >&2 + return 1 + fi +} + +verify_lock_hash + +validate_jsonl() { + local file="$1" + [ -f "$file" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + if ! printf '%s' "$line" | jq -e . >/dev/null; then + printf 'scope-lock-complete: invalid JSONL row in %s\n' "$file" >&2 + return 1 + fi + done < "$file" +} + +session_locks_file="${repo_root}/.claude/autodev-state/session-locks.jsonl" +in_progress_file="${repo_root}/.claude/autodev-state/in-progress.jsonl" +progress_dir="${repo_root}/.autodev/state" +progress_file="${progress_dir}/phase-progress.jsonl" + +ensure_regular_or_absent() { + local file="$1" + if [ -e "$file" ] && [ ! -f "$file" ]; then + printf 'scope-lock-complete: expected regular file or absent path: %s\n' "$file" >&2 + return 1 + fi +} + +ensure_regular_or_absent "$session_locks_file" +ensure_regular_or_absent "$in_progress_file" +ensure_regular_or_absent "$progress_file" +validate_jsonl "$session_locks_file" +validate_jsonl "$in_progress_file" + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -tmp_file=$(mktemp) +plan_tmp=$(mktemp "${plan_abs}.complete.XXXXXX") +session_locks_tmp="" +in_progress_tmp="" +progress_tmp="" + +cleanup_tmp() { + rm -f "$plan_tmp" "$session_locks_tmp" "$in_progress_tmp" "$progress_tmp" +} +trap cleanup_tmp EXIT + awk -v ts="$ts" ' !done && /^\*\*Status:\*\* Locked/ { print "**Status:** Complete " ts @@ -68,40 +171,47 @@ awk -v ts="$ts" ' next } { print } -' "$plan_abs" > "$tmp_file" -mv "$tmp_file" "$plan_abs" -rm -f "$lock_file" +' "$plan_abs" > "$plan_tmp" prune_jsonl() { local file="$1" [ -f "$file" ] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local tmp - tmp=$(mktemp) - jq -c \ - --arg name "$plan_name" \ - --arg rel "$plan_rel" \ - --arg abs "$plan_abs" \ - 'select((.pl? // "") != $name and (.pl? // "") != $rel and (.pl? // "") != $abs)' \ - "$file" > "$tmp" 2>/dev/null || { + tmp=$(mktemp "${file}.complete.XXXXXX") + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + pl=$(printf '%s' "$line" | jq -r '.pl // empty') || { + printf 'scope-lock-complete: invalid JSONL row in %s\n' "$file" >&2 rm -f "$tmp" - return 0 + return 1 } - mv "$tmp" "$file" + if [ -n "$pl" ]; then + resolved=$(canonical_path_from_base "$repo_root" "$pl" 2>/dev/null || true) + [ "$resolved" = "$plan_abs" ] && continue + fi + printf '%s\n' "$line" >> "$tmp" + done < "$file" + printf '%s\n' "$tmp" } -prune_jsonl "${repo_root}/.claude/autodev-state/session-locks.jsonl" -prune_jsonl "${repo_root}/.claude/autodev-state/in-progress.jsonl" +session_locks_tmp=$(prune_jsonl "$session_locks_file") +in_progress_tmp=$(prune_jsonl "$in_progress_file") -progress_dir="${repo_root}/.autodev/state" -mkdir -p "$progress_dir" 2>/dev/null || true -if command -v jq >/dev/null 2>&1; then - jq -nc \ - --arg ts "$ts" \ - --arg pl "$plan_name" \ - --arg e "$evidence" \ - '{ts:$ts,ev:"plan",pl:$pl,st:"complete"} + (if $e != "" then {e:$e} else {} end)' \ - >> "${progress_dir}/phase-progress.jsonl" 2>/dev/null || true -fi +mkdir -p "$progress_dir" +progress_tmp=$(mktemp "${progress_file}.complete.XXXXXX") +[ -f "$progress_file" ] && cat "$progress_file" > "$progress_tmp" +jq -nc \ + --arg ts "$ts" \ + --arg pl "$plan_name" \ + --arg e "$evidence" \ + '{ts:$ts,ev:"plan",pl:$pl,st:"complete"} + (if $e != "" then {e:$e} else {} end)' \ + >> "$progress_tmp" + +[ -n "$session_locks_tmp" ] && mv "$session_locks_tmp" "$session_locks_file" +[ -n "$in_progress_tmp" ] && mv "$in_progress_tmp" "$in_progress_file" +mv "$progress_tmp" "$progress_file" +mv "$plan_tmp" "$plan_abs" +rm -f "$lock_file" +trap - EXIT printf 'Completed %s and removed %s\n' "$plan_rel" "$(basename "$lock_file")" diff --git a/skills/adversarial-design-review/SKILL.md b/skills/adversarial-design-review/SKILL.md index ec13cba..909382d 100644 --- a/skills/adversarial-design-review/SKILL.md +++ b/skills/adversarial-design-review/SKILL.md @@ -222,9 +222,10 @@ On PASS: ## Dispatching the reviewer agent -Dispatch a `balanced`-tier subagent. Same tier as `alignment-check` and -`requesting-code-review` reviewers — this is review-class work, not -orchestration. +Dispatch a `balanced`-tier subagent whenever the host exposes subagent support. +Same tier as `alignment-check` and `requesting-code-review` reviewers — this is +review-class work, not orchestration. Inline adversarial review is a fallback +only for hosts or sessions where subagents are genuinely unavailable. Use the Agent tool to dispatch: @@ -269,12 +270,14 @@ Agent tool (general-purpose, model: balanced): ```` - -Run the adversarial review inline: read the design (and plan, if -`--phase=plan`) plus `docs/design-guidance.md` or equivalent project guidance, -perform every bug-class scan in the checklist, and produce the Report format -above. The framing requirements still apply — adversarial mindset, ≥3 findings -or full transcript, no reflexive approval. + +When subagent tools are available in the current host/session, dispatch a +subagent with the full adversarial prompt above. Inline adversarial review is +permitted only when subagent tools are genuinely unavailable: read the design +(and plan, if `--phase=plan`) plus `docs/design-guidance.md` or equivalent +project guidance, perform every bug-class scan in the checklist, and produce +the Report format above. The framing requirements still apply — adversarial +mindset, ≥3 findings or full transcript, no reflexive approval. ## Integration diff --git a/skills/scope-lock/SKILL.md b/skills/scope-lock/SKILL.md index 33e70a4..a04f373 100644 --- a/skills/scope-lock/SKILL.md +++ b/skills/scope-lock/SKILL.md @@ -49,7 +49,7 @@ The manifest is a section the plan author writes during `writing-plans`. After ` | 2 | | Task 3, Task 4 | feat/-2 | | ... | ... | ... | ... | -**Status:** Draft | Locked YYYY-MM-DDTHH:MM:SSZ | Amended YYYY-MM-DDTHH:MM:SSZ (see decisions/NNNN) +**Status:** Draft | Locked YYYY-MM-DDTHH:MM:SSZ | Amended YYYY-MM-DDTHH:MM:SSZ (see decisions/NNNN) | Complete YYYY-MM-DDTHH:MM:SSZ ``` Every plan task ID listed under `Tasks` in the table must exist in the plan body. Every task in the plan body must appear in exactly one PR row. @@ -64,8 +64,9 @@ backports and task notes outside the manifest do not change the lock hash. ``` alignment-check PASS - Draft ─────────────────────────────────► Locked - ▲ │ + Draft ─────────────────────────────────► Locked ─────► Complete + ▲ │ verified design done; + │ │ scope-lock-complete │ │ user approves manifest amendment; │ alignment-check FAIL → revise │ recording-decisions writes ADR; │ ▼ @@ -78,6 +79,9 @@ backports and task notes outside the manifest do not change the lock hash. - **Draft**: the plan author is still revising. No execution is permitted. - **Locked**: alignment passed. The manifest hash is recorded. Execution is permitted; renegotiation is not. - **Amended**: the user explicitly approved a manifest change, or a bug/assumption backport required one; an ADR was written; design/plan/manifest were updated; alignment was re-run on the amended plan, which produced a new Locked stamp. The original Locked stamp is preserved in the ADR's Context for audit. +- **Complete**: the locked design is fully verified; `scope-lock-complete` + verified the lock file, removed it, pruned reminder traces, and recorded + completion evidence. There is no "Expanded" state by design. Adding scope mid-flight requires going back to Draft (re-do brainstorming for the new scope). This is intentional friction. @@ -168,7 +172,7 @@ When the whole locked design is genuinely complete and verified, close the lock instead of leaving stale reminders in the workspace: ```bash -hooks/scope-lock-complete docs/plans/.md --evidence "" +bash "${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-complete" docs/plans/.md --evidence "" ``` The helper verifies the current manifest hash when the checker is available, diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index efac960..8732bbb 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -5,6 +5,9 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" +export LC_ALL=C +export LANG=C +export LC_CTYPE=C failures=0 @@ -27,7 +30,7 @@ require_jq() { run_hook() { local hook="$1" local payload="$2" - printf '%s' "$payload" | "hooks/${hook}" + printf '%s' "$payload" | env LC_ALL=C LANG=C LC_CTYPE=C "hooks/${hook}" } run_hook_wrapper() { @@ -125,15 +128,16 @@ test_prompt_strict_no_output_without_trigger() { pass "prompt-strict-interpretation: no output without trigger" } -test_prompt_strict_ignores_unrelated_locked_plan_when_session_has_no_lock() { +test_prompt_strict_ignores_ambiguous_workspace_locks_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" - cat >"$tmp/docs/plans/unrelated.md" <<'PLAN' -# Unrelated Plan + for name in one two; do + cat >"$tmp/docs/plans/${name}.md" </dev/null + bash hooks/scope-lock-apply "$tmp/docs/plans/${name}.md" >/dev/null + done output="$(run_hook prompt-strict-interpretation '{"prompt":"continue autonomously","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" if [ -n "$output" ]; then - fail "prompt-strict-interpretation: expected unrelated workspace lock to be ignored for session, got: ${output}" + fail "prompt-strict-interpretation: expected ambiguous workspace locks to be ignored for session, got: ${output}" return fi - pass "prompt-strict-interpretation: ignores unrelated workspace lock when session has no lock" + pass "prompt-strict-interpretation: ignores ambiguous workspace locks when session has no lock" +} + +test_prompt_strict_falls_back_to_single_workspace_lock() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" + cat >"$tmp/docs/plans/active.md" <<'PLAN' +# Active Plan + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 1 +**Out of scope:** +- (none) + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Active | Task 1 | feat/active | + +**Status:** Locked 2026-05-25T00:00:00Z + +### Task 1: Active +PLAN + bash hooks/scope-lock-apply "$tmp/docs/plans/active.md" >/dev/null + + output="$(run_hook prompt-strict-interpretation '{"prompt":"continue autonomously","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if ! printf '%s' "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("active.md")' >/dev/null; then + fail "prompt-strict-interpretation: expected single workspace lock fallback, got: ${output}" + return + fi + pass "prompt-strict-interpretation: falls back to single workspace lock" } test_prompt_strict_uses_session_locked_plan_only() { @@ -373,6 +415,9 @@ PLAN jq -nc --arg session "session.jsonl" --arg pl "docs/plans/example.md" \ '{ev:"session-lock",session:$session,pl:$pl}' \ > "$tmp/.claude/autodev-state/session-locks.jsonl" + jq -nc --arg session "other.jsonl" --arg pl "./docs/plans/example.md" \ + '{ev:"session-lock",session:$session,pl:$pl}' \ + >> "$tmp/.claude/autodev-state/session-locks.jsonl" jq -nc '{ev:"lock",pl:"example.md",st:"Locked 2026-05-25T00:00:00Z",h:"abc"}' \ > "$tmp/.claude/autodev-state/in-progress.jsonl" @@ -391,6 +436,20 @@ PLAN fail "scope-lock-complete: expected session lock trace to be pruned" return fi + if [ -s "$state_file" ] && jq -e 'select(.pl == "./docs/plans/example.md")' "$state_file" >/dev/null; then + fail "scope-lock-complete: expected equivalent relative session lock trace to be pruned" + return + fi + state_file="$tmp/.claude/autodev-state/in-progress.jsonl" + if [ -s "$state_file" ] && jq -e 'select(.pl == "example.md")' "$state_file" >/dev/null; then + fail "scope-lock-complete: expected compact lock snapshot to be pruned" + return + fi + state_file="$tmp/.autodev/state/phase-progress.jsonl" + if ! jq -e 'select(.ev == "plan" and .pl == "example.md" and .st == "complete" and .e == "tests pass")' "$state_file" >/dev/null; then + fail "scope-lock-complete: expected phase-progress completion evidence row" + return + fi compact_output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" if [ -n "$compact_output" ]; then fail "scope-lock-complete: expected completed plan to produce no pre-compact lock snapshot, got: ${compact_output}" @@ -399,6 +458,176 @@ PLAN pass "scope-lock-complete: marks complete and prunes lock traces" } +test_scope_lock_complete_requires_lock_file() { + local tmp output status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/tests" + cp tests/plan-scope-check.sh "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + 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 + + set +e + output="$(hooks/scope-lock-complete "$tmp/docs/plans/example.md" --evidence "tests pass" 2>&1)" + status=$? + set -e + + if [ "$status" -eq 0 ] || ! printf '%s' "$output" | grep -q 'lock file missing'; then + fail "scope-lock-complete: expected missing lock file failure, got status ${status}: ${output}" + return + fi + pass "scope-lock-complete: requires lock file" +} + +test_scope_lock_complete_rejects_bad_lock_without_project_checker() { + local tmp output status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + 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 + printf 'bogus\n' > "$tmp/docs/plans/example.md.scope-lock" + + set +e + output="$(cd "$tmp" && "$REPO_ROOT/hooks/scope-lock-complete" docs/plans/example.md --evidence "tests pass" 2>&1)" + status=$? + set -e + + if [ "$status" -eq 0 ] || ! printf '%s' "$output" | grep -q 'manifest hash mismatch'; then + fail "scope-lock-complete: expected bad lock failure without project checker, got status ${status}: ${output}" + return + fi + if ! grep -q '\*\*Status:\*\* Locked' "$tmp/docs/plans/example.md" || [ ! -f "$tmp/docs/plans/example.md.scope-lock" ]; then + fail "scope-lock-complete: bad lock failure mutated plan or removed lock" + return + fi + pass "scope-lock-complete: rejects bad lock without project checker" +} + +test_scope_lock_complete_preflights_progress_write() { + local tmp output status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + 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 + bash hooks/scope-lock-apply "$tmp/docs/plans/example.md" >/dev/null + : > "$tmp/.autodev" + + set +e + output="$(cd "$tmp" && "$REPO_ROOT/hooks/scope-lock-complete" docs/plans/example.md --evidence "tests pass" 2>&1)" + status=$? + set -e + + if [ "$status" -eq 0 ]; then + fail "scope-lock-complete: expected progress write preflight failure" + return + fi + if ! grep -q '\*\*Status:\*\* Locked' "$tmp/docs/plans/example.md" || [ ! -f "$tmp/docs/plans/example.md.scope-lock" ]; then + fail "scope-lock-complete: progress write failure mutated plan or removed lock: ${output}" + return + fi + pass "scope-lock-complete: preflights progress write before mutation" +} + +test_scope_lock_complete_rejects_progress_directory() { + local tmp output status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/.autodev/state/phase-progress.jsonl" + 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 + bash hooks/scope-lock-apply "$tmp/docs/plans/example.md" >/dev/null + + set +e + output="$(cd "$tmp" && "$REPO_ROOT/hooks/scope-lock-complete" docs/plans/example.md --evidence "tests pass" 2>&1)" + status=$? + set -e + + if [ "$status" -eq 0 ] || ! printf '%s' "$output" | grep -q 'expected regular file'; then + fail "scope-lock-complete: expected progress directory failure, got status ${status}: ${output}" + return + fi + if ! grep -q '\*\*Status:\*\* Locked' "$tmp/docs/plans/example.md" || [ ! -f "$tmp/docs/plans/example.md.scope-lock" ]; then + fail "scope-lock-complete: progress directory failure mutated plan or removed lock" + return + fi + pass "scope-lock-complete: rejects progress directory before mutation" +} + test_completion_continuation_block() { local tmp tmp="$(mktemp -d)" @@ -502,15 +731,16 @@ test_pretool_records_session_lock_for_scope_lock_apply() { pass "pre-tool-scope-guard: records scope-lock plan for current session" } -test_completion_ignores_unrelated_locked_plan_when_session_has_no_lock() { +test_completion_ignores_ambiguous_workspace_locks_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 + for name in one two; do + cat >"$tmp/docs/plans/${name}.md" </dev/null + bash hooks/scope-lock-apply "$tmp/docs/plans/one.md" >/dev/null + bash hooks/scope-lock-apply "$tmp/docs/plans/two.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}" + fail "completion-claim-guard: expected ambiguous workspace locks to be ignored for session, got: ${output}" + return + fi + pass "completion-claim-guard: ignores ambiguous workspace locks when session has no lock" +} + +test_completion_falls_back_to_single_workspace_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/active.md" <<'PLAN' +# Active Plan + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 1 +**Out of scope:** +- (none) + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Active | Task 1 | feat/active | + +**Status:** Locked 2026-05-25T00:00:00Z + +### Task 1: Active +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/active.md" >/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" | grep -q 'Completion checkpoint'; then + fail "completion-claim-guard: expected single workspace lock fallback to block completion, got: ${output}" return fi - pass "completion-claim-guard: ignores unrelated locked plans when session has no lock" + pass "completion-claim-guard: falls back to single workspace lock" } test_completion_uses_session_locked_plan_only() { @@ -758,7 +1029,8 @@ test_session_start_json test_wrapper_suppresses_unavailable_c_utf8_locale_noise test_prompt_strict_json test_prompt_strict_no_output_without_trigger -test_prompt_strict_ignores_unrelated_locked_plan_when_session_has_no_lock +test_prompt_strict_ignores_ambiguous_workspace_locks_when_session_has_no_lock +test_prompt_strict_falls_back_to_single_workspace_lock test_prompt_strict_uses_session_locked_plan_only test_pretool_pr_review_json test_posttool_pr_created_json @@ -766,10 +1038,15 @@ test_pre_compact_snapshot_json test_wrapper_suppresses_pre_compact_locale_noise test_pre_compact_snapshot_only_locked_plans test_scope_lock_complete_marks_complete_and_prunes_state +test_scope_lock_complete_requires_lock_file +test_scope_lock_complete_rejects_bad_lock_without_project_checker +test_scope_lock_complete_preflights_progress_write +test_scope_lock_complete_rejects_progress_directory 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_ignores_ambiguous_workspace_locks_when_session_has_no_lock +test_completion_falls_back_to_single_workspace_lock test_completion_uses_session_locked_plan_only test_completion_allows_hard_blocker test_pretool_allows_locked_plan_text_edit