diff --git a/.claude/settings.json b/.claude/settings.json index 6bbd714d727..439198fd468 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -24,6 +24,27 @@ } ] } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-autocompact.sh capture" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-autocompact.sh recover" + } + ] + } ] } } diff --git a/AGENTS.md b/AGENTS.md index b0cbb7d6c6e..6d71c699c3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ config/cmux-socket-password optional cmux control-socket password; LOCAL, gitig config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole + autocompact-resume.md Claude-only local compaction resume anchor; see docs/autocompact-recovery.md backlog.md task queue, dependencies, history captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update 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 @@ -127,6 +128,7 @@ For the tmux backend, the task window is always named `fm-`; per-backend win Session start is one command, not a sequence of separate reads. Run `bin/fm-session-start.sh`. +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. 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: 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. @@ -340,6 +342,7 @@ Route each piece of durable knowledge to its most specific home: When the captain invokes `/stow`, load the `stow` skill. 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. +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. **Delivery mode (choose at add).** `` 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): diff --git a/bin/fm-autocompact.sh b/bin/fm-autocompact.sh new file mode 100755 index 00000000000..7078bf9a8bb --- /dev/null +++ b/bin/fm-autocompact.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# Deterministic before/after bridge for Claude Code context compaction. +# +# The tracked .claude/settings.json invokes `capture` from PreCompact and +# `recover` from SessionStart with matcher `compact`. +# Capture atomically replaces data/autocompact-resume.md with a fresh local-only +# view of durable fleet state before either manual or automatic compaction. +# Recover prints that anchor and a fresh fm-session-start.sh digest to stdout, +# which Claude Code injects into the compacted context before the next model +# request. +# +# This script intentionally does not run /stow. +# A shell hook cannot make the model judge conversation-only knowledge, so the +# stow skill remains the one owner of that routing and must run periodically in +# long Claude sessions before compaction pressure becomes acute. +# +# The hook is inert outside a primary firstmate checkout. +# A plain main home is confirmed by equal git-dir and git-common-dir paths. +# A treehouse-leased secondmate home is also a primary when its validated +# .fm-secondmate-home marker names that exact FM_HOME. +# Unmarked linked worktrees are crewmate/scout worktrees and exit silently. +# +# Capture failures in an in-scope primary exit 2 so Claude blocks the +# compaction instead of silently crossing the boundary without a fresh anchor. +# Recovery is best-effort after the boundary: it always emits whatever durable +# context is available and reports a session-start failure inside that context. +# +# Usage: +# /dev/null && pwd -P) || exit 0 +FM_ROOT=${FM_ROOT_OVERRIDE:-$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)} || exit 0 +FM_HOME=${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}} +STATE=${FM_STATE_OVERRIDE:-$FM_HOME/state} +DATA=${FM_DATA_OVERRIDE:-$FM_HOME/data} +ANCHOR=$DATA/autocompact-resume.md +MODE=${1:-} + +usage() { + cat <<'EOF' +usage: fm-autocompact.sh capture|recover + +Reads a Claude Code hook payload from stdin. +capture accepts PreCompact payloads and atomically writes the durable resume anchor. +recover accepts SessionStart source=compact payloads and prints the anchor plus a fresh session-start digest. +The script is a silent no-op outside a primary firstmate checkout. +EOF +} + +case "$MODE" in + capture|recover) ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; +esac + +root_is_secondmate_home() { + local marker=$1/.fm-secondmate-home id root_real home_real LC_ALL=C + root_real=$(CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || return 1 + home_real=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) || return 1 + [ "$home_real" = "$root_real" ] || return 1 + [ -L "$marker" ] && return 1 + [ -f "$marker" ] || return 1 + IFS= read -r id < "$marker" 2>/dev/null || return 1 + id=${id//[[:space:]]/} + [ -n "$id" ] || return 1 + case "$id" in + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +in_primary_scope() { + local git_dir git_common_dir + [ -f "$FM_ROOT/AGENTS.md" ] || return 1 + [ -d "$FM_ROOT/bin" ] || return 1 + [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 + root_is_secondmate_home "$FM_ROOT" && return 0 + command -v git >/dev/null 2>&1 || return 1 + git_dir=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || return 1 + git_common_dir=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || return 1 + [ "$git_dir" = "$git_common_dir" ] +} + +in_primary_scope || exit 0 + +capture_failed() { + local message=$1 + printf 'FIRSTMATE AUTOCOMPACT CAPTURE FAILED: %s\n' "$message" >&2 + exit 2 +} + +json_string_field() { + local field=$1 payload=$2 + awk -v want="$field" ' + function invalid() { + exit 2 + } + function decode(start, i, c, escaped, hex, out) { + if (substr(input, start, 1) != "\"") { + invalid() + } + out = "" + for (i = start + 1; i <= length(input); i++) { + c = substr(input, i, 1) + if (c == "\"") { + value = out + return + } + if (c != "\\") { + if (c ~ /[[:cntrl:]]/) { + invalid() + } + out = out c + continue + } + i++ + if (i > length(input)) { + invalid() + } + escaped = substr(input, i, 1) + if (escaped == "\"" || escaped == "\\" || escaped == "/") { + out = out escaped + } else if (escaped == "b") { + out = out sprintf("%c", 8) + } else if (escaped == "f") { + out = out sprintf("%c", 12) + } else if (escaped == "n") { + out = out "\n" + } else if (escaped == "r") { + out = out "\r" + } else if (escaped == "t") { + out = out "\t" + } else if (escaped == "u") { + hex = substr(input, i + 1, 4) + if (hex !~ /^[[:xdigit:]][[:xdigit:]][[:xdigit:]][[:xdigit:]]$/) { + invalid() + } + out = out "\\u" hex + i += 4 + } else { + invalid() + } + } + invalid() + } + { + input = input (NR == 1 ? "" : "\n") $0 + } + END { + needle = "\"" want "\"" + pos = 1 + while (pos <= length(input)) { + relative = index(substr(input, pos), needle) + if (relative == 0) { + exit 1 + } + key = pos + relative - 1 + cursor = key + length(needle) + while (substr(input, cursor, 1) ~ /[[:space:]]/) { + cursor++ + } + if (substr(input, cursor, 1) != ":") { + pos = key + length(needle) + continue + } + cursor++ + while (substr(input, cursor, 1) ~ /[[:space:]]/) { + cursor++ + } + if (substr(input, cursor, 4) == "null") { + exit 1 + } + decode(cursor) + printf "%s", value + exit 0 + } + exit 1 + } + ' <<< "$payload" +} + +PAYLOAD= +RECOVERY_WARNING= +if ! PAYLOAD=$(cat 2>/dev/null); then + if [ "$MODE" = capture ]; then + capture_failed 'could not read the PreCompact payload' + fi + RECOVERY_WARNING='could not read the compact SessionStart payload; recovering from durable state' +fi + +if [ "$MODE" = capture ]; then + [ -n "$PAYLOAD" ] || capture_failed 'the PreCompact payload was empty' + EVENT=$(json_string_field hook_event_name "$PAYLOAD") \ + || capture_failed 'invalid PreCompact payload' + [ "$EVENT" = PreCompact ] || exit 0 +else + if [ -n "$RECOVERY_WARNING" ]; then + : + elif [ -z "$PAYLOAD" ]; then + RECOVERY_WARNING='the compact SessionStart payload was empty; recovering from durable state' + elif ! EVENT=$(json_string_field hook_event_name "$PAYLOAD"); then + RECOVERY_WARNING='the compact SessionStart payload was malformed or missing its event name; recovering from durable state' + elif [ "$EVENT" != SessionStart ]; then + RECOVERY_WARNING="the recovery hook received unexpected event $EVENT; recovering from durable state" + elif ! SOURCE=$(json_string_field source "$PAYLOAD"); then + RECOVERY_WARNING='the SessionStart payload was malformed or missing its source; recovering from durable state' + elif [ "$SOURCE" != compact ]; then + exit 0 + fi +fi + +render_anchor() { + local meta id meta_found=0 + printf '# Autocompact resume anchor\n\n' || return 1 + printf "Generated: \`%s\`\n" "$generated" || return 1 + printf "Trigger: \`%s\`\n" "$trigger" || return 1 + printf "Session: \`%s\`\n" "$session_id" || return 1 + printf "Transcript: \`%s\`\n\n" "$transcript" || return 1 + printf 'This file is the deterministic bridge across Claude Code context compaction.\n' || return 1 + printf "It captures durable file state only and does not replace the judgment-based \`stow\` skill.\n" || return 1 + 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 + printf '## Fleet pickup snapshot\n\n' || return 1 + printf ' %s\n' "${snapshot//$'\n'/$'\n '}" || return 1 + printf '\n## Backlog at capture\n\n' || return 1 + if [ -f "$DATA/backlog.md" ] && [ ! -L "$DATA/backlog.md" ]; then + sed 's/^/ /' "$DATA/backlog.md" || return 1 + else + printf ' (absent)\n' || return 1 + fi + printf '\n## In-flight metadata at capture\n' || return 1 + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] && [ ! -L "$meta" ] || continue + meta_found=1 + id=${meta##*/} + id=${id%.meta} + printf '\n### %s\n\n' "$id" || return 1 + sed 's/^/ /' "$meta" || return 1 + done + [ "$meta_found" -eq 1 ] || printf '\n(none)\n' || return 1 +} + +capture_anchor() { + local trigger session_id transcript generated snapshot tmp + trigger=$(json_string_field trigger "$PAYLOAD") \ + || capture_failed 'invalid PreCompact payload' + case "$trigger" in + auto|manual) ;; + *) capture_failed 'PreCompact payload has no recognized trigger' ;; + esac + session_id=$(json_string_field session_id "$PAYLOAD") || session_id=unknown + transcript=$(json_string_field transcript_path "$PAYLOAD") || transcript=unknown + generated=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + || capture_failed 'could not read the clock' + + if command -v jq >/dev/null 2>&1; then + snapshot=$( + FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_HOME="$FM_HOME" \ + FM_STATE_OVERRIDE="$STATE" \ + FM_DATA_OVERRIDE="$DATA" \ + "$SCRIPT_DIR/fm-bearings-snapshot.sh" \ + --all-in-flight \ + --all-decisions \ + --all-landed \ + --all-reports \ + --all-queued \ + --all-recorded-prs \ + --all-unhealthy \ + --fields bodies,paths,actions,endpoints + ) || capture_failed 'the deterministic fleet snapshot failed' + else + snapshot='LIMITED - jq is unavailable; the complete raw backlog and in-flight metadata below remain authoritative.' + printf '%s\n' 'FIRSTMATE AUTOCOMPACT CAPTURE LIMITED: jq is unavailable; capturing raw durable state without the bearings projection.' >&2 \ + || capture_failed 'could not report the limited capture' + fi + + if [ -L "$DATA" ] || { [ -e "$DATA" ] && [ ! -d "$DATA" ]; }; then + capture_failed "unsafe data directory at $DATA" + fi + mkdir -p "$DATA" || capture_failed "could not create data directory at $DATA" + [ -d "$DATA" ] && [ ! -L "$DATA" ] \ + || capture_failed "unsafe data directory at $DATA" + if [ -L "$ANCHOR" ] || { [ -e "$ANCHOR" ] && [ ! -f "$ANCHOR" ]; }; then + capture_failed "unsafe resume anchor at $ANCHOR" + fi + + umask 077 + tmp=$(mktemp "$DATA/.autocompact-resume.md.XXXXXX") \ + || capture_failed 'could not allocate a temporary anchor' + render_anchor > "$tmp" || { + rm -f "$tmp" || capture_failed 'could not clean the incomplete temporary anchor' + capture_failed 'could not render the resume anchor' + } + mv -f "$tmp" "$ANCHOR" || { + rm -f "$tmp" || capture_failed 'could not clean the unpublished temporary anchor' + capture_failed 'could not publish the resume anchor atomically' + } +} + +recover_context() { + local digest digest_rc + digest=$( + FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_HOME="$FM_HOME" \ + FM_STATE_OVERRIDE="$STATE" \ + FM_DATA_OVERRIDE="$DATA" \ + "$SCRIPT_DIR/fm-session-start.sh" 2>&1 + ) + digest_rc=$? + + printf '%s\n' 'FIRSTMATE AUTOCOMPACT RECOVERY CONTEXT' + if [ -n "$RECOVERY_WARNING" ]; then + printf 'FIRSTMATE AUTOCOMPACT RECOVERY WARNING: %s\n' "$RECOVERY_WARNING" + fi + printf '%s\n' 'Treat the fresh durable anchor and session-start digest below as authoritative over the lossy compaction summary.' + printf '%s\n' 'Resume the in-flight work directly after reconciling the drained wake queue and live endpoints.' + printf '\n=== FRESH RESUME ANCHOR: %s ===\n' "$ANCHOR" + if [ -f "$ANCHOR" ] && [ ! -L "$ANCHOR" ]; then + cat "$ANCHOR" || printf '%s\n' 'UNREADABLE - the resume anchor could not be read; rely on the session-start digest and surface the read failure.' + else + printf '%s\n' 'MISSING - PreCompact did not leave a readable anchor; rely on the session-start digest and surface the capture failure.' + fi + printf '\n=== NORMAL SESSION-START RECONCILIATION ===\n' + printf '%s\n' "$digest" + if [ "$digest_rc" -ne 0 ]; then + printf '\nSESSION-START RECONCILIATION FAILED WITH EXIT %s.\n' "$digest_rc" + printf '%s\n' 'Surface the failure and do not infer current fleet state from the compaction summary.' + fi +} + +if [ "$MODE" = capture ]; then + capture_anchor +else + recover_context +fi diff --git a/docs/architecture.md b/docs/architecture.md index 35abd51867a..45887027bd4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -244,6 +244,7 @@ Fleet state lives in each task's session-provider backend (tmux by hard default, For herdr, respawning after a server-restored layout closes and replaces confirmed no-agent or dead task-tab husks instead of requiring manual tab cleanup. At session start, confirmed-dead secondmate agent endpoints are closed and relaunched through the same secondmate spawn path, while ambiguous liveness reads are left untouched to avoid duplicate supervisors. Use `/stow` before an intentional reset when the conversation may hold durable knowledge that has not yet been written to disk; after that, the next firstmate session can reconcile and carry on. +Claude Code compaction uses the tracked deterministic anchor and compact-sourced session-start reconciliation documented in [`autocompact-recovery.md`](autocompact-recovery.md). ## Development notes diff --git a/docs/autocompact-recovery.md b/docs/autocompact-recovery.md new file mode 100644 index 00000000000..88afe043dbf --- /dev/null +++ b/docs/autocompact-recovery.md @@ -0,0 +1,78 @@ +# Claude context compaction recovery + +This document is the authoritative contract and empirical record for Firstmate's Claude Code context-compaction bridge. + +## Contract + +Tracked `.claude/settings.json` registers `bin/fm-autocompact.sh capture` for `PreCompact` and `bin/fm-autocompact.sh recover` for `SessionStart` with matcher `compact`. +The capture phase atomically replaces `data/autocompact-resume.md` with a deterministic view of durable fleet state before either manual or automatic compaction. +The anchor includes the full backlog, every in-flight `state/*.meta` file, and the complete local-only bearings projection with current task state, open decisions, held queued work, recorded PRs, reports, endpoint health, task paths, and next actions. +Hook payload parsing does not require `jq`; when `jq` is unavailable, capture logs a loud limitation and publishes the complete raw backlog and metadata while marking the derived bearings projection unavailable. +The capture path makes no GitHub or other network call. +An in-scope capture failure exits 2 and blocks compaction rather than silently crossing the boundary without a fresh anchor. + +After compaction, Claude Code emits a new `SessionStart` event with `source=compact` before the next model request. +The recovery phase prints the fresh anchor and the output of `bin/fm-session-start.sh` to stdout. +If its compact-scoped hook payload is unreadable or invalid, recovery prints a loud warning and still emits durable context; only a successfully parsed non-compact `SessionStart` is a silent no-op. +Claude Code adds that stdout to the compacted context, so Firstmate receives the normal lock, bootstrap, wake-queue, backlog, task, status-tail, endpoint, and supervision reconciliation before it resumes. +That injected digest is the resumed session's single session-start pass; Firstmate does not run `bin/fm-session-start.sh` again after control returns to the model. +The compact summary is explicitly treated as lossy and subordinate to those durable sources. + +The tracked hook is inert in a non-Firstmate repository and in an unmarked linked crewmate or scout worktree. +A valid secondmate home is in scope because it is a Firstmate primary in its own home. +The existing `Stop` and `PreToolUse` hooks remain separate and unchanged. + +## Conversation-only boundary + +A shell `PreCompact` hook cannot invoke the interactive `stow` skill or make the model judge uncaptured conversation-only knowledge. +The hook therefore captures only deterministic file state. +Long Claude primary runs periodically load `stow` while context is still available, and that skill remains the single owner of knowledge routing to captain preferences, fleet learnings, project memory, task notes, and backlog work. + +`PreCompact` stdout is not the recovery transport. +Manual compaction records successful hook stdout as local-command output, but automatic compaction has no equivalent user command boundary and Claude's documented context-output contract reserves direct stdout injection for events including `SessionStart`. +The compact-sourced `SessionStart` hook is therefore the reliable transport for the anchor and reconciliation digest. + +## Empirical validation - 2026-07-22 + +Event discovery ran in a git-initialized scratch project under `/tmp`, with project-only Claude settings and an isolated event log. +The implementation proof ran in a plain git fixture under the task worktree with an explicit fixture `FM_HOME`, and no tracked hook was registered into the live Firstmate primary settings. + +Claude Code version at the final probe was `2.1.217` on Darwin. +The scratch settings registered logging commands for `PreCompact`, `PostCompact`, and `SessionStart` matcher `compact`. + +Manual probe command: + +```text +claude --setting-sources project --dangerously-skip-permissions --model haiku --no-chrome +/compact empirical manual probe +``` + +The successful manual event payloads were: + +```text +PreCompact: {"hook_event_name":"PreCompact","trigger":"manual","custom_instructions":"empirical manual probe"} +SessionStart: {"hook_event_name":"SessionStart","source":"compact","model":"claude-haiku-4-5-20251001"} +PostCompact: {"hook_event_name":"PostCompact","trigger":"manual","compact_summary":"TURN_FOUR"} +``` + +Automatic probe command and setup: + +```text +CLAUDE_CODE_AUTO_COMPACT_WINDOW=20000 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=50 \ + claude --setting-sources project --dangerously-skip-permissions --model haiku --no-chrome +``` + +The scratch session accumulated bounded repeated Bash results until Claude displayed `Running PreCompact hooks`, compacted in the middle of the autonomous turn, ran the compact-sourced `SessionStart` and `PostCompact` hooks, and then resumed the same turn to its requested terminal response. +The successful automatic event payloads were: + +```text +PreCompact: {"hook_event_name":"PreCompact","trigger":"auto","custom_instructions":null} +SessionStart: {"hook_event_name":"SessionStart","source":"compact","model":"claude-haiku-4-5-20251001"} +PostCompact: {"hook_event_name":"PostCompact","trigger":"auto","compact_summary":""} +``` + +The event order for both successful paths was `PreCompact`, compact-sourced `SessionStart`, then `PostCompact`. +The manual probe also showed that `PreCompact` fires before an attempted `/compact` that later reports `Not enough messages to compact`, so capture must be safe and replace the prior anchor idempotently. +The implementation proof then compacted a fixture Firstmate session through the tracked commands, confirmed that the anchor held fixture backlog and PR markers, and asked the resumed model to repeat backlog, status-tail, and PR markers without reading files. +The model returned `RECOVERED:E2E_BACKLOG_ANCHOR_7421:E2E_IN_FLIGHT_ANCHOR_8842:9999`, proving that compact-sourced `SessionStart` stdout carried both the anchor and normal reconciliation into the resumed context. +Automated coverage in `tests/fm-autocompact.test.sh` verifies atomic refresh, complete pickup surfaces, failure blocking, primary scoping, tracked registration, and post-compact anchor plus session-start recovery. diff --git a/docs/scripts.md b/docs/scripts.md index ba4cb71550b..3ab0ce48c45 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -8,6 +8,7 @@ The shared no-mistakes gate refusal used by every directly invocable mutating co | Script | Purpose | | ------------------------ | ------------------------------------------------------------------------------------ | | `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest | +| `fm-autocompact.sh` | Bridge Claude context compaction through a durable anchor and session-start recovery | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | diff --git a/tests/fm-autocompact.test.sh b/tests/fm-autocompact.test.sh new file mode 100755 index 00000000000..2c27621ae5b --- /dev/null +++ b/tests/fm-autocompact.test.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Behavior tests for Claude Code's deterministic autocompact recovery bridge. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck disable=SC2153 +AUTOCOMPACT="$ROOT/bin/fm-autocompact.sh" +TMP_ROOT=$(fm_test_tmproot fm-autocompact-tests) +fm_git_identity fmtest fmtest@example.invalid + +new_primary() { + local root="$TMP_ROOT/$1/root" home="$TMP_ROOT/$1/home" + fm_git_init_commit "$root" + mkdir -p "$root/bin" "$home/state" "$home/data" "$home/config" "$home/projects" + printf '# Firstmate fixture\n' > "$root/AGENTS.md" + printf '%s|%s\n' "$root" "$home" +} + +capture() { + local root=$1 home=$2 trigger=${3:-auto} + printf '{"hook_event_name":"PreCompact","trigger":"%s","session_id":"session-%s","transcript_path":"%s/transcript.jsonl"}\n' \ + "$trigger" "$trigger" "$home" \ + | FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$AUTOCOMPACT" capture +} + +test_tracked_hook_registration_preserves_existing_hooks() { + local settings="$ROOT/.claude/settings.json" pre recover + pre=$(jq -r '.hooks.PreCompact[]?.hooks[]?.command // empty' "$settings") + recover=$(jq -r '.hooks.SessionStart[]? | select(.matcher == "compact") | .hooks[]?.command // empty' "$settings") + assert_contains "$pre" "\"\$CLAUDE_PROJECT_DIR\"/bin/fm-autocompact.sh capture" "PreCompact hook is not project-root anchored" + assert_contains "$recover" "\"\$CLAUDE_PROJECT_DIR\"/bin/fm-autocompact.sh recover" "compact SessionStart hook is not project-root anchored" + [ "$(jq '.hooks.Stop | length' "$settings")" -gt 0 ] || fail "Stop hooks were disturbed" + [ "$(jq '.hooks.PreToolUse | length' "$settings")" -gt 0 ] || fail "PreToolUse hooks were disturbed" + pass "tracked Claude settings register both compaction phases without disturbing existing hooks" +} + +test_capture_writes_fresh_durable_anchor() { + local rec root home anchor first second + rec=$(new_primary capture) + IFS='|' read -r root home < "$home/data/backlog.md" + fm_write_meta "$home/state/active-1.meta" \ + 'window=firstmate:fm-active-1' \ + "worktree=$home/worktree" \ + 'kind=ship' \ + 'pr=https://github.com/example/firstmate/pull/123' \ + 'mode=no-mistakes' + printf '%s\n' 'needs-decision: PR held for captain merge [key=merge-hold]' > "$home/state/active-1.status" + + capture "$root" "$home" manual + anchor="$home/data/autocompact-resume.md" + assert_present "$anchor" "PreCompact did not write the resume anchor" + first=$(cat "$anchor") + assert_contains "$first" "Trigger: \`manual\`" "manual trigger was not captured" + assert_contains "$first" 'in_flight[1]' "live fleet pickup state is missing" + assert_contains "$first" 'PR held for captain merge' "held merge decision is missing" + assert_contains "$first" 'https://github.com/example/firstmate/pull/123' "recorded PR is missing" + assert_contains "$first" '# backlog-v1' "full backlog is missing" + assert_contains "$first" 'window=firstmate:fm-active-1' "raw in-flight metadata is missing" + + printf '%s\n' '# backlog-v2' '## Queued' '- [ ] queued-2 - Replacement next step' > "$home/data/backlog.md" + capture "$root" "$home" auto + second=$(cat "$anchor") + assert_contains "$second" "Trigger: \`auto\`" "automatic trigger was not captured" + assert_contains "$second" '# backlog-v2' "fresh backlog did not replace the prior anchor" + assert_not_contains "$second" '# backlog-v1' "capture appended instead of atomically replacing the anchor" + pass "PreCompact atomically refreshes all durable pickup surfaces" +} + +test_capture_is_inert_in_child_worktree() { + local parent="$TMP_ROOT/worktree/parent" child="$TMP_ROOT/worktree/child" home="$TMP_ROOT/worktree/home" out + fm_git_worktree "$parent" "$child" task-branch + mkdir -p "$child/bin" "$home/state" "$home/data" + printf '# Firstmate fixture\n' > "$child/AGENTS.md" + out=$(capture "$child" "$home" auto 2>&1) + [ -z "$out" ] || fail "child worktree capture was noisy: $out" + assert_absent "$home/data/autocompact-resume.md" "child worktree wrote a primary resume anchor" + pass "tracked hook is a silent no-op in a crewmate worktree" +} + +test_capture_failure_blocks_compaction() { + local rec root home rc out + rec=$(new_primary failure) + IFS='|' read -r root home <&1) + rc=$? + set -e + expect_code 2 "$rc" "failed primary capture" + assert_contains "$out" 'FIRSTMATE AUTOCOMPACT CAPTURE FAILED' "capture failure was not surfaced" + assert_absent "$home/data/autocompact-resume.md" "failed capture published a partial anchor" + pass "an in-scope capture failure blocks compaction instead of silently losing the anchor" +} + +test_capture_and_recovery_do_not_require_jq() { + local rec root home no_jq anchor capture_out recover_out + rec=$(new_primary no-jq) + IFS='|' read -r root home < "$home/data/backlog.md" + fm_write_meta "$home/state/no-jq-1.meta" 'window=firstmate:fm-no-jq-1' 'kind=ship' + no_jq="$TMP_ROOT/no-jq/bash-env" + mkdir -p "$(dirname "$no_jq")" + cat > "$no_jq" <<'EOF' +command() { + if [ "${1:-}" = -v ] && [ "${2:-}" = jq ]; then + return 1 + fi + builtin command "$@" +} +jq() { + return 127 +} +EOF + + capture_out=$(printf '%s\n' '{"hook_event_name":"PreCompact","trigger":"auto","session_id":"session-no-jq","transcript_path":"/tmp/no-jq.jsonl"}' \ + | BASH_ENV="$no_jq" FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$AUTOCOMPACT" capture 2>&1) + anchor="$home/data/autocompact-resume.md" + assert_present "$anchor" "capture without jq did not publish an anchor" + assert_contains "$capture_out" 'FIRSTMATE AUTOCOMPACT CAPTURE LIMITED' "missing jq was not surfaced loudly" + assert_contains "$(cat "$anchor")" 'LIMITED - jq is unavailable' "limited anchor did not explain the omitted projection" + assert_contains "$(cat "$anchor")" '# no-jq-backlog' "capture without jq omitted the raw backlog" + assert_contains "$(cat "$anchor")" 'window=firstmate:fm-no-jq-1' "capture without jq omitted in-flight metadata" + + recover_out=$(printf '%s\n' '{"hook_event_name":"SessionStart","source":"compact","session_id":"session-no-jq"}' \ + | BASH_ENV="$no_jq" FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$AUTOCOMPACT" recover) + assert_contains "$recover_out" 'FIRSTMATE AUTOCOMPACT RECOVERY CONTEXT' "recovery without jq emitted no context" + assert_contains "$recover_out" '# no-jq-backlog' "recovery without jq omitted the durable anchor" + assert_contains "$recover_out" 'NORMAL SESSION-START RECONCILIATION' "recovery without jq skipped reconciliation output" + pass "capture and compact recovery preserve durable context without jq" +} + +test_intermediate_render_failure_preserves_prior_anchor() { + local rec root home anchor prior out rc fakebin real_sed + local -a leftovers + rec=$(new_primary render-failure) + IFS='|' read -r root home < "$home/data/backlog.md" + capture "$root" "$home" auto + anchor="$home/data/autocompact-resume.md" + prior=$(cat "$anchor") + printf '%s\n' '# render-v2' > "$home/data/backlog.md" + fakebin=$(fm_fakebin "$TMP_ROOT/render-failure") + real_sed=$(command -v sed) + cat > "$fakebin/sed" <<'EOF' +#!/usr/bin/env bash +if [ "${!#}" = "$FM_AUTOCOMPACT_FAIL_FILE" ]; then + exit 71 +fi +exec "$FM_AUTOCOMPACT_REAL_SED" "$@" +EOF + chmod +x "$fakebin/sed" + + set +e + out=$(printf '%s\n' '{"hook_event_name":"PreCompact","trigger":"auto","session_id":"session-render","transcript_path":"/tmp/render.jsonl"}' \ + | PATH="$fakebin:$PATH" \ + FM_AUTOCOMPACT_FAIL_FILE="$home/data/backlog.md" \ + FM_AUTOCOMPACT_REAL_SED="$real_sed" \ + FM_ROOT_OVERRIDE="$root" \ + FM_HOME="$home" \ + "$AUTOCOMPACT" capture 2>&1) + rc=$? + set -e + expect_code 2 "$rc" "intermediate anchor render failure" + assert_contains "$out" 'could not render the resume anchor' "intermediate render failure was not surfaced" + [ "$(cat "$anchor")" = "$prior" ] || fail "intermediate render failure replaced the prior good anchor" + shopt -s nullglob + leftovers=("$home/data"/.autocompact-resume.md.*) + shopt -u nullglob + [ "${#leftovers[@]}" -eq 0 ] || fail "intermediate render failure left a temporary anchor" + pass "every intermediate render failure blocks partial anchor publication" +} + +test_compact_sessionstart_injects_anchor_and_reconciles() { + local rec root home out + rec=$(new_primary recover) + IFS='|' read -r root home < "$home/data/backlog.md" + fm_write_meta "$home/state/active-1.meta" 'window=firstmate:fm-active-1' 'kind=ship' + capture "$root" "$home" auto + + out=$(printf '%s\n' '{"hook_event_name":"SessionStart","source":"compact","session_id":"session-auto"}' \ + | FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$AUTOCOMPACT" recover) + assert_contains "$out" 'FIRSTMATE AUTOCOMPACT RECOVERY CONTEXT' "recovery context marker is missing" + assert_contains "$out" '# Autocompact resume anchor' "fresh anchor was not re-read" + assert_contains "$out" 'SESSION START -' "normal session-start reconciliation did not run" + assert_contains "$out" '# recovery-backlog' "session-start did not read the current backlog" + assert_contains "$out" 'window=firstmate:fm-active-1' "session-start did not read in-flight metadata" + pass "compact SessionStart re-reads the anchor and runs normal durable-state reconciliation" +} + +test_recovery_payload_failures_still_emit_durable_context() { + local rec root home out payload fakebin real_cat + rec=$(new_primary recover-payload-failure) + IFS='|' read -r root home < "$home/data/backlog.md" + capture "$root" "$home" auto + + for payload in '' '{not-json' '{"hook_event_name":"SessionStart"}'; do + out=$(printf '%s' "$payload" \ + | FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$AUTOCOMPACT" recover) + assert_contains "$out" 'FIRSTMATE AUTOCOMPACT RECOVERY WARNING' "invalid recovery payload was not surfaced" + assert_contains "$out" '# recovery-payload-fallback' "invalid recovery payload suppressed the durable anchor" + assert_contains "$out" 'NORMAL SESSION-START RECONCILIATION' "invalid recovery payload suppressed reconciliation" + done + + fakebin=$(fm_fakebin "$TMP_ROOT/recover-payload-failure") + real_cat=$(command -v cat) + cat > "$fakebin/cat" <<'EOF' +#!/usr/bin/env bash +if [ "$#" -eq 0 ]; then + exit 72 +fi +exec "$FM_AUTOCOMPACT_REAL_CAT" "$@" +EOF + chmod +x "$fakebin/cat" + out=$(printf '%s\n' '{"hook_event_name":"SessionStart","source":"compact"}' \ + | PATH="$fakebin:$PATH" \ + FM_AUTOCOMPACT_REAL_CAT="$real_cat" \ + FM_ROOT_OVERRIDE="$root" \ + FM_HOME="$home" \ + "$AUTOCOMPACT" recover) + assert_contains "$out" 'FIRSTMATE AUTOCOMPACT RECOVERY WARNING' "unreadable recovery payload was not surfaced" + assert_contains "$out" '# recovery-payload-fallback' "unreadable recovery payload suppressed the durable anchor" + assert_contains "$out" 'NORMAL SESSION-START RECONCILIATION' "unreadable recovery payload suppressed reconciliation" + pass "recovery payload failures still emit all durable context" +} + +test_noncompact_sessionstart_is_inert() { + local rec root home out + rec=$(new_primary noncompact) + IFS='|' read -r root home <