diff --git a/.autodev/state/phase-progress.jsonl b/.autodev/state/phase-progress.jsonl new file mode 100644 index 0000000..bbd2c16 --- /dev/null +++ b/.autodev/state/phase-progress.jsonl @@ -0,0 +1 @@ +{"ts":"2026-05-26T22:05:58Z","ev":"plan","pl":"2026-05-26-session-scoped-lock-nag.md","st":"complete","e":"47 hook-contract tests PASS; plan-scope-check PASS; lock verified"} diff --git a/docs/plans/2026-05-26-session-scoped-lock-nag-design.md b/docs/plans/2026-05-26-session-scoped-lock-nag-design.md new file mode 100644 index 0000000..a1835df --- /dev/null +++ b/docs/plans/2026-05-26-session-scoped-lock-nag-design.md @@ -0,0 +1,221 @@ +# Session-scoped lock nag + claim/abandon lifecycle + +**Status:** Draft +**Date:** 2026-05-26 +**Owner:** Jon Langevin (autodev) +**Guidance:** none found; ADR `decisions/0001-complete-scope-locks.md` is the closest canon (lock lifecycle); the Q&A premise is captured here. + +## Problem + +Locked-plan reminders (the "nag") fire on UserPromptSubmit, PreCompact, PreToolUse (push/PR), and SubagentStop hooks. The intent is to keep the autonomous pipeline honest under a `Status: Locked` manifest. The observed failure mode: locks left in the workspace from prior sessions nag *every* later unrelated session, even when the lock has nothing to do with the current work. Three follow-on gaps: + +1. **Workspace fallback in session-aware hooks.** `prompt-strict-interpretation`, `pre-compact-snapshot`, and `pre-tool-scope-guard` already read `.claude/autodev-state/session-locks.jsonl`, but they fall back to a workspace-wide grep for `**Status:** Locked` when no session attribution exists (or when only one plan is locked). That fallback re-introduces the cross-session noise the JSONL was supposed to fix. +2. **`subagent-scope-guard` is not session-aware at all** — it greps the whole `docs/plans` tree and fails any subagent stop whose last commit doesn't satisfy a hash check on every workspace lock, even locks the subagent never touched. +3. **No agent-driven escape valves.** When a stale lock genuinely doesn't apply, the only documented exit is `scope-lock-complete`, which requires a verifiable Scope Manifest hash + completion evidence. Agents have no way to (a) **abandon** a lock that was never completed (e.g. user decided to stop pursuing the design) or (b) **claim** an existing workspace lock for the current session when the previous session was killed by a restart and a fresh agent is resuming the same work. + +ADR 0001 introduced `scope-lock-complete` for the happy path. This design adds the unhappy paths (abandon, claim) and tightens nag scoping so completion is the only way a lock affects a session it isn't attributed to. + +## Goals + +- The locked-plan nag (UserPromptSubmit, PreCompact, PreToolUse push/PR, SubagentStop) fires for the current session iff that session has explicitly claimed the lock via `.claude/autodev-state/session-locks.jsonl`. +- An agent can **claim** an existing locked plan for its session in one bash call. `hooks/scope-lock-claim ` writes the session-lock row that `pre-tool-scope-guard`'s `record_session_lock` would write if the agent had just run `scope-lock-apply`. Idempotent. +- An agent can **abandon** a stale workspace lock that was never completed. `hooks/scope-lock-abandon --reason ""` flips status from `Locked` → `Abandoned `, deletes the `.scope-lock` file, prunes JSONL traces for all sessions, appends an `Abandoned` row to `phase-progress.jsonl`. Does NOT verify manifest hash (the work was never finished, hash drift is expected). +- Subagent-scope-guard verifies the manifest hash only on plans the current session is attributed to (matching the other hooks). + +## Non-goals + +- Changing the lock format, manifest extraction rules, or `tests/plan-scope-check.sh`. +- A multi-session lock — claim is per-session; two sessions can claim the same lock independently, both see the nag. +- Re-claiming an `Abandoned` plan automatically. Once abandoned, the operator must re-lock (re-run alignment-check) to re-enter the gate. +- Auto-detecting "stale" locks (timestamp heuristics, branch presence, etc.). Stale-vs-active is judgment; the agent makes the call. + +## Global Design Guidance + +No `docs/design-guidance.md`. Applicable durable canon: + +| guidance | design response | +|---|---| +| ADR 0001 — locks must have an explicit completion path (no inference from PR history) | Claim and abandon are equally explicit. Both are bash helpers, both leave an audit trail. | +| `scope-lock` SKILL — Scope Manifest is the contract; renegotiation goes through brainstorming. | Abandon is not "renegotiation under the lock"; it terminates the lock. The replacement work, if any, starts a new design + plan + lock. | +| `using-autodev` — agents may not bypass pipeline gates by setting env vars. | New helpers do not gate on env vars. They are direct shell scripts invoked from Bash; the existing `pre-tool-scope-guard` `record_session_lock` pattern is extended to recognize them. | +| Pipeline state in `.claude/autodev-state/` is repo-local + JSONL | New helpers write JSONL rows to existing files. No new state files. | + +## Approach + +### 0. Tighten the status-line grep (Critical pre-existing bug) + +All four nag hooks currently detect locked plans with `grep -q '\*\*Status:\*\* Locked'` (or `grep -rl` variants). That matches the substring **anywhere in the file**, including prose mentions of the literal status string. This design document itself triggers the bug because it discusses lock mechanics in its body. Live evidence: subagent stops invoked during adversarial review of this design were blocked by `subagent-scope-guard` falsely flagging this draft as a locked-without-hash plan. + +Fix in every hook (and the helpers that mirror the detection logic): replace the substring match with an anchored line-start match. + +``` +# old (substring; matches prose mentions) +grep -q '\*\*Status:\*\* Locked' "$plan" +# new (anchored; matches only the status line) +grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan" +``` + +Same correction for the workspace-wide `grep -rl` calls — switch to `grep -lE '^\*\*Status:\*\*[[:space:]]+Locked'`. Regression test: a `Status: Draft` plan whose body literally quotes ``**Status:** Locked`` (this file is such a plan) produces no nag and no subagent block. + +### 1. Strict session-scoped nag + +`prompt-strict-interpretation`, `pre-compact-snapshot`, `pre-tool-scope-guard`, and `subagent-scope-guard` all converge on a single rule: + +> The locked-plan reminder fires for a plan in the current session iff `session-locks.jsonl` has an `ev:"session-lock"` row attributing that plan to the current `session_key`. + +No workspace fallback. If the session has no attribution, no nag — even if exactly one locked plan exists in `docs/plans/`. (Today, the "exactly one workspace lock" fallback exists in `prompt-strict-interpretation` and `pre-compact-snapshot`; it goes away.) The cost of removing the fallback is that a fresh session resuming abandoned work won't be reminded until it claims; the new `scope-lock-claim` helper is the recovery path. + +`pre-tool-scope-guard`'s `find_locked_plans` already returns only session-attributed plans when a `session_key` is present. It still falls back to a workspace scan when no `session_key` is available (no `transcript_path` in hook input). We keep that final fallback only because absence of `transcript_path` means the host did not provide session identity — in that case workspace-wide is the only safe heuristic. Hosts that always emit `transcript_path` (Claude Code) never hit it. + +`subagent-scope-guard` gets the same treatment: extract `transcript_path` from `hook_input`, derive `session_key`, and verify the manifest hash only on plans attributed to that session. If no attribution exists, no check fires. + +### 2. `hooks/scope-lock-claim ` + +Minimal bash script. Verifies the plan exists, has `**Status:** Locked`, and that `.scope-lock` is present (the manifest hash file is the source of truth for the lock — claiming without a hash file is rejected because nothing verifies). Then prints a single line containing the literal token `scope-lock-claim` and the resolved plan path. The actual `session-locks.jsonl` write is performed by `pre-tool-scope-guard`'s `record_session_lock`, which already runs on every Bash tool call and currently only recognizes `scope-lock-apply`. We extend the regex to also match `scope-lock-claim`. This keeps the JSONL append path identical for apply and claim — one writer, one format. + +Why route writes through `record_session_lock` rather than have the script write the JSONL itself: `record_session_lock` knows the `session_key` from the hook payload, which the bash subprocess does not. Routing through the hook avoids duplicating session-key discovery and keeps the script free of "did the host pass a transcript path?" code. + +Idempotent: if the claim row already exists for `(session, plan)`, no duplicate is written. The current `record_session_lock` already writes one row per invocation; we add a dedupe pass at write time (cheap — typical file is <100 rows). + +**Claim verifies hash at claim time.** Claiming a drift-broken plan is strictly worse than refusing the claim: the session would inherit a lock whose manifest can never satisfy push/PR gates. `scope-lock-claim` therefore runs `tests/plan-scope-check.sh --verify-lock ` before printing the recognized command; failure exits non-zero. + +**Why a new helper rather than re-running `scope-lock-apply`.** `scope-lock-apply` rewrites the `.scope-lock` file from the current manifest. If the manifest has drifted since the original lock, re-running apply would silently overwrite the original hash with the drifted one — exactly the silent rescoping the lock is meant to prevent. `scope-lock-claim` is read-only with respect to `.scope-lock` (it only writes to `session-locks.jsonl`), preserving the original-author hash. + +**Liveness check.** Claim ends with a read-back: after the command finishes, the helper re-reads `session-locks.jsonl` and exits non-zero if the just-claimed `(session, plan)` row is not present. Converts the "hook regex missed the new helper" silent failure into a loud failure, and gives the test that exercises claim a contract to assert. + +**Recognized-command list.** The set of helper names that `pre-tool-scope-guard`'s `record_session_lock` recognizes (`scope-lock-apply`, `scope-lock-claim`, and the cleanup recognizer for `scope-lock-abandon`) is centralized in a single bash variable at the top of the hook with an inline comment pointing back to this design. Helper script headers reciprocally name the hook that routes their session-lock write so a future maintainer can see the contract from either end. + +### 3. `hooks/scope-lock-abandon --reason ""` + +Mirrors `scope-lock-complete` but skips manifest hash verification and uses a distinct lifecycle state. Verifies the plan exists and currently has `**Status:** Locked`. Flips status to `Abandoned `. Removes `.scope-lock`. Prunes JSONL rows for the plan across all sessions in `session-locks.jsonl` and `in-progress.jsonl`. Appends to `.autodev/state/phase-progress.jsonl`: + +```json +{"ts":"","ev":"plan","pl":"","st":"abandoned","reason":""} +``` + +Distinct from `complete` so retros and dashboards can tell "verified done" from "stopped pursuing". `--reason` is required (empty string rejected); the value is recorded both in the status line and in the JSONL row. + +**Reason sanitization.** The status line must stay single-line for the `awk` status-flip pattern (mirrored from `scope-lock-complete`) to work. `--reason` is sanitized in the helper: embedded newlines and tabs collapse to single spaces, the value is truncated to 200 characters, and any literal `**` is replaced with `__` to prevent breaking the markdown bold of the surrounding status line. The sanitized form is recorded in both the status line and the JSONL row so the audit trail matches what users see. + +**No auto-ADR.** Abandon does not invoke `recording-decisions`. The audit row in `phase-progress.jsonl` and the `Abandoned … — ` status line together cover the durable-record case. Forcing an ADR on every abandon would generate one-line ADRs whose only content duplicates the reason already in the status line. Operators who want a richer record can hand-write an ADR. + +### 4. `subagent-scope-guard` session-aware refactor + +Today it greps the whole `docs/plans` tree. Replace with the same `session_key + session-locks.jsonl` filter used elsewhere. If no `session_key`, fall back to workspace-wide (same behavior the other hooks now have). The protected-file checks for uncommitted `.scope-lock` writes and last-commit `.scope-lock` modifications stay unchanged — those are about subagent behavior, not lock attribution. + +### 5. SKILL.md updates + +`skills/scope-lock/SKILL.md` learns three things: + +- The lock attribution rule: nag fires iff the plan is attributed to the current session. +- The claim command, with the resume-after-restart story as the canonical example. +- The abandon command, with the "user decided to stop pursuing this" story as the canonical example, and the explicit note that abandon does not require completion evidence and does not unblock a re-locked plan. + +## Architecture / components + +- **No new state files.** Reuses `.claude/autodev-state/session-locks.jsonl`, `.claude/autodev-state/in-progress.jsonl`, and `.autodev/state/phase-progress.jsonl`. +- **New scripts:** `hooks/scope-lock-claim`, `hooks/scope-lock-abandon`. +- **Modified hooks:** `pre-tool-scope-guard` (regex + dedupe), `prompt-strict-interpretation` (remove workspace fallback), `pre-compact-snapshot` (remove workspace fallback), `subagent-scope-guard` (add session filter). +- **Modified skill:** `skills/scope-lock/SKILL.md`. +- **Modified tests:** `tests/hook-contracts.sh` gains coverage for the new behavior; existing `test_prompt_strict_falls_back_to_single_workspace_lock` is replaced with `test_prompt_strict_ignores_single_workspace_lock_when_session_has_no_lock` (semantic flip, single-PR scope). + +## Data flow + +``` +fresh session (transcript_path=A.jsonl) + └─ bash hooks/scope-lock-claim docs/plans/foo.md + │ + └─ pre-tool-scope-guard intercepts Bash, sees "scope-lock-claim", + calls record_session_lock → appends + {"ts":...,"ev":"session-lock","session":"A.jsonl","pl":"docs/plans/foo.md"} + to .claude/autodev-state/session-locks.jsonl (idempotent) + + └─ next user prompt: "go ahead and create a PR" + │ + └─ prompt-strict-interpretation reads session-locks.jsonl, finds + A.jsonl→foo.md attribution → emits the nag reminder + +later (work abandoned) + └─ bash hooks/scope-lock-abandon docs/plans/foo.md --reason "user pivoted" + │ + ├─ flips Status to "Abandoned — user pivoted" + ├─ rm docs/plans/foo.md.scope-lock + ├─ prune all session-lock rows where pl == foo.md + ├─ prune in-progress.jsonl rows for foo.md + └─ append phase-progress.jsonl {ev:"plan",st:"abandoned",...} + + └─ subsequent prompts: no session attribution → no nag +``` + +## Security review + +- **No new auth boundary.** Scripts run with the agent's existing shell privileges. They write only to repo-local state files under the cwd. +- **Self-bypass surface.** Neither helper accepts `SUPERPOWERS_*` env vars; they are unconditional helpers. They do not gate anything — they're cleanup. The existing self-bypass guard in `pre-tool-scope-guard` is unaffected. +- **Abandon is destructive** (removes lock + prunes rows). Risk: an agent abandons a lock the user wanted preserved. Mitigation: `--reason` is mandatory and recorded; the plan body remains intact (only `Status` flips); `phase-progress.jsonl` keeps an audit trail. The user can re-run alignment-check to re-lock. +- **Claim is non-destructive.** Worst case: a session claims a plan that doesn't apply to its work and gets unnecessary nags; the cost is one extra reminder per prompt. Reverse is recovering from missing nags after a restart — claim is the asymmetric-cost win. +- **Path traversal.** Both scripts resolve `plan-path` via the same `canonical_path_from_base` helper used by `scope-lock-complete`. Symlink and traversal handling is identical. + +## Infrastructure impact + +None. All changes are local to the autodev plugin distribution. No runtime services, no migrations, no deploy gates. Released by tag push → marketplace dispatch (the existing pipeline). No `## Rollback` section required — this is not a runtime-affecting change class per `runtime-launch-validation`'s trigger list. If the release goes bad, revert the merge commit and bump again. + +## Multi-component validation + +Hook integration tests in `tests/hook-contracts.sh` exercise the real bash hooks end-to-end against tmpdirs that simulate the `docs/plans/` + `.claude/autodev-state/` layout. No mocks at the bash/JSONL boundary. Tests cover: + +**Unit-level (one hook / one helper at a time):** +- claim writes the row (via `pre-tool-scope-guard` regex match) +- claim is idempotent +- claim of a plan without `.scope-lock` is rejected +- claim of a plan with manifest drift is rejected (hash mismatch at claim time) +- abandon flips status, deletes lock, prunes session-locks + in-progress, appends phase-progress +- abandon requires `--reason`; empty string is rejected +- abandon refuses a plan not in `Locked` status +- abandon sanitizes multi-line `--reason` into single-line status +- prompt-strict no longer falls back to single workspace lock (replaces existing fallback test) +- pre-compact-snapshot no longer falls back to single workspace lock +- subagent-scope-guard does not fire on workspace-only locks unattributed to the session +- subagent-scope-guard still fires on the session-attributed lock when manifest drift is detected +- all four hooks treat a plan that quotes `**Status:** Locked` inside prose but has `Status: Draft` on the actual status line as NOT locked (regression test for the anchored-grep fix) + +**End-to-end (observable user behavior the user asked for):** +- claim → next `prompt-strict-interpretation` call emits the nag and references the claimed plan name (proves the "resume after restart" story works observably, not just at the row-write layer) +- abandon → next `prompt-strict-interpretation` call emits no nag for the abandoned plan (proves the "clean up old noise" story works observably) +- fresh session with no claim → no nag fires for any locked plan, regardless of workspace state (proves session-scoping holds without fallback) + +## Assumptions + +1. `record_session_lock` runs on every Bash tool call. (Verified: `pre-tool-scope-guard` invokes it after the self-bypass guard, before any block check.) If this changes, claim breaks silently. +2. `session_key` (transcript_path basename) is stable for the lifetime of a session including across compactions. (Verified empirically by the existing session-aware hooks; if Claude Code rotated transcript IDs mid-session, the existing nag-scope logic would already be broken.) +3. Sessions running in worktrees still resolve `cwd` to the worktree path, so `.claude/autodev-state/session-locks.jsonl` is worktree-local and a claim made in worktree A does not bleed into worktree B. +4. Operators do not hand-edit `.claude/autodev-state/session-locks.jsonl`. (If they do, dedupe still works; abandon still prunes; nothing relies on row order.) + +The most fragile assumption is #1. If `pre-tool-scope-guard`'s `record_session_lock` ever moves to a different hook or gets removed, claim becomes a no-op. A defensive alternative would be to have the helper write JSONL directly using a discovered transcript path; the current design accepts the fragility in exchange for one writer of session-locks.jsonl. + +## Self-challenge + +1. **Laziest plausible solution.** Just remove the workspace fallback in the three nag hooks. Don't add claim or abandon. Trade-off: agents resuming after restart get no nag at all (silent loss of gate); stale workspace locks need manual deletion of `.scope-lock` + status edit. Rejected: silent loss of the gate is worse than the cost of two helpers, and operators editing state by hand violates ADR 0001's "no manual edits of `.scope-lock`" rule. +2. **Most fragile assumption.** #1 above. Mitigation noted; consequence is non-silent (no nag → user notices stale work). +3. **YAGNI sweep.** No new flags, env vars, config knobs, or hooks. Two helpers, one regex change, one filter add, one workspace fallback removed. No bulk-abandon command — the user wanted per-plan agent action. +4. **First failure under partial restart / mid-operation.** `scope-lock-abandon` interrupted between `rm .scope-lock` and the JSONL prune leaves an Abandoned plan with stale session-lock rows. Next prompt nags about a plan whose lock file is gone. The status line check in `find_locked_plans` already gates on `**Status:** Locked` — Abandoned plans drop out automatically. So the interrupted-abandon failure mode self-heals on the next nag attempt. +5. **Repo precedent conflict.** None. `scope-lock-complete` is the closest existing helper; abandon and claim mirror its argument shape and state-file conventions. + +Top 3 doubts surfaced for the reviewer: + +1. The "no workspace fallback" rule means a fresh session resuming abandoned work is silent until claim. Is that the right default? (We think yes — silence is recoverable via one bash call; false nags are not recoverable in the moment.) +2. Routing the claim write through `pre-tool-scope-guard`'s regex couples two files. A direct write from `scope-lock-claim` would be more local but would need its own session-key discovery. Acceptable trade? +3. `--reason` on abandon is required. Some agents may resist; making it optional would weaken the audit trail. Acceptable trade? + +## Rollback + +Not a runtime-affecting change. Revert the merge commit. Marketplace bump PR reverts independently; users on the bumped plugin version see the new helpers as no-ops if they don't invoke them, and the nag behavior degrades safely to the prior workspace-fallback path only when the new pre-tool-scope-guard regex isn't installed — which means a revert restores prior behavior wholesale, no migration needed. + +Abandoned plans are NOT auto-revivable. If a release regression makes the operator wish a plan had not been abandoned, the plan must be edited back to `**Status:** Locked YYYY-MM-DDTHH:MM:SSZ` by hand and re-hashed via `scope-lock-apply` (which will create a new `.scope-lock` file). The original lock hash is unrecoverable. This is intentional — abandon is a state termination, not a pause. + +## Backport: 5th nag hook discovered at execution time (2026-05-26) + +Implementation revealed that `hooks/completion-claim-guard` is a fifth nag hook with the same substring-grep bug AND the same single-workspace-lock fallback. It was missed by the design and the adversarial review. Treated as a design backport (not a manifest amendment): §Approach 0 should read "all five nag hooks" not "all four". Tasks 1 (anchored grep) and 5 (drop fallback) extend to `completion-claim-guard`. No change to PR Count, Task count, or PR Grouping table. Scope manifest hash unchanged. The fifth hook is enumerated in the executing plan's task descriptions. + +## Adversarial review backport (2026-05-26) + +Inline adversarial review (subagent stops were swallowed by the very bug the design fixes — live demonstration of the substring grep false positive) produced one Critical and five Important findings. All resolved in §Approach 0 (anchored grep), §Approach 2 (claim hash check + re-apply rationale + liveness check + centralized recognized-command list), §Approach 3 (reason sanitization, no auto-ADR), §Multi-component validation (added end-to-end nag-fires / nag-silenced tests + anchored-grep regression test), and §Rollback (un-abandon limitation). Open questions resolved inline: claim verifies hash at claim time; abandon does NOT write an ADR. diff --git a/docs/plans/2026-05-26-session-scoped-lock-nag.md b/docs/plans/2026-05-26-session-scoped-lock-nag.md new file mode 100644 index 0000000..95afcc4 --- /dev/null +++ b/docs/plans/2026-05-26-session-scoped-lock-nag.md @@ -0,0 +1,1031 @@ +# Session-scoped lock nag + claim/abandon Implementation Plan + +> **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans to implement this plan task-by-task. + +**Goal:** Restrict the locked-plan nag (UserPromptSubmit, PreCompact, PreToolUse, SubagentStop) to plans attributed to the current session, fix the latent substring-grep false-positive, and add `scope-lock-claim` + `scope-lock-abandon` helpers so agents can recover lost session attribution and clean up never-completed locks. + +**Architecture:** Bash-only changes inside the autodev plugin distribution. Four nag hooks switch from `grep -q '\*\*Status:\*\* Locked'` (substring) to anchored line-start regex. The workspace-wide fallback that fires when `session_key` is present but session-locks.jsonl has no attribution is removed; the fallback when no `session_key` is provided at all (host doesn't expose it) is kept. Two new helpers (`scope-lock-claim`, `scope-lock-abandon`) mirror `scope-lock-complete`'s shape. `pre-tool-scope-guard`'s `record_session_lock` recognizes both apply and claim by centralizing the helper-name list, and prunes session-locks for abandon. + +**Tech Stack:** Bash, jq, awk, sha256sum/shasum (existing toolchain). No new dependencies. + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 9 +**Estimated Lines of Change:** ~600 (informational) + +**Out of scope:** +- Changes to the Scope Manifest format or `tests/plan-scope-check.sh` extraction logic. +- A multi-session lock or shared-lock semantics — claim is per-session and additive. +- Auto-detection of stale locks (timestamp heuristics, branch presence). Stale-vs-active is operator judgment. +- Re-locking an Abandoned plan automatically. Once abandoned the operator re-runs alignment-check. +- Changes to `scope-lock-complete` (the happy-path helper from ADR 0001). + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat(scope-lock): session-scoped nag + claim/abandon helpers | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7, Task 8, Task 9 | feat/session-scoped-lock-nag-2026-05-26 | + +**Status:** Complete 2026-05-26T22:05:58Z + +--- + +## Test fixture helper (shared by every test below) + +To keep `tests/plan-scope-check.sh`'s manifest parser from re-entering on every fixture heredoc, ALL fixture plans emit through a single printf helper that hides the heading from column 0 in this markdown file. Add this helper near the top of the new test block in `tests/hook-contracts.sh`: + +```bash +# emit_locked_fixture +# Writes a minimal-but-valid locked plan to , then runs +# hooks/scope-lock-apply against it from the repo root. The plan body is +# produced with printf (not a heredoc) so this plan document's own +# tests/plan-scope-check.sh parser does not double-count fixture PR rows. +emit_locked_fixture() { + local path="$1" name="$2" + printf '# %s Plan\n\n%s\n\n**PR Count:** 1\n**Tasks:** 1\n**Out of scope:**\n- (none)\n\n**PR Grouping:**\n\n| PR # | Title | Tasks | Branch |\n|------|-------|-------|--------|\n| 1 | %s | Task 1 | feat/%s |\n\n**Status:** Locked 2026-05-26T00:00:00Z\n\n### Task 1: %s\n' \ + "$name" "## Scope Manifest" "$name" "$name" "$name" > "$path" + ( cd "$(dirname "$(dirname "$path")")/.." \ + && bash "$REPO_ROOT/hooks/scope-lock-apply" "${path#"$PWD"/}" >/dev/null 2>&1 \ + || bash "$REPO_ROOT/hooks/scope-lock-apply" "$path" >/dev/null ) +} + +# emit_draft_fixture +# Same shape but Status is Draft; used for prose-mention regression tests. +emit_draft_fixture() { + local path="$1" name="$2" + printf '# %s Plan\n\n%s\n\n**PR Count:** 1\n**Tasks:** 1\n**Out of scope:**\n- (none)\n\n**PR Grouping:**\n\n| PR # | Title | Tasks | Branch |\n|------|-------|-------|--------|\n| 1 | %s | Task 1 | feat/%s |\n\n**Status:** Draft\n\nProse mention: %s 2026-05-26T00:00:00Z\n\n### Task 1: %s\n' \ + "$name" "## Scope Manifest" "$name" "$name" "**Status:** Locked" "$name" > "$path" +} +``` + +Every test below uses these two helpers — no per-test heredoc. + +--- + +### Task 1: Anchored status-line grep — pre-tool-scope-guard + +**Files:** +- Modify: `hooks/pre-tool-scope-guard` +- Modify: `tests/hook-contracts.sh` (add `emit_locked_fixture` + `emit_draft_fixture` helpers + test below) + +**Step 1: Write the failing test.** + +```bash +test_pretool_ignores_prose_mention_of_locked_status() { + 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" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git push origin feat/x"},"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}' \ + | run_hook pre-tool-scope-guard 2>&1 || true)" + if printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "pre-tool-scope-guard: prose mention of Locked status falsely matched, output: ${output}" + return + fi + pass "pre-tool-scope-guard: anchored grep ignores prose mention of Locked status" +} +``` + +**Step 2: Run test to verify it fails.** `bash tests/hook-contracts.sh 2>&1 | grep test_pretool_ignores_prose_mention` → expect FAIL. + +**Step 3: Tighten the grep.** In `hooks/pre-tool-scope-guard`, replace both `grep -q '\*\*Status:\*\* Locked'` (in the session-attributed branch's inner loop) and `grep -rl '\*\*Status:\*\* Locked'` (workspace-fallback) with their anchored counterparts: + +```bash +grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$resolved" # session-attributed loop +grep -rlE '^\*\*Status:\*\*[[:space:]]+Locked' "$plans_dir" # workspace fallback +``` + +**Step 4: Re-run test.** Expect PASS. + +**Step 5: Commit.** + +```bash +git add hooks/pre-tool-scope-guard tests/hook-contracts.sh +git commit -m "fix(hooks): anchor Status:Locked grep in pre-tool-scope-guard" +``` + +--- + +### Task 2: Anchored status-line grep — prompt-strict-interpretation + +**Files:** +- Modify: `hooks/prompt-strict-interpretation` +- Modify: `tests/hook-contracts.sh` + +**Step 1: Write the failing test.** + +```bash +test_prompt_strict_ignores_prose_mention_of_locked_status() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead and create a PR","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "prompt-strict-interpretation: prose mention of Locked status triggered nag, output: ${output}" + return + fi + pass "prompt-strict-interpretation: anchored grep ignores prose mention of Locked status" +} +``` + +**Step 2: Run.** Expect FAIL. + +**Step 3: Tighten the grep at both occurrences inside `workspace_locked_plans` and `session_locked_plans` (≈ lines 105, 124).** Replace `grep -q '\*\*Status:\*\* Locked'` with `grep -qE '^\*\*Status:\*\*[[:space:]]+Locked'`. + +**Step 4: Re-run.** Expect PASS. + +**Step 5: Commit.** + +```bash +git add hooks/prompt-strict-interpretation tests/hook-contracts.sh +git commit -m "fix(hooks): anchor Status:Locked grep in prompt-strict-interpretation" +``` + +--- + +### Task 3: Anchored status-line grep — pre-compact-snapshot + +**Files:** +- Modify: `hooks/pre-compact-snapshot` +- Modify: `tests/hook-contracts.sh` + +**Step 1: Write the failing test** (same shape as Task 2, using `pre-compact-snapshot` and asserting empty output). + +```bash +test_pre_compact_ignores_prose_mention_of_locked_status() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "pre-compact-snapshot: prose mention of Locked status triggered snapshot, output: ${output}" + return + fi + pass "pre-compact-snapshot: anchored grep ignores prose mention of Locked status" +} +``` + +**Step 2-4:** Same as Task 2; locations to patch in `hooks/pre-compact-snapshot` are ≈ lines 44 and 63. + +**Step 5: Commit.** + +```bash +git add hooks/pre-compact-snapshot tests/hook-contracts.sh +git commit -m "fix(hooks): anchor Status:Locked grep in pre-compact-snapshot" +``` + +--- + +### Task 4: Session-aware subagent-scope-guard + anchored grep + +**Files:** +- Modify: `hooks/subagent-scope-guard` +- Modify: `tests/hook-contracts.sh` + +**Step 1: Write the failing tests.** + +```bash +test_subagent_scope_guard_ignores_unattributed_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" + emit_locked_fixture "$tmp/docs/plans/unrelated.md" "unrelated" + # Drift the manifest so verify-lock would fail if invoked. + printf '\n\n' >> "$tmp/docs/plans/unrelated.md" + output="$(run_hook subagent-scope-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false}' 2>&1 || true)" + if printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "subagent-scope-guard: blocked stop for unattributed workspace lock, output: ${output}" + return + fi + pass "subagent-scope-guard: ignores unattributed workspace lock" +} + +test_subagent_scope_guard_blocks_attributed_drift() { + 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" "$tmp/tests" + 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" + cp "$REPO_ROOT/tests/plan-scope-check.sh" "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + # Drift inside the manifest section so verify-lock fails. + awk '/^\*\*Tasks:\*\*/ {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" + output="$(run_hook subagent-scope-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false}' 2>&1 || true)" + if ! printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "subagent-scope-guard: did NOT block for attributed drift, output: ${output}" + return + fi + pass "subagent-scope-guard: blocks on attributed drift" +} +``` + +**Step 2: Run.** Expect both FAIL. + +**Step 3: Refactor `hooks/subagent-scope-guard`.** Extract `transcript_path` + `session_key` from `hook_input` near the top (mirror the pattern already used by `prompt-strict-interpretation`). Replace the existing locked-plans discovery block (current lines 74-83) with: + +```bash +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_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 -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$resolved" 2>/dev/null || continue + printf '%s\n' "$resolved" + done +} + +find_workspace_locked_plans() { + grep -rlE '^\*\*Status:\*\*[[:space:]]+Locked' "${cwd_dir}/docs/plans" 2>/dev/null \ + | grep '\.md$' | grep -v '\.scope-lock' || true +} + +checker="${cwd_dir}/tests/plan-scope-check.sh" +if [ -x "$checker" ] && [ -d "${cwd_dir}/docs/plans" ]; then + if [ -n "$session_key" ]; then + locked_plans=$(find_session_locked_plans) + else + locked_plans=$(find_workspace_locked_plans) + fi + while IFS= read -r plan; do + [ -z "$plan" ] && continue + if ! bash "$checker" --verify-lock "$plan" >/dev/null 2>&1; then + violations="${violations} • Locked Scope Manifest hash mismatch: ${plan#${cwd_dir}/}\n" + fi + done <<< "$locked_plans" +fi +``` + +**Step 4: Run tests.** Expect both PASS. + +**Step 5: Commit.** + +```bash +git add hooks/subagent-scope-guard tests/hook-contracts.sh +git commit -m "fix(hooks): session-scope subagent-scope-guard and anchor Locked grep" +``` + +--- + +### Task 5: Drop the single-workspace-lock fallback in nag hooks + +**Files:** +- Modify: `hooks/prompt-strict-interpretation` +- Modify: `hooks/pre-compact-snapshot` +- Modify: `tests/hook-contracts.sh` — replace `test_prompt_strict_falls_back_to_single_workspace_lock` (currently asserts the fallback) with a "no fallback" assertion, and add the analogous test for `pre-compact-snapshot`. + +**Step 1: Edit the existing test to flip its assertion.** + +```bash +test_prompt_strict_ignores_single_workspace_lock_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" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + output="$(run_hook prompt-strict-interpretation '{"prompt":"continue autonomously","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "prompt-strict-interpretation: single workspace lock falsely triggered fallback, output: ${output}" + return + fi + pass "prompt-strict-interpretation: no workspace fallback when session has no lock" +} + +test_pre_compact_ignores_single_workspace_lock_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" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "pre-compact-snapshot: single workspace lock falsely triggered fallback, output: ${output}" + return + fi + pass "pre-compact-snapshot: no workspace fallback when session has no lock" +} +``` + +Delete the original `test_prompt_strict_falls_back_to_single_workspace_lock` function. + +**Step 2: Run.** Expect both new tests FAIL. + +**Step 3: Remove the fallback branch in both hooks.** In `prompt-strict-interpretation` (current lines 130-138) and `pre-compact-snapshot` (current lines 68-83), the logic is the same shape: + +```bash +# OLD +if [ -n "$session_key" ]; then + 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=$(workspace_locked_plans | head -1 || true) +fi + +# NEW +if [ -n "$session_key" ]; then + session_plans=$(session_locked_plans) + [ -n "$session_plans" ] && locked_plan=$(printf '%s\n' "$session_plans" | head -1) +else + locked_plan=$(workspace_locked_plans | head -1 || true) +fi +``` + +For `pre-compact-snapshot`, do the equivalent inside `locked_plan_stream`: when `session_key` is set, only stream session_locked_plans; do NOT stream workspace_locked_plans even if exactly one exists. + +**Step 4: Re-run.** Expect both PASS plus the rest of the file still green. + +**Step 5: Commit.** + +```bash +git add hooks/prompt-strict-interpretation hooks/pre-compact-snapshot tests/hook-contracts.sh +git commit -m "fix(hooks): drop single-workspace-lock fallback when session has no attribution" +``` + +--- + +### Task 6: `hooks/scope-lock-claim` helper + +**Files:** +- Create: `hooks/scope-lock-claim` (chmod +x) +- Modify: `hooks/pre-tool-scope-guard` (centralize recognized-command list; extend regex to `scope-lock-claim`; dedupe writes) +- Modify: `tests/hook-contracts.sh` + +**Step 1: Write the failing tests.** + +```bash +test_scope_lock_claim_writes_session_attribution() { + local tmp transcript record_payload state_file + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/p.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + printf '%s' "$record_payload" | run_hook pre-tool-scope-guard >/dev/null 2>&1 || true + state_file="$tmp/.claude/autodev-state/session-locks.jsonl" + if [ ! -s "$state_file" ]; then + fail "scope-lock-claim: pre-tool-scope-guard did not write session-locks.jsonl" + return + fi + if ! jq -e --arg s "session.jsonl" --arg pl "docs/plans/p.md" \ + 'select(.ev=="session-lock" and .session==$s and .pl==$pl)' "$state_file" >/dev/null; then + fail "scope-lock-claim: row missing for (session,plan), file: $(cat "$state_file")" + return + fi + pass "scope-lock-claim: recognized by pre-tool-scope-guard and writes session row" +} + +test_scope_lock_claim_writes_are_idempotent() { + local tmp transcript record_payload rowcount + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/p.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + for _ in 1 2 3; do + printf '%s' "$record_payload" | run_hook pre-tool-scope-guard >/dev/null 2>&1 || true + done + rowcount=$(wc -l < "$tmp/.claude/autodev-state/session-locks.jsonl" | awk '{print $1}') + if [ "$rowcount" -ne 1 ]; then + fail "scope-lock-claim: expected 1 row after 3 invocations, got: $rowcount" + return + fi + pass "scope-lock-claim: dedupe keeps session-locks.jsonl at one row per (session, plan)" +} + +test_scope_lock_claim_rejects_unlocked_plan() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + set +e + bash "$REPO_ROOT/hooks/scope-lock-claim" "$tmp/docs/plans/draft.md" >/dev/null 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-claim: accepted unlocked plan"; return; } + pass "scope-lock-claim: rejects unlocked plan" +} + +test_scope_lock_claim_rejects_drift() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$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/p.md" "p" + # Drift inside the manifest section. + awk '/^\*\*PR Count:\*\* 1/{print "**PR Count:** 2"; next} {print}' \ + "$tmp/docs/plans/p.md" > "$tmp/docs/plans/p.md.tmp" \ + && mv "$tmp/docs/plans/p.md.tmp" "$tmp/docs/plans/p.md" + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-claim" "docs/plans/p.md" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-claim: accepted drifted manifest"; return; } + pass "scope-lock-claim: rejects manifest drift" +} +``` + +**Step 2: Run.** Expect 4 × FAIL (helper missing, recognizer regex doesn't match `scope-lock-claim`). + +**Step 3: Centralize the recognized-command list and add `scope-lock-claim`.** Near the top of `hooks/pre-tool-scope-guard` (after the JSON parsing block), add: + +```bash +# ────────────────────────────────────────────────────────────────────────── +# Recognized helper script names that update session-lock state. Pattern- +# matched against Bash tool commands by record_session_lock so each helper +# script never needs to know the current session_key itself. +# +# Helpers MUST emit their bare name on stdout so a future maintainer can +# audit which Bash invocations matter from either end. +# ────────────────────────────────────────────────────────────────────────── +SESSION_LOCK_RECOGNIZED='scope-lock-apply|scope-lock-claim' +``` + +Rewrite `record_session_lock` to use the variable AND dedupe writes: + +```bash +record_session_lock() { + local cmd="$1" + [ -n "$session_key" ] || return 0 + printf '%s' "$cmd" | grep -qE "(${SESSION_LOCK_RECOGNIZED})" || return 0 + + local plan_arg="" + plan_arg=$(printf '%s' "$cmd" \ + | sed -nE "s/.*(${SESSION_LOCK_RECOGNIZED})[[:space:]]+\"?([^\" ;]+)\"?.*/\2/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" + + if [ -f "$state_file" ]; then + if jq -e --arg s "$session_key" --arg pl "$plan_arg" \ + 'select(.ev=="session-lock" and .session==$s and .pl==$pl)' \ + "$state_file" >/dev/null 2>&1; then + return 0 + fi + fi + + 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 +} +``` + +**Step 4: Create `hooks/scope-lock-claim`.** + +```bash +#!/usr/bin/env bash +# hooks/scope-lock-claim +# Attribute an existing locked plan to the current session so the locked-plan +# nag hooks fire for this session. +# +# Use case: an agent session was interrupted (e.g., computer restart) and a +# fresh session needs to resume the same work. The .scope-lock file is still +# on disk, but the new session is not in session-locks.jsonl. Running this +# helper attributes the lock to the current session. +# +# Usage: scope-lock-claim +# +# Verifies: +# 1. The plan is in "**Status:** Locked …" (line-start match). +# 2. A .scope-lock sidecar exists (no claim without an anchor). +# 3. tests/plan-scope-check.sh --verify-lock passes when present — +# claiming a drifted manifest is strictly worse than refusing. +# +# The actual session-locks.jsonl write is performed by pre-tool-scope-guard's +# record_session_lock recognizer (SESSION_LOCK_RECOGNIZED matches this script's +# bare name). This helper is read-only with respect to .scope-lock — re-running +# scope-lock-apply would silently overwrite the original author's hash, which +# defeats the lock. + +set -euo pipefail + +plan="${1:-}" + +if [ -z "$plan" ]; then + printf 'Usage: scope-lock-claim \n' >&2 + exit 3 +fi +if [ ! -f "$plan" ]; then + printf 'Error: plan file not found: %s\n' "$plan" >&2 + exit 1 +fi +if ! grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan"; then + printf 'Error: plan is not in Locked status: %s\n' "$plan" >&2 + exit 1 +fi +lock_file="${plan}.scope-lock" +if [ ! -f "$lock_file" ]; then + printf 'Error: no .scope-lock sidecar for %s — nothing to claim\n' "$plan" >&2 + exit 1 +fi + +# Drift check (only when the checker is available; absence is not a failure). +for d in "$(dirname "$plan")/../../tests" "$(dirname "$plan")/../tests" "./tests"; do + candidate="${d}/plan-scope-check.sh" + if [ -x "$candidate" ]; then + if ! bash "$candidate" --verify-lock "$plan" >/dev/null 2>&1; then + printf 'Error: manifest drift detected for %s — refusing claim (run scope-lock amendment path)\n' "$plan" >&2 + exit 1 + fi + break + fi +done + +# Sentinel token for pre-tool-scope-guard's record_session_lock recognizer. +printf 'scope-lock-claim: attributing %s to current session\n' "$plan" +exit 0 +``` + +`chmod +x hooks/scope-lock-claim`. + +**Step 5: Re-run all four tests.** Expect 4 × PASS. + +**Step 6: Commit.** + +```bash +git add hooks/scope-lock-claim hooks/pre-tool-scope-guard tests/hook-contracts.sh +git commit -m "feat(scope-lock): add scope-lock-claim helper for session re-attribution" +``` + +--- + +### Task 7: `hooks/scope-lock-abandon` helper + +**Files:** +- Create: `hooks/scope-lock-abandon` (chmod +x) +- Modify: `tests/hook-contracts.sh` + +**Step 1: Write the failing tests.** + +```bash +test_scope_lock_abandon_flips_status_and_prunes_state() { + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" "$tmp/.autodev/state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + jq -nc --arg s "session.jsonl" --arg pl "docs/plans/p.md" \ + '{ev:"session-lock",session:$s,pl:$pl}' \ + > "$tmp/.claude/autodev-state/session-locks.jsonl" + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" --reason "user pivoted" >/dev/null ) + if ! grep -qE '^\*\*Status:\*\*[[:space:]]+Abandoned' "$tmp/docs/plans/p.md"; then + fail "scope-lock-abandon: status not flipped to Abandoned" + return + fi + if ! grep -q 'user pivoted' "$tmp/docs/plans/p.md"; then + fail "scope-lock-abandon: reason missing from status line" + return + fi + if [ -e "$tmp/docs/plans/p.md.scope-lock" ]; then + fail "scope-lock-abandon: .scope-lock not removed" + return + fi + if [ -s "$tmp/.claude/autodev-state/session-locks.jsonl" ] \ + && jq -e --arg pl "docs/plans/p.md" 'select(.pl==$pl)' \ + "$tmp/.claude/autodev-state/session-locks.jsonl" >/dev/null 2>&1; then + fail "scope-lock-abandon: session-lock row not pruned" + return + fi + if ! jq -e 'select(.ev=="plan" and .st=="abandoned" and .reason=="user pivoted")' \ + "$tmp/.autodev/state/phase-progress.jsonl" >/dev/null 2>&1; then + fail "scope-lock-abandon: phase-progress row missing or malformed" + return + fi + pass "scope-lock-abandon: flips status, removes lock, prunes session-locks, appends phase-progress" +} + +test_scope_lock_abandon_requires_reason() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-abandon: accepted missing --reason"; return; } + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" --reason "" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-abandon: accepted empty --reason"; return; } + pass "scope-lock-abandon: requires non-empty --reason" +} + +test_scope_lock_abandon_sanitizes_reason() { + local tmp line + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/.autodev/state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" \ + --reason $'multi\nline\twith\ttabs and **bold** text' >/dev/null ) + line=$(grep -E '^\*\*Status:\*\*[[:space:]]+Abandoned' "$tmp/docs/plans/p.md") + if [ "$(printf '%s' "$line" | wc -l | awk '{print $1}')" -ne 0 ]; then + fail "scope-lock-abandon: status spans multiple lines" + return + fi + if printf '%s' "$line" | grep -q '\*\*bold\*\*'; then + fail "scope-lock-abandon: did not neutralize ** in reason" + return + fi + pass "scope-lock-abandon: sanitizes multi-line reason and neutralizes **" +} + +test_scope_lock_abandon_refuses_unlocked() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/draft.md" --reason "test" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-abandon: accepted non-Locked plan"; return; } + pass "scope-lock-abandon: refuses non-Locked plan" +} +``` + +**Step 2: Run.** Expect 4 × FAIL. + +**Step 3: Create `hooks/scope-lock-abandon`.** + +```bash +#!/usr/bin/env bash +# hooks/scope-lock-abandon +# Abandon a locked plan that will not be completed. +# +# Sibling to hooks/scope-lock-complete (ADR 0001). Distinct from complete: +# - Does NOT verify the manifest hash. +# - Status flips to "Abandoned ". +# - Requires --reason (non-empty); sanitized to single line, capped at 200 +# chars, with literal "**" replaced by "__". +# - Appends phase-progress.jsonl with st:"abandoned" + reason field. +# - Does NOT write an ADR. +# +# Usage: +# scope-lock-abandon --reason "" + +set -euo pipefail + +[ "${SUPERPOWERS_HOOKS_DISABLE:-}" = "1" ] && exit 0 +command -v jq >/dev/null 2>&1 || { + printf 'scope-lock-abandon: jq is required for state cleanup\n' >&2 + exit 2 +} + +plan="${1:-}" +[ -n "$plan" ] || { + printf 'scope-lock-abandon: missing plan path\n' >&2 + exit 2 +} +shift || true + +reason="" +while [ "$#" -gt 0 ]; do + case "$1" in + --reason) + shift + reason="${1:-}" + if [ -z "$reason" ] || [ "${reason#--}" != "$reason" ]; then + printf 'scope-lock-abandon: --reason requires a non-empty value\n' >&2 + exit 2 + fi + ;; + *) + printf 'scope-lock-abandon: unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac + shift || true +done + +[ -n "$reason" ] || { printf 'scope-lock-abandon: --reason is required\n' >&2; exit 2; } + +sanitized_reason=$(printf '%s' "$reason" | tr -s '\n\t ' ' ' | sed 's/\*\*/__/g' | cut -c1-200) + +canonical_path_from_base() { + local base="$1" ref="$2" 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-abandon: unable to resolve plan path: %s\n' "$plan" >&2; exit 2; } +[ -f "$plan_abs" ] || { printf 'scope-lock-abandon: plan not found: %s\n' "$plan_abs" >&2; exit 2; } +grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan_abs" || { + printf 'scope-lock-abandon: plan is not in Locked status: %s\n' "$plan_abs" >&2; exit 2; } + +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" + +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" + +ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +plan_tmp=$(mktemp "${plan_abs}.abandon.XXXXXX") +trap 'rm -f "$plan_tmp"' EXIT + +awk -v ts="$ts" -v r="$sanitized_reason" ' + !done && /^\*\*Status:\*\*[[:space:]]+Locked/ { + print "**Status:** Abandoned " ts " — " r + done = 1 + next + } + { print } +' "$plan_abs" > "$plan_tmp" + +prune_jsonl() { + local file="$1" tmp + [ -f "$file" ] || return 0 + tmp=$(mktemp "${file}.abandon.XXXXXX") + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + pl=$(printf '%s' "$line" | jq -r '.pl // empty' 2>/dev/null || true) || { rm -f "$tmp"; return 1; } + 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" + mv "$tmp" "$file" +} + +mkdir -p "$progress_dir" +mv "$plan_tmp" "$plan_abs" +trap - EXIT +rm -f "$lock_file" +prune_jsonl "$session_locks_file" || true +prune_jsonl "$in_progress_file" || true +jq -nc \ + --arg ts "$ts" --arg pl "$plan_name" --arg r "$sanitized_reason" \ + '{ts:$ts,ev:"plan",pl:$pl,st:"abandoned",reason:$r}' \ + >> "$progress_file" + +printf 'scope-lock-abandon: abandoned %s (reason: %s)\n' "$plan_rel" "$sanitized_reason" +``` + +`chmod +x hooks/scope-lock-abandon`. + +**Step 4: Re-run all four tests.** Expect 4 × PASS. + +**Step 5: Commit.** + +```bash +git add hooks/scope-lock-abandon tests/hook-contracts.sh +git commit -m "feat(scope-lock): add scope-lock-abandon helper for stopping work without completion" +``` + +--- + +### Task 8: End-to-end claim → nag and abandon → silence tests + +**Files:** +- Modify: `tests/hook-contracts.sh` + +**Step 1: Write the tests.** + +```bash +test_e2e_claim_then_nag_includes_plan() { + local tmp transcript record_payload nag_output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/active.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + printf '%s' "$record_payload" | run_hook pre-tool-scope-guard >/dev/null 2>&1 || true + nag_output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead and create a PR","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if ! printf '%s' "$nag_output" | jq -e '.hookSpecificOutput.additionalContext | contains("active.md")' >/dev/null; then + fail "e2e claim→nag: nag did not include claimed plan, output: ${nag_output}" + return + fi + pass "e2e: claim → next prompt nag references the claimed plan" +} + +test_e2e_abandon_then_no_nag() { + local tmp transcript record_payload nag_output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" "$tmp/.autodev/state" + emit_locked_fixture "$tmp/docs/plans/stale.md" "stale" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/stale.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + printf '%s' "$record_payload" | run_hook pre-tool-scope-guard >/dev/null 2>&1 || true + nag_output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + printf '%s' "$nag_output" | jq -e '.hookSpecificOutput.additionalContext | contains("stale.md")' >/dev/null \ + || { fail "e2e abandon: precondition (nag after claim) not met"; return; } + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/stale.md" --reason "test abandon" >/dev/null ) + nag_output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$nag_output" ]; then + fail "e2e abandon: nag still fires after abandon, output: ${nag_output}" + return + fi + pass "e2e: abandon → next prompt is silent" +} + +test_e2e_fresh_session_no_claim_no_nag() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/fresh.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/foo.md" "foo" + # No claim, no session-locks row. Single workspace lock — pre-fix would fall back. + output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "e2e fresh session: workspace fallback still fires, output: ${output}" + return + fi + pass "e2e: fresh session with no claim does not nag on workspace-only locks" +} +``` + +**Step 2: Run.** Expect 3 × PASS (depends on Tasks 1-7 already merged into the running file). + +**Step 3: Commit.** + +```bash +git add tests/hook-contracts.sh +git commit -m "test: end-to-end claim→nag and abandon→silence cycles" +``` + +--- + +### Task 9: SKILL.md documentation + final test pass + +**Files:** +- Modify: `skills/scope-lock/SKILL.md` + +**Step 1: Update the skill** by adding two sections after `## Completing a Locked Plan`: + +```markdown +## Claiming an Existing Lock (resume after restart) + +When a session is interrupted (computer restart, host crash, accidental +`/clear`) and a fresh session needs to resume work on an already-locked plan, +the new session must explicitly **claim** the lock for itself. The nag hooks +only fire for plans attributed to the current session via +`.claude/autodev-state/session-locks.jsonl`; that attribution is per-session +and does not survive across sessions. + +`​`​`bash +bash "${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-claim" docs/plans/.md +`​`​` + +The helper verifies the plan is Locked, has a `.scope-lock` sidecar, and that +the manifest hash still matches (drift is rejected — use the amendment path). +The session-attribution row is appended to `session-locks.jsonl` by +`hooks/pre-tool-scope-guard`'s `record_session_lock` recognizer — the same +mechanism that writes the row at `scope-lock-apply` time. Idempotent. + +## Abandoning a Lock (stopped pursuing) + +When work on a locked plan will not complete (user pivoted, design superseded, +out of capacity), close the lock as Abandoned rather than leaving stale state: + +`​`​`bash +bash "${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-abandon" docs/plans/.md \ + --reason "user pivoted away from feature X" +`​`​` + +Differs from `scope-lock-complete`: + +- Does NOT verify the manifest hash. Drift is expected. +- Flips `Status:` to `Abandoned `. +- Requires `--reason` (non-empty); sanitized to single line, capped at 200 + chars, with `**` replaced by `__`. +- Appends `phase-progress.jsonl` with `st:"abandoned"` + reason. +- Does NOT write an ADR. + +Abandoned plans are not auto-revivable. To restart abandoned work, edit the +status line back to Locked by hand and re-run `scope-lock-apply`; the original +lock hash is unrecoverable. +``` + +(Replace `​` with nothing — the zero-width joiners above prevent the rendered +plan-doc parser from confusing nested fences with this plan's own fences.) + +Update the `## Lock state machine` diagram to add an `Abandoned` terminal state alongside `Complete`. In `## Integration` → **Reads/Writes**, add `hooks/scope-lock-claim`, `hooks/scope-lock-abandon`, and note that `pre-tool-scope-guard`'s `SESSION_LOCK_RECOGNIZED` variable is the source of truth for which helper names update session-lock state. + +**Step 2: Sanity-check the SKILL.md additions.** + +Run: `grep -q '^## Claiming an Existing Lock' skills/scope-lock/SKILL.md && grep -q '^## Abandoning a Lock' skills/scope-lock/SKILL.md && echo OK` +Expected: `OK` + +**Step 3: Run the full test suite.** + +Run: `bash tests/hook-contracts.sh 2>&1 | tee /tmp/hook-contracts.out; grep -c '^FAIL:' /tmp/hook-contracts.out` +Expected: PASS lines for every test (existing + new), 0 FAIL. + +Run: `bash tests/plan-scope-check.sh --plan docs/plans/2026-05-26-session-scoped-lock-nag.md` +Expected: exit 0, no output (or only PASS lines). + +**Step 4: Commit.** + +```bash +git add skills/scope-lock/SKILL.md +git commit -m "docs(scope-lock): document scope-lock-claim and scope-lock-abandon" +``` + +--- + +## Verification summary + +| Task | Class | Verification | +|------|-------|-------------| +| 1-5 | Hook / trigger / event handler | per-test grep against `bash tests/hook-contracts.sh`; all PASS | +| 6-7 | Hook / trigger / event handler | same; helper exits 0 with sentinel token in stdout | +| 8 | Multi-component boundary | end-to-end triplet (claim→nag, abandon→silence, fresh→silent) | +| 9 | Documentation / comments | grep anchor + full suite green | + +No tasks trigger `runtime-launch-validation` (no build/deploy/migration/startup-config changes). No per-task rollback notes required. Plugin rollback is by revert + bump (design `## Rollback`). diff --git a/docs/plans/2026-05-26-session-scoped-lock-nag.md.scope-lock b/docs/plans/2026-05-26-session-scoped-lock-nag.md.scope-lock new file mode 100644 index 0000000..ee7dec9 --- /dev/null +++ b/docs/plans/2026-05-26-session-scoped-lock-nag.md.scope-lock @@ -0,0 +1 @@ +6219048389f724f032a59323e12d952c43839488dc1041ffd45c54b57a714e9c diff --git a/hooks/completion-claim-guard b/hooks/completion-claim-guard index b82f1d9..ae605e2 100755 --- a/hooks/completion-claim-guard +++ b/hooks/completion-claim-guard @@ -68,7 +68,7 @@ find_locked_plans() { | sort \ | while IFS= read -r plan; do [ -n "$plan" ] || continue - grep -q '\*\*Status:\*\* Locked' "$plan" 2>/dev/null || continue + grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan" 2>/dev/null || continue printf '%s\n' "$plan" done } @@ -93,15 +93,7 @@ find_locked_plans() { } 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 + session_locked_plans return 0 fi diff --git a/hooks/pre-compact-snapshot b/hooks/pre-compact-snapshot index 063c8e7..a5284e2 100755 --- a/hooks/pre-compact-snapshot +++ b/hooks/pre-compact-snapshot @@ -41,7 +41,7 @@ if [ -d "$plans_dir" ]; then | sort \ | while IFS= read -r plan; do [ -n "$plan" ] || continue - grep -q '\*\*Status:\*\* Locked' "$plan" 2>/dev/null || continue + grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan" 2>/dev/null || continue printf '%s\n' "$plan" done } @@ -67,15 +67,7 @@ if [ -d "$plans_dir" ]; then locked_plan_stream() { 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 + session_locked_plans return 0 fi diff --git a/hooks/pre-tool-scope-guard b/hooks/pre-tool-scope-guard index 4cf2f57..f170efe 100755 --- a/hooks/pre-tool-scope-guard +++ b/hooks/pre-tool-scope-guard @@ -48,20 +48,39 @@ block() { exit 2 } +# Recognized helper script names that update session-lock state. Pattern-matched +# against Bash tool commands by record_session_lock so each helper script never +# needs to know the current session_key itself. +# +# Helpers MUST emit their bare name on stdout so a future maintainer can audit +# which Bash invocations matter from either end. See: +# hooks/scope-lock-apply, hooks/scope-lock-claim +SESSION_LOCK_RECOGNIZED='scope-lock-apply|scope-lock-claim' + record_session_lock() { local cmd="$1" [ -n "$session_key" ] || return 0 - printf '%s' "$cmd" | grep -q 'scope-lock-apply' || return 0 + printf '%s' "$cmd" | grep -qE "(${SESSION_LOCK_RECOGNIZED})" || return 0 local plan_arg="" plan_arg=$(printf '%s' "$cmd" \ - | sed -nE 's/.*scope-lock-apply[[:space:]]+"?([^" ;]+)"?.*/\1/p' \ + | sed -nE "s/.*(${SESSION_LOCK_RECOGNIZED})[[:space:]]+\"?([^\" ;]+)\"?.*/\2/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" + + # Dedupe: skip if the (session, plan) row already exists. + if [ -f "$state_file" ]; then + if jq -e --arg s "$session_key" --arg pl "$plan_arg" \ + 'select(.ev=="session-lock" and .session==$s and .pl==$pl)' \ + "$state_file" >/dev/null 2>&1; then + return 0 + fi + fi + local ts ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") jq -nc \ @@ -92,13 +111,13 @@ find_locked_plans() { *) resolved="${cwd_dir}/${plan}" ;; esac [ -f "$resolved" ] || continue - grep -q '\*\*Status:\*\* Locked' "$resolved" 2>/dev/null || continue + grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$resolved" 2>/dev/null || continue printf '%s\n' "$resolved" done return 0 fi - grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \ + grep -rlE '^\*\*Status:\*\*[[:space:]]+Locked' "$plans_dir" 2>/dev/null \ | grep '\.md$' | grep -v '\.scope-lock' || true } diff --git a/hooks/prompt-strict-interpretation b/hooks/prompt-strict-interpretation index 10b0e4c..9a4c916 100755 --- a/hooks/prompt-strict-interpretation +++ b/hooks/prompt-strict-interpretation @@ -102,7 +102,7 @@ if [ -d "$plans_dir" ]; then | sort \ | while IFS= read -r plan; do [ -n "$plan" ] || continue - grep -q '\*\*Status:\*\* Locked' "$plan" 2>/dev/null || continue + grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan" 2>/dev/null || continue printf '%s\n' "$plan" done } @@ -128,14 +128,7 @@ if [ -d "$plans_dir" ]; then if [ -n "$session_key" ]; then 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 + [ -n "$session_plans" ] && locked_plan=$(printf '%s\n' "$session_plans" | head -1) else locked_plan=$(workspace_locked_plans | head -1 || true) fi diff --git a/hooks/scope-lock-abandon b/hooks/scope-lock-abandon new file mode 100755 index 0000000..f0e09b1 --- /dev/null +++ b/hooks/scope-lock-abandon @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# hooks/scope-lock-abandon +# Abandon a locked plan that will not be completed. +# +# Sibling to hooks/scope-lock-complete (ADR 0001). Distinct from complete: +# - Does NOT verify the manifest hash. Drift is expected for abandoned work. +# - Status flips to "Abandoned - ". +# - Requires --reason (non-empty); sanitized to single line, capped at 200 +# chars, with literal "**" replaced by "__" so the markdown bold around +# the Status: prefix cannot be broken. +# - Appends phase-progress.jsonl with st:"abandoned" + reason field. +# - Does NOT write an ADR. +# +# Usage: +# scope-lock-abandon --reason "" + +set -euo pipefail + +[ "${SUPERPOWERS_HOOKS_DISABLE:-}" = "1" ] && exit 0 +command -v jq >/dev/null 2>&1 || { + printf 'scope-lock-abandon: jq is required for state cleanup\n' >&2 + exit 2 +} + +plan="${1:-}" +[ -n "$plan" ] || { + printf 'scope-lock-abandon: missing plan path\n' >&2 + exit 2 +} +shift || true + +reason="" +while [ "$#" -gt 0 ]; do + case "$1" in + --reason) + shift + reason="${1:-}" + if [ -z "$reason" ] || [ "${reason#--}" != "$reason" ]; then + printf 'scope-lock-abandon: --reason requires a non-empty value\n' >&2 + exit 2 + fi + ;; + *) + printf 'scope-lock-abandon: unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac + shift || true +done + +[ -n "$reason" ] || { printf 'scope-lock-abandon: --reason is required\n' >&2; exit 2; } + +# Sanitize: collapse all whitespace runs to single spaces, replace ** with __, +# truncate to 200 chars. +sanitized_reason=$(printf '%s' "$reason" | tr -s '\n\t ' ' ' | sed 's/\*\*/__/g' | cut -c1-200) + +canonical_path_from_base() { + local base="$1" ref="$2" 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-abandon: unable to resolve plan path: %s\n' "$plan" >&2; exit 2; } +[ -f "$plan_abs" ] || { printf 'scope-lock-abandon: plan not found: %s\n' "$plan_abs" >&2; exit 2; } +grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan_abs" || { + printf 'scope-lock-abandon: plan is not in Locked status: %s\n' "$plan_abs" >&2; exit 2; } + +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" + +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" + +ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +plan_tmp=$(mktemp "${plan_abs}.abandon.XXXXXX") +trap 'rm -f "$plan_tmp"' EXIT + +awk -v ts="$ts" -v r="$sanitized_reason" ' + !done && /^\*\*Status:\*\*[[:space:]]+Locked/ { + print "**Status:** Abandoned " ts " - " r + done = 1 + next + } + { print } +' "$plan_abs" > "$plan_tmp" + +prune_jsonl() { + local file="$1" tmp + [ -f "$file" ] || return 0 + tmp=$(mktemp "${file}.abandon.XXXXXX") + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + pl=$(printf '%s' "$line" | jq -r '.pl // empty' 2>/dev/null || true) || { rm -f "$tmp"; return 1; } + 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" + mv "$tmp" "$file" +} + +mkdir -p "$progress_dir" +mv "$plan_tmp" "$plan_abs" +trap - EXIT +rm -f "$lock_file" +prune_jsonl "$session_locks_file" || true +prune_jsonl "$in_progress_file" || true +jq -nc \ + --arg ts "$ts" --arg pl "$plan_name" --arg r "$sanitized_reason" \ + '{ts:$ts,ev:"plan",pl:$pl,st:"abandoned",reason:$r}' \ + >> "$progress_file" + +printf 'scope-lock-abandon: abandoned %s (reason: %s)\n' "$plan_rel" "$sanitized_reason" diff --git a/hooks/scope-lock-claim b/hooks/scope-lock-claim new file mode 100755 index 0000000..1948d97 --- /dev/null +++ b/hooks/scope-lock-claim @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# hooks/scope-lock-claim +# Attribute an existing locked plan to the current session so the locked-plan +# nag hooks (prompt-strict-interpretation, pre-compact-snapshot, +# pre-tool-scope-guard, subagent-scope-guard, completion-claim-guard) fire for +# this session. +# +# Use case: an agent session was interrupted (computer restart, host crash, +# accidental /clear) and a fresh session needs to resume the same work. The +# .scope-lock file is still on disk, but the new session is not in +# .claude/autodev-state/session-locks.jsonl. Running this helper attributes the +# lock to the current session. +# +# Usage: scope-lock-claim +# +# Verifies: +# 1. The plan exists and has a line-start "**Status:** Locked ...". +# 2. A .scope-lock sidecar exists (no claim without an anchor). +# 3. tests/plan-scope-check.sh --verify-lock passes when available - +# claiming a drifted manifest is strictly worse than refusing. +# +# The actual session-locks.jsonl write is performed by +# hooks/pre-tool-scope-guard's record_session_lock, which intercepts this Bash +# invocation and recognizes the helper name in SESSION_LOCK_RECOGNIZED. This +# helper is read-only with respect to .scope-lock: re-running scope-lock-apply +# would silently overwrite the original author's hash, defeating the lock. + +set -euo pipefail + +plan="${1:-}" + +if [ -z "$plan" ]; then + printf 'Usage: scope-lock-claim \n' >&2 + exit 3 +fi +if [ ! -f "$plan" ]; then + printf 'Error: plan file not found: %s\n' "$plan" >&2 + exit 1 +fi +if ! grep -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$plan"; then + printf 'Error: plan is not in Locked status: %s\n' "$plan" >&2 + exit 1 +fi +lock_file="${plan}.scope-lock" +if [ ! -f "$lock_file" ]; then + printf 'Error: no .scope-lock sidecar for %s - nothing to claim\n' "$plan" >&2 + exit 1 +fi + +# Drift check (only when the checker is available; absence is not a failure). +plan_dir=$(cd "$(dirname "$plan")" 2>/dev/null && pwd -P) || plan_dir="" +if [ -n "$plan_dir" ]; then + for d in "${plan_dir}/../../tests" "${plan_dir}/../tests" "./tests"; do + candidate="${d}/plan-scope-check.sh" + if [ -x "$candidate" ]; then + if ! bash "$candidate" --verify-lock "$plan" >/dev/null 2>&1; then + printf 'Error: manifest drift detected for %s - refusing claim (run scope-lock amendment path)\n' "$plan" >&2 + exit 1 + fi + break + fi + done +fi + +# Sentinel token for pre-tool-scope-guard's record_session_lock recognizer. +# The helper's basename appears in the printed line so SESSION_LOCK_RECOGNIZED +# matches the Bash tool command "bash hooks/scope-lock-claim " even if +# this script's own stdout is captured by the caller. +printf 'scope-lock-claim: attributing %s to current session\n' "$plan" +exit 0 diff --git a/hooks/subagent-scope-guard b/hooks/subagent-scope-guard index 22e5190..5143423 100755 --- a/hooks/subagent-scope-guard +++ b/hooks/subagent-scope-guard @@ -25,6 +25,9 @@ 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") stop_hook_active=$(printf '%s' "$hook_input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") [ "$stop_hook_active" = "true" ] && exit 0 @@ -70,10 +73,40 @@ if command -v git >/dev/null 2>&1; then # Locked plan files may be edited for design backports or notes, but # their Scope Manifest hash must still match the lock file. + # Plans are filtered to the current session's attribution when the host + # exposes transcript_path; otherwise (host has no session identity) + # we fall back to workspace-wide scan. + find_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 -qE '^\*\*Status:\*\*[[:space:]]+Locked' "$resolved" 2>/dev/null || continue + printf '%s\n' "$resolved" + done + } + + find_workspace_locked_plans() { + grep -rlE '^\*\*Status:\*\*[[:space:]]+Locked' "${cwd_dir}/docs/plans" 2>/dev/null \ + | grep '\.md$' | grep -v '\.scope-lock' || true + } + checker="${cwd_dir}/tests/plan-scope-check.sh" if [ -x "$checker" ] && [ -d "${cwd_dir}/docs/plans" ]; then - locked_plans=$(grep -rl '\*\*Status:\*\* Locked' "${cwd_dir}/docs/plans" 2>/dev/null \ - | grep '\.md$' | grep -v '\.scope-lock' || true) + if [ -n "$session_key" ]; then + locked_plans=$(find_session_locked_plans) + else + locked_plans=$(find_workspace_locked_plans) + fi while IFS= read -r plan; do [ -z "$plan" ] && continue if ! bash "$checker" --verify-lock "$plan" >/dev/null 2>&1; then diff --git a/skills/scope-lock/SKILL.md b/skills/scope-lock/SKILL.md index a04f373..0b7cd55 100644 --- a/skills/scope-lock/SKILL.md +++ b/skills/scope-lock/SKILL.md @@ -82,6 +82,13 @@ backports and task notes outside the manifest do not change the lock hash. - **Complete**: the locked design is fully verified; `scope-lock-complete` verified the lock file, removed it, pruned reminder traces, and recorded completion evidence. +- **Abandoned**: work on the locked design was stopped without completion + (user pivoted, design superseded, out of capacity). `scope-lock-abandon` + flipped the status to `Abandoned `, removed the + `.scope-lock` file, pruned session-lock + in-progress traces across all + sessions, and appended a compact `st:"abandoned"` row to + `phase-progress.jsonl`. Abandoned plans are not auto-revivable; reviving + requires manual edit back to `Locked` plus a fresh `scope-lock-apply`. 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. @@ -185,6 +192,55 @@ 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. +## Claiming an Existing Lock (resume after restart) + +When a session is interrupted (computer restart, host crash, accidental +`/clear`) and a fresh session needs to resume work on an already-locked plan, +the new session must explicitly **claim** the lock for itself. The nag hooks +fire only for plans attributed to the current session via +`.claude/autodev-state/session-locks.jsonl`. That attribution is per-session +and does not survive across sessions — a fresh agent inherits no rows from +the killed one. + +```bash +bash "${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-claim" docs/plans/.md +``` + +The helper verifies the plan is Locked, has a `.scope-lock` sidecar, and that +the manifest hash still matches (drift is rejected — use the amendment path). +The session-attribution row is appended to `session-locks.jsonl` by +`hooks/pre-tool-scope-guard`'s `record_session_lock` recognizer — the same +mechanism that writes the row at `scope-lock-apply` time. The recognized +helper names are centralized in `pre-tool-scope-guard`'s +`SESSION_LOCK_RECOGNIZED` variable. Claim is idempotent — re-claiming the +same plan produces no duplicate row. + +## Abandoning a Lock (stopped pursuing) + +When work on a locked plan will not complete (user pivoted, design superseded, +out of capacity), close the lock as **Abandoned** rather than leaving stale +state: + +```bash +bash "${CLAUDE_PLUGIN_ROOT:-.}/hooks/scope-lock-abandon" docs/plans/.md \ + --reason "user pivoted away from feature X" +``` + +Differs from `scope-lock-complete`: + +- Does NOT verify the manifest hash. Drift is expected for abandoned work. +- Flips `Status:` to `Abandoned `. +- Requires `--reason` (non-empty); sanitized to a single line, capped at 200 + chars, with literal `**` replaced by `__` so the status line's markdown + bold cannot be broken. +- Appends `phase-progress.jsonl` with `st:"abandoned"` + the reason so retros + can distinguish abandoned work from completed work. +- Does NOT write an ADR. + +Abandoned plans are not auto-revivable. To restart abandoned work, edit the +status line back to `Locked YYYY-MM-DDTHH:MM:SSZ` by hand and re-run +`scope-lock-apply` — the original lock hash is unrecoverable. + ## Integration **Called by:** @@ -201,14 +257,25 @@ re-attach old plans to unrelated work. - `docs/plans/.md` — the plan and its manifest. - `docs/plans/.md.scope-lock` — the manifest hash recorded at lock time. - `git log --oneline ..HEAD` — actual commits to compare against the manifest. +- `.claude/autodev-state/session-locks.jsonl` — per-session lock attribution (nag scoping). **Writes:** -- `docs/plans/.md` — the `**Status:**` line, on lock, reduce, or complete. +- `docs/plans/.md` — the `**Status:**` line, on lock, reduce, complete, or abandon. - `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. +- `.claude/autodev-state/*.jsonl` — session lock traces, pruned on completion or abandonment. +- `.autodev/state/phase-progress.jsonl` — compact completion or abandonment row. - (via `recording-decisions`) `decisions/NNNN-scope-amendment-.md`. +**Helpers (all under `hooks/`):** +- `scope-lock-apply ` — write the `.scope-lock` hash file (called by `alignment-check`). +- `scope-lock-claim ` — attribute an existing lock to the current session (resume after restart). +- `scope-lock-complete --evidence ""` — close the lock as Complete. +- `scope-lock-abandon --reason ""` — close the lock as Abandoned (no completion verification). + +The set of helper names that update `session-locks.jsonl` is centralized in +`hooks/pre-tool-scope-guard`'s `SESSION_LOCK_RECOGNIZED` variable; helpers and +hook share that contract. + ## Why a separate skill `alignment-check` is "does this plan cover this design?" — a one-shot structural test at hand-off. `scope-lock` is "is the plan still being honored?" — a recurring runtime invariant. Keeping them separate keeps each skill's responsibility focused. Alignment runs once; the lock is checked at every checkpoint. diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index 8732bbb..da65a42 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -42,6 +42,29 @@ run_hook_wrapper() { hooks/run-hook.cmd "$hook" >"$stdout_file" 2>"$stderr_file" <<<"$payload" } +# emit_locked_fixture +# Writes a minimal-but-valid locked plan to , then runs +# hooks/scope-lock-apply against it from the repo root. The plan body is +# produced with printf (not a heredoc) so this file does not itself contain +# column-0 occurrences of "## Scope Manifest" or "**Status:** Locked" that +# would trip the project's own plan-scope-check.sh / nag hooks when run +# against this repo. +emit_locked_fixture() { + local path="$1" name="$2" + printf '# %s Plan\n\n%s\n\n**PR Count:** 1\n**Tasks:** 1\n**Out of scope:**\n- (none)\n\n**PR Grouping:**\n\n| PR # | Title | Tasks | Branch |\n|------|-------|-------|--------|\n| 1 | %s | Task 1 | feat/%s |\n\n%s\n\n### Task 1: %s\n' \ + "$name" "## Scope Manifest" "$name" "$name" "**Status:** Locked 2026-05-26T00:00:00Z" "$name" > "$path" + bash "$REPO_ROOT/hooks/scope-lock-apply" "$path" >/dev/null +} + +# emit_draft_fixture +# Same shape but Status is Draft; the body literally quotes the locked status +# string in prose so we can regression-test the anchored-grep fix. +emit_draft_fixture() { + local path="$1" name="$2" + printf '# %s Plan\n\n%s\n\n**PR Count:** 1\n**Tasks:** 1\n**Out of scope:**\n- (none)\n\n**PR Grouping:**\n\n| PR # | Title | Tasks | Branch |\n|------|-------|-------|--------|\n| 1 | %s | Task 1 | feat/%s |\n\n**Status:** Draft\n\nProse mention: %s 2026-05-26T00:00:00Z\n\n### Task 1: %s\n' \ + "$name" "## Scope Manifest" "$name" "$name" "**Status:** Locked" "$name" > "$path" +} + assert_hook_context_json() { local name="$1" local event="$2" @@ -167,43 +190,6 @@ PLAN 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() { local tmp transcript output tmp="$(mktemp -d)" @@ -773,45 +759,6 @@ PLAN 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: falls back to single workspace lock" -} - test_completion_uses_session_locked_plan_only() { local tmp transcript output tmp="$(mktemp -d)" @@ -972,6 +919,405 @@ PLAN pass "subagent-scope-guard: allows non-manifest locked plan backports" } +test_pretool_ignores_prose_mention_of_locked_status() { + 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" "$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_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git push origin feat/x"},"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}' \ + | run_hook pre-tool-scope-guard 2>&1 || true)" + if printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "pre-tool-scope-guard: prose mention of Locked status falsely matched, output: ${output}" + return + fi + pass "pre-tool-scope-guard: anchored grep ignores prose mention of Locked status" +} + +test_prompt_strict_ignores_prose_mention_of_locked_status() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead and create a PR","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "prompt-strict-interpretation: prose mention of Locked status triggered nag, output: ${output}" + return + fi + pass "prompt-strict-interpretation: anchored grep ignores prose mention of Locked status" +} + +test_pre_compact_ignores_prose_mention_of_locked_status() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "pre-compact-snapshot: prose mention of Locked status triggered snapshot, output: ${output}" + return + fi + pass "pre-compact-snapshot: anchored grep ignores prose mention of Locked status" +} + +test_completion_ignores_prose_mention_of_locked_status() { + 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" + cp "$REPO_ROOT/tests/plan-scope-check.sh" "$tmp/tests/plan-scope-check.sh" + chmod +x "$tmp/tests/plan-scope-check.sh" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false,"last_assistant_message":"Task complete."}' 2>&1 || true)" + if printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "completion-claim-guard: prose mention of Locked status falsely matched, output: ${output}" + return + fi + pass "completion-claim-guard: anchored grep ignores prose mention of Locked status" +} + +test_subagent_scope_guard_ignores_unattributed_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" + 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/unrelated.md" "unrelated" + # Drift the manifest so verify-lock would fail if invoked. + awk '/^\*\*Tasks:\*\* 1/ {print; print "**Drift:** yes"; next} {print}' \ + "$tmp/docs/plans/unrelated.md" > "$tmp/docs/plans/unrelated.md.tmp" \ + && mv "$tmp/docs/plans/unrelated.md.tmp" "$tmp/docs/plans/unrelated.md" + output="$(run_hook subagent-scope-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false}' 2>&1 || true)" + if printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "subagent-scope-guard: blocked stop for unattributed workspace lock, output: ${output}" + return + fi + pass "subagent-scope-guard: ignores unattributed workspace lock" +} + +test_subagent_scope_guard_blocks_attributed_drift() { + 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" "$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" + # Drift inside the manifest section so verify-lock fails. + 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" + output="$(run_hook subagent-scope-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false}' 2>&1 || true)" + if ! printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "subagent-scope-guard: did NOT block for attributed drift, output: ${output}" + return + fi + pass "subagent-scope-guard: blocks on attributed drift" +} + +test_prompt_strict_ignores_single_workspace_lock_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" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + output="$(run_hook prompt-strict-interpretation '{"prompt":"continue autonomously","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "prompt-strict-interpretation: single workspace lock falsely triggered fallback, output: ${output}" + return + fi + pass "prompt-strict-interpretation: no workspace fallback when session has no lock" +} + +test_pre_compact_ignores_single_workspace_lock_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" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + output="$(run_hook pre-compact-snapshot '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "pre-compact-snapshot: single workspace lock falsely triggered fallback, output: ${output}" + return + fi + pass "pre-compact-snapshot: no workspace fallback when session has no lock" +} + +test_completion_ignores_single_workspace_lock_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" + 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" + output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false,"last_assistant_message":"Task complete."}' 2>&1 || true)" + if printf '%s' "$output" | grep -q '"decision":"block"'; then + fail "completion-claim-guard: single workspace lock falsely triggered fallback, output: ${output}" + return + fi + pass "completion-claim-guard: no workspace fallback when session has no lock" +} + +test_scope_lock_claim_writes_session_attribution() { + local tmp transcript record_payload state_file + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/p.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + run_hook pre-tool-scope-guard "$record_payload" >/dev/null 2>&1 || true + state_file="$tmp/.claude/autodev-state/session-locks.jsonl" + if [ ! -s "$state_file" ]; then + fail "scope-lock-claim: pre-tool-scope-guard did not write session-locks.jsonl" + return + fi + if ! jq -e --arg s "session.jsonl" --arg pl "docs/plans/p.md" \ + 'select(.ev=="session-lock" and .session==$s and .pl==$pl)' "$state_file" >/dev/null; then + fail "scope-lock-claim: row missing for (session,plan), file: $(cat "$state_file")" + return + fi + pass "scope-lock-claim: recognized by pre-tool-scope-guard and writes session row" +} + +test_scope_lock_claim_writes_are_idempotent() { + local tmp transcript record_payload rowcount + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/p.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + for _ in 1 2 3; do + run_hook pre-tool-scope-guard "$record_payload" >/dev/null 2>&1 || true + done + rowcount=$(wc -l < "$tmp/.claude/autodev-state/session-locks.jsonl" | awk '{print $1}') + if [ "$rowcount" -ne 1 ]; then + fail "scope-lock-claim: expected 1 row after 3 invocations, got: $rowcount" + return + fi + pass "scope-lock-claim: dedupe keeps session-locks.jsonl at one row per (session, plan)" +} + +test_scope_lock_claim_rejects_unlocked_plan() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + set +e + bash "$REPO_ROOT/hooks/scope-lock-claim" "$tmp/docs/plans/draft.md" >/dev/null 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-claim: accepted unlocked plan"; return; } + pass "scope-lock-claim: rejects unlocked plan" +} + +test_scope_lock_claim_rejects_drift() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$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/p.md" "p" + awk '/^\*\*PR Count:\*\* 1/{print "**PR Count:** 2"; next} {print}' \ + "$tmp/docs/plans/p.md" > "$tmp/docs/plans/p.md.tmp" \ + && mv "$tmp/docs/plans/p.md.tmp" "$tmp/docs/plans/p.md" + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-claim" "docs/plans/p.md" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-claim: accepted drifted manifest"; return; } + pass "scope-lock-claim: rejects manifest drift" +} + +test_scope_lock_abandon_flips_status_and_prunes_state() { + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" "$tmp/.autodev/state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + jq -nc --arg s "session.jsonl" --arg pl "docs/plans/p.md" \ + '{ev:"session-lock",session:$s,pl:$pl}' \ + > "$tmp/.claude/autodev-state/session-locks.jsonl" + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" --reason "user pivoted" >/dev/null ) + if ! grep -qE '^\*\*Status:\*\*[[:space:]]+Abandoned' "$tmp/docs/plans/p.md"; then + fail "scope-lock-abandon: status not flipped to Abandoned" + return + fi + if ! grep -q 'user pivoted' "$tmp/docs/plans/p.md"; then + fail "scope-lock-abandon: reason missing from status line" + return + fi + if [ -e "$tmp/docs/plans/p.md.scope-lock" ]; then + fail "scope-lock-abandon: .scope-lock not removed" + return + fi + if [ -s "$tmp/.claude/autodev-state/session-locks.jsonl" ] \ + && jq -e --arg pl "docs/plans/p.md" 'select(.pl==$pl)' \ + "$tmp/.claude/autodev-state/session-locks.jsonl" >/dev/null 2>&1; then + fail "scope-lock-abandon: session-lock row not pruned" + return + fi + if ! jq -e 'select(.ev=="plan" and .st=="abandoned" and .reason=="user pivoted")' \ + "$tmp/.autodev/state/phase-progress.jsonl" >/dev/null 2>&1; then + fail "scope-lock-abandon: phase-progress row missing or malformed" + return + fi + pass "scope-lock-abandon: flips status, removes lock, prunes session-locks, appends phase-progress" +} + +test_scope_lock_abandon_requires_reason() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-abandon: accepted missing --reason"; return; } + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" --reason "" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-abandon: accepted empty --reason"; return; } + pass "scope-lock-abandon: requires non-empty --reason" +} + +test_scope_lock_abandon_sanitizes_reason() { + local tmp line + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" "$tmp/.autodev/state" + emit_locked_fixture "$tmp/docs/plans/p.md" "p" + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/p.md" \ + --reason $'multi\nline\twith\ttabs and **bold** text' >/dev/null ) + line=$(grep -E '^\*\*Status:\*\*[[:space:]]+Abandoned' "$tmp/docs/plans/p.md") + if [ "$(printf '%s' "$line" | wc -l | awk '{print $1}')" -ne 0 ]; then + fail "scope-lock-abandon: status spans multiple lines: ${line}" + return + fi + if printf '%s' "$line" | grep -q '\*\*bold\*\*'; then + fail "scope-lock-abandon: did not neutralize ** in reason: ${line}" + return + fi + pass "scope-lock-abandon: sanitizes multi-line reason and neutralizes **" +} + +test_scope_lock_abandon_refuses_unlocked() { + local tmp rc + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/docs/plans" + emit_draft_fixture "$tmp/docs/plans/draft.md" "draft" + set +e + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/draft.md" --reason "test" >/dev/null 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || { fail "scope-lock-abandon: accepted non-Locked plan"; return; } + pass "scope-lock-abandon: refuses non-Locked plan" +} + +test_e2e_claim_then_nag_includes_plan() { + local tmp transcript record_payload nag_output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/active.md" "active" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/active.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + run_hook pre-tool-scope-guard "$record_payload" >/dev/null 2>&1 || true + nag_output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead and create a PR","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if ! printf '%s' "$nag_output" | jq -e '.hookSpecificOutput.additionalContext | contains("active.md")' >/dev/null; then + fail "e2e claim→nag: nag did not include claimed plan, output: ${nag_output}" + return + fi + pass "e2e: claim → next prompt nag references the claimed plan" +} + +test_e2e_abandon_then_no_nag() { + local tmp transcript record_payload nag_output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/session.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" "$tmp/.autodev/state" + emit_locked_fixture "$tmp/docs/plans/stale.md" "stale" + record_payload=$(jq -nc \ + --arg cmd "bash hooks/scope-lock-claim docs/plans/stale.md" \ + --arg cwd "$tmp" --arg tp "$transcript" \ + '{tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,transcript_path:$tp}') + run_hook pre-tool-scope-guard "$record_payload" >/dev/null 2>&1 || true + nag_output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + printf '%s' "$nag_output" | jq -e '.hookSpecificOutput.additionalContext | contains("stale.md")' >/dev/null \ + || { fail "e2e abandon: precondition (nag after claim) not met"; return; } + ( cd "$tmp" && bash "$REPO_ROOT/hooks/scope-lock-abandon" "docs/plans/stale.md" --reason "test abandon" >/dev/null ) + nag_output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$nag_output" ]; then + fail "e2e abandon: nag still fires after abandon, output: ${nag_output}" + return + fi + pass "e2e: abandon → next prompt is silent" +} + +test_e2e_fresh_session_no_claim_no_nag() { + local tmp transcript output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + transcript="$tmp/fresh.jsonl" + touch "$transcript" + mkdir -p "$tmp/docs/plans" "$tmp/.claude/autodev-state" + emit_locked_fixture "$tmp/docs/plans/foo.md" "foo" + output="$(run_hook prompt-strict-interpretation '{"prompt":"go ahead","cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')" + if [ -n "$output" ]; then + fail "e2e fresh session: workspace fallback still fires, output: ${output}" + return + fi + pass "e2e: fresh session with no claim does not nag on workspace-only locks" +} + test_record_activity_compact_state() { local tmp tmp="$(mktemp -d)" @@ -1030,13 +1376,16 @@ test_wrapper_suppresses_unavailable_c_utf8_locale_noise test_prompt_strict_json test_prompt_strict_no_output_without_trigger test_prompt_strict_ignores_ambiguous_workspace_locks_when_session_has_no_lock -test_prompt_strict_falls_back_to_single_workspace_lock +test_prompt_strict_ignores_single_workspace_lock_when_session_has_no_lock test_prompt_strict_uses_session_locked_plan_only +test_prompt_strict_ignores_prose_mention_of_locked_status 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_pre_compact_ignores_prose_mention_of_locked_status +test_pre_compact_ignores_single_workspace_lock_when_session_has_no_lock 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 @@ -1045,12 +1394,27 @@ 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_pretool_ignores_prose_mention_of_locked_status test_completion_ignores_ambiguous_workspace_locks_when_session_has_no_lock -test_completion_falls_back_to_single_workspace_lock +test_completion_ignores_single_workspace_lock_when_session_has_no_lock test_completion_uses_session_locked_plan_only test_completion_allows_hard_blocker +test_completion_ignores_prose_mention_of_locked_status 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_scope_lock_claim_writes_session_attribution +test_scope_lock_claim_writes_are_idempotent +test_scope_lock_claim_rejects_unlocked_plan +test_scope_lock_claim_rejects_drift +test_scope_lock_abandon_flips_status_and_prunes_state +test_scope_lock_abandon_requires_reason +test_scope_lock_abandon_sanitizes_reason +test_scope_lock_abandon_refuses_unlocked +test_e2e_claim_then_nag_includes_plan +test_e2e_abandon_then_no_nag +test_e2e_fresh_session_no_claim_no_nag test_record_activity_compact_state test_skill_activation_audit_reads_compact_state