Skip to content

Commit 7fe76a0

Browse files
authored
feat: preserve Firstmate continuity across Claude context compaction (#20)
* feat: recover Claude sessions after compaction * no-mistakes(review): Captain, harden jq-free autocompact recovery * no-mistakes(review): Captain, preserve recovery on malformed payloads * no-mistakes(document): Document Claude autocompaction recovery contracts
1 parent ca4e068 commit 7fe76a0

7 files changed

Lines changed: 713 additions & 0 deletions

File tree

.claude/settings.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@
2424
}
2525
]
2626
}
27+
],
28+
"PreCompact": [
29+
{
30+
"hooks": [
31+
{
32+
"type": "command",
33+
"command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-autocompact.sh capture"
34+
}
35+
]
36+
}
37+
],
38+
"SessionStart": [
39+
{
40+
"matcher": "compact",
41+
"hooks": [
42+
{
43+
"type": "command",
44+
"command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-autocompact.sh recover"
45+
}
46+
]
47+
}
2748
]
2849
}
2950
}

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ config/cmux-socket-password optional cmux control-socket password; LOCAL, gitig
8787
config/wedge-alarm optional active-alert directives for wedged terminal-backed away-mode compatibility delivery; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md
8888
config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present
8989
data/ personal fleet records; LOCAL, gitignored as a whole
90+
autocompact-resume.md Claude-only local compaction resume anchor; see docs/autocompact-recovery.md
9091
backlog.md task queue, dependencies, history
9192
captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update
9293
learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store
@@ -128,6 +129,7 @@ For the tmux backend, the task window is always named `fm-<id>`; per-backend win
128129

129130
Session start is one command, not a sequence of separate reads.
130131
Run `bin/fm-session-start.sh`.
132+
On a Claude compact-sourced recovery, the injected `FIRSTMATE AUTOCOMPACT RECOVERY CONTEXT` already contains this session's one session-start digest; do not run the command again, and follow `docs/autocompact-recovery.md` for the hook boundary.
131133
It composes today's `fm-lock.sh`, `fm-bootstrap.sh`, and `fm-wake-drain.sh` - calling each as a real subprocess, never reimplementing their logic - then prints a full context digest and fleet-state digest, in one ordered, clearly delimited report:
132134

133135
1. **Lock** - acquires the per-home session lock first, before anything mutates shared state.
@@ -343,6 +345,7 @@ Load `memory-hygiene` before writing or leaning `data/captain.md` or `data/learn
343345

344346
When the captain invokes `/stow`, load the `stow` skill.
345347
It sweeps the current session for uncaptured durable knowledge, routes findings with this table, files undone next steps to the backlog, and reports whether the session is safe to reset.
348+
During a long Claude primary run, periodically load `stow` before compaction pressure becomes acute because the tracked `PreCompact` bridge captures deterministic file state only; `docs/autocompact-recovery.md` owns that boundary and recovery contract.
346349

347350
**Delivery mode (choose at add).** `<mode>` is how a finished change reaches `main`, picked per project when you add it and recorded in the registry line (`fm-project-mode.sh` parses it; `fm-spawn` records it into each task's meta):
348351

bin/fm-autocompact.sh

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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

Comments
 (0)