diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 498c44f354..005b9b1147 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -100,7 +100,7 @@ Do not substitute another harness's wait shape when resuming supervision. Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. -Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. +Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts`, `.pi/extensions/fm-primary-pi-watch.ts`, and `.pi/extensions/fm-primary-decision-nudge.ts`, all project-local extensions the Pi engine auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. ## Launch profile axes @@ -295,8 +295,8 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens 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`. -`bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both the turn-end guard and watcher extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback. -When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. +`bin/fm-session-start.sh` reports when the live Pi-family session has not loaded the turn-end guard, watcher, and captain-attention nudge extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback. +When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with `-e .pi/extensions/fm-primary-turnend-guard.ts`, `-e .pi/extensions/fm-primary-pi-watch.ts`, and `-e .pi/extensions/fm-primary-decision-nudge.ts`, all already present in the secondmate home's git worktree. ## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) diff --git a/.pi/extensions/fm-primary-decision-nudge.ts b/.pi/extensions/fm-primary-decision-nudge.ts new file mode 100644 index 0000000000..196a7e76bd --- /dev/null +++ b/.pi/extensions/fm-primary-decision-nudge.ts @@ -0,0 +1,153 @@ +// Pi primary captain-attention nudge. +// +// Pi normally asks the captain in chat. Once agent_settled proves no automatic +// retry, compaction, or follow-up remains, this extension inspects the latest +// assistant text and arms bin/fm-decision-nudge.sh only for an explicit +// captain-facing question or decision request. The shared script owns primary +// scope, Telegram opt-in, marker, timer, and send semantics. +import { spawn } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { extensionVersionOf, lockOwnership } from "./lib/fm-primary-loaded-marker.ts"; + +const extensionFile = fileURLToPath(import.meta.url); +const extensionDir = dirname(extensionFile); +const root = resolve(extensionDir, "../.."); +const fmRoot = process.env.FM_ROOT_OVERRIDE || root; +const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; +const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; +const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; +const nudgeScript = `${fmRoot}/bin/fm-decision-nudge.sh`; +const loadedMarker = `${state}/.pi-decision-nudge-extension-loaded`; +const extensionVersion = extensionVersionOf(extensionFile); + +// Same loaded-marker contract as the two sibling primary extensions, so +// bin/fm-session-start.sh can report this one as missing instead of silently +// losing the nudge when project trust was never approved. +function markLoaded(): void { + try { + if (!existsSync(state) || lockOwnership(state) === "other") return; + writeFileSync(loadedMarker, `${extensionVersion}\n${process.pid}\n`); + } catch { + } +} + +// A bare decision verb is not an ask: a settled watcher turn like "Captain, PR +// #7 is merged. I'll confirm the deploy once you're back." must not page him. +// The verb only counts in an imperative (sentence- or vocative-initial) or +// second-person/explicit-request position. +const DECISION_VERBS = "choose|select|pick|decide|confirm|approve"; +const DECISION_PATTERNS = [ + /\b(?:yes\s*\/\s*no|yes\s+or\s+no)\b/i, + /\b(?:do you want|would you like|shall I|should I|may I|can I)\b/i, + new RegExp(String.raw`(?:^|[.!?]\s+|\n\s*|\bcaptain\s*[,:-]\s*)(?:please\s+)?(?:${DECISION_VERBS})\b`, "i"), + new RegExp( + String.raw`\b(?:please|need you to|needs you to|want you to|waiting (?:on|for) you to|for you to|your call|up to you)\b[^.?!]{0,60}\b(?:${DECISION_VERBS})\b`, + "i", + ), + new RegExp(String.raw`\byou\s+(?:${DECISION_VERBS})\b`, "i"), + /\b(?:need|needs|awaiting|requires?|requesting)\b.{0,80}\b(?:decision|approval|choice|answer|confirmation)\b/i, + /\b(?:decision|approval|choice|answer|confirmation)\b.{0,80}\b(?:needed|required|awaiting|please)\b/i, + /\boptions?\s*:/i, +]; + +export function isCaptainAttentionWait(text: string): boolean { + const candidate = text.trim(); + if (!candidate || candidate === "Captain, shipshape.") return false; + if (!/\bCaptain\b/.test(candidate)) return false; + return candidate.includes("?") || DECISION_PATTERNS.some((pattern) => pattern.test(candidate)); +} + +type SessionMessageEntry = { + type?: string; + id?: string; + message?: { + role?: string; + content?: unknown; + }; +}; + +function assistantText(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + return content + .filter((block): block is { type: "text"; text: string } => ( + typeof block === "object" && block !== null && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + )) + .map((block) => block.text) + .join("\n") + .trim(); +} + +export function latestCaptainAttentionWait(ctx: Pick): { id: string; text: string } | null { + const branch = ctx.sessionManager.getBranch() as SessionMessageEntry[]; + for (let index = branch.length - 1; index >= 0; index -= 1) { + const entry = branch[index]; + if (entry.type !== "message") continue; + // The scan stops at the turn boundary: an ask from an earlier turn the + // captain already answered must never re-arm. + if (entry.message?.role === "user") return null; + if (entry.message?.role !== "assistant") continue; + const text = assistantText(entry.message.content); + if (!text) continue; + const id = typeof entry.id === "string" ? entry.id : ""; + return id && isCaptainAttentionWait(text) ? { id, text } : null; + } + return null; +} + +function invokeNudge(mode: "--pi-arm" | "--pi-resolved", id = ""): void { + try { + const args = id ? [mode, id] : [mode]; + const child = spawn(nudgeScript, args, { + detached: true, + env: { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_STATE_OVERRIDE: state, + FM_CONFIG_OVERRIDE: config, + }, + stdio: "ignore", + }); + child.on("error", () => {}); + child.unref(); + } catch { + // Notification support must never interfere with the Pi session. + } +} + +export default function (pi: ExtensionAPI) { + const disarm = (): void => invokeNudge("--pi-resolved"); + + // A real interactive or RPC input is direct evidence that the captain is + // present. Extension-injected operational messages are not presence signals. + pi.on("input", (event) => { + if (event.source !== "extension") disarm(); + return { action: "continue" }; + }); + + // Covers expanded prompts and any run started without traversing input. + pi.on("before_agent_start", () => { + disarm(); + }); + + pi.on("agent_settled", (_event, ctx) => { + const wait = latestCaptainAttentionWait(ctx); + if (wait) invokeNudge("--pi-arm", wait.id); + }); + + pi.on("session_shutdown", () => { + disarm(); + }); + + pi.on?.("session_start", () => { + markLoaded(); + }); + + markLoaded(); +} diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 94bce9838f..95fcf5631c 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -8,9 +8,8 @@ // a new live generation so monitoring can arm again without restarting Pi. Terminal // quit leaves the final generation stopped so late callbacks cannot rearm. Stale // callbacks from a prior generation are no-ops against the active replacement. -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; @@ -22,14 +21,13 @@ import { FIRSTMATE_CALM_PRESENTATION_EVENT, } from "./lib/fm-calm-visibility.ts"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; +import { extensionVersionOf, lockOwnership } from "./lib/fm-primary-loaded-marker.ts"; type ArmResult = { ok: boolean; message: string; }; -type LockOwnership = "owned" | "missing" | "other"; - type CloseClassification = { kind: "actionable" | "failure"; message: string; @@ -84,7 +82,7 @@ const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; const marker = `${state}/.pi-watch-extension-loaded`; -const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; +const extensionVersion = extensionVersionOf(extensionFile); const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); @@ -110,40 +108,8 @@ function positiveInteger(name: string, fallback: number): number { return Math.floor(value); } -function parentPid(pid: string): string { - const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); - if (result.status !== 0) return ""; - return result.stdout.trim(); -} - -function pidAlive(pid: string): boolean { - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } -} - -function lockOwnership(): LockOwnership { - let lockPid = ""; - try { - lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); - } catch { - return "missing"; - } - if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; - let pid = String(process.pid); - for (let i = 0; i < 8; i += 1) { - if (pid === lockPid) return "owned"; - pid = parentPid(pid); - if (!pid || pid === "1") break; - } - return pidAlive(lockPid) ? "other" : "missing"; -} - function markLoaded(): void { - if (lockOwnership() === "other") return; + if (lockOwnership(state) === "other") return; mkdirSync(state, { recursive: true }); writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } @@ -317,7 +283,7 @@ export default function (pi: ExtensionAPI) { function scheduleRetry(owner: SessionGeneration, message: string, predecessorArmPid: string): void { if (!generationIsLive(owner) || owner.child || owner.retryTimer) return; - const ownership = lockOwnership(); + const ownership = lockOwnership(state); if (ownership !== "owned") { surfaceFailure(owner, `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`); return; @@ -341,7 +307,7 @@ export default function (pi: ExtensionAPI) { function startArm(owner: SessionGeneration, predecessorArmPid = ""): ArmResult { if (!generationIsLive(owner)) return { ok: false, message: shuttingDownMessage }; - const ownership = lockOwnership(); + const ownership = lockOwnership(state); if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; if (ownership === "missing") { return { diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 113a1bcdd8..171015196f 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -1,57 +1,23 @@ import { spawn, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; +import { extensionVersionOf, lockOwnership } from "./lib/fm-primary-loaded-marker.ts"; let guardFollowupActive = false; -type LockOwnership = "owned" | "missing" | "other"; - const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; const marker = `${state}/.pi-turnend-extension-loaded`; -const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; - -function parentPid(pid: string): string { - const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); - if (result.status !== 0) return ""; - return result.stdout.trim(); -} - -function pidAlive(pid: string): boolean { - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } -} - -function lockOwnership(): LockOwnership { - let lockPid = ""; - try { - lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); - } catch { - return "missing"; - } - if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; - let pid = String(process.pid); - for (let i = 0; i < 8; i += 1) { - if (pid === lockPid) return "owned"; - pid = parentPid(pid); - if (!pid || pid === "1") break; - } - return pidAlive(lockPid) ? "other" : "missing"; -} +const extensionVersion = extensionVersionOf(extensionFile); function markLoaded(): void { - if (!existsSync(state) || lockOwnership() === "other") return; + if (!existsSync(state) || lockOwnership(state) === "other") return; writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } diff --git a/.pi/extensions/lib/fm-primary-loaded-marker.ts b/.pi/extensions/lib/fm-primary-loaded-marker.ts new file mode 100644 index 0000000000..eb91b9b478 --- /dev/null +++ b/.pi/extensions/lib/fm-primary-loaded-marker.ts @@ -0,0 +1,46 @@ +// One definition of "this Pi session owns the firstmate session lock" and of the +// extension version stamp, shared by every tracked primary extension that writes +// a state/.pi-*-extension-loaded marker. bin/fm-session-start.sh reads those +// markers and compares each stamp against the hash of the extension file it +// found on disk, so all writers must agree on both contracts. +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +export type LockOwnership = "owned" | "missing" | "other"; + +function parentPid(pid: string): string { + const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); + if (result.status !== 0) return ""; + return result.stdout.trim(); +} + +function pidAlive(pid: string): boolean { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +export function lockOwnership(state: string): LockOwnership { + let lockPid = ""; + try { + lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); + } catch { + return "missing"; + } + if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; + let pid = String(process.pid); + for (let i = 0; i < 8; i += 1) { + if (pid === lockPid) return "owned"; + pid = parentPid(pid); + if (!pid || pid === "1") break; + } + return pidAlive(lockPid) ? "other" : "missing"; +} + +export function extensionVersionOf(extensionFile: string): string { + return `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; +} diff --git a/README.md b/README.md index 2487096a9f..c311ea9f08 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Optional Telegram mode** - opt in with one local flag file so the messages you send your own phone-inbox Telegram bot reach firstmate as work or questions and get a real reply in the same chat, with up to three bounded completion follow-ups; while you are away, each away-mode escalation batch also reaches that same chat once, as notice only that grants no approval; it stays fully inert until you opt in, authorizes only reversible lifecycle actions from the phone, and keeps the bot token entirely in the separate phone-inbox deployment. +- **Optional Telegram mode** - opt in with one local flag file so the messages you send your own phone-inbox Telegram bot reach firstmate as work or questions and get a real reply in the same chat, with up to three bounded completion follow-ups; a Pi-family session that settles waiting on a decision only you can make pings that chat once after 30 seconds with a content-free "something's awaiting your attention" message that never repeats the question; while you are away, each away-mode escalation batch also reaches that same chat once, as notice only that grants no approval; it stays fully inert until you opt in, authorizes only reversible lifecycle actions from the phone, and keeps the bot token entirely in the separate phone-inbox deployment. - **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. diff --git a/bin/fm-decision-nudge.sh b/bin/fm-decision-nudge.sh index 4544a9f24c..c7de6e7a3f 100755 --- a/bin/fm-decision-nudge.sh +++ b/bin/fm-decision-nudge.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash -# Captain-attention nudge for the firstmate PRIMARY session (Claude Code). +# Captain-attention nudge for firstmate PRIMARY sessions (Claude Code and Pi). # -# When the primary session blocks on a direct interactive decision prompt - -# Claude Code's AskUserQuestion tool or a permission dialog - and the captain -# has not answered within FM_DECISION_NUDGE_DELAY_SECS (default 30), send him -# ONE deliberately content-free Telegram message through the phone-inbox tg -# client. The nudge never describes the question; if he asks what it is from -# his phone, the existing Telegram-mode flow (fmtg-respond) answers normally. +# When the primary session waits on a direct captain decision - Claude Code's +# AskUserQuestion tool or a permission dialog, or a settled Pi turn that ends +# by asking the captain something - and he has not answered within +# FM_DECISION_NUDGE_DELAY_SECS (default 30), send him ONE deliberately +# content-free Telegram message through the phone-inbox tg client. The nudge +# never describes the question; if he asks what it is from his phone, the +# existing Telegram-mode flow (fmtg-respond) answers normally. # -# Hook wiring (.claude/settings.json, this repo only - never the captain's +# Claude wiring (.claude/settings.json, this repo only - never the captain's # global settings). Event payloads below were captured live from Claude Code # 2.1.226 (docs/verification/decision-nudge.md): # Notification matcher permission_prompt -> --claude-pending (arm) @@ -26,7 +27,14 @@ # The turn ended, so nothing is blocking. # (internal) --wait -> detached 30s timer # -# Known residuals (both verified live, both accepted): +# Pi wiring (.pi/extensions/fm-primary-decision-nudge.ts): +# agent_settled -> --pi-arm (arm after heuristic) +# input -> --pi-resolved (disarm on genuine captain presence) +# before_agent_start -> --pi-resolved (disarm before any next run) +# session_shutdown -> --pi-resolved (disarm when this session leaves) +# The Pi extension passes only the assistant entry id, never the question text. +# +# Known Claude residuals (both verified live, both accepted): # 1. Declining a permission dialog with "No" aborts the turn without firing # any hook event, so a decline followed by 30 idle seconds still sends # the one nudge. The session genuinely is idle awaiting the captain's @@ -43,8 +51,11 @@ # case (he just saw the dialog), so the coarse rule stays. # # Scope and consent: -# - fm_primary_scope_matches gates arming, so crewmate/scout task worktrees -# of this repo (linked worktrees, no secondmate marker) never nudge. +# - fm_primary_scope_matches gates arming AND disarming, so crewmate/scout +# task worktrees of this repo (linked worktrees, no secondmate marker) +# never nudge, and - because such a pane can inherit FM_HOME from the +# daemon env and resolve STATE to the captain's primary home - can never +# cancel a nudge the captain's own session armed either. # - Telegram mode's opt-in flag (config/telegram-mode, fmtg_enabled) gates # every send: without the captain's standing opt-in this script arms # nothing and sends nothing, matching the away-mode escalation precedent @@ -53,7 +64,8 @@ # script never reads or prints any credential. # # Pending-marker protocol (state/.decision-nudge-pending): -# prompt_id= the user-turn this prompt belongs to +# prompt_id=|pi: +# identifies the harness wait # nonce= binds the marker to its own timer # status=pending|sent sent suppresses re-arming for the same turn # The marker is private volatile state; disarm simply removes it. One nudge @@ -66,10 +78,10 @@ # Environment overrides (tests): FM_ROOT_OVERRIDE, FM_HOME, FM_STATE_OVERRIDE, # FM_CONFIG_OVERRIDE, FM_DECISION_NUDGE_DELAY_SECS, FMTG_TG_BIN. # -# Other harnesses: this covers the Claude Code primary only. A Pi primary -# equivalent (nudging on a settled turn that ends with a captain-facing -# question in chat) is separate work, and the remaining primary harnesses are -# a known follow-up. See docs/configuration.md "Captain-attention nudge". +# Other harnesses: this covers Claude Code and Pi/pi-signed primaries. The +# remaining primary harnesses are a known follow-up. See docs/configuration.md +# "Captain-attention nudge"; Pi verification lives in +# docs/verification/pi-decision-nudge.md. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -84,21 +96,28 @@ case "$DELAY" in ''|*[!0-9]*|0) DELAY=30 ;; esac MODE="${1:-}" -# --claude-resolved is on the hot path of every tool call and turn end, so it -# drains its payload and exits before any sourcing, JSON parsing, or scope -# work; the common no-marker case costs one stat on top of that. -# The drain is not optional: PostToolUse payloads embed tool_response, which -# routinely exceeds the pipe buffer, and exiting with the pipe unread would -# EPIPE the harness mid-write. -if [ "$MODE" = --claude-resolved ]; then - cat >/dev/null 2>&1 || true +# Both resolve modes are on the hot path of every tool call and turn end, so +# the common no-marker case costs one stat and exits before any sourcing, +# JSON parsing, or scope work. Only a session that could have armed this +# marker is allowed to clear it. +# The --claude-resolved drain is not optional: PostToolUse payloads embed +# tool_response, which routinely exceeds the pipe buffer, and exiting with the +# pipe unread would EPIPE the harness mid-write. --pi-resolved is spawned by +# the Pi extension with stdio ignored, so it has no payload to drain. +if [ "$MODE" = --claude-resolved ] || [ "$MODE" = --pi-resolved ]; then + [ "$MODE" = --claude-resolved ] && { cat >/dev/null 2>&1 || true; } [ -e "$MARKER" ] || exit 0 - rm -f "$MARKER" 2>/dev/null || true - exit 0 fi # shellcheck source=bin/fm-primary-scope-lib.sh . "$SCRIPT_DIR/fm-primary-scope-lib.sh" + +if [ "$MODE" = --claude-resolved ] || [ "$MODE" = --pi-resolved ]; then + fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 + rm -f "$MARKER" 2>/dev/null || true + exit 0 +fi + # shellcheck source=bin/fm-tg-lib.sh . "$SCRIPT_DIR/fm-tg-lib.sh" @@ -107,19 +126,28 @@ marker_field() { # } case "$MODE" in - --claude-pending) - # Reading stdin first keeps the hook pipe drained even on an early exit. - PAYLOAD=$(cat 2>/dev/null || true) - [ -n "$PAYLOAD" ] || exit 0 - # jq is the repo's established JSON dependency; without it degrade to a - # silent no-op exactly like the turn-end guard. - command -v jq >/dev/null 2>&1 || exit 0 - NTYPE=$(printf '%s' "$PAYLOAD" | jq -r '.notification_type // ""' 2>/dev/null) || exit 0 - [ "$NTYPE" = permission_prompt ] || exit 0 + --claude-pending|--pi-arm) + if [ "$MODE" = --claude-pending ]; then + # Reading stdin first keeps the hook pipe drained even on an early exit. + PAYLOAD=$(cat 2>/dev/null || true) + [ -n "$PAYLOAD" ] || exit 0 + # jq is the repo's established JSON dependency; without it degrade to a + # silent no-op exactly like the turn-end guard. + command -v jq >/dev/null 2>&1 || exit 0 + NTYPE=$(printf '%s' "$PAYLOAD" | jq -r '.notification_type // ""' 2>/dev/null) || exit 0 + [ "$NTYPE" = permission_prompt ] || exit 0 + PROMPT_ID=$(printf '%s' "$PAYLOAD" | jq -r '.prompt_id // .session_id // "unknown"' 2>/dev/null) || exit 0 + else + # The Pi extension already applied the captain-facing-ask heuristic; all + # that arrives here is the assistant entry id it settled on. + ENTRY_ID=${2:-} + [ -n "$ENTRY_ID" ] || exit 0 + case "$ENTRY_ID" in *$'\n'*|*$'\r'*) exit 0 ;; esac + PROMPT_ID="pi:$ENTRY_ID" + fi fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 # No standing Telegram opt-in means the whole feature stays inert. fmtg_enabled "$FM_HOME" || exit 0 - PROMPT_ID=$(printf '%s' "$PAYLOAD" | jq -r '.prompt_id // .session_id // "unknown"' 2>/dev/null) || exit 0 if [ -f "$MARKER" ] && [ "$(marker_field "$MARKER" prompt_id)" = "$PROMPT_ID" ]; then # Same user turn: either the timer is already running or the one nudge # for this turn was already sent. Never arm a second timer. @@ -174,7 +202,7 @@ case "$MODE" in exit 0 ;; *) - echo "usage: $(basename "$0") --claude-pending | --claude-resolved | --wait " >&2 + echo "usage: $(basename "$0") --claude-pending | --claude-resolved | --pi-arm | --pi-resolved | --wait " >&2 exit 2 ;; esac diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 00fef8ae55..b0ee307d7d 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -317,16 +317,20 @@ TG_MODE_PRESENT=0 if [ "$PRIMARY_HARNESS" = pi ] || [ "$PRIMARY_HARNESS" = pi-signed ]; then PI_EXT="$FM_ROOT/.pi/extensions/fm-primary-pi-watch.ts" PI_TURNEND_EXT="$FM_ROOT/.pi/extensions/fm-primary-turnend-guard.ts" + PI_NUDGE_EXT="$FM_ROOT/.pi/extensions/fm-primary-decision-nudge.ts" PI_WATCH_MARKER="$STATE/.pi-watch-extension-loaded" PI_TURNEND_MARKER="$STATE/.pi-turnend-extension-loaded" + PI_NUDGE_MARKER="$STATE/.pi-decision-nudge-extension-loaded" PI_LOCK="$STATE/.lock" PI_RESTART_COMMAND=$PRIMARY_HARNESS [ "$PRIMARY_HARNESS" != pi ] || PI_RESTART_COMMAND='plain pi' PI_WATCH_VERSION=$(hash_file "$PI_EXT" || printf '') PI_TURNEND_VERSION=$(hash_file "$PI_TURNEND_EXT" || printf '') + PI_NUDGE_VERSION=$(hash_file "$PI_NUDGE_EXT" || printf '') if ! pi_extension_loaded "$PI_WATCH_MARKER" "$PI_WATCH_VERSION" "$PI_LOCK" \ - || ! pi_extension_loaded "$PI_TURNEND_MARKER" "$PI_TURNEND_VERSION" "$PI_LOCK"; then - printf 'PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart %s so %s and %s auto-load for turn-end guard and background wake coverage; use -e %s -e %s only if project hooks are not trusted\n' "$PI_RESTART_COMMAND" "$PI_TURNEND_EXT" "$PI_EXT" "$PI_TURNEND_EXT" "$PI_EXT" + || ! pi_extension_loaded "$PI_TURNEND_MARKER" "$PI_TURNEND_VERSION" "$PI_LOCK" \ + || ! pi_extension_loaded "$PI_NUDGE_MARKER" "$PI_NUDGE_VERSION" "$PI_LOCK"; then + printf 'PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart %s so %s, %s, and %s auto-load for turn-end guard, background wake, and captain-attention nudge coverage; use -e %s -e %s -e %s only if project hooks are not trusted\n' "$PI_RESTART_COMMAND" "$PI_TURNEND_EXT" "$PI_EXT" "$PI_NUDGE_EXT" "$PI_TURNEND_EXT" "$PI_EXT" "$PI_NUDGE_EXT" fi fi "$SCRIPT_DIR/fm-supervision-instructions.sh" \ diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4ba08f4e5d..a2fba7fd50 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -114,6 +114,9 @@ # at startup) # __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home # __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home +# __PINUDGE__ absolute path to .pi/extensions/fm-primary-decision-nudge.ts in a pi secondmate +# home (a secondmate clone has no approved project trust, so the tracked +# captain-attention nudge only loads through this explicit -e) # __OPINPUT__ absolute path to the canonical operational-input encoder # Verified per-harness turn-end hooks are installed automatically where enabled; some live outside the worktree. # Kimi uses one surgically installed Firstmate region in $HOME/.kimi-code/config.toml, @@ -485,7 +488,7 @@ launch_template() { opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; pi|pi-signed) if [ "$kind" = secondmate ]; then - printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ -e __PINUDGE__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ __PICALMFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' fi @@ -1608,6 +1611,7 @@ if [ ! -e "$WT/.pi/extensions/fm-calm.ts" ]; then fi sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") +sq_pinudge=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-decision-nudge.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") @@ -1619,6 +1623,7 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PICALMFLAG__/$PICALMFLAG} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} +LAUNCH=${LAUNCH//__PINUDGE__/$sq_pinudge} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} # Crewmate panes are created by a long-lived tmux/herdr daemon that does not # inherit firstmate's current environment, so a bare `claude` in the pane falls diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 615dc09e9f..5e0e05bd43 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -96,6 +96,7 @@ esac checkpoint_seconds=${FM_CODEX_WATCH_CHECKPOINT:-180} pi_ext="$FM_ROOT/.pi/extensions/fm-primary-pi-watch.ts" pi_turnend_ext="$FM_ROOT/.pi/extensions/fm-primary-turnend-guard.ts" +pi_nudge_ext="$FM_ROOT/.pi/extensions/fm-primary-decision-nudge.ts" x_mode_env="$CONFIG/x-mode.env" tg_mode_env="$CONFIG/tg-mode.env" @@ -120,6 +121,7 @@ render_snippet() { while IFS= read -r line || [ -n "$line" ]; do line=${line//__FM_PI_EXT__/$pi_ext} line=${line//__FM_PI_TURNEND_EXT__/$pi_turnend_ext} + line=${line//__FM_PI_NUDGE_EXT__/$pi_nudge_ext} line=${line//__FM_X_MODE_ENV_SH__/$x_mode_env_sh} line=${line//__FM_X_MODE_ENV__/$x_mode_env} line=${line//__FM_TG_MODE_ENV_SH__/$tg_mode_env_sh} @@ -158,7 +160,7 @@ repair_line() { printf '%s%s%s%s\n' "$prefix" 'repair missing watcher supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds ' "$checkpoint_seconds" '.' ;; pi|pi-signed) - printf '%s%s%s%s%s%s\n' "$prefix" 'repair a missing or failed watcher cycle with the Pi tool fm_watch_arm_pi, or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' if the extensions are not loaded.' + printf '%s%s%s%s%s%s%s%s\n' "$prefix" 'repair a missing or failed watcher cycle with the Pi tool fm_watch_arm_pi, or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' -e ' "$pi_nudge_ext" ' if the extensions are not loaded.' ;; opencode) printf '%s%s\n' "$prefix" 'repair missing watcher supervision by letting the OpenCode TUI plugin arm after idle; use bin/fm-watch-arm.sh only as a manual recovery probe if the plugin reports failure.' diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index de924e4f62..542a60ae82 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -124,7 +124,7 @@ family_for_basename() { fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ - fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ + fm-operational-input.test.sh|fm-pi-decision-nudge.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ diff --git a/docs/architecture.md b/docs/architecture.md index e367c622b4..d9e5e348a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -238,7 +238,7 @@ On the locked session-start bootstrap step the flag creates `state/tg-watch.chec The `fmtg-respond` agent-only skill claims each note through phone-inbox's `inbox claim`, classifies it as work, a question, or a pure acknowledgment, runs actionable reversible requests through firstmate's normal lifecycle, and replies in the same chat through `inbox reply`. Work that spawns a longer-running task is linked by `bin/fm-tg-link.sh` (`tg_note=`, `tg_note_ts=`, `tg_followups=` in that task's meta), and later milestone and completion wakes send up to three bounded follow-ups through `bin/fm-tg-followup.sh`, with `--final` always clearing the link. Every live send notifies the captain's phone, so this mode never sends as a test. -The same opt-in flag also gates the content-free captain-attention nudge for a waiting Claude Code decision prompt, owned by the [Telegram mode configuration reference](configuration.md#captain-attention-nudge-claude-code-primary). +The same opt-in flag also gates the content-free captain-attention nudge: a waiting Claude Code decision prompt, or a Pi or pi-signed primary session that settles waiting on a decision the captain owns, pages that same chat once after a short delay with a message that carries no question text, owned by the [Telegram mode configuration reference](configuration.md#captain-attention-nudge). While away mode is active, the sub-supervisor also routes each captain-relevant escalation batch to the phone through that same client, once per batch increment, as notice only that grants no approval; `bin/fm-away-ledger-lib.sh` owns the batch ledger and delivery evidence, and the [Telegram mode configuration reference](configuration.md#telegram-mode-configtelegram-mode) owns the outcome, retry, and fallback contract. ## Project memory belongs to projects diff --git a/docs/configuration.md b/docs/configuration.md index 5e0dcdd70d..aa2ccf5f39 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -211,7 +211,7 @@ For Kimi crews, `fm-spawn.sh` runs `fm-kimi-turnend-hook.sh install`, drops a pe Kimi continues to use the captain's normal Kimi home, including the existing config, skills, and memory; Firstmate does not create an isolated Kimi home. The Kimi installer requires an existing regular non-symlink `~/.kimi-code/config.toml`, `python3` with `tomllib`, and `jq`; it validates but never serializes the captain's TOML and refuses before writing when the config is missing, malformed, or surprising or when either tool requirement is unavailable. Its `remove` action excises only the marker-delimited Firstmate region and removes Firstmate's hook files. -For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected executable with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. +For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected executable with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts`, `.pi/extensions/fm-primary-turnend-guard.ts`, and `.pi/extensions/fm-primary-decision-nudge.ts`, all already present from the secondmate home's git worktree. Ordinary Pi and pi-signed ship and scout launches instead carry the per-task turn-end extension plus an injected absolute `-e` for Firstmate's own tracked Calm extension and a pinned `FM_CALM_CONFIG_OVERRIDE`; [`calm.md`](calm.md) owns when that injection is omitted and how the shared preference and project trust behave. ## Crew dispatch profiles (config/crew-dispatch.json) @@ -400,6 +400,12 @@ Completion follow-ups go through `bin/fm-tg-followup.sh`, which sends through th Follow-ups are deliberately not gated on the opt-in flag: removing the flag stops new notes and new links, but a task linked before opt-out may still finish its follow-up thread within the same cap and window, so a request the captain made from his phone always gets its outcome. Every live send notifies the captain's phone, so nothing in this mode sends as a test. +The same opt-in flag gates the captain-attention nudge, this section's third outbound class and the single owner of its contract. +When a Pi or pi-signed primary session settles on a message that addresses the captain and asks him a question or clearly requests a decision, `.pi/extensions/fm-primary-decision-nudge.ts` arms `bin/fm-decision-nudge.sh`, which records a private `state/.decision-nudge-pending` marker, waits `FM_DECISION_NUDGE_DELAY_SECS` (default 30) in a detached timer, and then sends exactly one deliberately content-free message, `Captain, something's awaiting your attention.`, through the same phone-inbox `tg` client (`FMTG_TG_BIN`). +The nudge never carries the question text, sends at most once per waiting turn, and is cancelled outright when the captain answers, a new agent run starts, or the session shuts down; the routine `Captain, shipshape.` reply and empty or tool-only turns never arm it, and crewmate and scout worktrees of this repo neither arm nor cancel it. +Without the flag it arms nothing and sends nothing, exactly like the rest of this mode. +The script header owns the full per-harness event contract, and [`verification/pi-decision-nudge.md`](verification/pi-decision-nudge.md) records the Pi evidence and why Pi's coverage stops at settled chat asks. + While `state/.afk` is present, the away daemon also routes each captain-relevant escalation batch through the same phone-inbox `tg` client before its in-session delivery. Each notice carries an explicit reminder that delivery grants no approval for a merge, privileged change, destructive action, or security-sensitive action. `bin/fm-away-ledger-lib.sh` owns one ledger per batch in `state/.subsuper-escalations.since` - the batch identity plus its `reserved`, `confirmed`, and `accounted` line counts, its attempt ordinal, and its retry schedule - and the away daemon, away start, and away return all query and transition that one owner instead of keeping counters of their own. Older record shapes are migrated in place on read, so upgrading the daemon mid-session keeps the batch's counts rather than failing closed. @@ -418,7 +424,9 @@ Every send goes through the ledger by construction: `telegram_away_deliver` requ A successful `tg` exit means the Telegram API returned an accepted response end to end; it does not prove the captain read the notice. This away-only use does not change inbound notes, linked-task follow-ups, opt-out behavior, watcher ownership, return catch-up, or buffering outside away mode. -## Captain-attention nudge (Claude Code primary) +## Captain-attention nudge + +Both covered primaries share `bin/fm-decision-nudge.sh`, its marker protocol, and this one message; they differ only in the event that arms it. When the primary Claude Code session blocks on a direct interactive decision prompt - an AskUserQuestion question or a permission dialog - and the captain has not answered within 30 seconds, firstmate sends him one deliberately content-free Telegram message ("Captain, something's awaiting your attention.") through the same phone-inbox client Telegram mode uses. The nudge never describes the question: if the captain asks what it is from his phone, the normal Telegram-mode note flow answers. @@ -426,9 +434,12 @@ It is gated on the same `config/telegram-mode` opt-in flag and on a genuine prim The tracked `.claude/settings.json` registers the hook points (a `Notification` `permission_prompt` arm plus `PostToolUse`, `UserPromptSubmit`, and `Stop` disarms), and `bin/fm-decision-nudge.sh`'s header owns the marker protocol, the delay override, and the known residual cases. A question answered inside the delay never nudges, and one prompt sends at most one nudge. The disarm is deliberately uncorrelated (no hook payload ties a finished tool back to the waiting prompt), so any completed tool clears the turn's pending nudge; the two accepted residuals - a decline that fires no event, and a sibling tool completing while the dialog still waits - are documented in the script header and the verification record. -This covers the Claude Code primary only: a Pi primary equivalent (nudging when a settled turn ends on a captain-facing question in chat) is separate work owned outside this surface, and the remaining primary harnesses are a known follow-up. Live hook-payload evidence and the verification procedure live in `docs/verification/decision-nudge.md`. +The Pi and pi-signed primary arms on a settled turn instead of a blocking prompt, as described under [Telegram mode](#telegram-mode-configtelegram-mode) above: `.pi/extensions/fm-primary-decision-nudge.ts` arms the same script through `--pi-arm` when the settled turn's latest assistant chat text addresses the captain and asks him something, and disarms through `--pi-resolved` on captain input, before a new agent run, and on session shutdown. +Pi exposes no global hook around an arbitrary blocking `ctx.ui` prompt, so Pi coverage stops at settled chat asks; `docs/verification/pi-decision-nudge.md` records that evidence. +The remaining primary harnesses are a known follow-up. + ## Environment variables Runtime tuning via environment variables (defaults shown): @@ -483,6 +494,7 @@ FMTG_REOFFER_SECS=1800 # seconds before a still-unclaimed Telegram note is offe FMTG_TG_BIN= # phone-inbox outbound client used for Telegram follow-ups; unset means ~/dev/phone-inbox/tg FMTG_FOLLOWUP_MAX_AGE_SECS=604800 # local window for sending Telegram-mode completion follow-ups (7 days) FMTG_FOLLOWUP_MAX_COUNT=3 # local cap on Telegram-mode completion follow-ups per linked note +FM_DECISION_NUDGE_DELAY_SECS=30 # seconds a captain-facing decision may sit unanswered before the one content-free Telegram nudge is sent FM_LOCK_STALE_AFTER=2 # seconds before dead-pid lock records can be reclaimed; mid-acquire locks keep at least 2s grace FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and the primary turn-end guard treat a watcher beacon as stale FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for the Stop auto-arm's claim, health, or fresh rewake epoch before re-blocking diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index ad60444744..185f4c972a 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -315,6 +315,10 @@ "path": "docs/verification/decision-nudge.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/pi-decision-nudge.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/runtime-backends.md", "audience": "maintainer-verification" diff --git a/docs/scripts.md b/docs/scripts.md index 6ad34b7635..251c4494eb 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -30,7 +30,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | | `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | -| `fm-decision-nudge.sh` | Claude Code hook sending one content-free Telegram nudge when a primary decision prompt waits unanswered (docs/configuration.md "Captain-attention nudge") | +| `fm-decision-nudge.sh` | Claude Code and Pi primary hook entrypoint sending one content-free Telegram nudge when a decision the captain owns waits unanswered (docs/configuration.md "Captain-attention nudge") | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | | `fm-kimi-turnend-hook.sh` | Surgically install or remove Kimi's guarded global crew turn-end hook | diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 2316428a83..644c605c88 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -2,7 +2,7 @@ Mode: Pi extension background wake. When this session owns supervision and away mode is not active: 1. Drain first with `bin/fm-wake-drain.sh`. -2. Confirm the Pi primary auto-loaded both project extensions (plain `pi` or `pi-signed`, after approving project trust once per clone); if not, restart the selected executable with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__` as a trust-free fallback. +2. Confirm the Pi primary auto-loaded all three project extensions (plain `pi` or `pi-signed`, after approving project trust once per clone); if not, restart the selected executable with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__ -e __FM_PI_NUDGE_EXT__` as a trust-free fallback. 3. First cycle only: make the one required `fm_watch_arm_pi` call. Use `/fm-watch-arm-pi` only as a human-entered fallback. Never run `bin/fm-watch-arm.sh` through Pi's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. @@ -13,11 +13,12 @@ When this session owns supervision and away mode is not active: 7. After an actionable child close, the extension rechecks session-lock ownership and verifies one successor before it delivers the follow-up wake; its bounded fallback is defined in `docs/watcher-continuity.md`. 8. Ordinary work, turn completion, and ordinary signal, stale, check, heartbeat, or other wake handling: do not call `fm_watch_arm_pi` again because continuity is extension-owned rather than model-memory-owned. 9. An unexpected child close enters bounded exponential retry, and an exhausted retry or lost session lock is surfaced as a watcher failure instead of disappearing. -10. Missing, failed, or unhealthy cycle only: if a later notification explicitly reports one of those repair conditions, drain queued wakes, inspect the failure text, call `fm_watch_arm_pi`, and restart the selected Pi-family executable with both extensions loaded if needed. +10. Missing, failed, or unhealthy cycle only: if a later notification explicitly reports one of those repair conditions, drain queued wakes, inspect the failure text, call `fm_watch_arm_pi`, and restart the selected Pi-family executable with all three extensions loaded if needed. A redundant call while the extension owns an arm child or scheduled retry is an ownership-based `watcher: unchanged` no-op, not an independent health claim. 11. Never use shell `&` for watcher supervision. The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The turn-end guard extension lives at `__FM_PI_TURNEND_EXT__`. The watcher extension lives at `__FM_PI_EXT__`. -Both are tracked, project-local `.pi/extensions/*.ts` files that Pi auto-discovers once the project is trusted; `bin/fm-session-start.sh` reports when the running Pi session has not loaded both required extensions. +The captain-attention nudge extension lives at `__FM_PI_NUDGE_EXT__`. +All three are tracked, project-local `.pi/extensions/*.ts` files that Pi auto-discovers once the project is trusted; `bin/fm-session-start.sh` reports when the running Pi session has not loaded every required extension. diff --git a/docs/verification/pi-decision-nudge.md b/docs/verification/pi-decision-nudge.md new file mode 100644 index 0000000000..342191221d --- /dev/null +++ b/docs/verification/pi-decision-nudge.md @@ -0,0 +1,44 @@ +# Pi captain-attention nudge verification + +Audience: maintainer verification. + +## Current guarantee + +As of 2026-08-10, Pi 0.82.1 primary sessions use `.pi/extensions/fm-primary-decision-nudge.ts` to inspect the latest assistant text after `agent_settled`. +The extension arms only for non-empty text that addresses `Captain` and presents a question or explicit decision request, and it excludes the exact routine reply `Captain, shipshape.`. +A decision verb only counts in an imperative, vocative-initial, second-person, or explicit-request position, so a settled watcher turn such as `Captain, PR #7 is merged and CI is green. I'll confirm the deploy once you're back.` does not page him. +The backward scan skips empty and tool-only assistant entries and stops at the turn boundary, so an ask the captain already answered can never re-arm. +`bin/fm-decision-nudge.sh` owns the shared primary-scope check, Telegram opt-in check, private pending marker, detached delay, single claim, disarm, and content-free phone send. +The primary-scope check gates disarming as well as arming: this tracked entrypoint also runs in crewmate and scout worktrees, which can inherit `FM_HOME` from the daemon environment, and must never cancel a nudge the captain's own session armed. +The Pi extension disarms on interactive or RPC input, before a new agent run, and on session shutdown. +It writes the same `state/.pi-decision-nudge-extension-loaded` marker its two sibling primary extensions write, so `bin/fm-session-start.sh` reports it as not loaded instead of silently losing the nudge, and `bin/fm-spawn.sh` passes it with an explicit `-e` in pi secondmate homes, where project trust is never approved. +All three primary extensions take their session-lock-ownership and version-stamp contract from one place, `.pi/extensions/lib/fm-primary-loaded-marker.ts`, so the writers cannot drift from what the session-start diagnostic checks. +The rendered Pi supervision snippet and the read-only repair line name all three extensions, so the documented trust-free `-e` fallback is exactly what clears the diagnostic. +The shared script header is the single owner of its Claude-compatible and Pi-compatible CLI. + +Pi 0.82.1 exposes lifecycle events around agent runs and extension-owned UI calls, but it does not expose a global event when arbitrary code enters or leaves `ctx.ui.confirm`, `ctx.ui.select`, `ctx.ui.input`, or `ctx.ui.custom`. +The tracked Pi path therefore covers settled chat asks and does not guess at unrelated UI overlay state. + +## Deterministic regression + +Command: + +```sh +tests/fm-pi-decision-nudge.test.sh +``` + +Output: + +```text +ok - Pi heuristic arms only explicit captain-facing waits +ok - Pi agent_settled arms chat waits and captain presence disarms +ok - Pi arm and resolve own one pending marker +ok - detached timer claims once and suppresses duplicate sends +ok - a captain answer inside the delay cancels the send +ok - missing Telegram opt-in is inert +ok - non-primary linked worktrees stay out of scope +ok - only a primary session can disarm the captain's pending nudge +ok - Pi captain-attention decision-nudge suite complete +``` + +The regression uses a capture executable through `FMTG_TG_BIN`, so it verifies the exact content-free message without contacting Telegram. diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index b34812e2af..2d3fcec252 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -1056,6 +1056,7 @@ test_rendering_and_session_lifecycle() { cp "$NONCONVERSATION_LAYOUT" "$fixture/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/lib/fm-operational-input.ts" + cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$fixture/lib/fm-primary-loaded-marker.ts" cp "$WATCH_EXT" "$fixture/fm-primary-pi-watch.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" @@ -2765,6 +2766,7 @@ test_interactive_terminal_e2e() { : > "$project/AGENTS.md" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$project/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$project/.pi/extensions/lib/fm-primary-loaded-marker.ts" cp "$WATCH_EXT" "$project/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$project/.pi/extensions/fm-primary-turnend-guard.ts" cp \ diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index b6d75aaf51..0a65b134e5 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -24,7 +24,7 @@ test_existing_launch_templates_are_byte_pinned() { assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c \"notify=[\\\"bash\\\",\\\"-c\\\",\\\"touch __TURNEND__\\\"]\" \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\\''{\"permission\":{\"*\":\"allow\"}}'\\'' opencode __MODELFLAG__--prompt \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" + assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ -e __PINUDGE__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ __PICALMFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" pass "fm-spawn: non-Pi and secondmate launch templates stay byte-pinned while ordinary Pi includes Calm" diff --git a/tests/fm-pi-decision-nudge.test.sh b/tests/fm-pi-decision-nudge.test.sh new file mode 100755 index 0000000000..ebdd588761 --- /dev/null +++ b/tests/fm-pi-decision-nudge.test.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Behavior tests for the Pi primary captain-attention extension and the shared +# decision-nudge marker/timer transport. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +NUDGE="$ROOT/bin/fm-decision-nudge.sh" +EXT="$ROOT/.pi/extensions/fm-primary-decision-nudge.ts" +TMP=$(fm_test_tmproot fm-pi-decision-nudge) +FIX="$TMP/primary" +SENT="$TMP/tg-sent.log" +MARKER="$FIX/state/.decision-nudge-pending" +export NODE_NO_WARNINGS=1 + +mkdir -p "$FIX/bin" "$FIX/state" "$FIX/config" +fm_git_identity +git -C "$FIX" init -q +touch "$FIX/AGENTS.md" +git -C "$FIX" add AGENTS.md +git -C "$FIX" commit -qm initial +touch "$FIX/config/telegram-mode" + +cat > "$TMP/tg-capture" <> '$SENT' +SH +chmod +x "$TMP/tg-capture" + +export FM_ROOT_OVERRIDE="$FIX" FM_HOME="$FIX" FM_STATE_OVERRIDE="$FIX/state" +export FM_CONFIG_OVERRIDE="$FIX/config" FMTG_TG_BIN="$TMP/tg-capture" +export FM_DECISION_NUDGE_DELAY_SECS=1 + +wait_for_file() { # + local path=$1 i=0 + while [ "$i" -lt 40 ]; do + [ -e "$path" ] && return 0 + sleep 0.05 + i=$((i + 1)) + done + return 1 +} + +wait_for_status() { # + local expected=$1 i=0 + while [ "$i" -lt 60 ]; do + [ "$(sed -n 's/^status=//p' "$MARKER" 2>/dev/null)" = "$expected" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +# --- heuristic --------------------------------------------------------------- + +PLUGIN="$EXT" node --input-type=module <<'JS' || fail "Pi wait heuristic assertions failed" +import { pathToFileURL } from "node:url"; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +const yes = [ + "Captain, should I merge this?", + "Captain, I need your decision: choose A or B.", + "Captain, options: keep the old behavior or use the new behavior.", + "Captain, please approve the deploy plan before I continue.", + "Captain, choose the branch you want me to land this on.", +]; +for (const text of yes) { + if (!mod.isCaptainAttentionWait(text)) throw new Error(`expected arm: ${text}`); +} +const no = [ + "Captain, shipshape.", + "Captain, the review is complete.", + "Should I continue?", + "", + "Captain, PR #7 is merged and CI is green. I'll confirm the deploy once you're back.", + "Captain, the watcher cycle is healthy and I'll pick the next task from the queue.", + "Captain, I approved the crewmate's plan and landed it on main.", +]; +for (const text of no) { + if (mod.isCaptainAttentionWait(text)) throw new Error(`unexpected arm: ${text}`); +} +const ctx = { + sessionManager: { + getBranch() { + return [ + { type: "message", id: "user1", message: { role: "user", content: "go" } }, + { type: "message", id: "assistant1", message: { role: "assistant", content: [ + { type: "thinking", thinking: "private" }, + { type: "text", text: "Captain, approve option A?" }, + ] } }, + ]; + }, + }, +}; +const wait = mod.latestCaptainAttentionWait(ctx); +if (wait?.id !== "assistant1" || wait.text !== "Captain, approve option A?") { + throw new Error(`latest assistant wait not found: ${JSON.stringify(wait)}`); +} +const branchCtx = (entries) => ({ sessionManager: { getBranch: () => entries } }); +const toolNoise = mod.latestCaptainAttentionWait(branchCtx([ + { type: "message", id: "user1", message: { role: "user", content: "go" } }, + { type: "message", id: "assistant1", message: { role: "assistant", content: [{ type: "text", text: "Captain, approve option A?" }] } }, + { type: "message", id: "assistant2", message: { role: "assistant", content: [{ type: "tool_use", id: "t1" }] } }, +])); +if (toolNoise?.id !== "assistant1") { + throw new Error(`tool-only trailing entry hid the latest non-empty ask: ${JSON.stringify(toolNoise)}`); +} +const answered = mod.latestCaptainAttentionWait(branchCtx([ + { type: "message", id: "assistant1", message: { role: "assistant", content: [{ type: "text", text: "Captain, approve option A?" }] } }, + { type: "message", id: "user2", message: { role: "user", content: "yes" } }, +])); +if (answered !== null) { + throw new Error(`scan crossed the turn boundary: ${JSON.stringify(answered)}`); +} +JS +pass "Pi heuristic arms only explicit captain-facing waits" + +# --- extension event wiring -------------------------------------------------- + +cp "$NUDGE" "$FIX/bin/fm-decision-nudge.sh" +cp "$ROOT/bin/fm-primary-scope-lib.sh" "$FIX/bin/fm-primary-scope-lib.sh" +cp "$ROOT/bin/fm-tg-lib.sh" "$FIX/bin/fm-tg-lib.sh" +cp "$ROOT/bin/fm-x-lib.sh" "$FIX/bin/fm-x-lib.sh" +chmod +x "$FIX/bin/fm-decision-nudge.sh" + +PLUGIN="$EXT" node --input-type=module <<'JS' || fail "Pi extension event wiring failed" +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +const handlers = new Map(); +const pi = { + on(name, handler) { + handlers.set(name, handler); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +for (const name of ["input", "before_agent_start", "agent_settled", "session_shutdown"]) { + if (!handlers.has(name)) throw new Error(`missing ${name} handler`); +} +const context = (id, text) => ({ + sessionManager: { + getBranch() { + return [{ type: "message", id, message: { role: "assistant", content: [{ type: "text", text }] } }]; + }, + }, +}); +handlers.get("agent_settled")({}, context("ask1", "Captain, should I continue?")); +const marker = `${process.env.FM_STATE_OVERRIDE}/.decision-nudge-pending`; +for (let i = 0; i < 50 && !existsSync(marker); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (!existsSync(marker)) throw new Error("settled captain question did not arm"); +handlers.get("input")({ source: "interactive" }); +for (let i = 0; i < 50 && existsSync(marker); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (existsSync(marker)) throw new Error("interactive captain input did not disarm"); +handlers.get("agent_settled")({}, context("ask2", "Captain, choose option A or B.")); +for (let i = 0; i < 50 && !existsSync(marker); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (!existsSync(marker)) throw new Error("second settled captain question did not arm"); +handlers.get("before_agent_start")({}); +for (let i = 0; i < 50 && existsSync(marker); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (existsSync(marker)) throw new Error("before_agent_start did not disarm"); +handlers.get("agent_settled")({}, context("idle1", "Captain, shipshape.")); +await new Promise((resolve) => setTimeout(resolve, 100)); +if (existsSync(marker)) throw new Error("routine shipshape reply armed"); +handlers.get("agent_settled")({}, context("noise1", "")); +await new Promise((resolve) => setTimeout(resolve, 100)); +if (existsSync(marker)) throw new Error("empty assistant turn armed"); +JS +pass "Pi agent_settled arms chat waits and captain presence disarms" + +# --- marker, timer, claim, and duplicate suppression ------------------------- + +"$NUDGE" --pi-arm wait-one || fail "Pi arm exited nonzero" +wait_for_file "$MARKER" || fail "Pi arm did not create the pending marker" +assert_contains "$(cat "$MARKER")" "prompt_id=pi:wait-one" "Pi marker prompt identity" +assert_contains "$(cat "$MARKER")" "status=pending" "Pi marker pending state" +"$NUDGE" --pi-resolved || fail "Pi resolve exited nonzero" +[ ! -e "$MARKER" ] || fail "Pi resolve left the marker behind" +pass "Pi arm and resolve own one pending marker" + +: > "$SENT" +"$NUDGE" --pi-arm wait-two || fail "timer arm exited nonzero" +wait_for_status sent || fail "timer did not claim and mark the wait sent" +[ "$(cat "$SENT")" = "Captain, something's awaiting your attention." ] \ + || fail "timer did not send exactly the content-free message: $(cat "$SENT" 2>/dev/null)" +"$NUDGE" --pi-arm wait-two || fail "duplicate arm exited nonzero" +sleep 1.5 +[ "$(grep -c Captain "$SENT")" = 1 ] || fail "same wait sent more than one nudge" +"$NUDGE" --pi-resolved +pass "detached timer claims once and suppresses duplicate sends" + +: > "$SENT" +"$NUDGE" --pi-arm wait-three +"$NUDGE" --pi-resolved +sleep 1.5 +[ ! -s "$SENT" ] || fail "resolved wait still sent a nudge" +pass "a captain answer inside the delay cancels the send" + +# --- consent and scope ------------------------------------------------------- + +rm -f "$FIX/config/telegram-mode" +"$NUDGE" --pi-arm no-optin +sleep 0.2 +[ ! -e "$MARKER" ] || fail "missing Telegram opt-in still armed" +[ ! -s "$SENT" ] || fail "missing Telegram opt-in still sent" +touch "$FIX/config/telegram-mode" +pass "missing Telegram opt-in is inert" + +WT="$TMP/task-worktree" +git -C "$FIX" worktree add -q "$WT" -b fm-pi-nudge-linked-test +mkdir -p "$WT/state" "$WT/config" +touch "$WT/config/telegram-mode" +FM_ROOT_OVERRIDE="$WT" FM_HOME="$WT" FM_STATE_OVERRIDE="$WT/state" \ + FM_CONFIG_OVERRIDE="$WT/config" "$NUDGE" --pi-arm linked-task +sleep 0.2 +[ ! -e "$WT/state/.decision-nudge-pending" ] || fail "linked task worktree armed the nudge" +pass "non-primary linked worktrees stay out of scope" + +FM_DECISION_NUDGE_DELAY_SECS=10 "$NUDGE" --pi-arm scope-disarm \ + || fail "primary arm before the crew disarm probe failed" +wait_for_file "$MARKER" || fail "primary arm did not create the pending marker" +FM_ROOT_OVERRIDE="$WT" FM_HOME="$FIX" FM_STATE_OVERRIDE="$FIX/state" \ + FM_CONFIG_OVERRIDE="$FIX/config" "$NUDGE" --pi-resolved +[ -e "$MARKER" ] || fail "a crew worktree cancelled the captain's armed nudge" +"$NUDGE" --pi-resolved || fail "primary resolve exited nonzero" +[ ! -e "$MARKER" ] || fail "primary resolve left the marker behind" +pass "only a primary session can disarm the captain's pending nudge" + +printf 'ok - Pi captain-attention decision-nudge suite complete\n' diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index b32fb803d6..6395e7c0ae 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -195,6 +195,7 @@ run_native_ahoy_regressions() { git init -q "$AHOY_PROJECT" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$AHOY_PROJECT/.pi/extensions/" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$AHOY_PROJECT/.pi/extensions/lib/" + cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$AHOY_PROJECT/.pi/extensions/lib/" cp \ "$ROOT/bin/fm-sessionstart-nudge.sh" \ "$ROOT/bin/fm-primary-scope-lib.sh" \ @@ -275,6 +276,7 @@ cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$PROJECT/.pi/e cp "$ROOT/.pi/extensions/lib/fm-calm-tool-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-tool-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$PROJECT/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$PROJECT/.pi/extensions/lib/fm-operational-input.ts" +cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$PROJECT/.pi/extensions/lib/fm-primary-loaded-marker.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts" cp "$ROOT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm.sh" cp "$ROOT/bin/fm-operational-input.sh" "$PROJECT/bin/fm-operational-input.sh" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 5c8af94017..333b7debe2 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -27,6 +27,7 @@ trap cleanup EXIT mkdir -p "$TMP_ROOT/lib" "$TMP_ROOT/node_modules/@earendil-works" "$TMP_ROOT/node_modules/@types" cp "$ROOT/.pi/extensions/fm-calm.ts" "$TMP_ROOT/fm-calm.ts" +cp "$ROOT/.pi/extensions/fm-primary-decision-nudge.ts" "$TMP_ROOT/fm-primary-decision-nudge.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$TMP_ROOT/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turnend-guard.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" @@ -35,6 +36,7 @@ cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/ cp "$ROOT/.pi/extensions/lib/fm-calm-tool-layout.ts" "$TMP_ROOT/lib/fm-calm-tool-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operational-input.ts" +cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$TMP_ROOT/lib/fm-primary-loaded-marker.ts" ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$TMP_ROOT/node_modules/@earendil-works/pi-tui" ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$TMP_ROOT/node_modules/typebox" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 518df0e874..9e7735165b 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -7,6 +7,7 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-pi-watch-extension) EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" +LOADED_MARKER_LIB="$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" # Node 24 warns when these test-only dynamic imports load tracked ESM plugins # from a clean checkout with no tracked .opencode/package.json. The warning is # unrelated to plugin output, which the assertions intentionally require empty. @@ -22,6 +23,7 @@ install_pi_watch_extension_fixture() { cp "$EXT" "$repo/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$repo/.pi/extensions/lib/fm-primary-loaded-marker.ts" mkdir -p "$repo/bin" cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" chmod +x "$repo/bin/fm-operational-input.sh" @@ -60,10 +62,12 @@ JS } test_tracked_extension_present_and_self_hashing() { - local text expected_config_source + local text lib_text expected_config_source expected_config_source="config_dir=\\\"\${FM_CONFIG_OVERRIDE:-\$FM_HOME/config}\\\"" assert_present "$EXT" "tracked Pi primary watcher extension is missing" text=$(cat "$EXT") + assert_present "$LOADED_MARKER_LIB" "shared Pi primary loaded-marker library is missing" + lib_text=$(cat "$LOADED_MARKER_LIB") assert_contains "$text" "fm_watch_arm_pi" "tracked extension missing tool name" assert_contains "$text" "fm-watch-arm-pi" "tracked extension missing command name" assert_contains "$text" "fm-watch-arm.sh" "tracked extension missing watcher arm" @@ -71,13 +75,15 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'encodeFirstmateOperationalInput' "tracked extension does not construct typed synthetic user-role wakes" assert_contains "$text" "deliverAs: \"followUp\"" "tracked extension missing followUp delivery" assert_contains "$text" ".pi-watch-extension-loaded" "tracked extension missing loaded marker" - assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" + assert_contains "$text" 'extensionVersionOf(extensionFile)' "tracked extension does not self-hash its own content for extensionVersion" + assert_contains "$text" './lib/fm-primary-loaded-marker.ts' "tracked extension does not share the loaded-marker and lock-ownership contract" + assert_contains "$lib_text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "shared loaded-marker library does not self-hash the extension content for extensionVersion" assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" - assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "other"' "tracked extension does not distinguish missing lock from another owner" - assert_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension does not read the effective session lock" - assert_contains "$text" 'return pidAlive(lockPid) ? "other" : "missing"' "tracked extension does not allow a pre-lock load marker" - assert_contains "$text" 'if (lockOwnership() === "other") return' "tracked extension overwrites another live session marker" - assert_contains "$text" 'const ownership = lockOwnership()' "tracked extension arm does not inspect the distinct lock ownership state" + assert_contains "$lib_text" 'export type LockOwnership = "owned" | "missing" | "other"' "shared loaded-marker library does not distinguish missing lock from another owner" + assert_contains "$lib_text" "readFileSync(\`\${state}/.lock\`" "shared loaded-marker library does not read the effective session lock" + assert_contains "$lib_text" 'return pidAlive(lockPid) ? "other" : "missing"' "shared loaded-marker library does not allow a pre-lock load marker" + assert_contains "$text" 'if (lockOwnership(state) === "other") return' "tracked extension overwrites another live session marker" + assert_contains "$text" 'const ownership = lockOwnership(state)' "tracked extension arm does not inspect the distinct lock ownership state" assert_contains "$text" 'if (ownership === "other") return { ok: false' "tracked extension arm does not preserve the live-other read-only refusal" assert_contains "$text" 'if (ownership === "missing")' "tracked extension arm collapses a stale or absent lock into the live-other refusal" assert_contains "$text" "no live session holds the lock" "tracked extension arm missing stale-lock recovery guidance" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9bcf04eddb..d6b4a5c217 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -532,6 +532,12 @@ install_pi_watch_extension_fixture() { cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$root/.pi/extensions/fm-primary-pi-watch.ts" } +install_pi_decision_nudge_extension_fixture() { + local root=$1 + mkdir -p "$root/.pi/extensions" + cp "$ROOT/.pi/extensions/fm-primary-decision-nudge.ts" "$root/.pi/extensions/fm-primary-decision-nudge.ts" +} + write_pi_watch_loaded_marker() { local home=$1 root=$2 pid=$3 version version=$(hash_file_for_test "$root/.pi/extensions/fm-primary-pi-watch.ts") @@ -544,10 +550,17 @@ write_pi_turnend_loaded_marker() { printf '%s\n%s\n' "$version" "$pid" > "$home/state/.pi-turnend-extension-loaded" } +write_pi_decision_nudge_loaded_marker() { + local home=$1 root=$2 pid=$3 version + version=$(hash_file_for_test "$root/.pi/extensions/fm-primary-decision-nudge.ts") + printf '%s\n%s\n' "$version" "$pid" > "$home/state/.pi-decision-nudge-extension-loaded" +} + write_pi_loaded_markers() { local home=$1 root=$2 pid=$3 write_pi_watch_loaded_marker "$home" "$root" "$pid" write_pi_turnend_loaded_marker "$home" "$root" "$pid" + write_pi_decision_nudge_loaded_marker "$home" "$root" "$pid" } # --- context digest: absent vs empty vs present ----------------------------- @@ -1247,7 +1260,7 @@ EOF assert_contains "$out" "SUPERVISION OPERATING INSTRUCTIONS - primary harness: pi" "pi supervision block missing" assert_contains "$out" "Mode: Pi extension background wake." "pi snippet missing from session start" assert_contains "$out" "PI_WATCH_EXTENSION: not loaded" "pi extension load diagnostic missing" - assert_contains "$out" "restart plain pi so $root/.pi/extensions/fm-primary-turnend-guard.ts and $root/.pi/extensions/fm-primary-pi-watch.ts auto-load" "pi extension load diagnostic omits the turn-end guard extension" + assert_contains "$out" "restart plain pi so $root/.pi/extensions/fm-primary-turnend-guard.ts, $root/.pi/extensions/fm-primary-pi-watch.ts, and $root/.pi/extensions/fm-primary-decision-nudge.ts auto-load" "pi extension load diagnostic omits the turn-end guard extension" wake_line=$(printf '%s\n' "$out" | grep -n '^WAKE QUEUE$' | head -1 | cut -d: -f1) sup_line=$(printf '%s\n' "$out" | grep -n '^SUPERVISION OPERATING INSTRUCTIONS' | head -1 | cut -d: -f1) @@ -1275,7 +1288,7 @@ EOF "pi-signed primary did not reuse Pi's supervision protocol" assert_contains "$out" "PI_WATCH_EXTENSION: not loaded" \ "pi-signed primary skipped Pi extension validation" - assert_contains "$out" "restart pi-signed so $root/.pi/extensions/fm-primary-turnend-guard.ts and $root/.pi/extensions/fm-primary-pi-watch.ts auto-load" \ + assert_contains "$out" "restart pi-signed so $root/.pi/extensions/fm-primary-turnend-guard.ts, $root/.pi/extensions/fm-primary-pi-watch.ts, and $root/.pi/extensions/fm-primary-decision-nudge.ts auto-load" \ "pi-signed extension diagnostic did not preserve the executable identity" pass "session start preserves pi-signed primary identity while applying Pi extension guarantees" @@ -1294,6 +1307,7 @@ EOF make_fake_ps_pi_holder "$fakebin" "$holder_pid" install_pi_turnend_extension_fixture "$root" install_pi_watch_extension_fixture "$root" + install_pi_decision_nudge_extension_fixture "$root" marker="$home/state/.pi-watch-extension-loaded" printf 'stale-extension-version\n%s\n' "$holder_pid" > "$marker" write_pi_turnend_loaded_marker "$home" "$root" "$holder_pid" @@ -1321,6 +1335,7 @@ EOF make_fake_ps_pi_holder "$fakebin" "$holder_pid" install_pi_turnend_extension_fixture "$root" install_pi_watch_extension_fixture "$root" + install_pi_decision_nudge_extension_fixture "$root" write_pi_loaded_markers "$home" "$root" "$holder_pid" @@ -1346,8 +1361,10 @@ EOF make_fake_ps_pi_holder "$fakebin" "$holder_pid" install_pi_turnend_extension_fixture "$root" install_pi_watch_extension_fixture "$root" + install_pi_decision_nudge_extension_fixture "$root" write_pi_watch_loaded_marker "$home" "$root" "$holder_pid" + write_pi_decision_nudge_loaded_marker "$home" "$root" "$holder_pid" out=$(FM_FAKE_HARNESS=pi run_session_start "$home" "$root" "$fakebin:$BASE_PATH") kill "$holder_pid" 2>/dev/null || true @@ -1358,6 +1375,33 @@ EOF pass "session start rejects Pi sessions missing the turn-end guard marker" } +test_pi_diagnostic_rejects_missing_decision_nudge_marker() { + local rec root home fakebin out holder_pid + rec=$(new_world pi-missing-decision-nudge-marker) + IFS='|' read -r root home fakebin </dev/null || true + wait "$holder_pid" 2>/dev/null || true + + assert_contains "$out" "PI_WATCH_EXTENSION: not loaded" "pi diagnostic trusted a session without the captain-attention nudge extension" + + pass "session start rejects Pi sessions missing the captain-attention nudge marker" +} + test_pi_diagnostic_rejects_previous_session_loaded_marker() { local rec root home fakebin out marker version holder_pid rec=$(new_world pi-previous-session-loaded-marker) @@ -1371,6 +1415,7 @@ EOF make_fake_ps_pi_holder "$fakebin" "$holder_pid" install_pi_turnend_extension_fixture "$root" install_pi_watch_extension_fixture "$root" + install_pi_decision_nudge_extension_fixture "$root" marker="$home/state/.pi-watch-extension-loaded" version=$(hash_file_for_test "$root/.pi/extensions/fm-primary-pi-watch.ts") printf '%s\n999999\n' "$version" > "$marker" @@ -1412,4 +1457,5 @@ test_pi_signed_primary_uses_pi_extensions_without_identity_normalization test_pi_diagnostic_rejects_stale_loaded_marker test_pi_diagnostic_accepts_prelock_loaded_marker test_pi_diagnostic_rejects_missing_turnend_guard_marker +test_pi_diagnostic_rejects_missing_decision_nudge_marker test_pi_diagnostic_rejects_previous_session_loaded_marker diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 0f19d21971..7991893eb7 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -655,7 +655,7 @@ test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { "pi-signed secondmate spawn did not preserve its runtime identity" assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed default default launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "FM_PI_HARNESS=pi-signed pi-signed -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ + assert_contains "$launch" "FM_PI_HARNESS=pi-signed pi-signed -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts' -e '$sm/.pi/extensions/fm-primary-decision-nudge.ts'" \ "pi-signed secondmate did not share Pi's primary extension launch shape" assert_not_contains "$launch" "fm-calm.ts" \ "secondmate Pi launch unexpectedly received the ordinary-crewmate Calm extension" diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index 1ac5e19521..7937dc7cd8 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -156,15 +156,18 @@ test_grok_command_sources_effective_config() { } test_pi_snippet_uses_effective_extension_path() { - local home out turnend watch + local home out turnend watch nudge home="$TMP_ROOT/pi-home" turnend="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" watch="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" + nudge="$ROOT/.pi/extensions/fm-primary-decision-nudge.ts" mkdir -p "$home/state" "$home/config" out=$(FM_HOME="$home" "$RENDER" --harness pi) - assert_contains "$out" "-e $turnend -e $watch" "pi snippet did not render both effective extension launch paths" + assert_contains "$out" "-e $turnend -e $watch -e $nudge" "pi snippet did not render every effective extension launch path" assert_contains "$out" "The turn-end guard extension lives at \`$turnend\`" "pi snippet did not render the turn-end guard extension path" assert_contains "$out" "The watcher extension lives at \`$watch\`" "pi snippet did not render the watcher extension path" + assert_contains "$out" "The captain-attention nudge extension lives at \`$nudge\`" "pi snippet did not render the captain-attention nudge extension path" + assert_not_contains "$out" "__FM_PI_NUDGE_EXT__" "renderer leaked the Pi nudge extension path placeholder" assert_not_contains "$out" "__FM_PI_EXT__" "renderer leaked the Pi extension path placeholder" assert_not_contains "$out" "__FM_PI_TURNEND_EXT__" "renderer leaked the Pi turn-end extension path placeholder" assert_not_contains "$out" "state/fm-primary-pi-watch.ts" "pi snippet kept the old generated state-relative extension path" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 979ca7ab25..19b4837820 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -910,6 +910,7 @@ test_pi_extension_injects_once_per_logical_agent_run() { 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/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$repo/.pi/extensions/lib/fm-primary-loaded-marker.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 @@ -976,6 +977,7 @@ test_pi_extension_retries_after_followup_delivery_failure() { 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/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$repo/.pi/extensions/lib/fm-primary-loaded-marker.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