Skip to content

Latest commit

 

History

History
97 lines (78 loc) · 9.49 KB

File metadata and controls

97 lines (78 loc) · 9.49 KB

Primary turn-end supervision guard

This is the authoritative current contract for the "no turn ends blind" primary backstop referenced from AGENTS.md section 8. The predicate lives in bin/fm-turnend-guard.sh. Primary scope lives in bin/fm-primary-scope-lib.sh, shared with the native session-start nudge in sessionstart-nudge.md. Harness hook files adapt each enabled primary harness integration's turn-end mechanism to that shared predicate.

Related PreToolUse guards deny unsafe commands before execution rather than detecting a blind turn end afterward. Their separate owners are arm-pretool-check.md, cd-guard.md, and subagent-guard.md. Do not infer this guard's scope, loop safety, or compatibility tradeoffs for those guards.

Current invariant

bin/fm-guard.sh is a pull-based warning that runs only when another supervision command invokes it. The turn-end guard closes the remaining gap at the primary's own turn boundary. When work is in flight and no identity-matched watcher has a fresh beacon, the harness integration must either block the turn end or force one bounded follow-up that uses the recovery instruction from the emitted session-start protocol. The guard remains a backstop; watcher-continuity.md owns normal continuity.

Shared predicate

The guard first calls the shared primary scope. A secondmate home runs its own primary Firstmate session, so a genuine .fm-secondmate-home marker includes it whether the home is a linked worktree or plain clone. The marker must be a regular non-symlink file whose whitespace-stripped first line is a non-empty identifier containing only letters, digits, dots, underscores, and dashes. An unmarked checkout or invalid marker falls through to the git-dir check. That check keeps crewmate and scout linked worktrees inert because their git dir differs from their git common dir. It also requires AGENTS.md, bin/, and the effective state directory.

For an in-scope primary, the guard counts in-flight work from state/*.meta. The default cross-harness mode exits silently with no work in flight. Claude's --claude mode also treats state/x-watch.check.sh as supervision need, so X-mode relay polling remains guarded without an in-flight task. Otherwise it calls fm_watcher_healthy <state-dir> <watch-path> [grace-seconds] [home] from bin/fm-wake-lib.sh, the same identity-matched lock and fresh-beacon check used by bin/fm-watch-arm.sh. A stale beacon blocks even when a watcher pid is live. A fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched.

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

Harness integrations

  • Claude registers two Stop hooks in .claude/settings.json, both anchored through CLAUDE_PROJECT_DIR: bin/fm-turnend-guard.sh --claude, and bin/fm-claude-stop-autoarm.sh with asyncRewake: true and timeout: 28800.
  • Codex registers a Stop hook in .codex/hooks.json, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard.
  • OpenCode listens for session.idle in .opencode/plugins/fm-primary-turnend-guard.js, lets the watcher coordinator act first, and calls client.session.promptAsync once when the guard returns 2.
  • Pi listens for agent_settled in .pi/extensions/fm-primary-turnend-guard.ts, runs once per logical agent run, and calls pi.sendUserMessage(..., { deliverAs: "followUp" }) once when the guard returns 2.
  • Grok registers a Stop hook in .grok/hooks/fm-primary-turnend-guard.json and delegates capability selection to bin/fm-turnend-guard-grok.sh. The tracked Claude Stop entries are inert when GROK_AGENT is present, so Grok's Claude-compatible settings loading cannot create a second continuation path.

Claude and Codex can block a Stop directly with exit status 2 and stderr. Both payloads carry stop_hook_active. In the default Codex mode, a true value lets the second stop finish after one forced continuation.

Claude runs the guard with --claude, which ignores stop_hook_active and cooperates with the Stop-owned auto-arm. Claude Code sets stop_hook_active=true on every stop after any stop-hook continuation, including asyncRewake rewakes, which re-opened the 2026-07-21 blind window under the default one-shot behavior. The Claude mode waits up to FM_CLAUDE_AUTOARM_SYNC_WAIT_MS (default 800 milliseconds) and allows the stop when the watcher is healthy, state/.claude-autoarm.lock has a live owner, or state/.claude-autoarm-epoch contains a fresh rewake outcome. When none of those proofs appears, it re-blocks up to FM_CLAUDE_TURNEND_BLOCK_BUDGET times (default 3, below Claude's 8-block override), then allows degraded with a visible systemMessage. Any allow resets the budget.

OpenCode, Pi, and pi-signed expose passive callbacks for this purpose. Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks. The generated prompts use the canonical turn-end-guard kind after the U+2063 FIRSTMATE_OP: prefix, so Ahoy does not treat them as captain messages. Each passive adapter owns a loop latch. Pi keeps the latch across internal tool turns and clears it only when the generated follow-up settles or delivery fails. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless opencode run.

Grok makes exactly one typed capability decision from each running Stop payload. A boolean stopHookActive selects native blocking, including both false on the initial stop and true on the bounded continuation. The camel-case field has precedence when both spellings appear; when it is absent, a boolean stop_hook_active selects the same native path for compatibility. The native path returns the shared guard's status and stderr to the same Grok process and never starts grok --resume. When both capability spellings are absent, the adapter preserves one pre-native grok --resume fallback guarded by GROK_TURNEND_GUARD_ACTIVE and intentionally omits --permission-mode. Malformed JSON, a selected field with a non-boolean type, missing jq, missing hook prerequisites, or an already-active legacy guard allows the stop without starting either continuation path. Grok's project hook requires the checkout to be trusted with /hooks-trust or launch-time --trust; genuine pre-native builds can run the same tracked hook from an isolated global hook directory.

If a passive adapter cannot invoke its SDK, or the Grok legacy fallback cannot find grok or a session id, the next pull-based fm-guard.sh call reports the problem. That warning uses bin/fm-supervision-instructions.sh --repair-line, so it always points to the active harness protocol rather than embedding another repair command.

Compatibility limits

  • Child crewmate and scout worktrees are outside scope.
  • A valid secondmate home is in scope; an idle secondmate endpoint with no X-mode relay poll remains healthy because it has no supervision need.
  • The direct-blocking and bounded passive-follow-up split is limited to the primary integrations listed above.
  • OpenCode headless mode and untrusted Grok project hooks remain fail-open at the host boundary.
  • Kimi Code CLI 0.29.1 exposes only global [[hooks]] configuration in ~/.kimi-code/config.toml, including a Stop event with snake_case payload fields hook_event_name, session_id, cwd, and stop_hook_active.
  • Kimi has no project-level hook configuration and remains outside the primary guard integrations above.
  • Captain-approved Kimi crew wake support uses bin/fm-kimi-turnend-hook.sh to edit only one marker-delimited Firstmate region in that global config and install a silent always-zero hook.
  • The hook remains inert unless the payload cwd contains a per-task token pointer that resolves through Firstmate's private registry to one state/<id>.turn-ended marker.
  • Installation refuses before writing unless python3 with tomllib and jq are available.
  • If jq is removed after installation, the hook remains silent and exits 0, turn-end wakes stop, and Kimi crews fall back to idle detection.
  • Unreadable hook input remains fail-open.
  • No harness adapter uses a shell ampersand to manufacture supervision.

Regression coverage

tests/fm-turnend-guard.test.sh covers the predicate, main and secondmate primary scope, child-worktree exclusion, FM_HOME and FM_STATE_OVERRIDE precedence, the cooperative --claude claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-jq behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. tests/fm-kimi-harness.test.sh covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. tests/fm-supervision-instructions.test.sh covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh is the opt-in isolated Pi path. verification/supervision.md records the active cross-harness empirical evidence, including the 2026-07-24 Claude asyncRewake revalidation.