Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"name": "autodev",
"description": "Autonomous development workflow skills for coding agents",
"version": "6.0.3",
"version": "6.0.4",
"source": "./",
"author": {
"name": "Jon Langevin",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "autodev",
"description": "Autonomous development workflow skills for coding agents: design, review, planning, execution, monitoring, and retrospectives",
"version": "6.0.3",
"version": "6.0.4",
"author": {
"name": "Jon Langevin",
"email": "jon@gocodealone.com"
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "autodev",
"displayName": "Autonomous Dev Kit",
"description": "Autonomous development workflow skills for coding agents",
"version": "6.0.3",
"version": "6.0.4",
"author": {
"name": "Jon Langevin",
"email": "jon@gocodealone.com"
Expand Down
14 changes: 14 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Autonomous Dev Kit Release Notes

## v6.0.4 (2026-05-26)

### Stop-hook feedback formatting

- Added an explicit separator after the locked plan filename in the stop-hook
completion checkpoint so hosts that flatten hook feedback do not display
`plan.mdBefore stopping`.
- Scoped stop-hook completion checks and pre-push lock verification to plans
locked by the current session when the host provides a transcript path,
avoiding cross-agent interference from unrelated locked plans in the same
workspace.
- Added a hook-contract regression that flattens the checkpoint reason and
verifies the filename remains separated from the next sentence.

## v6.0.3 (2026-05-26)

### Hook JSON reliability
Expand Down
33 changes: 30 additions & 3 deletions hooks/completion-claim-guard
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ stop_hook_active=$(printf '%s' "$hook_input" | jq -r '.stop_hook_active // false
[ "$stop_hook_active" = "true" ] && exit 0
last_assistant_message=$(printf '%s' "$hook_input" | jq -r '.last_assistant_message // .assistant_message // empty' 2>/dev/null || true)
transcript_path=$(printf '%s' "$hook_input" | jq -r '.transcript_path // empty' 2>/dev/null || true)
session_key=""
[ -n "$transcript_path" ] && session_key=$(basename "$transcript_path")
if [ -z "$last_assistant_message" ] && [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then
last_assistant_message=$(jq -rs '
map(select((.type // "") == "assistant")) |
Expand All @@ -58,8 +60,32 @@ block() {
plans_dir="${cwd_dir}/docs/plans"
[ -d "$plans_dir" ] || exit 0

locked_plans=$(grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \
| grep '\.md$' | grep -v '\.scope-lock' || true)
find_locked_plans() {
if [ -n "$session_key" ]; then
local state_file="${cwd_dir}/.claude/autodev-state/session-locks.jsonl"
[ -f "$state_file" ] || return 0
jq -r --arg session "$session_key" \
'select(.ev == "session-lock" and .session == $session) | .pl' \
"$state_file" 2>/dev/null \
| awk 'NF && !seen[$0]++' \
| while IFS= read -r plan; do
[ -n "$plan" ] || continue
case "$plan" in
/*) resolved="$plan" ;;
*) resolved="${cwd_dir}/${plan}" ;;
esac
[ -f "$resolved" ] || continue
grep -q '\*\*Status:\*\* Locked' "$resolved" 2>/dev/null || continue
printf '%s\n' "$resolved"
done
return 0
fi

grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \
| grep '\.md$' | grep -v '\.scope-lock' || true
}

locked_plans=$(find_locked_plans)

[ -z "$locked_plans" ] && exit 0 # no locked plans — nothing to guard

Expand Down Expand Up @@ -101,7 +127,8 @@ if [ -z "$failures" ]; then
first_plan=$(printf '%s\n' "$locked_plans" | head -1)
first_plan_name=$(basename "$first_plan")
progress_file="${cwd_dir}/.autodev/state/phase-progress.jsonl"
block "Completion checkpoint — locked plan still in effect: ${first_plan_name}
checkpoint_prefix="Completion checkpoint — locked plan still in effect: ${first_plan_name} "
block "${checkpoint_prefix}

Before stopping, decide whether this is:
1. A phase/task completion inside the locked design, or
Expand Down
63 changes: 57 additions & 6 deletions hooks/pre-tool-scope-guard
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ hook_input=$(cat || true)
tool_name=$(printf '%s' "$hook_input" | jq -r '.tool_name // empty' 2>/dev/null || true)
cwd_dir=$(printf '%s' "$hook_input" | jq -r '.cwd // empty' 2>/dev/null || true)
[ -z "$cwd_dir" ] && cwd_dir="${PWD}"
transcript_path=$(printf '%s' "$hook_input" | jq -r '.transcript_path // empty' 2>/dev/null || true)
session_key=""
[ -n "$transcript_path" ] && session_key=$(basename "$transcript_path")

# Output a block decision and exit 2 (the exit code Claude Code uses for blocks).
block() {
Expand All @@ -45,12 +48,58 @@ block() {
exit 2
}

# Return the path of the first locked plan found under docs/plans/, or empty.
find_locked_plan() {
record_session_lock() {
local cmd="$1"
[ -n "$session_key" ] || return 0
printf '%s' "$cmd" | grep -q 'scope-lock-apply' || return 0

local plan_arg=""
plan_arg=$(printf '%s' "$cmd" \
| sed -nE 's/.*scope-lock-apply[[:space:]]+"?([^" ;]+)"?.*/\1/p' \
| head -1 || true)
[ -n "$plan_arg" ] || return 0

local state_dir="${cwd_dir}/.claude/autodev-state"
mkdir -p "$state_dir" 2>/dev/null || return 0
local state_file="${state_dir}/session-locks.jsonl"
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq -nc \
--arg ts "$ts" \
--arg session "$session_key" \
--arg pl "$plan_arg" \
'{ts:$ts,ev:"session-lock",session:$session,pl:$pl}' \
>> "$state_file" 2>/dev/null || true
}

# Return locked plans relevant to this session. If the host does not provide a
# transcript path, fall back to the legacy workspace-wide scan.
find_locked_plans() {
local plans_dir="${cwd_dir}/docs/plans"
[ -d "$plans_dir" ] || return 0

if [ -n "$session_key" ]; then
local state_file="${cwd_dir}/.claude/autodev-state/session-locks.jsonl"
[ -f "$state_file" ] || return 0
jq -r --arg session "$session_key" \
'select(.ev == "session-lock" and .session == $session) | .pl' \
"$state_file" 2>/dev/null \
| awk 'NF && !seen[$0]++' \
| while IFS= read -r plan; do
[ -n "$plan" ] || continue
case "$plan" in
/*) resolved="$plan" ;;
*) resolved="${cwd_dir}/${plan}" ;;
esac
[ -f "$resolved" ] || continue
grep -q '\*\*Status:\*\* Locked' "$resolved" 2>/dev/null || continue
printf '%s\n' "$resolved"
done
return 0
fi

grep -rl '\*\*Status:\*\* Locked' "$plans_dir" 2>/dev/null \
| grep '\.md$' | grep -v '\.scope-lock' | head -1 || true
| grep '\.md$' | grep -v '\.scope-lock' || true
}

# Run plan-scope-check --verify-lock; returns 0 if hash matches.
Expand Down Expand Up @@ -88,6 +137,8 @@ case "$tool_name" in
# ── Global opt-out (operator-level only, checked after self-bypass guard) ──
[ "${SUPERPOWERS_HOOKS_DISABLE:-}" = "1" ] && exit 0

record_session_lock "$cmd"

# ── 1. Force push (always blocked) ──────────────────────────────────────
# Catches: --force, --force-with-lease, -f flag
# These overwrite remote refs and lose commits for anyone who already pulled.
Expand All @@ -106,12 +157,12 @@ case "$tool_name" in
# ── 3. push / PR creation while locked plan hash mismatches ─────────────
# Catches post-lock plan tampering before anything reaches the remote.
if printf '%s' "$cmd" | grep -qE '(git push|gh pr create|gh pr merge)'; then
locked_plan=$(find_locked_plan)
if [ -n "$locked_plan" ]; then
while IFS= read -r locked_plan; do
[ -n "$locked_plan" ] || continue
if ! verify_lock "$locked_plan"; then
block "Locked plan hash mismatch: $(basename "$locked_plan") — the Scope Manifest has been modified since it was locked. This means either the manifest was edited without the amendment path or the scope-lock file was corrupted. Resolve via the scope-lock amendment path (recording-decisions → update manifest → re-run alignment-check) before pushing or creating PRs."
fi
fi
done < <(find_locked_plans)
fi

# ── 4. Push/commit to default branch (blocked unless opt-out) ───────────
Expand Down
148 changes: 148 additions & 0 deletions tests/hook-contracts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,150 @@ PLAN
pass "completion-claim-guard: blocks phase completion and requests progress log"
}

test_completion_continuation_block_keeps_heading_separator_when_flattened() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
mkdir -p "$tmp/docs/plans" "$tmp/tests"
cat >"$tmp/docs/plans/example.md" <<'PLAN'
# Example Plan

## Scope Manifest

**PR Count:** 1
**Tasks:** 1
**Out of scope:**
- (none)

**PR Grouping:**

| PR # | Title | Tasks | Branch |
|------|-------|-------|--------|
| 1 | Example | Task 1 | feat/example |

**Status:** Locked 2026-05-25T00:00:00Z

### Task 1: Example
PLAN
cp tests/plan-scope-check.sh "$tmp/tests/plan-scope-check.sh"
chmod +x "$tmp/tests/plan-scope-check.sh"
bash hooks/scope-lock-apply "$tmp/docs/plans/example.md" >/dev/null

local output flat_reason
output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","stop_hook_active":false,"last_assistant_message":"Task 1 complete."}')"
flat_reason="$(printf '%s' "$output" | jq -r '.reason' | tr -d '\r\n')"

if ! printf '%s' "$flat_reason" | grep -q 'example.md Before stopping'; then
fail "completion-claim-guard: expected flattened checkpoint to keep separator before 'Before stopping', got: ${flat_reason}"
return
fi
pass "completion-claim-guard: flattened checkpoint keeps heading separator"
}

test_pretool_records_session_lock_for_scope_lock_apply() {
local tmp transcript output state_file
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
transcript="$tmp/session.jsonl"
touch "$transcript"

output="$(run_hook pre-tool-scope-guard '{"tool_name":"Bash","tool_input":{"command":"bash hooks/scope-lock-apply docs/plans/active.md"},"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}')"
if [ -n "$output" ]; then
fail "pre-tool-scope-guard: expected scope-lock recording to pass silently, got: ${output}"
return
fi

state_file="$tmp/.claude/autodev-state/session-locks.jsonl"
if ! jq -e 'select(.ev == "session-lock" and .session == "session.jsonl" and .pl == "docs/plans/active.md")' "$state_file" >/dev/null; then
fail "pre-tool-scope-guard: expected session lock row in ${state_file}"
return
fi
pass "pre-tool-scope-guard: records scope-lock plan for current session"
}

test_completion_ignores_unrelated_locked_plan_when_session_has_no_lock() {
local tmp transcript output
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
transcript="$tmp/session.jsonl"
touch "$transcript"
mkdir -p "$tmp/docs/plans" "$tmp/tests"
cat >"$tmp/docs/plans/unrelated.md" <<'PLAN'
# Unrelated Plan

## Scope Manifest

**PR Count:** 1
**Tasks:** 1
**Out of scope:**
- (none)

**PR Grouping:**

| PR # | Title | Tasks | Branch |
|------|-------|-------|--------|
| 1 | Unrelated | Task 1 | feat/unrelated |

**Status:** Locked 2026-05-25T00:00:00Z

### Task 1: Unrelated
PLAN
cp tests/plan-scope-check.sh "$tmp/tests/plan-scope-check.sh"
chmod +x "$tmp/tests/plan-scope-check.sh"
bash hooks/scope-lock-apply "$tmp/docs/plans/unrelated.md" >/dev/null

output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false,"last_assistant_message":"Task complete."}')"
if [ -n "$output" ]; then
fail "completion-claim-guard: expected unrelated workspace lock to be ignored for session, got: ${output}"
return
fi
pass "completion-claim-guard: ignores unrelated locked plans when session has no lock"
}

test_completion_uses_session_locked_plan_only() {
local tmp transcript output
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
transcript="$tmp/session.jsonl"
touch "$transcript"
mkdir -p "$tmp/docs/plans" "$tmp/tests"
for name in active unrelated; do
cat >"$tmp/docs/plans/${name}.md" <<PLAN
# ${name} Plan

## Scope Manifest

**PR Count:** 1
**Tasks:** 1
**Out of scope:**
- (none)

**PR Grouping:**

| PR # | Title | Tasks | Branch |
|------|-------|-------|--------|
| 1 | ${name} | Task 1 | feat/${name} |

**Status:** Locked 2026-05-25T00:00:00Z

### Task 1: ${name}
PLAN
done
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
bash hooks/scope-lock-apply "$tmp/docs/plans/unrelated.md" >/dev/null

run_hook pre-tool-scope-guard '{"tool_name":"Bash","tool_input":{"command":"bash hooks/scope-lock-apply docs/plans/active.md"},"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'"}' >/dev/null
output="$(run_hook completion-claim-guard '{"cwd":"'"$tmp"'","transcript_path":"'"$transcript"'","stop_hook_active":false,"last_assistant_message":"Task complete."}')"

if ! printf '%s' "$output" | jq -e '.decision == "block" and (.reason | contains("active.md")) and (.reason | contains("unrelated.md") | not)' >/dev/null; then
fail "completion-claim-guard: expected only the session locked plan to block, got: ${output}"
return
fi
pass "completion-claim-guard: uses only session locked plans"
}

test_completion_allows_hard_blocker() {
local tmp
tmp="$(mktemp -d)"
Expand Down Expand Up @@ -395,6 +539,10 @@ test_pretool_pr_review_json
test_posttool_pr_created_json
test_pre_compact_snapshot_json
test_completion_continuation_block
test_completion_continuation_block_keeps_heading_separator_when_flattened
test_pretool_records_session_lock_for_scope_lock_apply
test_completion_ignores_unrelated_locked_plan_when_session_has_no_lock
test_completion_uses_session_locked_plan_only
test_completion_allows_hard_blocker
test_pretool_allows_locked_plan_text_edit
test_subagent_allows_non_manifest_plan_backport
Expand Down
Loading