aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-27 13:32:34 -0500
committerCraig Jennings <c@cjennings.net>2026-07-27 13:32:34 -0500
commit2c664cb5651fbf03bc52d7848add0c571708adcd (patch)
tree95d44306c84197affa6f98b645d623d2ca74cfce
parent79ed3b09a9ee2a63fb55d2354aa0c77ea24c6efa (diff)
downloadrulesets-2c664cb5651fbf03bc52d7848add0c571708adcd.tar.gz
rulesets-2c664cb5651fbf03bc52d7848add0c571708adcd.zip
fix(hooks): don't let a wrap sentinel outlive its session
wrap-it-up drops /tmp/ai-wrap-teardown-<project> to ask the Stop hook to kill the tmux session once the wrap certifies clean. When certification fails the Stop hook blocks and leaves the sentinel armed, which I did on purpose so a wrap blocked by a dirty tree can retry on a later stop without re-running the workflow. I never bounded that retry to the session. An uncertified sentinel sat armed indefinitely and fired in whatever session next reached a clean tree. work's 11:37 wrap today left one armed. The 13:20 session ran startup, committed the task filing and the template sync, went clean, and the next stop consumed the two-hour-old sentinel and killed the terminal mid-work. Every stop before those commits had been blocked by the same sentinel failing certification, so the session spent its whole life either blocked or dead. archsetup's had been armed since Saturday on a live attached terminal, and home's was armed and waiting. session-start-disarm.sh clears the project's sentinels at SessionStart. A new session means the wrap that armed one is gone, so its pending teardown is meaningless. Within-session retry is untouched, since the hook only runs at session start, and a test pins that. If teardown is still wanted, wrap-it-up re-arms it. I disarmed the three live ones by hand before writing this, backed up under /tmp/disarmed-sentinels. Four tests cover the disarm, one pins the retry behavior I did not want to lose. The scoping test matters most: a concurrent session in another project keeps its own sentinel.
-rw-r--r--.ai/workflows/wrap-it-up.org2
-rw-r--r--.claude/settings.json6
-rw-r--r--claude-templates/.ai/workflows/wrap-it-up.org2
-rwxr-xr-xhooks/session-start-disarm.sh42
-rw-r--r--scripts/tests/ai-wrap-teardown-hook.bats52
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"
+}