This is the authoritative contract for the "no turn ends blind" primary guard referenced from AGENTS.md section 8.
The shared predicate lives in bin/fm-turnend-guard.sh.
Harness-specific tracked hook files only adapt each verified harness's real turn-end mechanism to that shared predicate.
Two related but separate PreToolUse seatbelts deny a bad command shape before it runs rather than detecting a blind turn end afterward: the watcher-arm seatbelt (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md) and the cd-guard (bin/fm-cd-pretool-check.sh, docs/cd-guard.md), which reuses this guard's linked-worktree exemption but deliberately remains active in secondmate homes.
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 and there is no live identity-matched watcher with a fresh beacon, a harness hook must either block the turn end or force a bounded follow-up turn that tells the primary to resume the session-start supervision protocol for its harness.
The guard first scopes itself to the real primary checkout.
It is inert in secondmate homes because .fm-secondmate-home exists there.
It is inert in crewmate and scout worktrees 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 in-flight work from state/*.meta.
If no task is in flight, it exits silently.
If work is in flight, it requires fm_watcher_healthy <state-dir> <watch-path> [grace-seconds] [home] from bin/fm-wake-lib.sh.
That is the same identity-matched live lock and fresh beacon check used by bin/fm-watch-arm.sh.
A stale beacon blocks even if a watcher pid is still live.
A fresh leftover beacon blocks if the watcher lock is missing, dead, or identity-mismatched.
FM_STATE_OVERRIDE wins over FM_HOME/state, and FM_HOME wins over repo-root state/.
FM_GUARD_GRACE controls the 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.
All verified primary harnesses have a tracked integration:
claude:.claude/settings.jsonregisters aStophook command anchored through"$CLAUDE_PROJECT_DIR"/bin/fm-turnend-guard.sh.codex:.codex/hooks.jsonregisters aStophook 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'sbin/fm-turnend-guard.sh.opencode:.opencode/plugins/fm-primary-turnend-guard.jslistens forsession.idle, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and usesclient.session.promptAsyncto force one follow-up prompt when the guard returns 2.pi:.pi/extensions/fm-primary-turnend-guard.tslistens foragent_settled, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and usespi.sendUserMessage(..., { deliverAs: "followUp" })to force one follow-up prompt when the guard returns 2.grok:.grok/hooks/fm-primary-turnend-guard.jsonregisters aStophook that invokesbin/fm-turnend-guard-grok.sh. The adapter runs the shared guard and, when it returns 2, invokesgrok --resume <sessionId> -p <guard-reason>withGROK_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.
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.
All harnesses were validated on 2026-07-08 in scratch repos or throwaway homes, not against the captain's live primary fleet state.
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.
The complete pane contained one guard message and zero foreground bin/fm-watch-arm.sh bash calls.
/quit printed PI_EXIT=0, and the second arm process plus its watcher child were both gone afterward.
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.
tests/fm-turnend-guard.test.sh covers the shared predicate, primary scoping, FM_HOME and FM_STATE_OVERRIDE precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without jq, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression.
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.