#!/usr/bin/env bash # Stop hook: tear down the ai-term session (or power off) AFTER a wrap-up. # # wrap-it-up.org drops a sentinel only at the very end of a teardown- or # shutdown-mode wrap, once commit+push is verified and the valediction is # delivered. This hook fires when Claude stops responding; on every NORMAL # stop there is no sentinel, so it is a silent no-op. Only after a wrap that # requested teardown does the matching sentinel exist, and only then does this # hook act — which is what keeps a routine end-of-turn from killing the # session. # # Decoupling via the Stop hook (rather than an inline workflow step) is what # lets the valediction flush before the session dies: teardown kills the very # tmux session Claude runs in, so it cannot happen inline. # # Two sentinels, keyed by the project basename (one ai-term session per # project, named aiv-): # /tmp/ai-wrap-teardown- -> cj/ai-term-quit "" # kill the aiv- tmux session (takes claude with it), kill the # vterm buffer, restore geometry. Defined in .emacs.d/modules/ai-term.el. # /tmp/ai-wrap-shutdown- -> cj/ai-term-shutdown-countdown # abort-able 10->1 echo-area countdown, then sudo shutdown now. Shutdown # supersedes teardown (killing the buffer is moot if powering off). # # The sentinel is removed BEFORE the emacsclient call: cj/ai-term-quit kills # the tmux session this hook runs inside, so the hook process may not survive # the call. Clearing first guarantees the sentinel never lingers to re-fire on # a later session in the same project. The emacs daemon is a separate process, # so it completes the teardown even when the hook is cut off mid-call. # # emacsclient absent or daemon unreachable: clear the sentinel and exit 0. The # session simply stays up — graceful degradation, never a wedge. # # Wire in ~/.claude/settings.json (see hooks/settings-snippet.json): # # "Stop": [ # { "hooks": [ # { "type": "command", # "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] } ] set -u payload="$(cat)" # Stop-hook stdin JSON carries cwd; basename it to the project / aiv- session. cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" [ -z "$cwd" ] && cwd="$PWD" proj="$(basename "$cwd")" teardown_sentinel="/tmp/ai-wrap-teardown-${proj}" shutdown_sentinel="/tmp/ai-wrap-shutdown-${proj}" fire() { # $1 = elisp form. Best-effort: only when emacsclient resolves. command -v emacsclient >/dev/null 2>&1 || return 0 emacsclient -e "$1" >/dev/null 2>&1 || true } # A sentinel means wrap-up claimed completion. Re-prove that claim immediately # before consuming it: the certificate binds a prior strict check to HEAD, and # verify also performs a fresh strict check to catch late writes. verify_wrap() { local gate="${GIT_WORKTREE_GATE:-}" detail if [ -z "$gate" ]; then gate="$(command -v git-worktree-gate 2>/dev/null || true)" fi if [ -z "$gate" ] || [ ! -x "$gate" ]; then gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate" fi if [ ! -x "$gate" ]; then detail="wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry" else detail="$("$gate" verify "$cwd" 2>&1)" && return 0 fi # Codex and Claude consume different Stop-hook response fields. Codex # command-hook payloads always include model; Claude's do not. if printf '%s' "$payload" | jq -e '.model? != null' >/dev/null 2>&1; then jq -n --arg reason "$detail" \ '{continue:false, stopReason:$reason, systemMessage:$reason}' else jq -n --arg reason "$detail" \ '{decision:"block", reason:$reason}' fi return 1 } consume_certificate() { local dir dir="$(git -C "$cwd" rev-parse --absolute-git-dir 2>/dev/null)" || return 0 rm -f "$dir/ai-wrap-clean" } # Shutdown supersedes teardown when both are somehow present. if [ -f "$shutdown_sentinel" ]; then verify_wrap || exit 0 rm -f "$shutdown_sentinel" "$teardown_sentinel" consume_certificate fire '(cj/ai-term-shutdown-countdown)' exit 0 fi if [ -f "$teardown_sentinel" ]; then verify_wrap || exit 0 rm -f "$teardown_sentinel" consume_certificate fire "(cj/ai-term-quit \"${proj}\")" exit 0 fi exit 0