diff options
| -rw-r--r-- | .ai/workflows/wrap-it-up.org | 2 | ||||
| -rw-r--r-- | .claude/settings.json | 6 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/wrap-it-up.org | 2 | ||||
| -rwxr-xr-x | hooks/session-start-disarm.sh | 42 | ||||
| -rw-r--r-- | scripts/tests/ai-wrap-teardown-hook.bats | 52 |
5 files changed, 103 insertions, 1 deletions
diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org index 7d55a55..d212195 100644 --- a/.ai/workflows/wrap-it-up.org +++ b/.ai/workflows/wrap-it-up.org @@ -597,6 +597,8 @@ touch "/tmp/ai-wrap-teardown-$(basename "$PWD")" That is the whole step. Don't run any =tmux kill-session=, =emacsclient=, or buffer kill inline — the =Stop= hook reads the sentinel when this response ends and runs =cj/ai-term-quit=, which kills the =aiv-<project>= session (taking =claude= with it), kills the vterm buffer, and restores geometry. The basename of =$PWD= is the key the hook matches, so the sentinel names the session it tears down. +*The sentinel is session-scoped.* If certification fails, the =Stop= hook blocks and leaves the sentinel armed on purpose, so a wrap blocked by a dirty tree retries on a later stop without re-running this workflow. It does *not* survive the session: =session-start-disarm.sh= clears it at =SessionStart=, because a wrap that never certified is not a pending teardown once its session is gone. Before that hook existed, an uncertified sentinel sat armed indefinitely and fired in whatever session next reached a clean tree — work's 2026-07-27 11:37 wrap killed the 13:20 session mid-work, and archsetup's sat armed on a live terminal for two days. If teardown is still wanted in a new session, run this workflow again. + *** Shutdown mode Confirm commit + push succeeded, then evaluate the safety gate *before* committing to the shutdown — never power the box off out from under another live session: diff --git a/.claude/settings.json b/.claude/settings.json index 3585af4..5e1cfcc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -24,7 +24,7 @@ "hooks": [ { "type": "command", - "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Popup choice menus are disabled per interaction.md (No Popup Menus for Choices) — present options inline in chat as a numbered list and ask the user to reply with a number.\"}}'" + "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Popup choice menus are disabled per interaction.md (No Popup Menus for Choices) \u2014 present options inline in chat as a numbered list and ask the user to reply with a number.\"}}'" } ] } @@ -46,6 +46,10 @@ { "type": "command", "command": "~/.claude/hooks/session-title.sh" + }, + { + "type": "command", + "command": "~/.claude/hooks/session-start-disarm.sh" } ] }, diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org index 7d55a55..d212195 100644 --- a/claude-templates/.ai/workflows/wrap-it-up.org +++ b/claude-templates/.ai/workflows/wrap-it-up.org @@ -597,6 +597,8 @@ touch "/tmp/ai-wrap-teardown-$(basename "$PWD")" That is the whole step. Don't run any =tmux kill-session=, =emacsclient=, or buffer kill inline — the =Stop= hook reads the sentinel when this response ends and runs =cj/ai-term-quit=, which kills the =aiv-<project>= session (taking =claude= with it), kills the vterm buffer, and restores geometry. The basename of =$PWD= is the key the hook matches, so the sentinel names the session it tears down. +*The sentinel is session-scoped.* If certification fails, the =Stop= hook blocks and leaves the sentinel armed on purpose, so a wrap blocked by a dirty tree retries on a later stop without re-running this workflow. It does *not* survive the session: =session-start-disarm.sh= clears it at =SessionStart=, because a wrap that never certified is not a pending teardown once its session is gone. Before that hook existed, an uncertified sentinel sat armed indefinitely and fired in whatever session next reached a clean tree — work's 2026-07-27 11:37 wrap killed the 13:20 session mid-work, and archsetup's sat armed on a live terminal for two days. If teardown is still wanted in a new session, run this workflow again. + *** Shutdown mode Confirm commit + push succeeded, then evaluate the safety gate *before* committing to the shutdown — never power the box off out from under another live session: diff --git a/hooks/session-start-disarm.sh b/hooks/session-start-disarm.sh new file mode 100755 index 0000000..520d8c0 --- /dev/null +++ b/hooks/session-start-disarm.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# SessionStart: disarm any wrap sentinel left over from a previous session. +# +# wrap-it-up drops /tmp/ai-wrap-teardown-<project> (or -shutdown-) to ask the +# Stop hook to kill the tmux session once the wrap certifies clean. The Stop +# hook deliberately PRESERVES that sentinel when certification fails, so a wrap +# blocked by a dirty tree can retry on a later stop in the same session without +# the user re-running the workflow. +# +# Nothing bounded that retry to the session. A sentinel armed by a wrap that +# never certified survived indefinitely and fired in whatever session next +# happened to reach a clean tree: +# +# work, 2026-07-27. The 11:37 wrap requested teardown, failed certification +# on a dirty tree, and left the sentinel armed. A fresh session started at +# 13:20, committed twice during startup, went clean — and the next stop +# consumed the two-hour-old sentinel and killed the terminal mid-work. +# archsetup's had been armed for two days on a live attached session. +# +# A new session means the wrap that armed the sentinel is gone, so its pending +# teardown is meaningless: clear it. Within-session retry is untouched, because +# this only runs at session start. If the user still wants teardown, wrap-it-up +# re-arms it. +# +# Scoped to the current project's sentinels only — a concurrent session in +# another project keeps its own. +# +# Silent and exit 0 always. A SessionStart hook must never block a session from +# starting, and there is nothing here a user needs told. + +set -u + +payload="$(cat 2>/dev/null || true)" + +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" +[ -z "$cwd" ] && cwd="$PWD" +proj="$(basename "$cwd")" + +rm -f "/tmp/ai-wrap-teardown-${proj}" "/tmp/ai-wrap-shutdown-${proj}" + +exit 0 diff --git a/scripts/tests/ai-wrap-teardown-hook.bats b/scripts/tests/ai-wrap-teardown-hook.bats index 6e33cca..12ac941 100644 --- a/scripts/tests/ai-wrap-teardown-hook.bats +++ b/scripts/tests/ai-wrap-teardown-hook.bats @@ -7,6 +7,7 @@ setup() { REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" SCRIPT="$REPO_ROOT/hooks/ai-wrap-teardown.sh" + DISARM="$REPO_ROOT/hooks/session-start-disarm.sh" GATE="$REPO_ROOT/claude-templates/bin/git-worktree-gate" TMPDIR_T="$(mktemp -d)" PROJ="proj-$$-$BATS_TEST_NUMBER" # unique so /tmp sentinels don't collide @@ -157,3 +158,54 @@ run_hook() { [ "$status" -eq 0 ] [ ! -f "$cert" ] } + +# --- Cross-session leakage (the 2026-07-27 work-session kill) --------------- +# +# A sentinel is deliberately preserved when certification fails, so a blocked +# wrap can retry within the same session once the tree is clean. But nothing +# bounded that to the session: the sentinel outlived it and detonated in +# whatever session next happened to have a clean tree. work's 11:37 wrap left +# one armed; the 13:20 session committed during startup, went clean, and was +# torn down mid-work. archsetup's had been armed for two days on a live +# terminal. +# +# A new session means the wrap that armed the sentinel is gone, so its pending +# teardown is moot. session-start-disarm.sh clears it. + +@test "session-start disarm: removes a teardown sentinel left by a prior session" { + : > "$TEARDOWN_SENTINEL" + printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" \ + | bash "$DISARM" + [ ! -f "$TEARDOWN_SENTINEL" ] +} + +@test "session-start disarm: removes a shutdown sentinel too" { + : > "$SHUTDOWN_SENTINEL" + printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" \ + | bash "$DISARM" + [ ! -f "$SHUTDOWN_SENTINEL" ] +} + +@test "session-start disarm: silent and exit 0 when nothing is armed" { + run bash -c "printf '{\"cwd\":\"$CWD\",\"hook_event_name\":\"SessionStart\"}' | bash '$DISARM'" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "session-start disarm: only clears this project, not another's" { + other="/tmp/ai-wrap-teardown-someotherproj" + : > "$TEARDOWN_SENTINEL" + : > "$other" + printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" | bash "$DISARM" + [ ! -f "$TEARDOWN_SENTINEL" ] + [ -f "$other" ] + rm -f "$other" +} + +@test "within-session retry still works: sentinel survives a blocked stop" { + : > "$TEARDOWN_SENTINEL" + echo dirt > "$CWD/untracked.txt" + run run_hook + [ -f "$TEARDOWN_SENTINEL" ] + rm -f "$CWD/untracked.txt" +} |
