Skip to content

Latest commit

 

History

History
221 lines (182 loc) · 28.4 KB

File metadata and controls

221 lines (182 loc) · 28.4 KB

Primary turn-end supervision guard

This is the authoritative contract for the "no turn ends blind" primary guard referenced from AGENTS.md section 8. The turn-end supervision predicate lives in bin/fm-turnend-guard.sh. Its primary-checkout scope lives in bin/fm-primary-scope-lib.sh, shared with the native session-start nudge documented in docs/sessionstart-nudge.md. Harness-specific tracked hook files only adapt each verified harness's real turn-end mechanism to that shared predicate. Related but separate PreToolUse guards deny a bad tool or command shape before it runs rather than detecting a blind turn end afterward: the supervision-arm seatbelt (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md), the cd-guard (bin/fm-cd-pretool-check.sh, docs/cd-guard.md), the primary delegation-shape guard (bin/fm-subagent-pretool-check.sh, docs/subagent-guard.md), and the lavish-guard (bin/fm-lavish-pretool-check.sh, docs/lavish-access.md), which unlike the others is not primary-checkout-scoped. Each guard's own document defines its scope; do not infer this guard's scoping, loop safety, or fail-open tradeoffs for its PreToolUse siblings.

Gap Closed

bin/fm-guard.sh is pull-based: it warns whenever some other supervision script happens to run, and prints nothing otherwise. The primary can otherwise end a turn after handling wakes without resuming supervision, then sit blind until another fleet command happens to run. On 2026-07-04, that exact gap left a parked no-mistakes gate unwatched for about nine hours.

bin/fm-turnend-guard.sh closes the gap by checking the primary's own turn-end path. When tasks are in flight, a harness hook must either prove both supervision halves or force a bounded follow-up turn with the typed repair for whichever half is missing.

Shared Predicate

The guard first calls the shared primary scope to constrain itself to a real primary checkout. A secondmate home runs its own primary firstmate session, so a genuine .fm-secondmate-home marker force-includes it whether treehouse leased it as a linked worktree or it is a git-cloned plain checkout. The marker must be a regular non-symlink file whose first line, after all whitespace is removed, contains a non-empty identifier made only of letters, digits, dots, underscores, and dashes. An unmarked checkout, or one with an invalid marker, falls through to the git-dir check. That check keeps crewmate and scout worktrees inert because firstmate provisions them as linked git worktrees, where git rev-parse --git-dir differs from git rev-parse --git-common-dir. It also requires AGENTS.md, bin/, and the effective state directory to exist.

