aboutsummaryrefslogtreecommitdiff
path: root/hooks/ai-wrap-teardown.sh
blob: ca73ae64654fe2e694812faa49aeba5a57d8acf0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/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-<basename>):
#   /tmp/ai-wrap-teardown-<basename>  -> cj/ai-term-quit "<basename>"
#       kill the aiv-<basename> tmux session (takes claude with it), kill the
#       vterm buffer, restore geometry. Defined in .emacs.d/modules/ai-term.el.
#   /tmp/ai-wrap-shutdown-<basename>  -> 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