diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6af6130..f410585 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "autodev", "description": "Autonomous development workflow skills for coding agents", - "version": "6.5.5", + "version": "6.5.6", "source": "./", "author": { "name": "Jon Langevin", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f55d4c3..004094f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "autodev", "description": "Autonomous development workflow skills for coding agents: design, review, planning, execution, monitoring, and retrospectives", - "version": "6.5.5", + "version": "6.5.6", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 302e648..177165e 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "autodev", "displayName": "Autonomous Dev Kit", "description": "Autonomous development workflow skills for coding agents", - "version": "6.5.5", + "version": "6.5.6", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/.github/workflows/hooks-check.yml b/.github/workflows/hooks-check.yml index cee7beb..2a7b000 100644 --- a/.github/workflows/hooks-check.yml +++ b/.github/workflows/hooks-check.yml @@ -27,3 +27,5 @@ jobs: run: bash tests/hook-stdout-discipline.sh - name: Hook contract tests run: bash tests/hook-contracts.sh + - name: Subagent scope guard regression tests (#86) + run: bash hooks/subagent-scope-guard.test.sh diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 2374e7a..d36e2cd 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,13 @@ # Autonomous Dev Kit Release Notes +## v6.5.6 — 2026-06-22 + +Fix for issue #86: `subagent-scope-guard` false-positived on committed `.scope-lock` files and its block message instructed subagents to auto-revert them — producing spurious `git revert`/`reapply` commits from read-only subagents. + +- `hooks/subagent-scope-guard`: removed the unattributed "did HEAD's last commit touch a `.scope-lock`" check. A committed `.scope-lock` is always legitimate (`hooks/scope-lock-apply` is the only sanctioned writer; the Write/Edit/MultiEdit tools are guarded against `*.scope-lock` paths), so the check could only false-positive — on every subagent stopping in a repo whose HEAD carried a committed lock (the steady state mid-pipeline). Manifest drift is still enforced by the uncommitted working-tree check (in-flight writes) and the push/PR + Stop hash guards (committed drift). +- `hooks/subagent-scope-guard`: the block message no longer instructs `git revert` (the instruction that made compliant subagents produce spurious commits). It now tells the subagent to run no git command, surface the finding to the lead, and notes that pre-existing committed locks need no action. +- `hooks/subagent-scope-guard.test.sh` (new): regression coverage — committed lock at HEAD does NOT block; uncommitted working-tree write DOES block with a non-destructive message; clean repo passes. + ## v6.5.5 — 2026-06-22 Projects-layer alignment: the `project-design-guidance` skill now also consults the projects tracker (`docs/PROJECTS.md`) for current initiative focus + phase. diff --git a/hooks/subagent-scope-guard b/hooks/subagent-scope-guard index 0f04704..5b21d4d 100755 --- a/hooks/subagent-scope-guard +++ b/hooks/subagent-scope-guard @@ -63,18 +63,17 @@ if command -v git >/dev/null 2>&1; then done <<< "$scope_lock_dirty" fi - # Last commit: did it touch scope-lock files? - if git rev-parse --verify HEAD >/dev/null 2>&1; then - last_commit_files=$(git diff-tree --no-commit-id -r --name-only HEAD 2>/dev/null || true) - - lock_in_commit=$(printf '%s' "$last_commit_files" | grep '\.scope-lock$' || true) - if [ -n "$lock_in_commit" ]; then - while IFS= read -r f; do - violations="${violations} • Last commit modified scope-lock file: ${f}\n" - done <<< "$lock_in_commit" - fi - - fi + # NOTE: we intentionally do NOT inspect HEAD's last commit for .scope-lock + # files. A committed .scope-lock is always legitimate: hooks/scope-lock-apply + # is the only sanctioned writer (the Write/Edit/MultiEdit tools are guarded + # against *.scope-lock paths by pre-tool-scope-guard), so any committed lock + # was produced by the scope-lock skill or the operator. Checking HEAD's last + # commit attributes that commit to whichever subagent stops next, false- + # positives on every subsequent subagent, and the old block message then + # instructed the subagent to `git revert` the legitimate lock — producing + # spurious revert/reapply commits (autonomous-dev-kit#86). Manifest drift is + # still enforced: by the uncommitted working-tree check above for in-flight + # writes, and by the push/PR + Stop hash guards for committed drift. # Locked plan files may be edited for design backports or notes, but # their Scope Manifest hash must still match the lock file. @@ -126,17 +125,18 @@ fi [ -z "$violations" ] && exit 0 -block "Subagent stop blocked — protected files were modified: +block "Subagent stop blocked — protected file changes detected in the working tree: ${violations} -Subagents must not write .scope-lock files or change a Locked Scope Manifest -without the amendment path. Non-manifest design backports are allowed if the -manifest hash still verifies. - -Before this subagent's changes are accepted: - 1. Revert unauthorized .scope-lock or manifest changes. - 2. If a manifest change is genuinely needed, surface it to the lead agent and - go through the amendment path: recording-decisions → update manifest → - re-run alignment-check. +The working-tree check cannot attribute these changes to a specific actor or +turn — they may predate this subagent (e.g. an uncommitted lock left by an +earlier step). If so, no action is needed. +Do NOT run any git command (no revert/reset/checkout) and do NOT attempt to +'fix' this yourself — doing so has caused spurious revert/reapply commits in +the past (autonomous-dev-kit#86). Instead: + 1. Surface this finding to the lead agent in your final report and stop. + 2. The lead decides whether the change is legitimate or needs the amendment + path (recording-decisions → update manifest → re-run alignment-check → + re-run scope-lock-apply). See skills/scope-lock/SKILL.md for the amendment path." diff --git a/hooks/subagent-scope-guard.test.sh b/hooks/subagent-scope-guard.test.sh new file mode 100644 index 0000000..38314b8 --- /dev/null +++ b/hooks/subagent-scope-guard.test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# hooks/subagent-scope-guard.test.sh +# Lifecycle test for the subagent-scope-guard SubagentStop hook. +# +# Regression coverage for autonomous-dev-kit#86: +# (1) a COMMITTED .scope-lock at HEAD (legitimate; written by scope-lock-apply) +# must NOT trigger a block — the old code inspected HEAD's last commit and +# false-positived on every subsequent subagent, then instructed `git revert`. +# (2) an UNCOMMITTED working-tree .scope-lock write (a subagent directly writing +# a lock) MUST trigger a block — but the message must be NON-destructive: it +# must NOT instruct the subagent to revert/run git (the #86 hazard), and must +# tell it to surface to the lead instead. +# (3) a clean repo (no scope-lock changes) must NOT trigger a block. +# +# Run: bash hooks/subagent-scope-guard.test.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname -- "$0")/.." && pwd)" +HOOK="${REPO_ROOT}/hooks/subagent-scope-guard" + +export LC_ALL=C LANG=C LC_CTYPE=C +export SUPERPOWERS_HOOKS_DISABLE= # ensure the guard is active during the test + +failures=0 +fail() { printf 'FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); } +pass() { printf 'PASS: %s\n' "$*"; } + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + printf 'SKIP: jq not found; subagent-scope-guard test requires jq\n' + exit 0 + fi +} + +# run_hook — feeds a SubagentStop payload (stop_hook_active=false) on stdin. +run_hook() { + local cwd="$1" + jq -nc --arg cwd "$cwd" --arg tp "/tmp/fake-transcript-${RANDOM}.jsonl" \ + '{stop_hook_active:false, cwd:$cwd, transcript_path:$tp, tool_name:"Agent"}' \ + | env LC_ALL=C LANG=C LC_CTYPE=C "$HOOK" +} + +setup_repo() { + # Create a temp git repo with one ordinary committed file (clean baseline). + local d + d="$(mktemp -d)" + git -C "$d" init -q + git -C "$d" config user.email t@t.t + git -C "$d" config user.name t + printf 'hello\n' > "$d/README.md" + git -C "$d" add README.md + git -C "$d" commit -q -m init + printf '%s' "$d" +} + +require_jq + +# ── Test 1: committed .scope-lock at HEAD must NOT block (the #86 bug) ── +{ + repo="$(setup_repo)" + # simulate scope-lock-apply having written + committed the lock + mkdir -p "$repo/docs/plans" + printf 'manifest\n' > "$repo/docs/plans/x.plan.md" + printf 'abcdef0123456789\n' > "$repo/docs/plans/x.plan.md.scope-lock" + git -C "$repo" add docs/plans/x.plan.md docs/plans/x.plan.md.scope-lock + git -C "$repo" commit -q -m "lock scope" + out="$(run_hook "$repo" 2>/dev/null || true)" + if printf '%s' "$out" | jq -e '.decision=="block"' >/dev/null 2>&1; then + fail "Test1: committed lock at HEAD falsely blocked (the #86 bug): $(printf '%s' "$out" | jq -r .reason)" + else + pass "Test1: committed lock at HEAD does NOT block (regression fixed)" + fi + rm -rf "$repo" +} + +# ── Test 2: uncommitted working-tree .scope-lock write MUST block, non-destructively ── +{ + repo="$(setup_repo)" + # a subagent directly wrote a .scope-lock next to an already-tracked plan + # (the realistic case: the plan dir is tracked, so the new lock shows as a + # specific untracked file rather than a collapsed untracked dir). + mkdir -p "$repo/docs/plans" + printf '## Scope Manifest\n\n(locked content)\n' > "$repo/docs/plans/y.plan.md" + git -C "$repo" add docs/plans/y.plan.md + git -C "$repo" commit -q -m "add plan" + printf 'tampered\n' > "$repo/docs/plans/y.plan.md.scope-lock" + out="$(run_hook "$repo" 2>/dev/null || true)" + if ! printf '%s' "$out" | jq -e '.decision=="block"' >/dev/null 2>&1; then + fail "Test2: uncommitted .scope-lock write did NOT block (should be caught)" + else + pass "Test2: uncommitted .scope-lock write is blocked (legitimate catch)" + reason="$(printf '%s' "$out" | jq -r .reason)" + # Non-destructive: the OLD "Revert unauthorized" instruction must be GONE + if printf '%s' "$reason" | grep -qi 'Revert unauthorized'; then + fail "Test2: block message still instructs 'Revert unauthorized' (the #86 hazard)" + else + pass "Test2: block message is non-destructive (no 'Revert unauthorized' instruction)" + fi + # And it must tell the subagent to surface to the lead / run no git + if printf '%s' "$reason" | grep -qi 'Surface this finding to the lead'; then + pass "Test2: block message directs subagent to surface to the lead" + else + fail "Test2: block message does not direct subagent to surface to the lead" + fi + fi + rm -rf "$repo" +} + +# ── Test 3: clean repo must NOT block ── +{ + repo="$(setup_repo)" + out="$(run_hook "$repo" 2>/dev/null || true)" + if printf '%s' "$out" | jq -e '.decision=="block"' >/dev/null 2>&1; then + fail "Test3: clean repo falsely blocked" + else + pass "Test3: clean repo does NOT block" + fi + rm -rf "$repo" +} + +if [ "$failures" -gt 0 ]; then + printf '\n%d failure(s)\n' "$failures" >&2 + exit 1 +fi +printf '\nAll subagent-scope-guard tests passed.\n'