#!/usr/bin/env bats # hooks/ai-wrap-teardown.sh — Stop hook that tears down the ai-term session # (or powers off) after a wrap-up, gated on a sentinel wrap-it-up drops. On a # normal stop (no sentinel) it is a silent no-op. The emacsclient call is # stubbed here so the test records the elisp form without a live daemon. 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 CWD="$TMPDIR_T/$PROJ" mkdir -p "$CWD" git init -q "$CWD" git -C "$CWD" config user.email test@example.com git -C "$CWD" config user.name tester printf 'base\n' >"$CWD/tracked" git -C "$CWD" add tracked git -C "$CWD" commit -qm init bash "$GATE" certify "$CWD" TEARDOWN_SENTINEL="/tmp/ai-wrap-teardown-${PROJ}" SHUTDOWN_SENTINEL="/tmp/ai-wrap-shutdown-${PROJ}" # Stub emacsclient on PATH: record the elisp form it was called with. BIN="$TMPDIR_T/bin" mkdir -p "$BIN" EC_LOG="$TMPDIR_T/emacsclient.log" cat >"$BIN/emacsclient" < shift # drop -e printf '%s\n' "\$1" >> "$EC_LOG" EOF chmod +x "$BIN/emacsclient" } teardown() { rm -rf "$TMPDIR_T" rm -f "$TEARDOWN_SENTINEL" "$SHUTDOWN_SENTINEL" } run_hook() { # invoke with the stubbed emacsclient on PATH, feeding Stop-hook JSON printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT" } @test "no sentinel: silent no-op, emacsclient never called" { run run_hook [ "$status" -eq 0 ] [ -z "$output" ] [ ! -f "$EC_LOG" ] } @test "teardown sentinel: calls cj/ai-term-quit with the project basename" { : > "$TEARDOWN_SENTINEL" run run_hook [ "$status" -eq 0 ] grep -q "cj/ai-term-quit \"$PROJ\"" "$EC_LOG" } @test "teardown sentinel is removed after firing" { : > "$TEARDOWN_SENTINEL" run run_hook [ "$status" -eq 0 ] [ ! -f "$TEARDOWN_SENTINEL" ] } @test "shutdown sentinel: calls cj/ai-term-shutdown-countdown" { : > "$SHUTDOWN_SENTINEL" run run_hook [ "$status" -eq 0 ] grep -q "cj/ai-term-shutdown-countdown" "$EC_LOG" } @test "shutdown supersedes teardown when both sentinels exist" { : > "$TEARDOWN_SENTINEL" : > "$SHUTDOWN_SENTINEL" run run_hook [ "$status" -eq 0 ] grep -q "cj/ai-term-shutdown-countdown" "$EC_LOG" ! grep -q "cj/ai-term-quit" "$EC_LOG" [ ! -f "$TEARDOWN_SENTINEL" ] [ ! -f "$SHUTDOWN_SENTINEL" ] } @test "emacsclient absent: clears the sentinel and exits 0 (graceful)" { : > "$TEARDOWN_SENTINEL" status=0 output="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \ | GIT_WORKTREE_GATE="$GATE" PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$? [ "$status" -eq 0 ] [ ! -f "$TEARDOWN_SENTINEL" ] } @test "falls back to PWD basename when cwd is absent from JSON" { # No cwd key: hook uses $PWD. Run from CWD so basename resolves to PROJ. : > "$TEARDOWN_SENTINEL" run env "GIT_WORKTREE_GATE=$GATE" "PATH=$BIN:$PATH" \ bash -c "cd '$CWD' && printf '{}' | bash '$SCRIPT'" [ "$status" -eq 0 ] grep -q "cj/ai-term-quit \"$PROJ\"" "$EC_LOG" } @test "emits no stderr noise on a normal stop" { err="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT" 2>&1 >/dev/null)" [ -z "$err" ] } @test "dirty worktree blocks teardown, preserves sentinel, and names the path" { printf 'late\n' >>"$CWD/tracked" : >"$TEARDOWN_SENTINEL" run run_hook [ "$status" -eq 0 ] echo "$output" | jq -e '.decision == "block"' echo "$output" | jq -e '.reason | test("tracked")' [ -f "$TEARDOWN_SENTINEL" ] [ ! -f "$EC_LOG" ] } @test "changed HEAD after certification blocks teardown" { git -C "$CWD" commit -q --allow-empty -m later : >"$TEARDOWN_SENTINEL" run run_hook echo "$output" | jq -e '.reason | test("HEAD changed")' [ -f "$TEARDOWN_SENTINEL" ] [ ! -f "$EC_LOG" ] } @test "missing certificate blocks teardown" { rm -f "$(git -C "$CWD" rev-parse --absolute-git-dir)/ai-wrap-clean" : >"$TEARDOWN_SENTINEL" run run_hook echo "$output" | jq -e '.reason | test("no clean-tree certificate")' [ -f "$TEARDOWN_SENTINEL" ] } @test "Codex payload receives continue false and a stop reason" { printf 'late\n' >>"$CWD/tracked" : >"$TEARDOWN_SENTINEL" run bash -c \ "printf '{\"cwd\":\"$CWD\",\"hook_event_name\":\"Stop\",\"model\":\"gpt-test\"}' \ | GIT_WORKTREE_GATE='$GATE' PATH='$BIN:$PATH' bash '$SCRIPT'" [ "$status" -eq 0 ] echo "$output" | jq -e '.continue == false' echo "$output" | jq -e '.stopReason | test("tracked")' [ -f "$TEARDOWN_SENTINEL" ] } @test "successful teardown consumes the clean-tree certificate" { : >"$TEARDOWN_SENTINEL" cert="$(git -C "$CWD" rev-parse --absolute-git-dir)/ai-wrap-clean" [ -f "$cert" ] run 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" }