|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Deterministic before/after bridge for Claude Code context compaction. |
| 3 | +# |
| 4 | +# The tracked .claude/settings.json invokes `capture` from PreCompact and |
| 5 | +# `recover` from SessionStart with matcher `compact`. |
| 6 | +# Capture atomically replaces data/autocompact-resume.md with a fresh local-only |
| 7 | +# view of durable fleet state before either manual or automatic compaction. |
| 8 | +# Recover prints that anchor and a fresh fm-session-start.sh digest to stdout, |
| 9 | +# which Claude Code injects into the compacted context before the next model |
| 10 | +# request. |
| 11 | +# |
| 12 | +# This script intentionally does not run /stow. |
| 13 | +# A shell hook cannot make the model judge conversation-only knowledge, so the |
| 14 | +# stow skill remains the one owner of that routing and must run periodically in |
| 15 | +# long Claude sessions before compaction pressure becomes acute. |
| 16 | +# |
| 17 | +# The hook is inert outside a primary firstmate checkout. |
| 18 | +# A plain main home is confirmed by equal git-dir and git-common-dir paths. |
| 19 | +# A treehouse-leased secondmate home is also a primary when its validated |
| 20 | +# .fm-secondmate-home marker names that exact FM_HOME. |
| 21 | +# Unmarked linked worktrees are crewmate/scout worktrees and exit silently. |
| 22 | +# |
| 23 | +# Capture failures in an in-scope primary exit 2 so Claude blocks the |
| 24 | +# compaction instead of silently crossing the boundary without a fresh anchor. |
| 25 | +# Recovery is best-effort after the boundary: it always emits whatever durable |
| 26 | +# context is available and reports a session-start failure inside that context. |
| 27 | +# |
| 28 | +# Usage: |
| 29 | +# <PreCompact JSON | bin/fm-autocompact.sh capture |
| 30 | +# <SessionStart JSON | bin/fm-autocompact.sh recover |
| 31 | +set -u |
| 32 | + |
| 33 | +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0 |
| 34 | +FM_ROOT=${FM_ROOT_OVERRIDE:-$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)} || exit 0 |
| 35 | +FM_HOME=${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}} |
| 36 | +STATE=${FM_STATE_OVERRIDE:-$FM_HOME/state} |
| 37 | +DATA=${FM_DATA_OVERRIDE:-$FM_HOME/data} |
| 38 | +ANCHOR=$DATA/autocompact-resume.md |
| 39 | +MODE=${1:-} |
| 40 | + |
| 41 | +usage() { |
| 42 | + cat <<'EOF' |
| 43 | +usage: fm-autocompact.sh capture|recover |
| 44 | +
|
| 45 | +Reads a Claude Code hook payload from stdin. |
| 46 | +capture accepts PreCompact payloads and atomically writes the durable resume anchor. |
| 47 | +recover accepts SessionStart source=compact payloads and prints the anchor plus a fresh session-start digest. |
| 48 | +The script is a silent no-op outside a primary firstmate checkout. |
| 49 | +EOF |
| 50 | +} |
| 51 | + |
| 52 | +case "$MODE" in |
| 53 | + capture|recover) ;; |
| 54 | + -h|--help) |
| 55 | + usage |
| 56 | + exit 0 |
| 57 | + ;; |
| 58 | + *) |
| 59 | + usage >&2 |
| 60 | + exit 2 |
| 61 | + ;; |
| 62 | +esac |
| 63 | + |
| 64 | +root_is_secondmate_home() { |
| 65 | + local marker=$1/.fm-secondmate-home id root_real home_real LC_ALL=C |
| 66 | + root_real=$(CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || return 1 |
| 67 | + home_real=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) || return 1 |
| 68 | + [ "$home_real" = "$root_real" ] || return 1 |
| 69 | + [ -L "$marker" ] && return 1 |
| 70 | + [ -f "$marker" ] || return 1 |
| 71 | + IFS= read -r id < "$marker" 2>/dev/null || return 1 |
| 72 | + id=${id//[[:space:]]/} |
| 73 | + [ -n "$id" ] || return 1 |
| 74 | + case "$id" in |
| 75 | + *[!A-Za-z0-9._-]*) return 1 ;; |
| 76 | + esac |
| 77 | + return 0 |
| 78 | +} |
| 79 | + |
| 80 | +in_primary_scope() { |
| 81 | + local git_dir git_common_dir |
| 82 | + [ -f "$FM_ROOT/AGENTS.md" ] || return 1 |
| 83 | + [ -d "$FM_ROOT/bin" ] || return 1 |
| 84 | + [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 |
| 85 | + root_is_secondmate_home "$FM_ROOT" && return 0 |
| 86 | + command -v git >/dev/null 2>&1 || return 1 |
| 87 | + git_dir=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || return 1 |
| 88 | + git_common_dir=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || return 1 |
| 89 | + [ "$git_dir" = "$git_common_dir" ] |
| 90 | +} |
| 91 | + |
| 92 | +in_primary_scope || exit 0 |
| 93 | + |
| 94 | +capture_failed() { |
| 95 | + local message=$1 |
| 96 | + printf 'FIRSTMATE AUTOCOMPACT CAPTURE FAILED: %s\n' "$message" >&2 |
| 97 | + exit 2 |
| 98 | +} |
| 99 | + |
| 100 | +json_string_field() { |
| 101 | + local field=$1 payload=$2 |
| 102 | + awk -v want="$field" ' |
| 103 | + function invalid() { |
| 104 | + exit 2 |
| 105 | + } |
| 106 | + function decode(start, i, c, escaped, hex, out) { |
| 107 | + if (substr(input, start, 1) != "\"") { |
| 108 | + invalid() |
| 109 | + } |
| 110 | + out = "" |
| 111 | + for (i = start + 1; i <= length(input); i++) { |
| 112 | + c = substr(input, i, 1) |
| 113 | + if (c == "\"") { |
| 114 | + value = out |
| 115 | + return |
| 116 | + } |
| 117 | + if (c != "\\") { |
| 118 | + if (c ~ /[[:cntrl:]]/) { |
| 119 | + invalid() |
| 120 | + } |
| 121 | + out = out c |
| 122 | + continue |
| 123 | + } |
| 124 | + i++ |
| 125 | + if (i > length(input)) { |
| 126 | + invalid() |
| 127 | + } |
| 128 | + escaped = substr(input, i, 1) |
| 129 | + if (escaped == "\"" || escaped == "\\" || escaped == "/") { |
| 130 | + out = out escaped |
| 131 | + } else if (escaped == "b") { |
| 132 | + out = out sprintf("%c", 8) |
| 133 | + } else if (escaped == "f") { |
| 134 | + out = out sprintf("%c", 12) |
| 135 | + } else if (escaped == "n") { |
| 136 | + out = out "\n" |
| 137 | + } else if (escaped == "r") { |
| 138 | + out = out "\r" |
| 139 | + } else if (escaped == "t") { |
| 140 | + out = out "\t" |
| 141 | + } else if (escaped == "u") { |
| 142 | + hex = substr(input, i + 1, 4) |
| 143 | + if (hex !~ /^[[:xdigit:]][[:xdigit:]][[:xdigit:]][[:xdigit:]]$/) { |
| 144 | + invalid() |
| 145 | + } |
| 146 | + out = out "\\u" hex |
| 147 | + i += 4 |
| 148 | + } else { |
| 149 | + invalid() |
| 150 | + } |
| 151 | + } |
| 152 | + invalid() |
| 153 | + } |
| 154 | + { |
| 155 | + input = input (NR == 1 ? "" : "\n") $0 |
| 156 | + } |
| 157 | + END { |
| 158 | + needle = "\"" want "\"" |
| 159 | + pos = 1 |
| 160 | + while (pos <= length(input)) { |
| 161 | + relative = index(substr(input, pos), needle) |
| 162 | + if (relative == 0) { |
| 163 | + exit 1 |
| 164 | + } |
| 165 | + key = pos + relative - 1 |
| 166 | + cursor = key + length(needle) |
| 167 | + while (substr(input, cursor, 1) ~ /[[:space:]]/) { |
| 168 | + cursor++ |
| 169 | + } |
| 170 | + if (substr(input, cursor, 1) != ":") { |
| 171 | + pos = key + length(needle) |
| 172 | + continue |
| 173 | + } |
| 174 | + cursor++ |
| 175 | + while (substr(input, cursor, 1) ~ /[[:space:]]/) { |
| 176 | + cursor++ |
| 177 | + } |
| 178 | + if (substr(input, cursor, 4) == "null") { |
| 179 | + exit 1 |
| 180 | + } |
| 181 | + decode(cursor) |
| 182 | + printf "%s", value |
| 183 | + exit 0 |
| 184 | + } |
| 185 | + exit 1 |
| 186 | + } |
| 187 | + ' <<< "$payload" |
| 188 | +} |
| 189 | + |
| 190 | +PAYLOAD= |
| 191 | +RECOVERY_WARNING= |
| 192 | +if ! PAYLOAD=$(cat 2>/dev/null); then |
| 193 | + if [ "$MODE" = capture ]; then |
| 194 | + capture_failed 'could not read the PreCompact payload' |
| 195 | + fi |
| 196 | + RECOVERY_WARNING='could not read the compact SessionStart payload; recovering from durable state' |
| 197 | +fi |
| 198 | + |
| 199 | +if [ "$MODE" = capture ]; then |
| 200 | + [ -n "$PAYLOAD" ] || capture_failed 'the PreCompact payload was empty' |
| 201 | + EVENT=$(json_string_field hook_event_name "$PAYLOAD") \ |
| 202 | + || capture_failed 'invalid PreCompact payload' |
| 203 | + [ "$EVENT" = PreCompact ] || exit 0 |
| 204 | +else |
| 205 | + if [ -n "$RECOVERY_WARNING" ]; then |
| 206 | + : |
| 207 | + elif [ -z "$PAYLOAD" ]; then |
| 208 | + RECOVERY_WARNING='the compact SessionStart payload was empty; recovering from durable state' |
| 209 | + elif ! EVENT=$(json_string_field hook_event_name "$PAYLOAD"); then |
| 210 | + RECOVERY_WARNING='the compact SessionStart payload was malformed or missing its event name; recovering from durable state' |
| 211 | + elif [ "$EVENT" != SessionStart ]; then |
| 212 | + RECOVERY_WARNING="the recovery hook received unexpected event $EVENT; recovering from durable state" |
| 213 | + elif ! SOURCE=$(json_string_field source "$PAYLOAD"); then |
| 214 | + RECOVERY_WARNING='the SessionStart payload was malformed or missing its source; recovering from durable state' |
| 215 | + elif [ "$SOURCE" != compact ]; then |
| 216 | + exit 0 |
| 217 | + fi |
| 218 | +fi |
| 219 | + |
| 220 | +render_anchor() { |
| 221 | + local meta id meta_found=0 |
| 222 | + printf '# Autocompact resume anchor\n\n' || return 1 |
| 223 | + printf "Generated: \`%s\`\n" "$generated" || return 1 |
| 224 | + printf "Trigger: \`%s\`\n" "$trigger" || return 1 |
| 225 | + printf "Session: \`%s\`\n" "$session_id" || return 1 |
| 226 | + printf "Transcript: \`%s\`\n\n" "$transcript" || return 1 |
| 227 | + printf 'This file is the deterministic bridge across Claude Code context compaction.\n' || return 1 |
| 228 | + printf "It captures durable file state only and does not replace the judgment-based \`stow\` skill.\n" || return 1 |
| 229 | + printf "The compact-sourced SessionStart hook prints this anchor and then runs \`bin/fm-session-start.sh\` for normal lock, wake, backlog, task, and endpoint reconciliation.\n\n" || return 1 |
| 230 | + printf '## Fleet pickup snapshot\n\n' || return 1 |
| 231 | + printf ' %s\n' "${snapshot//$'\n'/$'\n '}" || return 1 |
| 232 | + printf '\n## Backlog at capture\n\n' || return 1 |
| 233 | + if [ -f "$DATA/backlog.md" ] && [ ! -L "$DATA/backlog.md" ]; then |
| 234 | + sed 's/^/ /' "$DATA/backlog.md" || return 1 |
| 235 | + else |
| 236 | + printf ' (absent)\n' || return 1 |
| 237 | + fi |
| 238 | + printf '\n## In-flight metadata at capture\n' || return 1 |
| 239 | + for meta in "$STATE"/*.meta; do |
| 240 | + [ -f "$meta" ] && [ ! -L "$meta" ] || continue |
| 241 | + meta_found=1 |
| 242 | + id=${meta##*/} |
| 243 | + id=${id%.meta} |
| 244 | + printf '\n### %s\n\n' "$id" || return 1 |
| 245 | + sed 's/^/ /' "$meta" || return 1 |
| 246 | + done |
| 247 | + [ "$meta_found" -eq 1 ] || printf '\n(none)\n' || return 1 |
| 248 | +} |
| 249 | + |
| 250 | +capture_anchor() { |
| 251 | + local trigger session_id transcript generated snapshot tmp |
| 252 | + trigger=$(json_string_field trigger "$PAYLOAD") \ |
| 253 | + || capture_failed 'invalid PreCompact payload' |
| 254 | + case "$trigger" in |
| 255 | + auto|manual) ;; |
| 256 | + *) capture_failed 'PreCompact payload has no recognized trigger' ;; |
| 257 | + esac |
| 258 | + session_id=$(json_string_field session_id "$PAYLOAD") || session_id=unknown |
| 259 | + transcript=$(json_string_field transcript_path "$PAYLOAD") || transcript=unknown |
| 260 | + generated=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ |
| 261 | + || capture_failed 'could not read the clock' |
| 262 | + |
| 263 | + if command -v jq >/dev/null 2>&1; then |
| 264 | + snapshot=$( |
| 265 | + FM_ROOT_OVERRIDE="$FM_ROOT" \ |
| 266 | + FM_HOME="$FM_HOME" \ |
| 267 | + FM_STATE_OVERRIDE="$STATE" \ |
| 268 | + FM_DATA_OVERRIDE="$DATA" \ |
| 269 | + "$SCRIPT_DIR/fm-bearings-snapshot.sh" \ |
| 270 | + --all-in-flight \ |
| 271 | + --all-decisions \ |
| 272 | + --all-landed \ |
| 273 | + --all-reports \ |
| 274 | + --all-queued \ |
| 275 | + --all-recorded-prs \ |
| 276 | + --all-unhealthy \ |
| 277 | + --fields bodies,paths,actions,endpoints |
| 278 | + ) || capture_failed 'the deterministic fleet snapshot failed' |
| 279 | + else |
| 280 | + snapshot='LIMITED - jq is unavailable; the complete raw backlog and in-flight metadata below remain authoritative.' |
| 281 | + printf '%s\n' 'FIRSTMATE AUTOCOMPACT CAPTURE LIMITED: jq is unavailable; capturing raw durable state without the bearings projection.' >&2 \ |
| 282 | + || capture_failed 'could not report the limited capture' |
| 283 | + fi |
| 284 | + |
| 285 | + if [ -L "$DATA" ] || { [ -e "$DATA" ] && [ ! -d "$DATA" ]; }; then |
| 286 | + capture_failed "unsafe data directory at $DATA" |
| 287 | + fi |
| 288 | + mkdir -p "$DATA" || capture_failed "could not create data directory at $DATA" |
| 289 | + [ -d "$DATA" ] && [ ! -L "$DATA" ] \ |
| 290 | + || capture_failed "unsafe data directory at $DATA" |
| 291 | + if [ -L "$ANCHOR" ] || { [ -e "$ANCHOR" ] && [ ! -f "$ANCHOR" ]; }; then |
| 292 | + capture_failed "unsafe resume anchor at $ANCHOR" |
| 293 | + fi |
| 294 | + |
| 295 | + umask 077 |
| 296 | + tmp=$(mktemp "$DATA/.autocompact-resume.md.XXXXXX") \ |
| 297 | + || capture_failed 'could not allocate a temporary anchor' |
| 298 | + render_anchor > "$tmp" || { |
| 299 | + rm -f "$tmp" || capture_failed 'could not clean the incomplete temporary anchor' |
| 300 | + capture_failed 'could not render the resume anchor' |
| 301 | + } |
| 302 | + mv -f "$tmp" "$ANCHOR" || { |
| 303 | + rm -f "$tmp" || capture_failed 'could not clean the unpublished temporary anchor' |
| 304 | + capture_failed 'could not publish the resume anchor atomically' |
| 305 | + } |
| 306 | +} |
| 307 | + |
| 308 | +recover_context() { |
| 309 | + local digest digest_rc |
| 310 | + digest=$( |
| 311 | + FM_ROOT_OVERRIDE="$FM_ROOT" \ |
| 312 | + FM_HOME="$FM_HOME" \ |
| 313 | + FM_STATE_OVERRIDE="$STATE" \ |
| 314 | + FM_DATA_OVERRIDE="$DATA" \ |
| 315 | + "$SCRIPT_DIR/fm-session-start.sh" 2>&1 |
| 316 | + ) |
| 317 | + digest_rc=$? |
| 318 | + |
| 319 | + printf '%s\n' 'FIRSTMATE AUTOCOMPACT RECOVERY CONTEXT' |
| 320 | + if [ -n "$RECOVERY_WARNING" ]; then |
| 321 | + printf 'FIRSTMATE AUTOCOMPACT RECOVERY WARNING: %s\n' "$RECOVERY_WARNING" |
| 322 | + fi |
| 323 | + printf '%s\n' 'Treat the fresh durable anchor and session-start digest below as authoritative over the lossy compaction summary.' |
| 324 | + printf '%s\n' 'Resume the in-flight work directly after reconciling the drained wake queue and live endpoints.' |
| 325 | + printf '\n=== FRESH RESUME ANCHOR: %s ===\n' "$ANCHOR" |
| 326 | + if [ -f "$ANCHOR" ] && [ ! -L "$ANCHOR" ]; then |
| 327 | + cat "$ANCHOR" || printf '%s\n' 'UNREADABLE - the resume anchor could not be read; rely on the session-start digest and surface the read failure.' |
| 328 | + else |
| 329 | + printf '%s\n' 'MISSING - PreCompact did not leave a readable anchor; rely on the session-start digest and surface the capture failure.' |
| 330 | + fi |
| 331 | + printf '\n=== NORMAL SESSION-START RECONCILIATION ===\n' |
| 332 | + printf '%s\n' "$digest" |
| 333 | + if [ "$digest_rc" -ne 0 ]; then |
| 334 | + printf '\nSESSION-START RECONCILIATION FAILED WITH EXIT %s.\n' "$digest_rc" |
| 335 | + printf '%s\n' 'Surface the failure and do not infer current fleet state from the compaction summary.' |
| 336 | + fi |
| 337 | +} |
| 338 | + |
| 339 | +if [ "$MODE" = capture ]; then |
| 340 | + capture_anchor |
| 341 | +else |
| 342 | + recover_context |
| 343 | +fi |
0 commit comments