From 7a462328e5bf1b15c83011eefa22bfae2155fba4 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:14:16 -0400 Subject: [PATCH 1/5] fix(pi): block the turn end from agent_end instead of reacting after settle The Pi primary turn-end guard hooked agent_settled, which fires after the agent loop has already exited. That emits the idle signal first, so anything watching for idle sees a blind turn end, and the forced follow-up only re-opens a turn that already ended. Pi drains both message queues before emitting agent_end, so a follow-up queued from an agent_end handler makes _handlePostAgentRun() return true and the session calls agent.continue(). The same run keeps going and agent_settled never fires until the guard stops queuing. Pi therefore blocks genuinely, like the Claude and Codex Stop hooks, rather than passively. Measured against pi 0.81.1 with a local mock provider: the shipped agent_end guard emits one idle signal, the agent_settled version emits two. A probe that re-queued from agent_end blocked 12 consecutive times with no ceiling, so the single-follow-up limit is Firstmate's latch and not a Pi limit. The re-entrancy latch is unchanged. Because agent_end also fires at auto-retry and compaction boundaries, a run needing one of those while supervision is already unhealthy spends its latched follow-up there instead of at the final boundary; the recovery instruction still lands in the same run, so this is an ordering nuance rather than a blind turn end. Adds tests/fm-turnend-guard.test.sh:test_pi_extension_blocks_before_the_idle_signal, which models Pi's loop and fails against the agent_settled version. --- .agents/skills/harness-adapters/SKILL.md | 8 +- .pi/extensions/fm-primary-turnend-guard.ts | 9 +- docs/turnend-guard.md | 16 ++- docs/verification/supervision.md | 33 ++++++- tests/fm-turnend-guard.test.sh | 110 ++++++++++++++++++--- 5 files changed, 156 insertions(+), 20 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 85cd11c35c..d86ed198e2 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -52,7 +52,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. The primary integrations for `claude`, `codex`, `opencode`, `pi`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`pi` also blocks directly, because a follow-up queued from its `agent_end` callback continues the same run and suppresses the idle signal. +`opencode` and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. `docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. @@ -280,8 +281,9 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. -**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).** -The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. +**Primary-session guard fact (verified 2026-07-26, Pi 0.81.1).** +The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_end`, not per-tool-loop `turn_end` and not post-settle `agent_settled`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. +Queuing from `agent_end` keeps the run going so the turn never settles blind; `docs/turnend-guard.md` owns the mechanism. Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 113a1bcdd8..179f103a6f 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -135,7 +135,14 @@ export default function (pi: ExtensionAPI) { return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; }); - pi.on("agent_settled", async () => { + // agent_end, not agent_settled. Pi drains both queues before emitting agent_end, + // so a follow-up queued from an agent_end handler makes _handlePostAgentRun() + // return true and the session calls agent.continue() - the run keeps going and + // never settles (pi 0.81.1 dist/core/agent-session.js:745-757 and :780-782). + // agent_settled fires after that loop exits, so a guard hooked there emits the + // idle signal first and only then re-opens the turn: reactive, not preventive. + // Verified 2026-07-26 against pi 0.81.1; see docs/turnend-guard.md. + pi.on("agent_end", async () => { if (guardFollowupActive) { guardFollowupActive = false; return; diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 5589ea2385..84ffdd8c4e 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -41,10 +41,16 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - 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. +- Pi listens for `agent_end` 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 uses `bin/fm-turnend-guard-grok.sh` to resume the reported session once when the shared guard returns 2. The adapter intentionally omits `--permission-mode`, so a passive hook cannot grant stronger permissions than the resumed session default. +Pi blocks genuinely rather than reacting after the fact. +Pi drains both message queues before emitting `agent_end`, so a follow-up queued from an `agent_end` handler makes `_handlePostAgentRun()` return true and the session calls `agent.continue()`. +The same run keeps going and `agent_settled`, the idle signal, never fires until the guard stops queuing. +A guard hooked on `agent_settled` instead runs after that loop has already exited, so the idle signal is emitted first and the follow-up only re-opens a turn that already ended blind. +Mechanism verified against pi 0.81.1 in [`verification/supervision.md`](verification/supervision.md#turn-end-guard). + 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. @@ -55,11 +61,13 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis 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 Grok expose passive callbacks for this purpose. +OpenCode and Grok 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 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. +Because `agent_end` also fires at auto-retry and compaction boundaries, a Pi run that needs one of those while supervision is already unhealthy spends its single latched follow-up at that earlier boundary rather than at the final one. +The recovery instruction still lands in the same run, so this is an ordering nuance rather than a blind turn end. Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`. @@ -70,7 +78,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa - 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. -- Claude and Codex block directly, while OpenCode, Pi, and Grok use bounded passive follow-ups. +- Claude, Codex, and Pi block the turn end directly, while OpenCode and Grok use bounded passive follow-ups. - 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. @@ -83,7 +91,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## 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, and Grok resume permission and recursion safety. +`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, Pi blocking before the idle signal, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion 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. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 4063f5566d..1ae88e6b9d 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -68,7 +68,7 @@ The direct and passive mechanisms were validated across all five harnesses on 20 | Claude | 2.1.219 | Cooperative blocking `Stop` guard plus `asyncRewake` auto-arm | A fresh unsupervised session ran session start first, reclaimed a stale dead-owner lock, completed two tokenless rewake cycles with no model arm command or guard continuation, and left a competing live owner unchanged. | | Codex | 0.142.1 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout and one continuation ran. | | OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. | -| Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. | +| Pi | 0.81.1 | Blocking `agent_end` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns, and the run continued without emitting the idle signal. | | Grok | 0.2.93 | Passive `Stop` plus bounded resume | Project hook ran under trust, resumed once without inherited bypass permissions, and the environment latch prevented recursion. | The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. @@ -88,6 +88,32 @@ Observed output: ok - Claude 2.1.219 (Claude Code) live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary ``` +### Pi blocks the turn end rather than reacting to it + +Measured with pi 0.81.1 on 2026-07-26 against a local mock provider, so no model request left the machine. +A probe extension appended `SETTLED` on `agent_settled` and the stubbed predicate appended `GUARD` and exited 2, giving the ordering directly. + +```sh +pi --version +pi -p "hello" --provider mockp --model mock-model \ + -e ./.pi/extensions/fm-primary-turnend-guard.ts -e ./.pi/extensions/probe.ts --no-session -nbt +``` + +Observed probe log with the shipped `agent_end` guard, and with the same file edited back to `agent_settled`: + +```text +0.81.1 + +agent_end GUARD SETTLED SETTLED count: 1 +agent_settled GUARD SETTLED SETTLED SETTLED count: 2 +``` + +One idle signal means the guard blocked and the follow-up was consumed inside the same run. +Two means the run settled blind before the follow-up re-opened it, which is the defect this mechanism replaces. + +Blocking is repeatable rather than one-shot: a probe that re-queued from `agent_end` continued for 12 consecutive blocks with no ceiling, and `agent_settled` fired exactly once after it stopped. +Firstmate still latches to a single follow-up per logical run, so the ceiling is Firstmate's policy and not a Pi limit. + Current entry points: ```sh @@ -96,6 +122,11 @@ tests/fm-supervision-instructions.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh ``` +The three Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly and need a Node build with TypeScript type stripping. +They pass on the CI runners. +On a Node compiled without type stripping, such as the Debian system build, the import fails with `ERR_UNKNOWN_FILE_EXTENSION` and the case reports `not ok` rather than skipping. +`tests/fm-pi-watch-extension.test.sh` and `tests/fm-calm-pi-extension.test.sh` have the same requirement. + ## Watcher continuity The cross-harness evidence combines the 2026-07-17 live pass with Claude's replacement Stop-owned path revalidated on 2026-07-24, all against isolated project and home state. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 813709d73a..ec6e343c8c 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -780,7 +780,8 @@ test_pi_extension_forces_followup() { ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" [ -f "$ext" ] || fail "tracked pi primary extension is missing" content=$(cat "$ext") - assert_contains "$content" 'agent_settled' "pi extension must run after one logical agent run settles" + assert_contains "$content" 'pi.on("agent_end"' "pi extension must block the turn end from agent_end, not after it settles" + assert_not_contains "$content" 'pi.on("agent_settled"' "pi extension must not guard from the post-settle idle signal" assert_contains "$content" 'fm-turnend-guard.sh' "pi extension must invoke the shared guard" assert_contains "$content" 'sendUserMessage' "pi extension must force a follow-up turn" assert_contains "$content" 'encodeFirstmateOperationalInput' "pi extension must use the typed operational-input constructor" @@ -796,7 +797,92 @@ test_pi_extension_forces_followup() { assert_contains "$content" 'runPretoolCheck(command)' "pi extension changed the PreToolUse checker invocation" assert_contains "$content" 'return { block: true, reason:' "pi extension changed the checker exit-2 block result" assert_not_contains "$content" 'Run bin/fm-watch-arm.sh as a background task' "pi extension must not hardcode the old watcher-arm instruction" - pass ".pi primary extension: agent_settled forces one follow-up through the shared guard" + pass ".pi primary extension: agent_end forces one follow-up through the shared guard" +} + +# Regression for the 2026-07-26 finding that the Pi guard only ever reacted to a +# turn end instead of preventing one. Pi CAN block: a follow-up queued from an +# agent_end handler keeps the same run going and agent_settled never fires. +# Hooking agent_settled instead emits the idle signal first, so anything watching +# for idle - the composer classifier, a pull-based guard - sees a blind turn end +# before the follow-up re-opens it. +# +# This models Pi's real loop (dist/core/agent-session.js:745-757, :780-782): +# await agent.prompt(...) -> emits agent_end +# while (await handlePostAgentRun()) -> true iff agent_end handlers queued work +# await agent.continue() -> emits agent_end again +# finally -> _emitAgentSettled() -> emits agent_settled, THE IDLE SIGNAL +# Against the agent_settled version nothing is queued at agent_end, the loop +# exits, and the idle signal escapes before any follow-up: this test fails. +test_pi_extension_blocks_before_the_idle_signal() { + local repo home ext out status + repo="$TMP_ROOT/pi-block-before-idle-root" + home="$TMP_ROOT/pi-block-before-idle-home" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state" + cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext" + cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" + cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' +#!/usr/bin/env bash +cat >/dev/null +printf 'supervision is off\n' >&2 +exit 2 +SH + cat > "$repo/bin/fm-arm-pretool-check.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$repo/bin/fm-turnend-guard.sh" "$repo/bin/fm-arm-pretool-check.sh" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let queued = []; +let followUps = 0; +let idleEmitted = false; +let idleEmittedBeforeFirstFollowUp = false; + +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message, options) { + followUps += 1; + if (options?.deliverAs !== "followUp") throw new Error("guard prompt was not a follow-up"); + if (idleEmitted) idleEmittedBeforeFirstFollowUp = true; + queued.push(message); + }, +}; + +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); + +const agentEnd = handlers.get("agent_end"); +if (!agentEnd) throw new Error("agent_end handler was not registered; the guard cannot block a turn end"); + +// Faithful model of Pi's agent loop. +let runs = 0; +await agentEnd({ type: "agent_end", messages: [] }, {}); +runs += 1; +while (queued.length > 0) { + queued = []; + if (runs > 10) throw new Error("guard did not converge; latch failed to release"); + await agentEnd({ type: "agent_end", messages: [] }, {}); + runs += 1; +} +idleEmitted = true; +await handlers.get("agent_settled")?.({ type: "agent_settled" }, {}); + +if (followUps !== 1) throw new Error(`expected exactly one blocking follow-up, saw ${followUps}`); +if (idleEmittedBeforeFirstFollowUp) throw new Error("idle signal was emitted before the guard blocked: the turn ended blind"); +if (runs !== 2) throw new Error(`expected the blocked run to continue exactly once, saw ${runs} runs`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must block the turn end before the idle signal fires" + [ -z "$out" ] || fail "Pi block-before-idle guard test printed output: $out" + pass ".pi primary extension: guard blocks the run before agent_settled emits the idle signal" } test_pi_extension_injects_once_per_logical_agent_run() { @@ -838,22 +924,23 @@ const pi = { if (!message.includes("watcher cycle is missing, failed, or unhealthy")) throw new Error(`guard prompt omitted recovery-only state: ${message}`); if (message.includes("Resume supervision according to the session-start operating block")) throw new Error(`guard prompt used ordinary continuity: ${message}`); if (options?.deliverAs !== "followUp") throw new Error("guard prompt was not a follow-up"); - await handlers.get("agent_settled")?.({ type: "agent_settled" }, {}); + // The queued follow-up makes Pi continue the same run, which ends again. + await handlers.get("agent_end")?.({ type: "agent_end", messages: [] }, {}); }, }; const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); if (handlers.has("turn_end")) throw new Error("guard still treats internal Pi turns as logical runs"); -const settled = handlers.get("agent_settled"); -if (!settled) throw new Error("agent_settled handler was not registered"); +const runEnd = handlers.get("agent_end"); +if (!runEnd) throw new Error("agent_end handler was not registered"); -await settled({ type: "agent_settled" }, {}); +await runEnd({ type: "agent_end", messages: [] }, {}); if (prompts !== 1) throw new Error(`no-tool run injected ${prompts} follow-ups`); for (let i = 0; i < 3; i += 1) { await handlers.get("turn_end")?.({ type: "turn_end", turnIndex: i }, {}); } -await settled({ type: "agent_settled" }, {}); +await runEnd({ type: "agent_end", messages: [] }, {}); if (prompts !== 2) throw new Error(`multi-tool run produced ${prompts - 1} follow-ups`); const guardRuns = readFileSync(process.env.FM_GUARD_LOG, "utf8").trim().split("\n").length; @@ -898,14 +985,14 @@ const pi = { async sendUserMessage() { attempts += 1; if (attempts === 1) throw new Error("synthetic delivery failure"); - await handlers.get("agent_settled")?.({ type: "agent_settled" }, {}); + await handlers.get("agent_end")?.({ type: "agent_end", messages: [] }, {}); }, }; const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -const settled = handlers.get("agent_settled"); -await settled({ type: "agent_settled" }, {}); -await settled({ type: "agent_settled" }, {}); +const runEnd = handlers.get("agent_end"); +await runEnd({ type: "agent_end", messages: [] }, {}); +await runEnd({ type: "agent_end", messages: [] }, {}); if (attempts !== 2) throw new Error(`expected delivery retry, saw ${attempts} attempts`); EOF ) @@ -1124,6 +1211,7 @@ test_codex_hook_ignores_nested_git_root_guard test_opencode_plugin_forces_followup test_opencode_plugin_anchors_guard_to_worktree test_pi_extension_forces_followup +test_pi_extension_blocks_before_the_idle_signal test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure test_grok_hook_invokes_adapter From 464fd3e5526edd2e9ecd1188da1f876589d5763a Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:29:20 -0400 Subject: [PATCH 2/5] no-mistakes(review): release Pi guard latch on the guaranteed settle event --- .pi/extensions/fm-primary-turnend-guard.ts | 14 +++ bin/fm-turnend-guard.sh | 4 +- docs/turnend-guard.md | 5 +- docs/verification/supervision.md | 4 +- tests/fm-turnend-guard.test.sh | 104 ++++++++++++++++++--- 5 files changed, 115 insertions(+), 16 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 179f103a6f..006be81086 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -165,5 +165,19 @@ export default function (pi: ExtensionAPI) { } }); + // Latch release of last resort. agent_end is emitted from inside the agent + // loop and only fires again if the run continues, so a run that dies after the + // guard queued its follow-up - ESC while the follow-up is still queued, a fatal + // session error, a discarded queue - leaves the latch set, and the NEXT logical + // run's agent_end consumes it and skips the guard, ending one turn blind. + // agent_settled is emitted from _runAgentPrompt's finally (pi 0.81.1 + // dist/core/agent-session.js:752), so it always runs. This cannot reintroduce + // re-entrancy: agent_settled fires only after the whole agent loop has exited, + // so no further agent_end for that run can follow it, and the handler never + // invokes the predicate or sends a message - it only assigns false. + pi.on("agent_settled", () => { + guardFollowupActive = false; + }); + markLoaded(); } diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 515a859cd2..dd56f84a8a 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -11,7 +11,9 @@ # This script is push-based: verified harness turn-end hooks invoke it every time # the primary is about to end a turn. # Claude and codex can block directly by preserving exit status 2 and stderr. -# OpenCode, pi, and grok adapters use the same predicate and force one bounded +# Pi also blocks directly: a follow-up queued from its agent_end handler keeps the +# same run going, so the idle signal never escapes. +# OpenCode and grok adapters use the same predicate and force one bounded # follow-up because their turn-end events are passive. # See docs/turnend-guard.md for the per-harness mechanics, validation evidence, # and fail-open tradeoffs. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 84ffdd8c4e..b232899d6d 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -41,7 +41,7 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - 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_end` 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. +- Pi listens for `agent_end` in `.pi/extensions/fm-primary-turnend-guard.ts`, injects at most one follow-up per logical agent run through `pi.sendUserMessage(..., { deliverAs: "followUp" })` when the guard returns 2, and runs the shared predicate at every `agent_end` the run reaches. - Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and uses `bin/fm-turnend-guard-grok.sh` to resume the reported session once when the shared guard returns 2. The adapter intentionally omits `--permission-mode`, so a passive hook cannot grant stronger permissions than the resumed session default. @@ -65,7 +65,8 @@ OpenCode and Grok 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 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. +Pi keeps the latch across internal tool turns and clears it when the generated follow-up settles or delivery fails. +A second `agent_settled` handler clears the latch and does nothing else, because `agent_end` fires again only if the run continues: a run that dies after the guard queued its follow-up would otherwise leave the latch set and let the next logical run end unguarded. Because `agent_end` also fires at auto-retry and compaction boundaries, a Pi run that needs one of those while supervision is already unhealthy spends its single latched follow-up at that earlier boundary rather than at the final one. The recovery instruction still lands in the same run, so this is an ordering nuance rather than a blind turn end. Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 1ae88e6b9d..164ba6344b 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -61,7 +61,7 @@ The detailed reconciliation and task chronology stay in the private audit report ## Turn-end guard -The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24. +The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24 and Pi's blocking `agent_end` path revalidated on 2026-07-26 against pi 0.81.1. | Harness | Version verified | Mechanism | Observed result | | --- | --- | --- | --- | @@ -122,7 +122,7 @@ tests/fm-supervision-instructions.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh ``` -The three Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly and need a Node build with TypeScript type stripping. +The four Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly and need a Node build with TypeScript type stripping. They pass on the CI runners. On a Node compiled without type stripping, such as the Debian system build, the import fails with `ERR_UNKNOWN_FILE_EXTENSION` and the case reports `not ok` rather than skipping. `tests/fm-pi-watch-extension.test.sh` and `tests/fm-calm-pi-extension.test.sh` have the same requirement. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index ec6e343c8c..d2b7b01066 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -781,7 +781,10 @@ test_pi_extension_forces_followup() { [ -f "$ext" ] || fail "tracked pi primary extension is missing" content=$(cat "$ext") assert_contains "$content" 'pi.on("agent_end"' "pi extension must block the turn end from agent_end, not after it settles" - assert_not_contains "$content" 'pi.on("agent_settled"' "pi extension must not guard from the post-settle idle signal" + assert_contains "$content" 'pi.on("agent_settled", () => { + guardFollowupActive = false; + });' "pi extension must release the latch from the guaranteed settle event, and only release it" + assert_not_contains "$content" 'pi.on("agent_settled", async' "pi extension must not guard from the post-settle idle signal" assert_contains "$content" 'fm-turnend-guard.sh' "pi extension must invoke the shared guard" assert_contains "$content" 'sendUserMessage' "pi extension must force a follow-up turn" assert_contains "$content" 'encodeFirstmateOperationalInput' "pi extension must use the typed operational-input constructor" @@ -803,17 +806,20 @@ test_pi_extension_forces_followup() { # Regression for the 2026-07-26 finding that the Pi guard only ever reacted to a # turn end instead of preventing one. Pi CAN block: a follow-up queued from an # agent_end handler keeps the same run going and agent_settled never fires. -# Hooking agent_settled instead emits the idle signal first, so anything watching -# for idle - the composer classifier, a pull-based guard - sees a blind turn end -# before the follow-up re-opens it. # # This models Pi's real loop (dist/core/agent-session.js:745-757, :780-782): # await agent.prompt(...) -> emits agent_end # while (await handlePostAgentRun()) -> true iff agent_end handlers queued work # await agent.continue() -> emits agent_end again # finally -> _emitAgentSettled() -> emits agent_settled, THE IDLE SIGNAL -# Against the agent_settled version nothing is queued at agent_end, the loop -# exits, and the idle signal escapes before any follow-up: this test fails. +# What this harness checks is that the guard is wired into that loop at all: the +# blocking handler exists on agent_end, it queues exactly one follow-up, and the +# queued follow-up drives exactly one continuation before the loop can exit. +# Against the agent_settled version there is no agent_end handler to run, so the +# loop exits with nothing queued and this test fails. +# The ordering itself - one idle signal with the agent_end guard versus two with +# the agent_settled version - is a real-Pi measurement, recorded in +# docs/verification/supervision.md; this harness does not prove it. test_pi_extension_blocks_before_the_idle_signal() { local repo home ext out status repo="$TMP_ROOT/pi-block-before-idle-root" @@ -840,8 +846,6 @@ import { pathToFileURL } from "node:url"; const handlers = new Map(); let queued = []; let followUps = 0; -let idleEmitted = false; -let idleEmittedBeforeFirstFollowUp = false; const pi = { on(event, handler) { @@ -850,7 +854,6 @@ const pi = { async sendUserMessage(message, options) { followUps += 1; if (options?.deliverAs !== "followUp") throw new Error("guard prompt was not a follow-up"); - if (idleEmitted) idleEmittedBeforeFirstFollowUp = true; queued.push(message); }, }; @@ -871,11 +874,9 @@ while (queued.length > 0) { await agentEnd({ type: "agent_end", messages: [] }, {}); runs += 1; } -idleEmitted = true; await handlers.get("agent_settled")?.({ type: "agent_settled" }, {}); if (followUps !== 1) throw new Error(`expected exactly one blocking follow-up, saw ${followUps}`); -if (idleEmittedBeforeFirstFollowUp) throw new Error("idle signal was emitted before the guard blocked: the turn ended blind"); if (runs !== 2) throw new Error(`expected the blocked run to continue exactly once, saw ${runs} runs`); EOF ) @@ -885,6 +886,86 @@ EOF pass ".pi primary extension: guard blocks the run before agent_settled emits the idle signal" } +# agent_end is emitted from inside Pi's agent loop, so it only fires again if the +# run actually continues. A run that dies after the guard queued its follow-up - +# ESC while the follow-up is still queued, a fatal session error, a discarded +# queue - would leave the latch set forever, and the next logical run's agent_end +# would spend it and skip the guard entirely: one blind turn end. agent_settled +# comes from _runAgentPrompt's finally and always runs, so it releases the latch. +# Without that release the first assertion below sees followUps === 1. +test_pi_extension_releases_the_latch_when_a_run_dies_before_continuing() { + local repo home ext out status + repo="$TMP_ROOT/pi-latch-release-root" + home="$TMP_ROOT/pi-latch-release-home" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state" + cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext" + cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" + cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' +#!/usr/bin/env bash +cat >/dev/null +printf 'supervision is off\n' >&2 +exit 2 +SH + cat > "$repo/bin/fm-arm-pretool-check.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$repo/bin/fm-turnend-guard.sh" "$repo/bin/fm-arm-pretool-check.sh" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let followUps = 0; + +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message, options) { + followUps += 1; + if (options?.deliverAs !== "followUp") throw new Error("guard prompt was not a follow-up"); + }, +}; + +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); + +const agentEnd = handlers.get("agent_end"); +if (!agentEnd) throw new Error("agent_end handler was not registered; the guard cannot block a turn end"); +const agentSettled = handlers.get("agent_settled"); +if (!agentSettled) throw new Error("agent_settled handler was not registered; the latch can never be released"); + +// A run that dies after the guard queued its follow-up: no second agent_end. +await agentEnd({ type: "agent_end", messages: [] }, {}); +if (followUps !== 1) throw new Error(`expected one follow-up from the interrupted run, saw ${followUps}`); +await agentSettled({ type: "agent_settled" }, {}); + +// The next logical run must still be guarded. +await agentEnd({ type: "agent_end", messages: [] }, {}); +if (followUps !== 2) { + throw new Error(`stuck latch skipped the guard on the run after an interrupted one: that turn ended blind (follow-ups: ${followUps})`); +} +await agentEnd({ type: "agent_end", messages: [] }, {}); +await agentSettled({ type: "agent_settled" }, {}); + +// The release must not re-open the guard: one follow-up per logical run, still. +for (let run = 0; run < 3; run += 1) { + const before = followUps; + await agentEnd({ type: "agent_end", messages: [] }, {}); + await agentEnd({ type: "agent_end", messages: [] }, {}); + await agentSettled({ type: "agent_settled" }, {}); + if (followUps - before !== 1) throw new Error(`logical run ${run} injected ${followUps - before} follow-ups`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must release its latch on the guaranteed settle event" + [ -z "$out" ] || fail "Pi latch-release guard test printed output: $out" + pass ".pi primary extension: an interrupted run releases the latch without re-opening the guard" +} + test_pi_extension_injects_once_per_logical_agent_run() { local repo home ext log out status repo="$TMP_ROOT/pi-logical-run-root" @@ -1212,6 +1293,7 @@ test_opencode_plugin_forces_followup test_opencode_plugin_anchors_guard_to_worktree test_pi_extension_forces_followup test_pi_extension_blocks_before_the_idle_signal +test_pi_extension_releases_the_latch_when_a_run_dies_before_continuing test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure test_grok_hook_invokes_adapter From 6d3f0452ecf65ed669ba34cac8bf8f9acd608847 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:33:13 -0400 Subject: [PATCH 3/5] no-mistakes(review): document the Pi settle listener as latch release only --- .agents/skills/harness-adapters/SKILL.md | 5 ++++- docs/turnend-guard.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index d86ed198e2..96d98ed0c7 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -282,8 +282,11 @@ The extension must listen for pi's `turn_end` event, not `agent_end`, so the wat Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. **Primary-session guard fact (verified 2026-07-26, Pi 0.81.1).** -The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_end`, not per-tool-loop `turn_end` and not post-settle `agent_settled`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. +The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` blocks from logical-run `agent_end`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. Queuing from `agent_end` keeps the run going so the turn never settles blind; `docs/turnend-guard.md` owns the mechanism. +The same file also registers a second `agent_settled` listener that does nothing but release the single-follow-up latch, because `agent_settled` is the only event pi guarantees once per logical run. +`agent_end` fires again only if the run actually continues, so a run that dies after the guard queued its follow-up would otherwise leave the latch set and let the next logical run end unguarded. +Removing that settle listener is a regression, not a cleanup; `tests/fm-turnend-guard.test.sh` fails without it. Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index b232899d6d..049a3ae76b 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -92,7 +92,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## 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, Pi blocking before the idle signal, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety. +`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, Pi blocking before the idle signal, Pi latch release after an interrupted run, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion 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. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. From b87e74b43f0c5ba511489570c32cf6125c9a0e68 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:49:02 -0400 Subject: [PATCH 4/5] no-mistakes(document): reconcile turn-end guard docs with Pi's blocking agent_end path --- bin/fm-turnend-guard.sh | 3 ++- docs/architecture.md | 2 +- docs/turnend-guard.md | 4 ++-- docs/verification/supervision.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index dd56f84a8a..307d575020 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -32,7 +32,8 @@ # Stop payloads carry stop_hook_active=true when the CURRENT stop attempt was # itself already forced by an earlier block this turn; on that signal we always # allow the stop, whether or not watcher supervision actually got resumed. -# Passive harness adapters provide their own one-follow-up guard before calling +# Harness adapters that inject a follow-up instead of returning exit status 2 +# (opencode, grok, and pi) provide their own one-follow-up guard before calling # this script. # That bounds those harnesses to at most one forced continuation per turn - # never a wedged, un-endable session - while still nagging again on a later turn diff --git a/docs/architecture.md b/docs/architecture.md index d1bbb1c1ff..3b3d58deb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,7 +66,7 @@ Its `--restart` mode signals only the watcher recorded in the current home's `st A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode. -On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct Stop hooks block and passive turn-end hooks force one bounded follow-up. +On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct hooks block the turn end and passive turn-end hooks force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 049a3ae76b..bf89da5429 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -61,7 +61,7 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis 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 and Grok expose passive callbacks for this purpose. +OpenCode and Grok expose passive callbacks for this purpose, and Pi's blocking `agent_end` callback shares the same injection path. 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 adapter owns a loop latch. @@ -72,7 +72,7 @@ The recovery instruction still lands in the same run, so this is an ordering nua Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`. -If a passive adapter cannot invoke its SDK, find `grok`, or recover a Grok session id, the next pull-based `fm-guard.sh` call reports the problem. +If a follow-up-injecting adapter cannot invoke its SDK, find `grok`, or recover a Grok 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 diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 164ba6344b..7bb811c23a 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -124,7 +124,7 @@ FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh The four Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly and need a Node build with TypeScript type stripping. They pass on the CI runners. -On a Node compiled without type stripping, such as the Debian system build, the import fails with `ERR_UNKNOWN_FILE_EXTENSION` and the case reports `not ok` rather than skipping. +On a Node compiled without type stripping, such as the Debian system build, the import fails with `ERR_UNKNOWN_FILE_EXTENSION`; the first such case reports `not ok` rather than skipping, and because `fail` in `tests/lib.sh` exits, the suite aborts there instead of reaching the remaining cases. `tests/fm-pi-watch-extension.test.sh` and `tests/fm-calm-pi-extension.test.sh` have the same requirement. ## Watcher continuity From 20ca39f00d22e047c6b234d895a3b348b874cacf Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:54:23 -0400 Subject: [PATCH 5/5] no-mistakes(document): record Pi test Node requirement as observed, CI unverified --- docs/verification/supervision.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 7bb811c23a..6c33d24b63 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -88,6 +88,14 @@ Observed output: ok - Claude 2.1.219 (Claude Code) live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary ``` +Current entry points: + +```sh +tests/fm-turnend-guard.test.sh +tests/fm-supervision-instructions.test.sh +FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh +``` + ### Pi blocks the turn end rather than reacting to it Measured with pi 0.81.1 on 2026-07-26 against a local mock provider, so no model request left the machine. @@ -114,18 +122,15 @@ Two means the run settled blind before the follow-up re-opened it, which is the Blocking is repeatable rather than one-shot: a probe that re-queued from `agent_end` continued for 12 consecutive blocks with no ceiling, and `agent_settled` fired exactly once after it stopped. Firstmate still latches to a single follow-up per logical run, so the ceiling is Firstmate's policy and not a Pi limit. -Current entry points: - -```sh -tests/fm-turnend-guard.test.sh -tests/fm-supervision-instructions.test.sh -FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh -``` +The four Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly, so they need a Node build with TypeScript type stripping. +On the Node v22.22.1 Debian system build used here that support is absent: the import fails with `ERR_UNKNOWN_FILE_EXTENSION`, and adding `--experimental-strip-types` fails with `ERR_NO_TYPESCRIPT` because the binary was compiled without TypeScript support. +Those cases therefore report `not ok` rather than skipping, and because `fail` in `tests/lib.sh` exits, the first one aborts the script before the remaining cases run. +`tests/fm-pi-watch-extension.test.sh` and `tests/fm-calm-pi-extension.test.sh` carry the same requirement and also fail on this Node build. +The same is true on unmodified `origin/main`, so this limitation is pre-existing rather than introduced by the `agent_end` guard. -The four Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly and need a Node build with TypeScript type stripping. -They pass on the CI runners. -On a Node compiled without type stripping, such as the Debian system build, the import fails with `ERR_UNKNOWN_FILE_EXTENSION`; the first such case reports `not ok` rather than skipping, and because `fail` in `tests/lib.sh` exits, the suite aborts there instead of reaching the remaining cases. -`tests/fm-pi-watch-extension.test.sh` and `tests/fm-calm-pi-extension.test.sh` have the same requirement. +Whether these cases pass in CI is unverified from this worktree. +`.github/workflows/ci.yml` pins only `runs-on: ubuntu-latest`, with no `setup-node` step and no pinned Node version, so the runner's Node is whatever GitHub currently ships and can change without a repository change. +No CI run was observed. ## Watcher continuity