For an in-scope primary checkout, it counts supervision-relevant work from state/*.meta. A kind=secondmate record with explicit state=resting remains registered but is excluded; every ordinary task, active secondmate, and legacy secondmate with no state still counts. An unread record in state/.wake-queue is protected work in its own right, because a terminal wake can outlive the state/*.meta record it came from: the guard stays active whenever FM_SUP_IN_FLIGHT is above zero or FM_SUP_QUEUE_PENDING is true, and the daemon-down banner then names the queued wakes instead of an in-flight count. That queue state is also passed to the repair-line renderer as --queue-pending, exactly as bin/fm-guard.sh does, so a session-delivery repair says the queued wakes must be drained before re-arming. If neither remains, it exits silently. If work is protected, the first half requires fm_watcher_healthy <state-dir> <watch-path> [grace-seconds] [home] from bin/fm-wake-lib.sh. That daemon predicate is unchanged: it requires an identity-matched live watcher lock and a fresh beacon. A stale beacon blocks even if the watcher pid is still live, and a fresh leftover beacon blocks if the watcher lock is missing, dead, or identity-mismatched. The second half requires fm_wake_stub_armed <state-dir> <stub-path> [home] from the same library. That delivery predicate requires a live pid whose recorded executable identity, home, and current session-lock pid match state/.wake-stub.lock. The stub needs no beacon because it is a pure blocking wait over the durable queue. Daemon failure and delivery failure produce separate repair lines. The daemon line names the scoped systemd instance restart or tmux keeper repair and is treated as a real supervision incident. The delivery line points to the active harness protocol and costs one cheap re-arm. While state/.afk is present, the away daemon consumes new durable queue records and intentionally replaces the session stub. The away flag alone does not satisfy the delivery half. fm_pusher_healthy <state-dir> requires state/.supervise-daemon.pid to name a live pid that matches the portable lock's pid and recorded process identity. Those are exactly the three files bin/fm-supervise-daemon.sh publishes at startup, and tests/fm-daemon.test.sh pins the predicate against that real publication rather than a fixture. Watcher health remains independently mandatory. The away delivery repair line names the concrete daemon relaunch the /afk skill prescribes for the resolved harness, and never tells an away session to arm a session delivery wait. A harness with a native tracked-background tool (claude, grok) is sent down the no-separate-terminal path: bin/fm-afk-launch.sh start-native, then FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh through that tool, with bin/fm-afk-launch.sh stop as the rollback when that native launch fails. Because the repair runs mid-away-session rather than at a fresh away entry, the line states in place that stop exits away mode by clearing state/.afk and must be followed immediately by a fresh away entry; that consequence is never left implicit. Every other harness gets the terminal-backed bin/fm-afk-launch.sh start. The away branch is deliberately never prefixed with the drain instruction, because the away daemon reads the durable queue through its own cursor and the session must not drain it. For the same reason the banner's closing maintenance line also branches on away mode: an away turn is told to restore away delivery and end silently, while a normal session keeps the drain-and-restore wording. An away banner that named a drain would be a data-loss instruction, since bin/fm-wake-drain.sh would consume records the daemon's cursor has not read yet; bin/fm-afk-return.sh owns that drain at return time instead.

If the away daemon dies during an unattended stretch, the next attempted turn end forces one maintenance continuation that directs the agent to restore away delivery. The hook loop guard then permits that turn to stop, so daemon death cannot create an autonomous endless continuation loop. A later independent turn alerts again until delivery is repaired. The daemon-owned max-defer alarm cannot run after the daemon itself dies, so it is not treated as substitute coverage; queued wakes remain durable and the turn-end force is the recovery signal.

FM_STATE_OVERRIDE wins over FM_HOME/state, and FM_HOME wins over repo-root state/. FM_GUARD_GRACE controls only the daemon beacon freshness window and defaults to 300 seconds. If jq is missing or hook stdin is empty, the guard fails open and exits 0 because it cannot safely read loop-guard fields.

Harness Integrations

All verified primary harnesses have a tracked integration:

  • claude: .claude/settings.json registers a Stop hook command anchored through "$CLAUDE_PROJECT_DIR"/bin/fm-turnend-guard.sh.
  • codex: .codex/hooks.json registers a Stop hook that reads the hook payload once, anchors the executable to the hook command process working directory, verifies that root is firstmate-shaped and hook-bearing, and pipes the original payload to that checkout's bin/fm-turnend-guard.sh.
  • opencode: .opencode/plugins/fm-primary-turnend-guard.js listens for session.idle, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and uses client.session.promptAsync to force one follow-up prompt when the guard returns 2.
  • pi: .pi/extensions/fm-primary-turnend-guard.ts listens for agent_settled, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and calls the shared structured delivery helper with the explicit turn-end-guard kind and deliverAs: "followUp" when the guard returns 2.
  • grok: .grok/hooks/fm-primary-turnend-guard.json registers a Stop hook that invokes bin/fm-turnend-guard-grok.sh. The adapter runs the shared guard and, when it returns 2, invokes grok --resume <sessionId> -p <guard-reason> with GROK_TURNEND_GUARD_ACTIVE=1. It does not pass --permission-mode, so the passive Stop hook cannot grant stronger tool permissions than Grok's resumed-session default.

Claude and Codex support a direct blocking Stop hook. For those harnesses, exit status 2 plus stderr from bin/fm-turnend-guard.sh blocks the stop and feeds the reason back into the model. Both payloads include stop_hook_active; when it is true, the shared guard exits 0 so the harness can end after one forced continuation.

OpenCode, Pi, and Grok expose passive lifecycle callbacks for this purpose. Their adapters fail open at the hook boundary to avoid corrupting a user session, but they force one follow-up turn when the shared predicate blocks. Those forced user-role prompts use the canonical turn-end-guard operational kind after the U+2063 FIRSTMATE_OP: prefix so Ahoy cannot mistake them for captain-authored boundaries. Each adapter carries its own in-process or environment loop guard so the forced follow-up does not recursively schedule another follow-up. Pi keeps that latch active across every internal tool turn and clears it only when the generated guard follow-up reaches agent_settled, or immediately when follow-up delivery fails. If a passive adapter cannot call its SDK method, cannot find grok, or cannot recover the Grok session id, it fails open and relies on the pull-based fm-guard.sh warning at the next fleet command. That warning uses bin/fm-supervision-instructions.sh --repair-line, so it points back to the active harness protocol instead of hardcoding one background-arm command.

Empirical Validation

All harnesses were validated on 2026-07-08 in scratch repos or throwaway homes, not against the captain's live primary fleet state.

The external-loop predicate was validated on 2026-07-23 against the real systemd 255 user manager on Linux with a disposable FM_HOME. SYSTEMD_UNIT_PATH="$PWD/systemd:/usr/lib/systemd/user" systemd-analyze --user verify "fm-watch@$(systemd-escape --path "$PWD").service" accepted the tracked template with no diagnostics after instantiated /%I path expansion. FM_SYSTEMD_LIVE=1 tests/fm-watcher-systemd-smoke.test.sh created a transient per-home unit with Restart=always, never installed or enabled a persistent unit, and established the unchanged identity-matched lock plus fresh-beacon predicate. The smoke armed bin/fm-wake-wait.sh, verified its pid, executable, home, and session-lock identity, sent the stub SIGTERM, and observed an empty durable queue afterward. One re-arm then delivered one queued wake while leaving exactly one record in state/.wake-queue for the model-owned drain. Sending SIGTERM to the systemd-owned watcher produced a different healthy watcher pid under the same active unit, proving daemon failure and delivery-stub failure have independent recovery paths. The default automated suite leaves this test skipped unless FM_SYSTEMD_LIVE=1 because it intentionally exercises the host's real user manager.

Claude Code 2.1.204 preserved the existing behavior. Hook file used: .claude/settings.json. Command run: claude -p "Say hi in exactly one word." --dangerously-skip-permissions --output-format json with a scratch Stop hook that printed SMOKETEST: you must say the word BANANA before stopping and exited 2. Observed output: the first stop payload had stop_hook_active=false, the stop was blocked, the model continued with BANANA, and the second stop payload had stop_hook_active=true and was allowed. Earlier validation on 2026-07-04 also verified that CLAUDE_PROJECT_DIR is set to the settings-loaded project root, while the hook command itself runs from the session cwd.

Codex codex-cli 0.142.1 was validated with a scratch .codex/hooks.json Stop hook. Hook file used: .codex/hooks.json. Command run: codex exec --dangerously-bypass-hook-trust --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --output-last-message last.txt 'Say hi in exactly one word.'. Observed output: the first model output was Hi, the Stop hook exited 2, Codex logged hook: Stop Blocked, the model continued with CODEXHOOK, and the second hook call had stop_hook_active=true. The Stop payload included cwd. Command run for root-signal probe: codex exec --ephemeral --json --dangerously-bypass-hook-trust --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --output-last-message last.txt 'Use the shell tool to run mkdir -p outside && cd outside && pwd, then use the shell tool again to run pwd. Your final answer must include the two observed outputs.'. Observed output: the first command printed <scratch>/outside, the second command printed <scratch>, the Stop hook process pwd -P printed <scratch>, payload cwd printed <scratch>, and CODEX_PROJECT_DIR, CODEX_WORKSPACE_ROOT, and CODEX_CWD were empty. The tracked command therefore treats hook process PWD as the hook-loaded firstmate root and does not let payload cwd choose an executable. It still passes the original payload to bin/fm-turnend-guard.sh, so the shared loop guard reads stop_hook_active.

OpenCode 1.17.6 was validated with project plugins under scratch .opencode/plugins/. Hook file used: .opencode/plugins/fm-smoke.js for throw testing and .opencode/plugins/fm-primary-turnend-guard.js for follow-up testing. Command run for passive behavior: opencode run --print-logs --log-level DEBUG --dangerously-skip-permissions 'Say hi in exactly one word.'. Observed output: the plugin received session.idle, threw an error, and opencode run still exited 0 with Hi, proving session.idle cannot block directly. Command run for follow-up behavior: OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' opencode --prompt 'Say hi in exactly one word.' --print-logs --log-level INFO. Observed output: the plugin called client.session.promptAsync, the TUI ran a second turn, and the second model output contained OPENCODEHOOK. In noninteractive opencode run, promptAsync returned successfully but the process exited before displaying the follow-up, so this adapter is trusted for primary TUI sessions and documented as passive/fail-open in headless mode.

Pi 0.80.5 was re-validated on 2026-07-09 in a disposable primary-shaped clone with isolated PI_CODING_AGENT_DIR, isolated FM_HOME, and tmux socket fm-pi-q6-lab. Hook files used: the tracked .pi/extensions/fm-primary-turnend-guard.ts and .pi/extensions/fm-primary-pi-watch.ts. Commands run inside separate interactive turns: printf PI_E2E_BASH_ONE through Pi's bash tool, README.md:1-5 through Pi's read tool, and printf PI_E2E_BASH_TWO through Pi's bash tool. Command used to make the shared predicate unhealthy: : > "$FM_HOME/state/pi-e2e.meta". The next no-tool prompt produced exactly one TURN WOULD END BLIND follow-up, and that follow-up called fm_watch_arm_pi once with output watcher: started Pi extension arm child 1. The three earlier tool turns produced no guard follow-up because no work was in flight. Command used to fire the watcher: printf 'done: pi e2e watcher fire\n' > "$FM_HOME/state/pi-e2e.status". Observed output after the wake: Pi ran bin/fm-wake-drain.sh, read the terminal status, called fm_watch_arm_pi, and rendered watcher: started Pi extension arm child 2. This 2026-07-09 observation predates the external watcher service; the current Pi child is only the delivery stub, and post-drain re-arming remains model-owned. The complete pane contained one guard message and zero foreground bin/fm-watch-arm.sh bash calls. /quit printed PI_EXIT=0, and the legacy arm process plus its watcher child were both gone afterward. That cleanup observation is historical rather than evidence that the current service should stop with Pi; the 2026-07-23 real-systemd smoke above owns the current service-lifetime evidence.

Grok 0.2.91 was validated with a scratch GROK_HOME and symlinked auth/config. Hook file used for tracked project-hook loading: <scratch-project>/.grok/hooks/fm-smoke.json, matching the tracked .grok/hooks/fm-primary-turnend-guard.json location. Command run for project-hook loading: GROK_HOME="$scratch/grok-home" grok --trust -p 'Say hi in exactly one word.' --permission-mode bypassPermissions --output-format plain --leader-socket "$scratch/leader.sock". Observed output: the project Stop hook fired under --trust and received GROK_HOOK_EVENT=stop, GROK_WORKSPACE_ROOT, and a payload containing sessionId. Hook file used for passive behavior and forced-resume behavior: $GROK_HOME/hooks/fm-primary-turnend-guard.json plus bin/fm-turnend-guard-grok.sh. Command run for passive behavior: GROK_HOME="$scratch/grok-home" grok -p 'Say hi in exactly one word.' --permission-mode bypassPermissions --output-format plain --leader-socket "$scratch/leader.sock". Observed output: the global Stop hook fired and received GROK_HOOK_EVENT=stop, GROK_WORKSPACE_ROOT, and a payload containing sessionId, but exiting 2 did not make the model continue. Command run for forced resume behavior: the Stop hook ran GROK_TURNEND_GUARD_ACTIVE=1 GROK_HOME="$scratch/grok-home" grok --resume "$session_id" -p 'SMOKETEST: say exactly GROKRESUMEHOOK...' --permission-mode bypassPermissions --output-format plain --leader-socket "$scratch/leader.sock". Observed output: the outer turn printed Hi, the nested resumed turn printed GROKRESUMEHOOK, and the nested Stop hook saw GROK_TURNEND_GUARD_ACTIVE=1 and did not recurse. That validation command used --permission-mode bypassPermissions only to keep the scratch smoke unattended; the tracked adapter intentionally omits --permission-mode. Project-local Grok hooks did not fire in scratch single mode without a trust grant. The primary integration therefore requires the primary firstmate checkout to be trusted for Grok hooks, which can be done with /hooks-trust or launch-time --trust. If Grok declines to load project hooks, this primary guard fails open and fm-guard.sh remains the next-command alarm.

2026-07-09 update: grok 0.2.93 broke the .grok/hooks/fm-primary-turnend-guard.json Stop hook with hook not executed: required env var(s) not set: ${root}, because grok's own ${VAR} expansion over the raw command string does not tolerate a bare local variable assigned earlier in the same bash -lc script. The hook command was fixed to reference ${GROK_WORKSPACE_ROOT:-} directly everywhere instead of assigning it to $root first, and re-validated against grok 0.2.93 to fire and complete cleanly. See docs/arm-pretool-check.md's "Harness wiring" section for the same Grok expansion requirement; that document's Grok hook shares the same fix.

2026-07-12: secondmate-home enablement and the autonomous background-notify wake

The guard originally early-exited in every secondmate home on the .fm-secondmate-home marker. That was a scoping choice inherited from the guard's primary-only origin, not a defense against any secondmate-specific hazard. A genuinely marked secondmate home is now force-included as a guarded primary regardless of whether it is a treehouse-leased linked worktree or a git-cloned plain checkout. Only unmarked child worktrees fall through to the linked-worktree exemption, and marker validation prevents an empty, malformed, or symlink marker from spoofing inclusion.

"No turn ends blind" for a secondmate is delivered by the same two mechanisms the main primary relies on. Mechanism B, the turn-end backstop, is this guard; its secondmate-home behavior is covered by hermetic tests in tests/fm-turnend-guard.test.sh (test_hook_blocks_in_secondmate_own_home, test_hook_blocks_in_treehouse_leased_secondmate_home, test_hook_silent_in_idle_secondmate_home, test_hook_silent_with_resting_secondmate_direct_report, test_hook_secondmate_loop_guard_allows_retry, test_hook_secondmate_reinvoke_recovery_loop, test_hook_silent_in_secondmate_child_worktree, and test_hook_exempts_linked_worktree_with_stray_marker). Mechanism A, the autonomous wake, is a harness property; the emitted supervision protocol owns whether the model or an extension/plugin re-arms the delivery wait after handling that wake. Mechanism A cannot be a hermetic CI assertion because it requires a live model session, so it is recorded here as a dated first-hand measurement while test_hook_secondmate_reinvoke_recovery_loop covers the guard's deterministic half of the same recovery loop.

Autonomous-re-invoke measurement, run first-hand on Claude Code 2.1.207 (Darwin 25.5.0) on 2026-07-12. Procedure: launch a detached run_in_background Bash task that models a one-shot watcher - it records a launch epoch, runs sleep 25, then records a completion epoch just before exit, writing only to the session scratchpad - then end the turn with no further tool calls and no pending question, a genuinely idle session with no human input. Observed marker timestamps:

launch_epoch    = 1783890980   (14:16:20)   turn ends, session goes idle
complete_epoch  = 1783891005   (14:16:45)   background task exits, 25s idle
reinvoke_epoch  = 1783891016   (14:16:56)   MODEL RE-INVOKED
--------------------------------------------------------------
wake latency (task complete -> model re-invoked): 11s, with ZERO human input

The re-invocation arrived as a <task-notification> whose accompanying system notice stated verbatim "No human input has been received since the last genuine user message in this conversation". So the model was re-invoked solely by the background task's completion while idle, which is Mechanism A - the same background-notify wake the Claude supervision protocol relies on for the main primary. This matches the harness tool contract that a run_in_background task "keeps running across turns and re-invokes you when it exits", and reproduces the 11s latency the task audit measured independently on the same harness version. No Herdr command was issued and no fleet state was touched; the experiment wrote only to the session scratchpad, which was discarded.

2026-07-25: the per-task crewmate hook moved off .claude/settings.local.json

This subsection covers the per-task CREWMATE turn-end hook that bin/fm-spawn.sh writes, not the primary guard the rest of this document owns. It is recorded here because this document already holds every Stop-hook validation transcript.

fm-spawn used to write that hook with a truncating redirect to <worktree>/.claude/settings.local.json. Claude Code writes that same path at runtime whenever a session records a permission, and a repository is free to track it, so the redirect silently destroyed any tracked content at that path for every firstmate-on-itself crewmate or scout. Teardown then refused forever, because its dirty-worktree filter only skipped that path when git reported it as untracked (?? ), so a tracked-and-dirty copy reached the uncommitted-changes check and only --force could clear it. The hook now goes to the distinct <worktree>/.claude/settings.fm-task.json, and teardown's filter skips exactly that one filename whether it is untracked or tracked-and-dirty.

Claude Code does not read arbitrary sibling settings files, so the rename alone would have silently disarmed the crewmate turn-end signal. The claude launch template therefore passes --settings <worktree>/.claude/settings.fm-task.json, and the pairing was validated first-hand rather than by reading code, because the whole failure mode is "looked right, silently did nothing".

Validated on 2026-07-25 against Claude Code 2.1.220 in a scratch git repo that tracked a .claude/settings.local.json containing {"permissions":{"allow":["Bash(git status:*)"]}}, with the overlay carrying the real touch Stop hook fm-spawn generates. Print-mode command run: CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --settings <wt>/.claude/settings.fm-task.json -p "Reply with exactly: ok". Observed output: the marker file was created, the tracked settings.local.json was byte-identical afterward, and Claude emitted Ignoring 1 permissions.allow entry from .claude/settings.local.json: this workspace has not been trusted, which proves it read BOTH files and that --settings adds to the merged settings rather than replacing the project scopes. Interactive command run, the exact shape fm-spawn launches: the same command without -p and with the brief as a positional prompt, started detached in a tmux pane. Observed output: the launch first showed the trust dialog (expected in a fresh untrusted scratch folder, and the dialog explicitly named the tracked settings.local.json pre-approval), the marker appeared within about 9 seconds of accepting it, a second prompt in the same session re-created the marker after it was deleted, confirming the hook fires at every turn boundary and not only at session end, and git status --porcelain reported only ?? .claude/settings.fm-task.json. Negative control command run: the identical print-mode invocation with the overlay file still present but --settings omitted. Observed output: the model answered normally and the marker was NOT created, confirming the flag is load-bearing and that shipping the rename without it would have disarmed supervision for every Claude crewmate.

Because the filename and the flag are coupled, fm-spawn only writes the overlay when the resolved launch command actually names it. The raw launch-command escape hatch therefore gets --settings <overlay> injected after the program word, but only when that word is genuinely claude (an exact name or a path whose basename is claude). A claude-shaped wrapper such as claude-yolo may not accept the flag, and splicing an unknown flag into its argv would turn a missing turn-end signal into a crewmate that never launches, so a wrapper - like a raw command carrying its own --settings - gets no overlay at all plus a warning that the turn-end signal is unarmed.

Teardown removes a pre-upgrade worktree's legacy .claude/settings.local.json hook only when git affirmatively reports that path untracked and it still holds firstmate's generated turn-end touch. Every inconclusive git answer - no git, not a work tree, a transient failure - keeps the file, so the failure direction is never the data loss this rename exists to prevent. A repository-tracked copy is left untouched for the same reason.

Firstmate deliberately does not track .claude/settings.local.json anywhere, and the tracked root .gitignore now also keeps it and its sibling harness runtime artifacts out of a checkout's porcelain status. configuration.md "Operational home layout and state" owns that contract and the self-update reason behind it; tests/fm-runtime-ignore.test.sh regresses it.

Tests

tests/fm-turnend-guard.test.sh covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, FM_HOME and FM_STATE_OVERRIDE precedence, dead and healthy away-pusher states with queued wakes, queued wakes with no state/*.meta record left, the drain-first delivery repair a queued-wake activation prints, both closing-banner branches (away restores delivery and never names a drain anywhere in the banner; a normal session keeps drain-and-restore), fail-open behavior without jq, tracked hook registration for all five harnesses, and passive-adapter loop guards. The per-task crewmate overlay recorded in the 2026-07-25 subsection is pinned from the spawn side by tests/fm-spawn-dispatch-profile.test.sh (a repository-tracked .claude/settings.local.json survives a claude spawn, the overlay is written under the distinct name and git-excluded, and the launch line carries --settings for it) and from the teardown side by tests/fm-teardown.test.sh cases (z) through (dd) (a tracked-and-dirty overlay still tears down, that tolerance does not mask other genuine uncommitted work, and a repository-tracked legacy settings.local.json is never removed). The default behavior suite does not invoke live language-model harnesses. FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh opts into the isolated interactive Pi regression recorded above.