aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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"
+}