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
1 change: 1 addition & 0 deletions .autodev/state/phase-progress.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
221 changes: 221 additions & 0 deletions docs/plans/2026-05-26-session-scoped-lock-nag-design.md

Large diffs are not rendered by default.

1,031 changes: 1,031 additions & 0 deletions docs/plans/2026-05-26-session-scoped-lock-nag.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
6219048389f724f032a59323e12d952c43839488dc1041ffd45c54b57a714e9c
12 changes: 2 additions & 10 deletions hooks/completion-claim-guard
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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

Expand Down
12 changes: 2 additions & 10 deletions hooks/pre-compact-snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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

Expand Down
27 changes: 23 additions & 4 deletions hooks/pre-tool-scope-guard
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
}

Expand Down
11 changes: 2 additions & 9 deletions hooks/prompt-strict-interpretation
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions hooks/scope-lock-abandon
Original file line number Diff line number Diff line change
@@ -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 <UTC> - <reason>".
# - 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 <plan-path> --reason "<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"
70 changes: 70 additions & 0 deletions hooks/scope-lock-claim
Original file line number Diff line number Diff line change
@@ -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 <plan-path>
#
# 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 <plan-path>\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 <plan>" 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
37 changes: 35 additions & 2 deletions hooks/subagent-scope-guard
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading