diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index b2804c7..916b306 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -25,7 +25,9 @@ It never tears down a task, merges a PR, dispatches new work, or mutates any tas If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them. + For each one, take the plain-language decision text from `bin/fm-attention.sh`, which is the single chat renderer for open decisions and meaningful waits and already renders the choice, the stakes, the options, and the recommendation; a decision with no recorded briefing renders as not yet written, and Captain's Call must say that rather than dressing up its raw note. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. + A declared external delay belongs in Charted Next with what it is waiting for and when it is next checked, both of which `bin/fm-attention.sh` renders; [`docs/captain-attention.md`](../../../docs/captain-attention.md) owns the explicit transition that can move it to Captain's Call. The `(main-inventory)` gate is an action-free integrity warning rather than queued work: render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. 2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.** diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 5db5690..22f3d66 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -17,6 +17,9 @@ This skill is the single policy owner for unresolved captain decisions discovere Every unresolved decision that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must become a structured captain-held work item in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete. The agent performs the semantic inventory because scripts must not infer decisions from report prose, visual-review artifacts, terminal output, or chat. Give each distinct unresolved decision a stable privacy-safe key, register it through `bin/fm-decision-hold.sh hold`, and use the same key on retry so registration is idempotent while different decisions retain different durable identities. +Register it with its captain briefing in the same call: the concrete choice, why it matters now, what waiting costs, at least one real option, and your recommendation. +Give that briefing a stable privacy-safe semantic revision, reuse the revision for wording-only paraphrases, and change it whenever the choice, stakes, waiting cost, options, or recommendation changes substantively. +A title and a one-line reason cannot carry a decision, so the script refuses a new hold without that briefing; the briefing is what makes the decision answerable without further investigation. After inventorying the whole report and review surface, run `bin/fm-decision-hold.sh complete` with every unresolved key, or with `--none` only when the reviewed surface contains no unresolved captain decision. A completed investigation and an ended visual review use this same owner and completion command; a visual tool, including Lavish, never owns a parallel completion policy. Run the command in the originating work's authoritative `FM_HOME`; main-home work creates main-home holds, and secondmate-owned work creates holds in that secondmate home's backlog rather than copying them into the main backlog. @@ -29,7 +32,7 @@ Bearings reads the resulting structured state and must never compensate by scrap 1. Read the complete investigation result and complete the visual review before declaring either complete. 2. Inventory only genuine unresolved choices that require the captain. -3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, and repository. +3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, repository, and the captain briefing fields. 4. Run the script's `complete` command with the full unresolved-key inventory for that review pass. 5. Relay the choices to the captain as decisions from Bearings' Captain's Call section under `AGENTS.md` section 9; do not use the word hold in captain chat. 6. After the captain decides, record dependent work with normal tasks-axi commands and block it by the hold identity. diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js index fb8f42e..cc963b7 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -3,9 +3,37 @@ import { realpathSync } from "node:fs"; import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; +// The shared guard has two independent stops and says which one fired in its own +// banner (bin/fm-turnend-guard.sh owns both headlines). A captain decision that +// has never been shown to the captain is not a supervision lapse, so the passive +// follow-up must not claim the watcher is down. +const CAPTAIN_CALL_HEADLINE = "TURN WOULD END WITHOUT TELLING THE CAPTAIN"; +const UNKNOWN_HEADLINE = "TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS"; + +function turnEndPrefix(stderr) { + if (typeof stderr === "string" && stderr.includes(CAPTAIN_CALL_HEADLINE)) { + return ( + "TURN WOULD END WITHOUT TELLING THE CAPTAIN. " + + "A decision is waiting on him that he has never been shown. Relay it in plain language before ending the turn.\n\n" + ); + } + if (typeof stderr === "string" && stderr.includes(UNKNOWN_HEADLINE)) { + return ( + "TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS. " + + "The open decision and wait list is unknown. Restore that list before reporting an all-clear.\n\n" + ); + } + return ( + "TURN WOULD END BLIND - supervision is off. " + + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + ); +} + + const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; let skipNextIdle = false; +const assistantMessages = new Map(); function runProcess(command, args, input = "") { return new Promise((resolve) => { @@ -42,16 +70,46 @@ function resolvePath(anchor) { } } -function runGuard(root) { +function runGuard(root, lastAssistantMessage) { if (!root) return Promise.resolve({ code: 0, stderr: "" }); - return runProcess(`${root}/bin/fm-turnend-guard.sh`, [], '{"stop_hook_active":false}'); + return runProcess( + `${root}/bin/fm-turnend-guard.sh`, + [], + JSON.stringify({ stop_hook_active: false, last_assistant_message: lastAssistantMessage }), + ); } async function letWatchArmRun(sessionID, client) { const coordinator = globalThis[COORDINATOR_KEY]; - if (!coordinator?.ensureArmed) return false; - const status = await coordinator.ensureArmed(sessionID, client); - return status === "armed" || status === "wake" || status === "failed"; + if (!coordinator?.ensureArmed) return; + try { + await coordinator.ensureArmed(sessionID, client); + } catch { + } +} + +function observeAssistantMessage(event) { + if (event.type === "message.updated") { + const info = event.properties?.info; + if (info?.role === "assistant" && info.sessionID && info.id) { + const current = assistantMessages.get(info.sessionID); + if (current?.messageID !== info.id) { + assistantMessages.set(info.sessionID, { messageID: info.id, parts: new Map() }); + } + } + return; + } + if (event.type !== "message.part.updated") return; + const part = event.properties?.part; + if (part?.type !== "text" || part.synthetic || part.ignored) return; + const current = assistantMessages.get(part.sessionID); + if (!current || current.messageID !== part.messageID) return; + current.parts.set(part.id, String(part.text ?? "")); +} + +function lastAssistantMessage(sessionID) { + const current = assistantMessages.get(sessionID); + return current ? [...current.parts.values()].join("\n") : ""; } export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => { @@ -59,28 +117,30 @@ export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => return { event: async ({ event }) => { + observeAssistantMessage(event); if (event.type !== "session.idle") return; + const suppressRoutineFollowup = skipNextIdle; if (skipNextIdle) { skipNextIdle = false; - return; } const sessionID = event.properties?.sessionID; if (!sessionID) return; - if (await letWatchArmRun(sessionID, client)) return; + await letWatchArmRun(sessionID, client); - const result = await runGuard(root); + const result = await runGuard(root, lastAssistantMessage(sessionID)); if (result.code !== 2) return; + const attentionStop = + result.stderr.includes(CAPTAIN_CALL_HEADLINE) || result.stderr.includes(UNKNOWN_HEADLINE); + if (suppressRoutineFollowup && !attentionStop) return; try { const text = await encodeFirstmateOperationalInput( root, "turn-end-guard", - "TURN WOULD END BLIND - supervision is off. " + - "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + - result.stderr, + turnEndPrefix(result.stderr) + result.stderr, ); await client.session.promptAsync({ path: { id: sessionID }, diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 113a1bc..7de8d8f 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -7,6 +7,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; let guardFollowupActive = false; +let lastAssistantMessage = ""; type LockOwnership = "owned" | "missing" | "other"; @@ -61,7 +62,33 @@ function runSessionstartNudge(): string { return result.stdout.trim(); } -function runGuard(): Promise<{ code: number; stderr: string }> { +// The shared guard has two independent stops and says which one fired in its own +// banner (bin/fm-turnend-guard.sh owns both headlines). A captain decision that +// has never been shown to the captain is not a supervision lapse, so the passive +// follow-up must not claim the watcher is down. +const CAPTAIN_CALL_HEADLINE = "TURN WOULD END WITHOUT TELLING THE CAPTAIN"; +const UNKNOWN_HEADLINE = "TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS"; + +function turnEndPrefix(stderr: string): string { + if (stderr.includes(CAPTAIN_CALL_HEADLINE)) { + return ( + "TURN WOULD END WITHOUT TELLING THE CAPTAIN. " + + "A decision is waiting on him that he has never been shown. Relay it in plain language before ending the turn.\n\n" + ); + } + if (stderr.includes(UNKNOWN_HEADLINE)) { + return ( + "TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS. " + + "The open decision and wait list is unknown. Restore that list before reporting an all-clear.\n\n" + ); + } + return ( + "TURN WOULD END BLIND - supervision is off. " + + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + ); +} + +function runGuard(message: string): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { stdio: ["pipe", "ignore", "pipe"], @@ -72,10 +99,24 @@ function runGuard(): Promise<{ code: number; stderr: string }> { }); child.on("error", () => resolveResult({ code: 0, stderr: "" })); child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); - child.stdin.end('{"stop_hook_active":false}'); + child.stdin.end(JSON.stringify({ stop_hook_active: false, last_assistant_message: message })); }); } +function assistantText(message: unknown): string { + if (!message || typeof message !== "object") return ""; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((part): part is { type: string; text: string } => + Boolean(part && typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string")) + .map((part) => part.text) + .join("\n"); +} + // PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md; // bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same // extension file rather than separate ones so no extra Pi -e flag is needed at @@ -135,22 +176,34 @@ export default function (pi: ExtensionAPI) { return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; }); + pi.on("agent_end", (event) => { + const messages = (event as { messages?: unknown[] }).messages; + if (!Array.isArray(messages)) return; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] as { role?: unknown }; + if (message?.role !== "assistant") continue; + lastAssistantMessage = assistantText(message); + return; + } + }); + pi.on("agent_settled", async () => { + const suppressRoutineFollowup = guardFollowupActive; if (guardFollowupActive) { guardFollowupActive = false; - return; } - const result = await runGuard(); + const result = await runGuard(lastAssistantMessage); if (result.code !== 2) return; + const attentionStop = + result.stderr.includes(CAPTAIN_CALL_HEADLINE) || result.stderr.includes(UNKNOWN_HEADLINE); + if (suppressRoutineFollowup && !attentionStop) return; guardFollowupActive = true; try { const content = encodeFirstmateOperationalInput( "turn-end-guard", - "TURN WOULD END BLIND - supervision is off. " + - "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + - result.stderr, + turnEndPrefix(result.stderr) + result.stderr, ); await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { diff --git a/AGENTS.md b/AGENTS.md index 009d606..0eb3b24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,8 @@ state/ volatile runtime signals; gitignored x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) + .captain-attention digest of the open decision/wait set most recently verified in an actual assistant reply; bounds the interrupt, never the ledger (bin/fm-attention-lib.sh) + .captain-attention-decisions .captain-attention-unknown turn-end surfaced-once bounds for the evidence-less decision stop and an unknown derivation (bin/fm-turnend-guard.sh); never touch .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .claude-autoarm.lock .claude-autoarm-epoch .turnend-claude-blocks Claude Stop auto-arm single-flight, epoch, and guard-budget records; never touch .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch @@ -142,11 +144,13 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai The secondmate liveness sweep deterministically accounts for every registered secondmate: it relaunches only from the recovery-grade `dead` or `missing` states, preserves ambiguous or unreadable targets, and reports skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_state`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the raw records prominently as this turn's first work queue; a bounded, clearly labeled historical status-event annotation may follow a valid `signal` record but never replaces it or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. -4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, each clearly delimited. +4. **Captain's call** - every open captain decision and meaningful wait, printed as this turn's second work queue before the context and the supervision block, so the first captain-facing reply carries them. + `bin/fm-attention.sh` renders it read-only; `docs/captain-attention.md` owns the operator contract and captain-receipt lifecycle. +5. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, each clearly delimited. A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). -5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. +6. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. -6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness. +7. **Supervision operating instructions and next step** - after the captain's call and before context, the digest emits exactly one operating block for the detected primary harness. The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders. The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. @@ -420,6 +424,11 @@ Reach the captain immediately for: - Anything destructive, irreversible, or security-sensitive. - A needed credential or login. +Never let the captain discover a stop by noticing that nothing moved. +`bin/fm-attention.sh` is the one place you read every open decision and meaningful wait from, and it prints them already translated; relay its content, and never send a reply that leaves an open decision unstated. +A decision must carry the concrete choice, why it matters now, what waiting costs, at least one real option, and your recommendation; a wait must say what is being awaited and when it is next checked. +Record that plain language durably when you register the decision, through the briefing fields of `bin/fm-decision-hold.sh hold`, so it survives the session that discovered it. + Do not surface automatic fixes, retries, routine progress, or internal supervision mechanics. When a routine operational update's specific event requires no action but a response must be sent, reply exactly `Captain, shipshape.` without characterizing the visible session's unrelated decisions. Batch non-urgent updates into the next natural reply. diff --git a/README.md b/README.md index 6d402df..c6ac867 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **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. +- **Nothing waits silently** - every open decision and meaningful wait is collected in one captain-facing place; decisions carry the concrete choice, what waiting costs, and a recommendation, while waits say what is being awaited and when it is next checked. + Each one is surfaced once, stays listed until it is answered or clears, and a home holding only an unanswered decision is no longer treated as idle. - **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. - **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. @@ -203,6 +205,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees. - [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits. +- [docs/captain-attention.md](docs/captain-attention.md) - how open decisions and waits are recorded, surfaced once, and cleared. - [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations. - [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh new file mode 100644 index 0000000..5971dcb --- /dev/null +++ b/bin/fm-attention-lib.sh @@ -0,0 +1,841 @@ +# shellcheck shell=bash +# fm-attention-lib.sh - the single owner of firstmate's captain-attention contract. +# Usage: . bin/fm-attention-lib.sh +# +# WHY THIS EXISTS +# Firstmate could stop on a decision or an external delay without the captain +# ever receiving a self-contained explanation of what was needed, why it +# mattered, or what would happen next. Three separate mechanisms each dropped +# that information: +# 1. a captain decision was recorded as a one-line title plus a one-line +# reason, so every renderer could only truncate it - there was nowhere +# durable to put the concrete choice, the consequence of waiting, the +# options, or the recommendation; +# 2. a declared external wait was deliberately excluded from the +# captain-relevant verb set (bin/fm-classify-lib.sh), which correctly stops +# wedge-nagging an idle pane but also removed the wait from every +# captain-facing surface, so a wait that never cleared looked identical to +# one that was about to; +# 3. supervision counted only state/.meta, so a home whose only live work +# was an unanswered decision or a standing wait reported as idle. +# This library replaces all three with ONE derived, read-only contract over +# state firstmate already keeps durably, so nothing here is a second status +# surface: it re-reads the backlog and the status event logs and says what is +# open right now. +# +# WHAT AN ATTENTION RECORD IS +# Exactly two classes, and every open item is exactly one of them: +# decision - the captain's own answer is needed before the work can move. +# wait - a meaningful delay that needs no captain action yet, including a +# declared external delay or backlog work held on another blocker. +# The captain is still owed what is being awaited and when it is +# next checked. +# Only an explicit action-required `needs-decision` or `blocked` status +# transition can turn a status-derived wait into a captain decision. Repetition +# never changes who must act. +# +# IDENTITY AND DEDUPLICATION +# Every record carries a stable, TEXT-FREE identity: +# decision: a captain-held backlog item +# decision::: an open keyed status decision +# wait::: an open keyed status wait +# wait: a held backlog row waiting on other work +# Identities exclude all prose deliberately: a wait re-declared hourly with +# slightly different wording keeps ONE identity while it stays open, so a +# standing delay is surfaced once rather than every hour. If that wait clears and +# later opens again, the generation changes. +# +# SURFACING +# state/.captain-attention records the digest of the set most recently verified +# in an actual assistant reply by bin/fm-attention.sh --record-visible: +# attention= +# decisions= +# A digest that differs from the recorded one means the set CHANGED and has not +# been shown since. Rendering is read-only. The explicit receipt path validates +# categories, headlines, and complete briefings before writing either digest. +# Decision digests use the briefing's explicit semantic revision, while wait +# identities stay text-free. The marker never suppresses the ledger itself - an +# open item stays rendered until it is resolved; the marker only bounds the +# INTERRUPT. +# +# CONSUMERS +# bin/fm-attention.sh the captain-facing chat renderer (pull, single place) +# bin/fm-guard.sh surfaces a changed set on every guarded command +# bin/fm-turnend-guard.sh stops a turn ending on an unsurfaced decision +# bin/fm-session-start.sh the session-start digest section +# bin/fm-decision-hold.sh writes the durable captain briefing this reads +# All of them are harness-agnostic and runtime-backend-agnostic: this library +# reads only home-local backlog and state records and never inspects a pane, an +# endpoint, or a harness. +# +# DEPENDENCIES +# jq, a successful backlog projection, and readable existing state inputs are +# required to know the complete set. When that derivation fails this library +# reports FM_ATT_AVAILABLE=false and FM_ATT_UNKNOWN=true; callers must not treat +# it as an empty set. + +_FM_ATTENTION_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_ATTENTION_LIB_DIR="." + +# shellcheck source=bin/fm-classify-lib.sh +# shellcheck disable=SC1091 +. "$_FM_ATTENTION_LIB_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-supervision-lib.sh +# shellcheck disable=SC1091 +. "$_FM_ATTENTION_LIB_DIR/fm-supervision-lib.sh" + +# The snapshot is the ONE owner of backlog row parsing; this library never +# re-implements it. --backlog-json is its cheap projection: one jq pass over +# data/backlog.md with no endpoint, secondmate, or network reads. +FM_ATTENTION_SNAPSHOT_BIN="${FM_ATTENTION_SNAPSHOT_BIN:-$_FM_ATTENTION_LIB_DIR/fm-fleet-snapshot.sh}" + +# --- durable captain briefing grammar --------------------------------------- +# +# The plain language a captain needs is not derivable from backlog metadata, so +# bin/fm-decision-hold.sh writes it into the hold's own durable body and this +# library reads it back. One writer, one reader, one grammar, stated here. +# +# Captain briefing v1: +# Semantic revision: +# Choice: +# Why now: +# If this waits: +# Option: (repeatable) +# Recommended: +# +# Every value is one line, matching the one-line validation the hold command +# already applies to titles and reasons. A hold with no briefing block still +# renders; it is marked as not yet written rather than dressing up a raw +# operational note as if it were plain language. +FM_ATTENTION_BRIEF_HEADER='Captain briefing v1:' +FM_ATTENTION_BRIEF_REVISION='Semantic revision:' +FM_ATTENTION_BRIEF_CHOICE='Choice:' +FM_ATTENTION_BRIEF_WHY='Why now:' +FM_ATTENTION_BRIEF_COST='If this waits:' +FM_ATTENTION_BRIEF_OPTION='Option:' +FM_ATTENTION_BRIEF_RECOMMEND='Recommended:' + +# fm_attention_brief_lines [option...] +# Print the durable briefing block for a hold body, or nothing when no field was +# supplied. Fields are emitted in fixed order so an idempotent re-registration +# produces byte-identical output. +fm_attention_brief_lines() { + local revision=${1:-} choice=${2:-} why=${3:-} cost=${4:-} recommend=${5:-} opt + shift 5 2>/dev/null || true + if [ -z "$revision" ] && [ -z "$choice" ] && [ -z "$why" ] && [ -z "$cost" ] && [ -z "$recommend" ] && [ "$#" -eq 0 ]; then + return 0 + fi + printf '%s\n' "$FM_ATTENTION_BRIEF_HEADER" + [ -z "$revision" ] || printf '%s %s\n' "$FM_ATTENTION_BRIEF_REVISION" "$revision" + [ -z "$choice" ] || printf '%s %s\n' "$FM_ATTENTION_BRIEF_CHOICE" "$choice" + [ -z "$why" ] || printf '%s %s\n' "$FM_ATTENTION_BRIEF_WHY" "$why" + [ -z "$cost" ] || printf '%s %s\n' "$FM_ATTENTION_BRIEF_COST" "$cost" + for opt in "$@"; do + [ -z "$opt" ] || printf '%s %s\n' "$FM_ATTENTION_BRIEF_OPTION" "$opt" + done + [ -z "$recommend" ] || printf '%s %s\n' "$FM_ATTENTION_BRIEF_RECOMMEND" "$recommend" +} + +# --- status-side collection -------------------------------------------------- + +# Seconds until the supervision cycle next re-checks a declared wait, or -1 when +# nothing is watching. bin/fm-classify-lib.sh owns the cadence constant; this +# only does the arithmetic against the status log's last write. +fm_attention_wait_next_check() { # + local f=$1 state=$2 cadence m now beat age grace remaining + cadence=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT} + case "$cadence" in ''|*[!0-9]*|0) cadence=$FM_PAUSE_RESURFACE_SECS_DEFAULT ;; esac + grace=${FM_GUARD_GRACE:-300} + beat="$state/.last-watcher-beat" + now=$(date +%s) + if [ -e "$beat" ]; then + m=$(fm_sup_stat_mtime "$beat") + if [ -n "$m" ]; then + age=$((now - m)) + [ "$age" -lt "$grace" ] || { printf '%s' -1; return 0; } + else + printf '%s' -1 + return 0 + fi + else + printf '%s' -1 + return 0 + fi + m=$(fm_sup_stat_mtime "$f") + [ -n "$m" ] || { printf '%s' "$cadence"; return 0; } + remaining=$((m + cadence - now)) + [ "$remaining" -ge 0 ] || remaining=0 + printf '%s' "$remaining" +} + +_fm_attention_drop_key() { # + local set=$1 key=$2 line out='' + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "$key"$'\t'*) : ;; + *) out="${out}${line}"$'\n' ;; + esac + done < + local set=$1 key=$2 line + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "$key"$'\t'*) printf '%s' "$line"; return 0 ;; + esac + done < + local set=$1 key=$2 line + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "$key"$'\t'*) printf '%s' "${line#*$'\t'}"; return 0 ;; + esac + done < + local set=$1 key=$2 gen=$3 + set=$(_fm_attention_drop_key "$set" "$key") + [ -n "$set" ] && set="${set}"$'\n' + printf '%s%s\t%s\n' "$set" "$key" "$gen" +} + +# Emit one TAB-separated status-derived row per open decision or wait: +# +# Only tasks with live metadata are considered: an id whose metadata is gone was +# torn down, and any decision it still owed was transferred to a durable backlog +# hold by bin/fm-decision-hold.sh. Terminal tasks are skipped for the same reason +# origin_open_decisions skips them. +fm_attention_status_rows() { # + local state=$1 meta_paths meta meta_text meta_line id status kind kind_seen last verb key note n next line stripped pause resolve held + local decisions waits decision_gens wait_gens existing gen dverb dnote dn dwgen dwait wgen wn wnote decision_next status_text + pause=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} + held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} + if [ ! -e "$state" ]; then + [ -L "$state" ] && return 1 + return 0 + fi + [ -d "$state" ] && [ -r "$state" ] && [ -x "$state" ] || return 1 + meta_paths=$(find -H "$state" -type d ! -path "$state" -prune -o -name '*.meta' -print 2>/dev/null) || return 1 + while IFS= read -r meta; do + [ -n "$meta" ] || continue + [ -f "$meta" ] || return 1 + meta_text=$(cat "$meta" 2>/dev/null) || return 1 + id=$(basename "$meta" .meta) + status="$state/$id.status" + if [ ! -e "$status" ]; then + [ -L "$status" ] && return 1 + continue + fi + [ -f "$status" ] || return 1 + kind='' + kind_seen=0 + while IFS= read -r meta_line || [ -n "$meta_line" ]; do + case "$meta_line" in + kind=*) kind=${meta_line#kind=}; kind_seen=1 ;; + esac + done </dev/null) || return 1 + while IFS= read -r line || [ -n "$line" ]; do + stripped=${line//[[:space:]]/} + [ -n "$stripped" ] || continue + verb=$(status_line_verb "$line") + key=$(_fm_decision_key "$line") || continue + note=$(status_line_note "$line") + note=${note//$'\t'/ } + if status_verb_opens_decision "$verb"; then + existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ dverb gen dnote dn dwgen dwait < +# Print the attention set as a JSON array, newest-identity-last within class. +# Read-only: it acquires no lock and writes nothing. +fm_attention_json() { # + local home=$1 state data backlog_json rows + state="${FM_STATE_OVERRIDE:-$home/state}" + data="${FM_DATA_OVERRIDE:-$home/data}" + command -v jq >/dev/null 2>&1 || return 1 + backlog_json=$(FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" \ + "$FM_ATTENTION_SNAPSHOT_BIN" --backlog-json 2>/dev/null) || return 1 + [ -n "$backlog_json" ] || return 1 + printf '%s' "$backlog_json" | jq -e 'type == "object" and (.records | type == "array")' >/dev/null 2>&1 || return 1 + rows=$(fm_attention_status_rows "$state") || return 1 + printf '%s' "$rows" | jq -Rn \ + --argjson backlog "$backlog_json" \ + --arg brief_header "$FM_ATTENTION_BRIEF_HEADER" \ + --arg f_revision "$FM_ATTENTION_BRIEF_REVISION" \ + --arg f_choice "$FM_ATTENTION_BRIEF_CHOICE" \ + --arg f_why "$FM_ATTENTION_BRIEF_WHY" \ + --arg f_cost "$FM_ATTENTION_BRIEF_COST" \ + --arg f_option "$FM_ATTENTION_BRIEF_OPTION" \ + --arg f_recommend "$FM_ATTENTION_BRIEF_RECOMMEND" ' + def field($lines; $label): + ([ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") | select(length > 0) ] | .[0]) // null; + def fields($lines; $label): + [ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") | select(length > 0) ]; + def empty_briefing: + {briefed:false,briefing_complete:false,semantic_revision:null,choice:null,why_now:null,cost_of_waiting:null,options:[],recommendation:null,briefing_missing:[]}; + def briefing($record): + ($record.body_lines // []) as $lines + | if ($lines | index($brief_header)) == null then + empty_briefing + else + (field($lines; $f_revision)) as $revision + | (field($lines; $f_choice)) as $choice + | (field($lines; $f_why)) as $why + | (field($lines; $f_cost)) as $cost + | (fields($lines; $f_option)) as $options + | (field($lines; $f_recommend)) as $recommend + | if ($choice == null and $why == null and $cost == null and ($options | length) == 0 and $recommend == null) then + empty_briefing + else + {briefed:true, + briefing_complete:($revision != null and $choice != null and $why != null and $cost != null and ($options | length) > 0 and $recommend != null), + semantic_revision:$revision, + choice:$choice, + why_now:$why, + cost_of_waiting:$cost, + options:$options, + recommendation:$recommend, + briefing_missing:([ + if $choice == null then "The choice" else empty end, + if $why == null then "Why it matters now" else empty end, + if $cost == null then "If this waits" else empty end, + if ($options | length) == 0 then "Options" else empty end, + if $recommend == null then "Recommended" else empty end + ])} + end + end; + # Selected on the captain hold itself, not on the snapshot captain_actionable + # flag, which also requires the item own kind to be "captain". The documented + # way to gate an ordinary work item on the captain is + # "tasks-axi hold --reason ... --kind captain", which leaves kind as + # ship, so that flag is false for exactly the threads this contract exists + # for and they would render as routine delays. A captain hold that still has + # unresolved blockers is not answerable yet and stays a wait below. + def is_captain_decision: + .structured == true + and (.state == "queued" or .state == "in_flight") + and .hold_kind == "captain" + and .hold_reason != null + and ((.unresolved_blocker_ids // []) | length) == 0; + def backlog_decisions: + [ $backlog.records[]? + | select(is_captain_decision) + | {class:"decision", + identity:("decision:" + .id), + source:"captain-hold", + id:.id, + key:.id, + headline:(.title // .id), + detail:(.hold_reason // "no reason recorded"), + awaiting:null, + blocked_by:[], + redeclares:0, + escalated:false, + combined_wait:false, + combined_wait_identity:null, + next_check_seconds:null} + + briefing(.) ]; + def backlog_waits: + [ $backlog.records[]? + | select(.structured == true + and (.state == "queued" or .state == "in_flight") + and (is_captain_decision | not) + and ((.hold_reason != null and .hold_kind != null) + or ((.unresolved_blocker_ids // []) | length) > 0)) + | {class:"wait", + identity:("wait:" + .id), + source:"backlog-hold", + id:.id, + key:.id, + headline:(.title // .id), + detail:(.hold_reason // .blocked_reason // "waiting on other work"), + awaiting:(.hold_reason // .blocked_reason // "other work to finish"), + blocked_by:(.unresolved_blocker_ids // []), + redeclares:0, + escalated:false, + combined_wait:false, + combined_wait_identity:null, + next_check_seconds:null, + briefed:false,briefing_complete:false,semantic_revision:null,choice:null,why_now:null,cost_of_waiting:null, + options:[],recommendation:null,briefing_missing:[]} ]; + def status_rows: + [ inputs + | select(length > 0) + | split("\t") + | select(length >= 10) + | {class:.[0],id:.[1],key:.[2],generation:.[3],verb:.[4], + redeclares:(.[5] | tonumber? // 0), + wait_generation:.[6], + next_check_seconds:(.[7] | tonumber? // -1), + note:.[8], + awaiting:(.[9:] | join("\t"))} ]; + # The title of the work item itself, so a captain-facing line names the piece + # of work rather than repeating the event note as its own heading. Falls back + # to the note when the backlog has no matching row, and never to the raw id, + # which is internal vocabulary. + def work_title($id): + ([ $backlog.records[]? | select(.structured == true and .id == $id) | .title ] | .[0]) // null; + def status_records: + [ status_rows[] + | {class:.class, + identity:(.class + ":" + .id + ":" + .key + ":" + .generation), + source:(if .class == "wait" and .verb == "blocked" then "status-blocker" + elif .class == "wait" then "declared-wait" + else "status-decision" + end), + id:.id, + key:.key, + headline:(work_title(.id) // (.note | if . == "" then .id else . end)), + detail:.note, + awaiting:(if .awaiting == "" then null else .awaiting end), + blocked_by:[], + redeclares:.redeclares, + escalated:false, + combined_wait:(.class == "decision" and .awaiting != ""), + combined_wait_identity: + (if .class == "decision" and .awaiting != "" + then ("wait:" + .id + ":" + .key + ":" + .wait_generation) + elif .class == "wait" + then (.class + ":" + .id + ":" + .key + ":" + .generation) + else null + end), + next_check_seconds:(if .class == "wait" or .awaiting != "" then .next_check_seconds else null end), + briefed:false,briefing_complete:false,semantic_revision:null,choice:null,why_now:null,cost_of_waiting:null, + options:[],recommendation:null,briefing_missing:[]} ]; + (backlog_decisions) as $backlog_decisions + | (status_records) as $status_records + | def synthetic_match($decision; $status): + ($status.id + "-decision-" + $status.key) == $decision.id; + def has_synthetic_owner($status): + $backlog_decisions | any(synthetic_match(.; $status)); + def matches_decision($decision; $status): + synthetic_match($decision; $status) + or ($decision.id == $status.id + and $decision.key == $status.key + and (has_synthetic_owner($status) | not)); + [ $backlog_decisions[] as $decision + | ([ $status_records[] | select(matches_decision($decision; .)) ] | .[0]) as $live + | {status_identity:($live.identity // null), + record: + (if $live == null then + $decision + elif (($live.combined_wait == true or $live.class == "wait") and $live.awaiting != null) then + $decision + { + combined_wait:true, + combined_wait_identity:$live.combined_wait_identity, + awaiting:$live.awaiting, + redeclares:$live.redeclares, + next_check_seconds:$live.next_check_seconds + } + else + $decision + end)} + ] as $decision_associations + | [ $decision_associations[].record ] as $merged_decisions + | [ $status_records[] + | . as $status + | select(($decision_associations + | any(.status_identity == $status.identity)) + | not) + ] as $unmatched_status + | ($merged_decisions + $unmatched_status) as $primary + # A live declared wait supersedes the backlog-level wait row for the same + # work item: both describe one thing waiting, and the live one carries the + # current wording and the next check time. Keeping both would show the + # captain the same item twice. + | ($primary + [ backlog_waits[] | select(. as $b | ($primary | any(.id == $b.id)) | not) ]) + | reduce .[] as $r ([]; if any(.[]; .identity == $r.identity) then . else . + [$r] end) + | sort_by([(if .class == "decision" then 0 else 1 end), .identity]) + ' +} + +_fm_attention_digest_of() { # + local text=$1 + [ -n "$text" ] || { printf 'empty'; return 0; } + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$text" | shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$text" | sha256sum | awk '{print $1}' + else + printf '%s' "$text" | cksum | awk '{print $1 "-" $2}' + fi +} + +_fm_attention_normalize_message() { + tr '\n\r\t' ' ' | awk '{$1=$1; print}' +} + +fm_attention_message_covers() { # + local message=$1 scope=${2:-all} required class expected normalized_message normalized_expected + local decision_cursor='' wait_cursor='' decision_started=0 wait_started=0 + normalized_message=$(printf '%s' "$message" | _fm_attention_normalize_message) + required=$(printf '%s' "$FM_ATT_JSON" | jq -r --arg scope "$scope" ' + def norm: + tostring + | gsub("[[:space:]]+"; " ") + | sub("^ "; "") + | sub(" $"; ""); + def decision_block: + if .briefing_complete == true then + ([.headline, + "The choice:", .choice, + "Why it matters now:", .why_now, + "If this waits:", .cost_of_waiting, + "Options:"] + + (.options | map("- " + .)) + + ["Recommended:", .recommendation] + + (if .combined_wait == true + then ["Waiting for:", .awaiting, "Next check:"] + else [] + end)) + | map(norm) + | join(" ") + else + "__FIRSTMATE_INCOMPLETE_BRIEFING__" + end; + def wait_block: + [.headline, "Waiting for:", (.awaiting // ""), "Next check:"] + | map(norm) + | join(" "); + if length == 0 then + "empty\tNothing needs your decision, and nothing is waiting." + else + .[] + | select($scope == "all" or .class == "decision") + | if .class == "decision" + then "decision\t\(decision_block)" + else "wait\t\(wait_block)" + end + end + ' 2>/dev/null) || return 1 + while IFS=$'\t' read -r class expected; do + [ -n "$expected" ] || continue + [ "$expected" != "__FIRSTMATE_INCOMPLETE_BRIEFING__" ] || return 1 + normalized_expected=$(printf '%s' "$expected" | _fm_attention_normalize_message) + case "$class" in + empty) + case "$normalized_message" in + *"$normalized_expected"*) ;; + *) return 1 ;; + esac + ;; + decision) + if [ "$decision_started" -eq 0 ]; then + case "$normalized_message" in + *"NEEDS YOUR DECISION"*) + decision_cursor=${normalized_message#*"NEEDS YOUR DECISION"} + decision_cursor=${decision_cursor%%"WAITING ON SOMETHING ELSE"*} + decision_started=1 + ;; + *) return 1 ;; + esac + fi + case "$decision_cursor" in + *"$normalized_expected"*) decision_cursor=${decision_cursor#*"$normalized_expected"} ;; + *) return 1 ;; + esac + ;; + wait) + if [ "$wait_started" -eq 0 ]; then + case "$normalized_message" in + *"WAITING ON SOMETHING ELSE"*) + wait_cursor=${normalized_message#*"WAITING ON SOMETHING ELSE"} + wait_started=1 + ;; + *) return 1 ;; + esac + fi + case "$wait_cursor" in + *"$normalized_expected"*) wait_cursor=${wait_cursor#*"$normalized_expected"} ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; + esac + done < + local state=$1 seen_unknown + # shellcheck disable=SC2034 # Read by callers (fm-guard.sh, fm-turnend-guard.sh, fm-attention.sh) after sourcing. + FM_ATT_AVAILABLE=false + # shellcheck disable=SC2034 # Read by callers after sourcing. + FM_ATT_UNKNOWN=true + FM_ATT_JSON='{"unknown":true}' + FM_ATT_DIGEST=unknown + FM_ATT_DECISION_DIGEST=unknown + FM_ATT_UNKNOWN_DIGEST=$(_fm_attention_digest_of 'unknown:attention-derivation') + FM_ATT_NEW=true + FM_ATT_DECISIONS_NEW=true + seen_unknown=$(sed -n 's/^unknown=//p' "$state/.captain-attention-unknown" 2>/dev/null | tail -1 || true) + # shellcheck disable=SC2034 # Read by callers after sourcing. + [ "$FM_ATT_UNKNOWN_DIGEST" = "$seen_unknown" ] || FM_ATT_UNKNOWN_NEW=true +} + +# fm_attention_status +# Populate for the home at $1: +# FM_ATT_AVAILABLE true/false - false when the complete set is unavailable +# FM_ATT_UNKNOWN true/false - true when the complete set is unknowable +# FM_ATT_JSON the record array +# FM_ATT_COUNT total open records +# FM_ATT_DECISIONS open records needing the captain +# FM_ATT_WAITS open declared delays +# FM_ATT_DIGEST receipt digest over every record +# FM_ATT_DECISION_DIGEST receipt digest over decision records only +# FM_ATT_NEW true when the whole set changed since it was surfaced +# FM_ATT_DECISIONS_NEW true when the decision set changed since it was surfaced +# FM_ATT_UNKNOWN_DIGEST digest for bounding repeated unknown turn-end stops +# FM_ATT_UNKNOWN_NEW true when the same unknown has not been surfaced +# Always returns 0. +fm_attention_status() { # + local home=$1 state seen_att seen_dec ids='' dec_ids='' summary + state="${FM_STATE_OVERRIDE:-$home/state}" + # shellcheck disable=SC2034 # Read by callers (fm-guard.sh, fm-turnend-guard.sh, fm-attention.sh) after sourcing. + FM_ATT_AVAILABLE=true + # shellcheck disable=SC2034 # Read by callers (fm-guard.sh, fm-turnend-guard.sh) after sourcing. + FM_ATT_UNKNOWN=false + FM_ATT_JSON='[]' + FM_ATT_COUNT=0 + FM_ATT_DECISIONS=0 + FM_ATT_WAITS=0 + FM_ATT_DIGEST=empty + FM_ATT_DECISION_DIGEST=empty + FM_ATT_UNKNOWN_DIGEST=empty + FM_ATT_NEW=false + FM_ATT_DECISIONS_NEW=false + # shellcheck disable=SC2034 # Read by callers (fm-turnend-guard.sh) after sourcing. + FM_ATT_UNKNOWN_NEW=false + + if ! command -v jq >/dev/null 2>&1 || ! FM_ATT_JSON=$(fm_attention_json "$home" 2>/dev/null) || [ -z "$FM_ATT_JSON" ]; then + _fm_attention_mark_unknown "$state" + return 0 + fi + # One jq pass for counts and both identity lists; this runs on every guarded + # command, so it must not spawn a process per field. + summary=$(printf '%s' "$FM_ATT_JSON" | jq -r ' + def semantic_identity: + .semantic_revision // "__missing_semantic_revision__"; + def decision_receipt_identity: + {identity,class,semantic_revision:semantic_identity} + | @json; + def attention_receipt_identity: + if .class == "decision" then + {decision:decision_receipt_identity,combined_wait_identity:(.combined_wait_identity // null)} + | @json + else + .identity + end; + "\(length) \([.[] | select(.class == "decision")] | length)", + "--attention--", (.[] | attention_receipt_identity), + "--decisions--", (.[] | select(.class == "decision") | decision_receipt_identity)' 2>/dev/null) || summary='' + if [ -n "$summary" ]; then + FM_ATT_COUNT=${summary%%$'\n'*} + FM_ATT_DECISIONS=${FM_ATT_COUNT#* } + FM_ATT_COUNT=${FM_ATT_COUNT%% *} + case "$FM_ATT_COUNT" in ''|*[!0-9]*) FM_ATT_COUNT=0 ;; esac + case "$FM_ATT_DECISIONS" in ''|*[!0-9]*) FM_ATT_DECISIONS=0 ;; esac + ids=$(printf '%s\n' "$summary" | awk '/^--attention--$/{s=1;next} /^--decisions--$/{s=0} s') + dec_ids=$(printf '%s\n' "$summary" | awk '/^--decisions--$/{s=1;next} s') + else + _fm_attention_mark_unknown "$state" + return 0 + fi + # shellcheck disable=SC2034 # Read by callers after sourcing. + FM_ATT_WAITS=$((FM_ATT_COUNT - FM_ATT_DECISIONS)) + FM_ATT_DIGEST=$(_fm_attention_digest_of "$ids") + FM_ATT_DECISION_DIGEST=$(_fm_attention_digest_of "$dec_ids") + + seen_att=$(sed -n 's/^attention=//p' "$state/.captain-attention" 2>/dev/null | tail -1 || true) + seen_dec=$(sed -n 's/^decisions=//p' "$state/.captain-attention" 2>/dev/null | tail -1 || true) + # shellcheck disable=SC2034 # Read by callers after sourcing. + [ "$FM_ATT_DIGEST" = "$seen_att" ] || FM_ATT_NEW=true + # shellcheck disable=SC2034 # Read by callers after sourcing. + [ "$FM_ATT_DECISION_DIGEST" = "$seen_dec" ] || FM_ATT_DECISIONS_NEW=true + return 0 +} + +# fm_attention_home_idle [grace-seconds] +# Exit 0 (true) only when this home genuinely has NOTHING accounted for: no +# in-flight task metadata, no X-mode relay poll, and no open decision or wait. +# +# This is the answer to the meta-blindness defect. bin/fm-supervision-lib.sh +# counts state/*.meta, which exist only after bin/fm-spawn.sh runs, so a primary +# that holds work itself - an unanswered decision, a standing delay - counted as +# zero and every guard concluded the home was idle. Unaccounted primary work must +# read as suspicious, not idle, so idleness is asserted here against the derived +# attention set as well as the metadata count. It is deliberately NOT folded into +# FM_SUP_NEEDED: a standing decision does not need a watcher, and treating it as +# watcher-need would nag forever on a home with long-lived open decisions. +fm_attention_home_idle() { # [grace] + local home=$1 grace=${2:-${FM_GUARD_GRACE:-300}} state + state="${FM_STATE_OVERRIDE:-$home/state}" + fm_supervision_status "$state" "$grace" + [ "$FM_SUP_NEEDED" = false ] || return 1 + fm_attention_status "$home" + [ "$FM_ATT_AVAILABLE" = true ] || return 1 + [ "$FM_ATT_COUNT" -eq 0 ] +} diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh new file mode 100755 index 0000000..71992c2 --- /dev/null +++ b/bin/fm-attention.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# fm-attention.sh - the single captain-facing place for open decisions and waits. +# +# Renders the captain-attention contract owned by bin/fm-attention-lib.sh, which +# derives the open set from home-local backlog and task state. This command +# parses no fleet state of its own. +# +# Every open item is exactly one of two things: +# a decision - your answer is needed before the work can move; +# a wait - a meaningful delay that needs no captain action yet, shown with +# what is being awaited and when it is next checked. +# Only an explicit action-required `needs-decision` or `blocked` status +# transition changes a status-derived wait into a captain decision. +# +# A turn-end adapter may pass the actual assistant reply to --record-visible. +# That mode validates the captain category, headline, and complete explanation +# before recording a receipt. Every rendering mode is read-only. +# +# The default view is captain-safe plain English under AGENTS.md section 9: it +# carries no internal identifiers or vocabulary and can be relayed as written. +# --brief is the firstmate-facing form and does carry identifiers, so it is a +# read-only diagnostic, never a captain-facing surface. +# +# Usage: +# fm-attention.sh captain-facing view (read-only) +# fm-attention.sh --brief one line per item, with identifiers (read-only) +# fm-attention.sh --json the raw record array (read-only) +# fm-attention.sh --status counts and change flags (read-only) +# fm-attention.sh --record-visible validate an assistant reply from stdin +# fm-attention.sh --no-mark compatibility alias for read-only rendering +# fm-attention.sh -h | --help +# +# Exit status is 0 whenever the requested read or receipt succeeds. +# Exit 3 means the complete set could not be derived, and exit 4 means the +# supplied assistant reply did not contain the required visible records. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-attention-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-attention-lib.sh" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +MODE=view +while [ "$#" -gt 0 ]; do + case "$1" in + --brief) MODE=brief ;; + --json) MODE=json ;; + --status) MODE=status ;; + --record-visible) MODE=record-visible ;; + --no-mark) ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +fm_attention_status "$FM_HOME" + +render_unknown() { + printf "CAPTAIN'S CALL\n" + printf 'I could not determine whether anything needs your decision or is waiting.\n' + printf 'Treat this as unresolved until Firstmate can read the open decision and wait list.\n' +} + +render_brief_unknown() { + printf "CAPTAIN'S CALL: unknown - the open decision and wait list could not be determined.\n" +} + +if [ "$FM_ATT_AVAILABLE" != true ]; then + case "$MODE" in + json) + printf '%s\n' "$FM_ATT_JSON" + ;; + status) + printf 'attention=unknown decisions=unknown waits=unknown new=true decisions_new=true unknown=true\n' + ;; + brief) + render_brief_unknown + ;; + *) + render_unknown + ;; + esac + exit 3 +fi + +record_visible() { + local message seen_att seen_dec wrote=0 + message=$(cat 2>/dev/null || true) + seen_att=$(sed -n 's/^attention=//p' "$STATE/.captain-attention" 2>/dev/null | tail -1 || true) + seen_dec=$(sed -n 's/^decisions=//p' "$STATE/.captain-attention" 2>/dev/null | tail -1 || true) + if fm_attention_message_covers "$message" all; then + seen_att=$FM_ATT_DIGEST + wrote=1 + fi + if [ "$FM_ATT_DECISIONS" -gt 0 ] && fm_attention_message_covers "$message" decisions; then + seen_dec=$FM_ATT_DECISION_DIGEST + wrote=1 + fi + [ "$wrote" -eq 1 ] || return 4 + [ -d "$STATE" ] || return 4 + printf 'attention=%s\ndecisions=%s\n' "$seen_att" "$seen_dec" > "$STATE/.captain-attention" 2>/dev/null || return 4 + rm -f "$STATE/.captain-attention-unknown" 2>/dev/null || true + return 0 +} + +# Wrap free prose to a readable width under a fixed indent. Keeps a long +# recorded explanation readable instead of running off the line. +wrap() { # + local indent=$1 text=$2 + printf '%s' "$text" | awk -v indent="$indent" -v width=78 ' + BEGIN { pad = sprintf("%" indent "s", "") } + { + n = split($0, w, /[ \t]+/) + line = "" + for (i = 1; i <= n; i++) { + if (w[i] == "") continue + if (line == "") { line = w[i]; continue } + if (length(pad) + length(line) + 1 + length(w[i]) > width) { + print pad line + line = w[i] + } else { + line = line " " w[i] + } + } + if (line != "") print pad line + }' +} + +# Wrap at , then replace the first line's indent with . +# The prefix must be exactly characters wide so continuation lines align +# under it ("1. " with indent 3, "- " with indent 2). +wrap_prefixed() { # + local prefix=$1 indent=$2 text=$3 + wrap "$indent" "$text" | awk -v prefix="$prefix" -v indent="$indent" ' + NR == 1 { print prefix substr($0, indent + 1); next } + { print }' +} + +count_phrase() { # + if [ "$1" -eq 1 ]; then printf '%s %s %s' "$1" "$2" "$4"; else printf '%s %s %s' "$1" "$3" "$5"; fi +} + +human_duration() { # + local s=$1 + if [ "$s" -lt 0 ]; then printf 'unknown'; return 0; fi + if [ "$s" -lt 90 ]; then printf 'under 2 minutes'; return 0; fi + if [ "$s" -lt 5400 ]; then printf 'about %s minutes' "$(( (s + 30) / 60 ))"; return 0; fi + if [ "$s" -lt 172800 ]; then printf 'about %s hours' "$(( (s + 1800) / 3600 ))"; return 0; fi + printf 'about %s days' "$(( (s + 43200) / 86400 ))" +} + +next_check_phrase() { # + local s=$1 + case "$s" in ''|null) printf 'when the work it is waiting on finishes'; return 0 ;; esac + if [ "$s" -lt 0 ]; then + printf 'nothing is checking this right now, because monitoring is not running' + return 0 + fi + if [ "$s" -le 0 ]; then printf 'due now'; return 0; fi + printf 'in %s' "$(human_duration "$s")" +} + +field() { # + printf '%s' "$FM_ATT_JSON" | jq -r --argjson i "$1" ".[\$i] | $2 // \"\"" 2>/dev/null +} + +render_view() { + local total=$FM_ATT_COUNT n=$FM_ATT_DECISIONS w=$FM_ATT_WAITS i num=0 + local class headline choice why cost recommend detail awaiting briefed next opts missing combined + + printf "CAPTAIN'S CALL\n" + if [ "$total" -eq 0 ]; then + printf 'Nothing needs your decision, and nothing is waiting.\n' + return 0 + fi + printf '%s you. %s waiting.\n' \ + "$(count_phrase "$n" decision decisions needs need)" \ + "$(count_phrase "$w" thing things "is" "are")" + + if [ "$n" -gt 0 ]; then + printf '\nNEEDS YOUR DECISION\n' + i=0 + while [ "$i" -lt "$total" ]; do + class=$(field "$i" '.class') + if [ "$class" != decision ]; then i=$((i + 1)); continue; fi + num=$((num + 1)) + headline=$(field "$i" '.headline') + briefed=$(field "$i" '.briefed') + detail=$(field "$i" '.detail') + combined=$(field "$i" '.combined_wait') + printf '\n' + wrap_prefixed "$(printf '%s. ' "$num")" 3 "$headline" + if [ "$briefed" = true ]; then + choice=$(field "$i" '.choice') + why=$(field "$i" '.why_now') + cost=$(field "$i" '.cost_of_waiting') + recommend=$(field "$i" '.recommendation') + [ -z "$choice" ] || { printf ' The choice:\n'; wrap 5 "$choice"; } + [ -z "$why" ] || { printf ' Why it matters now:\n'; wrap 5 "$why"; } + [ -z "$cost" ] || { printf ' If this waits:\n'; wrap 5 "$cost"; } + opts=$(printf '%s' "$FM_ATT_JSON" | jq -r --argjson i "$i" '.[$i].options[]?' 2>/dev/null) + if [ -n "$opts" ]; then + printf ' Options:\n' + while IFS= read -r opt; do + [ -n "$opt" ] || continue + wrap_prefixed ' - ' 7 "$opt" + done </dev/null) + if [ -n "$missing" ]; then + printf ' Still needs:\n' + while IFS= read -r missing; do + [ -n "$missing" ] || continue + wrap_prefixed ' - ' 7 "$missing" + done </dev/null) + printf ' Waiting for:\n' + wrap 5 "$awaiting" + printf ' Next check: %s\n' "$(next_check_phrase "$next")" + fi + i=$((i + 1)) + done + fi + + if [ "$w" -gt 0 ]; then + printf '\nWAITING ON SOMETHING ELSE\n' + i=0 + while [ "$i" -lt "$total" ]; do + class=$(field "$i" '.class') + if [ "$class" != wait ]; then i=$((i + 1)); continue; fi + headline=$(field "$i" '.headline') + awaiting=$(field "$i" '.awaiting') + next=$(printf '%s' "$FM_ATT_JSON" | jq -r --argjson i "$i" '.[$i].next_check_seconds // "null"' 2>/dev/null) + printf '\n' + wrap_prefixed '- ' 2 "$headline" + printf ' Waiting for:\n' + wrap 4 "$awaiting" + printf ' Next check: %s\n' "$(next_check_phrase "$next")" + i=$((i + 1)) + done + fi + printf '\nEverything above stays listed here until it is answered or clears.\n' +} + +render_brief() { + local total=$FM_ATT_COUNT i class id headline complete next mark + if [ "$total" -eq 0 ]; then + printf "CAPTAIN'S CALL: nothing open.\n" + return 0 + fi + printf "CAPTAIN'S CALL: %s decision(s) need the captain, %s wait(s) open.\n" \ + "$FM_ATT_DECISIONS" "$FM_ATT_WAITS" + i=0 + while [ "$i" -lt "$total" ]; do + class=$(field "$i" '.class') + id=$(field "$i" '.id') + headline=$(field "$i" '.headline') + complete=$(field "$i" '.briefing_complete') + mark='' + if [ "$class" = decision ]; then + if [ "$complete" != true ]; then + mark=' [no captain briefing recorded - add one with bin/fm-decision-hold.sh hold]' + fi + else + next=$(printf '%s' "$FM_ATT_JSON" | jq -r --argjson i "$i" '.[$i].next_check_seconds // "null"' 2>/dev/null) + mark=" [next check: $(next_check_phrase "$next")]" + fi + printf ' %-8s %s - %.90s%s\n' "$class" "$id" "$headline" "$mark" + i=$((i + 1)) + done + printf ' Relay these to the captain in plain language: bin/fm-attention.sh\n' +} + +case "$MODE" in + record-visible) + record_visible + exit $? + ;; + json) + printf '%s\n' "$FM_ATT_JSON" + ;; + status) + printf 'attention=%s decisions=%s waits=%s new=%s decisions_new=%s\n' \ + "$FM_ATT_COUNT" "$FM_ATT_DECISIONS" "$FM_ATT_WAITS" "$FM_ATT_NEW" "$FM_ATT_DECISIONS_NEW" + ;; + brief) + render_brief + ;; + *) + render_view + ;; +esac +exit 0 diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index d80840f..fdb83f1 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -201,6 +201,14 @@ $set EOF printf '%s' "$out" } + +status_verb_opens_decision() { # + case "$1" in + needs-decision|blocked) return 0 ;; + *) return 1 ;; + esac +} + # Fold the WHOLE status stream into the set of decisions still open. Prints one # TAB-separated "\t\t" line per still-open decision, in # most-recently-opened-last order; prints nothing when none are open. Pure read of @@ -217,13 +225,14 @@ status_open_decisions() { # [ -n "$stripped" ] || continue verb=$(status_line_verb "$line") key=$(_fm_decision_key "$line") || continue + if status_verb_opens_decision "$verb"; then + note=$(status_line_note "$line") + open=$(_fm_decision_drop "$open" "$key") + [ -n "$open" ] && open="${open}"$'\n' + open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' + continue + fi case "$verb" in - needs-decision|blocked) - note=$(status_line_note "$line") - open=$(_fm_decision_drop "$open" "$key") - [ -n "$open" ] && open="${open}"$'\n' - open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' - ;; "$resolve"|"$held") open=$(_fm_decision_drop "$open" "$key") [ -n "$open" ] && open="${open}"$'\n' diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index aeb140a..087e634 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -19,12 +19,27 @@ # Usage: # fm-decision-hold.sh id # fm-decision-hold.sh hold \ -# --title --reason <reason> [--repo <repo>] +# --title <title> --reason <reason> [--repo <repo>] \ +# --semantic-revision <privacy-safe-slug> \ +# --choice <text> --why-now <text> --cost-of-waiting <text> \ +# --option <text> [--option <text>]... --recommend <text> # fm-decision-hold.sh complete <origin-id> (--none | <decision-key>...) # fm-decision-hold.sh verify <origin-id> # fm-decision-hold.sh resolve <origin-id> <decision-key> \ # --decision-file <path> --routed-to <task-id> [--routed-to <task-id>...] # +# THE CAPTAIN BRIEFING. A title and a one-line reason cannot carry what the +# captain actually needs - the concrete choice, why it matters now, what waiting +# costs, the real options, and a recommendation - so every renderer could only +# truncate them. The briefing flags above record that plain language durably in +# the hold body, in the grammar owned by bin/fm-attention-lib.sh, and +# bin/fm-attention.sh renders it as the captain-facing explanation. Each value is +# one line. New holds and explicit briefing revisions require every field and a +# semantic revision slug. Reuse the slug for wording-only paraphrases and change +# it when the decision's substance changes. Supplying briefing flags rewrites +# the briefing block and preserves unrecognized body lines; supplying none +# leaves an existing complete briefing untouched. +# # `complete` is the shared investigation and visual-review completion gate. # `--none` is an explicit semantic attestation that the just-reviewed surface has # no unresolved captain decision. Later review passes may add keys; a live task's @@ -51,6 +66,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-attention-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-attention-lib.sh" # captain-briefing grammar owner usage() { awk ' @@ -80,6 +98,76 @@ validate_one_line() { # <label> <value> esac } +validate_brief_field() { # <label> <value> + local label=$1 value=$2 + validate_one_line "$label" "$value" +} + +validate_complete_briefing() { + local choice=$1 why=$2 cost=$3 recommend=$4 + shift 4 + [ -n "$choice" ] || fail "choice must not be empty" + [ -n "$why" ] || fail "why-now must not be empty" + [ -n "$cost" ] || fail "cost-of-waiting must not be empty" + [ "$#" -gt 0 ] || fail "at least one option is required" + [ -n "$recommend" ] || fail "recommend must not be empty" + validate_brief_field choice "$choice" + validate_brief_field why-now "$why" + validate_brief_field cost-of-waiting "$cost" + validate_brief_field recommend "$recommend" + local opt + for opt in "$@"; do validate_brief_field option "$opt"; done +} + +briefing_complete_body() { # <encoded-body> + printf '%s' "$1" | jq -se \ + --arg header "$FM_ATTENTION_BRIEF_HEADER" \ + --arg revision "$FM_ATTENTION_BRIEF_REVISION" \ + --arg choice "$FM_ATTENTION_BRIEF_CHOICE" \ + --arg why "$FM_ATTENTION_BRIEF_WHY" \ + --arg cost "$FM_ATTENTION_BRIEF_COST" \ + --arg option "$FM_ATTENTION_BRIEF_OPTION" \ + --arg recommend "$FM_ATTENTION_BRIEF_RECOMMEND" ' + if length != 1 or ((.[0] | type) != "string") then error("body") else .[0] end + | split("\n") as $lines + | def value($label): + any($lines[]; startswith($label + " ") and length > (($label | length) + 1)); + ($lines | index($header)) != null + and value($revision) and value($choice) and value($why) + and value($cost) and value($option) and value($recommend) + ' >/dev/null 2>&1 +} + +write_updated_body_file() { # <encoded-body> <replacement-prefix> + local encoded=$1 prefix=$2 body_file + body_file=$(mktemp "$STATE/.fm-decision-body.XXXXXX") || fail "could not create a temporary body file" + if ! printf '%s' "$encoded" | jq -sj \ + --arg prefix "$prefix" \ + --arg header "$FM_ATTENTION_BRIEF_HEADER" \ + --arg revision "$FM_ATTENTION_BRIEF_REVISION" \ + --arg choice "$FM_ATTENTION_BRIEF_CHOICE" \ + --arg why "$FM_ATTENTION_BRIEF_WHY" \ + --arg cost "$FM_ATTENTION_BRIEF_COST" \ + --arg option "$FM_ATTENTION_BRIEF_OPTION" \ + --arg recommend "$FM_ATTENTION_BRIEF_RECOMMEND" ' + def owned($line): + $line == $header + or ($line | startswith($revision)) or ($line | startswith($choice)) + or ($line | startswith($why)) or ($line | startswith($cost)) + or ($line | startswith($option)) or ($line | startswith($recommend)) + or ($line | startswith("Origin: ")) + or ($line | startswith("Decision key: ")) + or $line == "State: awaiting captain decision."; + if length != 1 or ((.[0] | type) != "string") then error("body") else .[0] end + | (split("\n") | map(select(owned(.) | not)) | join("\n")) as $preserved + | $prefix + (if ($preserved | length) > 0 then "\n" + $preserved else "" end) + ' > "$body_file" 2>/dev/null; then + rm -f "$body_file" + fail "could not decode the existing captain decision body" + fi + printf '%s\n' "$body_file" +} + sha256_text() { # <text> if command -v shasum >/dev/null 2>&1; then printf '%s' "$1" | shasum -a 256 | awk '{print $1}' @@ -102,6 +190,7 @@ tasks_axi() { require_tasks_axi() { fm_tasks_axi_compatible || fail "compatible tasks-axi is required" + command -v jq >/dev/null 2>&1 || fail "jq is required" tasks-axi hold --help 2>&1 | grep -F -- '--kind captain' >/dev/null \ || fail "tasks-axi does not expose the captain-hold contract" } @@ -230,6 +319,8 @@ command_id() { command_hold() { local origin=${1:-} key=${2:-} title='' reason='' repo='' id show state kind existing_title body + local semantic_revision='' choice='' why='' cost='' recommend='' brief_given=0 brief_block='' existing_body body_file + local -a options=() [ "$#" -ge 2 ] || { usage >&2; exit 2; } shift 2 while [ "$#" -gt 0 ]; do @@ -237,6 +328,12 @@ command_hold() { --title) shift; title=${1:-} ;; --reason) shift; reason=${1:-} ;; --repo) shift; repo=${1:-} ;; + --semantic-revision) shift; semantic_revision=${1:-}; brief_given=1 ;; + --choice) shift; choice=${1:-}; brief_given=1 ;; + --why-now) shift; why=${1:-}; brief_given=1 ;; + --cost-of-waiting) shift; cost=${1:-}; brief_given=1 ;; + --option) shift; options+=("${1:-}"); brief_given=1 ;; + --recommend) shift; recommend=${1:-}; brief_given=1 ;; *) usage >&2; exit 2 ;; esac shift @@ -246,6 +343,13 @@ command_hold() { validate_one_line title "$title" validate_one_line reason "$reason" case "$reason" in *'('*|*')'*) fail "reason must not contain parentheses (tasks-axi hold contract)" ;; esac + if [ "$brief_given" -eq 1 ]; then + validate_complete_briefing "$choice" "$why" "$cost" "$recommend" \ + "${options[@]+"${options[@]}"}" + validate_slug semantic-revision "$semantic_revision" + brief_block=$(fm_attention_brief_lines "$semantic_revision" "$choice" "$why" "$cost" "$recommend" \ + "${options[@]+"${options[@]}"}") + fi require_tasks_axi origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME" id=$(hold_id "$origin" "$key") @@ -256,7 +360,22 @@ command_hold() { [ "$state" != "done" ] || fail "captain decision $id is already durably resolved; use a new decision key for a new decision" [ "$kind" = captain ] || fail "existing backlog identity $id is not kind captain" [ "$existing_title" = "$title" ] || fail "existing captain hold $id has a different title" + existing_body=$(show_field "$show" body) + if [ "$brief_given" -eq 1 ]; then + body=$(printf 'Origin: %s\nDecision key: %s\nState: awaiting captain decision.' "$origin" "$key") + [ -z "$brief_block" ] || body=$(printf '%s\n\n%s' "$body" "$brief_block") + body_file=$(write_updated_body_file "$existing_body" "$body") + if ! tasks_axi update "$id" --body-file "$body_file" >/dev/null; then + rm -f "$body_file" + fail "could not record the captain briefing on $id" + fi + rm -f "$body_file" + else + briefing_complete_body "$existing_body" \ + || fail "existing captain decision $id has no complete captain briefing" + fi else + [ "$brief_given" -eq 1 ] || fail "a complete captain briefing is required for a new decision" if [ -z "$repo" ] && [ -f "$STATE/$origin.meta" ]; then repo=$(meta_value "$STATE/$origin.meta" project) repo=${repo%/} @@ -265,6 +384,7 @@ command_hold() { [ -n "$repo" ] || repo=firstmate validate_one_line repo "$repo" body=$(printf 'Origin: %s\nDecision key: %s\nState: awaiting captain decision.' "$origin" "$key") + [ -z "$brief_block" ] || body=$(printf '%s\n\n%s' "$body" "$brief_block") tasks_axi add "$id" "$title" --kind captain --repo "$repo" --body "$body" >/dev/null \ || fail "could not create captain decision item $id" fi diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 1dee81b..cd94304 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -138,11 +138,19 @@ validate_positive_bound FM_SNAPSHOT_REGISTRY_TIMEOUT "$FM_SNAPSHOT_REGISTRY_TIME usage() { cat <<'EOF' usage: fm-fleet-snapshot.sh --json + fm-fleet-snapshot.sh --backlog-json fm-fleet-snapshot.sh --secondmate-home-summary Print a read-only structured snapshot of the firstmate fleet. JSON is the stable machine-readable output contract. +--backlog-json emits ONLY the backlog object of the full --json contract, with +identical record fields. It exists so cheap, frequently-run consumers +(bin/fm-attention-lib.sh, and through it the turn-end and supervision guards) +reuse this one backlog parser instead of re-implementing it: it makes a single +jq pass over data/backlog.md and performs no endpoint, secondmate, report, or +network read. + --secondmate-home-summary emits the bounded structured summary used after a validated registered-home handoff. It is local-only, skips nested secondmate aggregation, and marks inventory contradictions or unavailable child state invalid. @@ -167,6 +175,7 @@ EOF OUTPUT_MODE=json case "${1:---json}" in --json) ;; + --backlog-json) OUTPUT_MODE="backlog-json" ;; --secondmate-home-summary) OUTPUT_MODE=secondmate-home-summary ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; @@ -267,6 +276,16 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG | if $v == null then null else ($v | trim) end; def metadata($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + ":[[:space:]]*(?<v>[^,)]*)"); + # Free-text metadata: a hold reason is captain-facing prose written by + # bin/fm-decision-hold.sh, which forbids parentheses but not commas, and + # tasks-axi always emits it as its own trailing "(hold: ...)" group. Reading + # it with the comma-terminated metadata() above silently truncated every + # reason at its first comma, which is how a recorded explanation reached the + # captain as a meaningless fragment. Free-text keys stop only at the closing + # parenthesis; comma-separated keys keep metadata() so "(repo: a, kind: b)" + # still parses. + def metadata_free($rest; $key): + cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + ":[[:space:]]*(?<v>[^)]*)"); def metadata_word($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + "[[:space:]]+(?<v>[^,)]*)"); def url_pattern: "https?://[^[:space:])\"<>]+"; @@ -332,7 +351,7 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG repo:metadata($rest; "repo"), kind:metadata($rest; "kind"), priority:metadata($rest; "priority"), - hold_reason:metadata($rest; "hold"), + hold_reason:metadata_free($rest; "hold"), hold_kind:metadata($rest; "hold-kind"), blocked_by:cap($rest; ".*blocked-by:[[:space:]]*(?<v>[^[:space:])]+).*"), blocked_by_ids:blocked_by_ids($rest), @@ -1291,6 +1310,14 @@ scout_report_lines() { } BACKLOG_JSON=$(backlog_json) || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } + +# Emitted before the task snapshot so this mode stays as cheap as it advertises: +# no endpoint reads, no secondmate aggregation, no report inventory. +if [ "$OUTPUT_MODE" = backlog-json ]; then + printf '%s\n' "$BACKLOG_JSON" + exit 0 +fi + TASKS_JSON=$(task_json_lines) || { echo "fm-fleet-snapshot: task snapshot failed" >&2; exit 1; } if [ "$OUTPUT_MODE" = secondmate-home-summary ]; then diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index e36b7f4..2df0017 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -1,10 +1,15 @@ #!/usr/bin/env bash -# Watcher liveness and worktree-tangle guard, called by supervision scripts, by -# fm-wake-drain.sh after it empties queued wakes, and by fm-session-start.sh in -# read-only advisory mode whenever session-lock ownership was not verified. +# Watcher liveness, captain's-call, and worktree-tangle guard, called by +# supervision scripts, by fm-wake-drain.sh after it empties queued wakes, and by +# fm-session-start.sh in read-only advisory mode whenever session-lock ownership +# was not verified. # First, always warn if the firstmate primary checkout (FM_ROOT) is on a named # non-default branch, because that means firstmate-on-itself work landed in the # primary instead of an isolated worktree. +# Second, surface a CHANGED captain's-call set (open decisions and meaningful +# waits, bin/fm-attention-lib.sh) before any in-flight test, so a home holding +# only unanswered captain work can no longer read as idle. Set +# FM_GUARD_NO_ATTENTION=1 to suppress that section. # Then, if any task is in flight (a state/<id>.meta exists) and the watcher's # liveness beacon (state/.last-watcher-beat, touched every poll cycle) is # missing or older than FM_GUARD_GRACE seconds, prints a loud, clearly delimited @@ -29,6 +34,10 @@ queue_pending=false READ_ONLY=${FM_GUARD_READ_ONLY:-0} case "$READ_ONLY" in 1|true|TRUE|yes|YES) READ_ONLY=1 ;; *) READ_ONLY=0 ;; esac CONTINUE_LINE=${FM_GUARD_CONTINUE_LINE:-This is a supervision warning only; the guarded operation WILL still run.} +# Escape hatch for a caller that must stay silent about captain-facing state +# (and for tests that assert the pre-existing watcher alarms in isolation). +FM_GUARD_NO_ATTENTION=${FM_GUARD_NO_ATTENTION:-0} +case "$FM_GUARD_NO_ATTENTION" in 1|true|TRUE|yes|YES) FM_GUARD_NO_ATTENTION=1 ;; *) FM_GUARD_NO_ATTENTION=0 ;; esac # Volatile, home-scoped episode marker: one line = the current stale-episode key. # Cleared when the home leaves the unhealthy state so a later episode re-arms. @@ -40,6 +49,8 @@ STALE_BANNER_MARKER="$STATE/.guard-watcher-stale-banner" . "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-supervision-lib.sh . "$SCRIPT_DIR/fm-supervision-lib.sh" +# shellcheck source=bin/fm-attention-lib.sh +. "$SCRIPT_DIR/fm-attention-lib.sh" # Deterministic episode key from beacon state: same continuous stale beacon # (or continuous absence) shares a key; a recovered-then-restale beacon gets a @@ -140,6 +151,38 @@ if [ -n "$tangle_branch" ]; then } >&2 fi +# Captain's call, checked BEFORE the in-flight test below and independent of it. +# This is where the old guard went blind: it returned early whenever no task +# metadata existed, so a home whose only live work was an unanswered decision or +# a standing delay produced no output at all and read as idle. The set is derived +# by bin/fm-attention-lib.sh from the backlog and the status event logs, and is +# surfaced once per CHANGE - identities carry no prose, so a delay re-reported +# hourly with new wording stays one item and never re-alarms, and an ordinary +# read that changes nothing prints nothing. +if [ "$FM_GUARD_NO_ATTENTION" != 1 ]; then + fm_attention_status "$FM_HOME" + if [ "$FM_ATT_AVAILABLE" != true ] && [ "${FM_ATT_UNKNOWN:-false}" = true ]; then + arule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + { + printf '●%s\n' "$arule" + printf "● CAPTAIN'S CALL UNKNOWN - open decisions and waits could not be determined\n" + "$SCRIPT_DIR/fm-attention.sh" --no-mark 2>/dev/null | sed 's/^/● /' + printf '● Do not report an all-clear until this can be read.\n' + printf '●%s\n' "$arule" + } >&2 + elif [ "$FM_ATT_AVAILABLE" = true ] && [ "$FM_ATT_NEW" = true ] && [ "$FM_ATT_COUNT" -gt 0 ]; then + arule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + { + printf '●%s\n' "$arule" + printf "● CAPTAIN'S CALL CHANGED - %s decision(s) and %s wait(s) are open\n" \ + "$FM_ATT_DECISIONS" "$FM_ATT_WAITS" + "$SCRIPT_DIR/fm-attention.sh" --no-mark 2>/dev/null | sed 's/^/● /' + printf '● Relay every open decision and wait to the captain in plain language in your next reply.\n' + printf '●%s\n' "$arule" + } >&2 + fi +fi + # Compute in-flight count and watcher-beacon freshness via the shared # grace-based predicate (bin/fm-supervision-lib.sh). Only act with tasks in # flight; count them so the banner can say how much is riding on an absent diff --git a/bin/fm-inbox-view.sh b/bin/fm-inbox-view.sh index 33a4209..2c4b021 100755 --- a/bin/fm-inbox-view.sh +++ b/bin/fm-inbox-view.sh @@ -133,9 +133,11 @@ else "$SCRIPT_DIR/fm-fleet-snapshot.sh" --json > "$SNAPSHOT" || die "fleet snapshot failed" fi -# Untruncated captain-hold text. fm-fleet-snapshot's metadata capture stops at -# the first comma, so a hold reason carrying options is unreadable from the -# snapshot alone; tasks-axi is the authority for the full text. +# Untruncated captain-hold text. fm-fleet-snapshot's metadata capture no longer +# stops at the first comma, but its aggregated output still length-caps a hold +# reason and carries no body at all, so the durable captain briefing written by +# fm-decision-hold.sh is unreachable from the snapshot alone; tasks-axi is the +# authority for the full text. FULLDIR="$TMP/full" mkdir -p "$FULLDIR" if [ "$FULL_TEXT" -eq 1 ] && command -v tasks-axi >/dev/null 2>&1; then diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 1abbace..7d3ca6b 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -35,14 +35,22 @@ # also run only when locked. # 3. wake-drain - mutates the durable wake queue, so it also only runs # when locked. -# 4. context digest - data/projects.md, data/secondmates.md, data/captain.md, +# 4. captain's call - every open captain decision and meaningful wait +# (bin/fm-attention.sh), printed before the supervision +# block and the context because it is this turn's second +# work queue and must be seen before the first +# captain-facing reply. Rendering this digest is read-only; +# docs/captain-attention.md owns how an actual assistant +# reply records a receipt. +# 5. supervision - the emitted operating block for this primary harness. +# 6. context digest - data/projects.md, data/secondmates.md, data/captain.md, # data/captain-shared.md, data/learnings.md: read-only, # always safe, always runs. -# 5. fleet digest - a compact data/backlog.md identity/metadata listing, +# 7. fleet digest - a compact data/backlog.md identity/metadata listing, # every state/*.meta, a bounded state/*.status tail, # state/.afk, and a cheap per-task endpoint-liveness read: # read-only, always runs. -# 6. closing reminder - prints the context-specific watcher next step; this +# 8. closing reminder - prints the context-specific watcher next step; this # script points back to the emitted harness supervision # block and deliberately never arms the watcher itself. # @@ -289,15 +297,18 @@ fi # authority, and another session may be actively draining it. It still runs # fm-guard.sh directly with non-mutating advisory text, so the same alarms # surface without repair commands. +# The guard's own captain's-call banner is suppressed here because step 4 below +# prints that section in full a few lines later; two copies in one digest is +# noise, not redundancy. subsection "WAKE QUEUE" if [ "$READ_ONLY" -eq 1 ]; then QLEN=0 [ -s "$STATE/.wake-queue" ] && QLEN=$(grep -c . "$STATE/.wake-queue" 2>/dev/null || printf '0') printf 'skipped (read-only session) - %s record(s) remain queued because this session lacks verified fleet-lock ownership.\n' "$QLEN" - GUARD_OUT=$(FM_GUARD_READ_ONLY=1 "$SCRIPT_DIR/fm-guard.sh" 2>&1) + GUARD_OUT=$(FM_GUARD_READ_ONLY=1 FM_GUARD_NO_ATTENTION=1 "$SCRIPT_DIR/fm-guard.sh" 2>&1) [ -n "$GUARD_OUT" ] && printf '%s\n' "$GUARD_OUT" else - DRAIN_OUT=$("$SCRIPT_DIR/fm-wake-drain.sh" 2>&1) + DRAIN_OUT=$(FM_GUARD_NO_ATTENTION=1 "$SCRIPT_DIR/fm-wake-drain.sh" 2>&1) if [ -n "$DRAIN_OUT" ]; then printf '%s\n' "$DRAIN_OUT" else @@ -305,7 +316,19 @@ else fi fi -# --- 4. supervision operating instructions ---------------------------------- +# --- 4. captain's call --------------------------------------------------- +# Printed straight after the wake queue and before anything else, because it is +# this turn's second work queue and must be seen before the first captain-facing +# reply is composed. bin/fm-attention-lib.sh owns the set; this only renders it. +subsection "CAPTAIN'S CALL" +ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --no-mark 2>&1) +if [ -n "$ATTENTION_OUT" ]; then + printf '%s\n' "$ATTENTION_OUT" +else + printf '(unavailable)\n' +fi + +# --- 5. supervision operating instructions ---------------------------------- AFK_PRESENT=0 [ -e "$STATE/.afk" ] && AFK_PRESENT=1 X_MODE_PRESENT=0 @@ -332,7 +355,7 @@ fi --afk "$AFK_PRESENT" \ --x-mode "$X_MODE_PRESENT" -# --- 4. context digest ----------------------------------------------------- +# --- 6. context digest ----------------------------------------------------- section "CONTEXT" print_file_or_absent "$DATA/projects.md" "data/projects.md" print_file_or_absent "$DATA/secondmates.md" "data/secondmates.md" @@ -340,7 +363,7 @@ print_file_or_absent "$DATA/captain.md" "data/captain.md" print_file_or_absent "$DATA/captain-shared.md" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" print_file_or_absent "$DATA/learnings.md" "data/learnings.md" -# --- 5. fleet-state digest --------------------------------------------- +# --- 7. fleet-state digest --------------------------------------------- section "FLEET STATE" print_backlog_compact "$DATA/backlog.md" "data/backlog.md" @@ -394,7 +417,7 @@ else printf 'absent\n' fi -# --- 6. closing reminder ----------------------------------------------- +# --- 8. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then cat <<'EOF' diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 6cd8769..bb1b5ee 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -155,6 +155,23 @@ repair_line() { esac } +captain_call_line() { + local status decisions waits + status=$("$SCRIPT_DIR/fm-attention.sh" --status --no-mark 2>/dev/null) || return 0 + decisions=${status#*decisions=} + decisions=${decisions%% *} + waits=${status#*waits=} + waits=${waits%% *} + case "$decisions" in ''|*[!0-9]*) return 0 ;; esac + case "$waits" in ''|*[!0-9]*) waits=0 ;; esac + if [ "$decisions" -eq 0 ] && [ "$waits" -eq 0 ]; then + printf '%s\n' "- Captain's call: nothing open." + else + printf -- '- Captain%s call: %s decision(s) need the captain and %s wait(s) are open; relay them in plain language with bin/fm-attention.sh before this turn ends.\n' \ + "'s" "$decisions" "$waits" + fi +} + ordinary_wake_line() { case "$HARNESS" in claude) @@ -203,6 +220,7 @@ if [ "$X_MODE" -eq 1 ]; then else printf '%s\n' '- X mode: inactive; use the default watcher cadence.' fi +captain_call_line ordinary_wake_line printf '\n' render_snippet diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index 1930700..7faaf9f 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -10,6 +10,19 @@ # bin/fm-turnend-guard.sh uses the status fields here for its banner but performs # its end-of-turn block decision with the live watcher lock check in # bin/fm-wake-lib.sh. +# +# SCOPE, and the blind spot this library deliberately does NOT close. +# FM_SUP_IN_FLIGHT counts state/*.meta, which exist only after bin/fm-spawn.sh +# runs. That makes this library an accurate answer to "does a watcher need to be +# running", and a WRONG answer to "is this home idle": a primary holding work +# itself - an unanswered captain decision, a standing external delay - has no +# metadata to count and reads as zero. Unaccounted primary work must look +# suspicious rather than idle, so the idleness predicate is owned by +# fm_attention_home_idle in bin/fm-attention-lib.sh, which adds the derived +# captain-attention set to the counts below. It is kept OUT of FM_SUP_NEEDED on +# purpose: a standing decision needs the captain, not a watcher, and folding it +# in here would demand a live watcher forever on any home with a long-lived open +# decision. # Portable mtime; Linux stat lacks -f, macOS stat lacks -c. fm_sup_stat_mtime() { diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 16d719d..b3ae180 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -131,7 +131,7 @@ family_for_basename() { fm-test-run.test.sh|fm-test-isolation-proof.test.sh) printf '%s\n' pure-contract-unit ;; - fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ + fm-attention.test.sh|fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ fm-wake-queue.test.sh|fm-watch-checkpoint.test.sh|fm-watch-triage.test.sh|\ fm-watcher-lock.test.sh) @@ -677,6 +677,19 @@ families_for_changed_path() { ;; bin/fm-bearings-snapshot.sh|bin/fm-fleet-snapshot.sh|bin/fm-fleet-view.sh) printf '%s\n' snapshot-bearings + # The captain-attention contract reads the snapshot's backlog projection, + # so a backlog-parser change must re-run its coverage too. + printf '%s\n' watcher-wake-lock + ;; + bin/fm-attention.sh|bin/fm-attention-lib.sh) + printf '%s\n' watcher-wake-lock + printf '%s\n' snapshot-bearings + printf '%s\n' session-bootstrap + printf '%s\n' pure-contract-unit + # fm-backend's old-vs-new conformance fixture copies the CURRENT + # fm-guard.sh and fm-decision-hold.sh into a synthetic old bin/, and both + # source this library, so that suite must be selected here too. + printf '%s\n' backend-dispatch ;; bin/fm-inbox-view.sh|bin/fm-inbox-render.py) printf '%s\n' "__script__:fm-inbox-view.test.sh" diff --git a/bin/fm-turnend-guard-grok.sh b/bin/fm-turnend-guard-grok.sh index 3dcfd6f..d31567c 100755 --- a/bin/fm-turnend-guard-grok.sh +++ b/bin/fm-turnend-guard-grok.sh @@ -22,6 +22,8 @@ printf '%s' "$PAYLOAD" | jq -n --stream -e ' $item[0][0] == "sessionId" or $item[0][0] == "stopHookActive" or $item[0][0] == "stop_hook_active" + or $item[0][0] == "lastAssistantMessage" + or $item[0][0] == "last_assistant_message" ) ) then .[$item[0][0]] = ((.[$item[0][0]] // 0) + 1) @@ -59,11 +61,9 @@ if [ "$CAPABILITY" = native ]; then fi # Only a genuine pre-native payload reaches this bounded compatibility path. -[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -er ' .sessionId | select(type == "string" and length > 0) ' 2>/dev/null) || exit 0 -command -v grok >/dev/null 2>&1 || exit 0 ERR=$(mktemp "${TMPDIR:-/tmp}/fm-turnend-grok.XXXXXX") || exit 0 trap 'rm -f "$ERR"' EXIT @@ -74,10 +74,30 @@ RC=$? REASON=$(cat "$ERR" 2>/dev/null || true) [ -n "$REASON" ] || REASON='tasks in flight, no live watcher - repair missing watcher supervision according to the session-start operating block before ending the turn' +if [ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ]; then + case "$REASON" in + *'TURN WOULD END WITHOUT TELLING THE CAPTAIN'*|*'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS'*) ;; + *) exit 0 ;; + esac +fi +command -v grok >/dev/null 2>&1 || exit 0 +# The shared guard has two independent stops and says which one fired in its own +# banner (bin/fm-turnend-guard.sh owns both headlines). A captain decision that +# has never been shown to the captain is not a supervision lapse, so this bounded +# resume must not claim the watcher is down. +HEADLINE='TURN WOULD END BLIND - supervision is off. Repair missing watcher supervision according to the session-start operating block before ending the turn.' +case "$REASON" in + *'TURN WOULD END WITHOUT TELLING THE CAPTAIN'*) + HEADLINE='TURN WOULD END WITHOUT TELLING THE CAPTAIN. A decision is waiting on him that he has never been shown. Relay it in plain language before ending the turn.' + ;; + *'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS'*) + HEADLINE='TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS. The open decision and wait list is unknown. Restore that list before reporting an all-clear.' + ;; +esac # shellcheck source=bin/fm-operational-input.sh . "$ROOT/bin/fm-operational-input.sh" fm_operational_input_encode turn-end-guard \ - "TURN WOULD END BLIND - supervision is off. Repair missing watcher supervision according to the session-start operating block before ending the turn. + "$HEADLINE $REASON" \ PROMPT || exit 0 diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 2e96fb3..2cb533a 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -28,10 +28,24 @@ # primary checkout - the main home or a genuinely marked secondmate home - and # stay a silent, fast no-op inside child task worktrees. # -# Loop-guard, codex/Grok (default) mode: never block twice in the same turn. +# Two independent turn-end stops live here, in this order: +# 1. supervision is off while work is in flight (the original backstop); +# 2. a captain decision is open that has never reached a captain-facing +# surface (the captain's-call stop, bin/fm-attention-lib.sh). +# The second only runs on the paths where the first ALLOWS, so the two never +# stack and the load-bearing watcher alarm keeps priority. Its internal render is +# read-only; only an actual assistant reply carrying the complete alert records +# the captain receipt. When the Stop payload cannot carry the assistant reply, +# state/.captain-attention-decisions bounds the decision stop to one block per +# changed decision set - mirroring the unknown marker - so an evidence-less +# harness can always end its turn while the pull surfaces keep the decision +# visible. +# +# Loop-guard, codex/Grok (default) mode: watcher recovery never blocks twice in the same turn. # Codex uses stop_hook_active and Grok uses stopHookActive; typed camel-case # takes precedence when both spellings are present. A true value means the -# current stop attempt already follows a block, so this guard always allows it. +# current stop attempt already follows a watcher-recovery block; the independent +# captain-attention gate still evaluates it. # Passive harness adapters provide their own one-follow-up guard before calling # this script. # That bounds those harnesses to at most one forced continuation per turn - @@ -70,6 +84,10 @@ CLAUDE_MODE=0 SYNC_WAIT_MS=${FM_CLAUDE_AUTOARM_SYNC_WAIT_MS:-800} EPOCH_FRESH=${FM_CLAUDE_AUTOARM_EPOCH_FRESH:-15} BLOCK_BUDGET=${FM_CLAUDE_TURNEND_BLOCK_BUDGET:-3} +# The captain-decision turn-end stop. On by default; set to 0 to disable it +# without touching the watcher-liveness backstop. +FM_ATTENTION_TURNEND_BLOCK=${FM_ATTENTION_TURNEND_BLOCK:-1} +case "$FM_ATTENTION_TURNEND_BLOCK" in 1|true|TRUE|yes|YES) FM_ATTENTION_TURNEND_BLOCK=1 ;; *) FM_ATTENTION_TURNEND_BLOCK=0 ;; esac case "$SYNC_WAIT_MS" in ''|*[!0-9]*) SYNC_WAIT_MS=800 ;; esac case "$EPOCH_FRESH" in ''|*[!0-9]*|0) EPOCH_FRESH=15 ;; esac case "$BLOCK_BUDGET" in ''|*[!0-9]*|0) BLOCK_BUDGET=3 ;; esac @@ -83,6 +101,8 @@ done # shellcheck source=bin/fm-supervision-lib.sh . "$SCRIPT_DIR/fm-supervision-lib.sh" +# shellcheck source=bin/fm-attention-lib.sh +. "$SCRIPT_DIR/fm-attention-lib.sh" # shellcheck source=bin/fm-primary-scope-lib.sh . "$SCRIPT_DIR/fm-primary-scope-lib.sh" @@ -105,10 +125,21 @@ STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r ' else false end ' 2>/dev/null) || exit 0 -if [ "$CLAUDE_MODE" -eq 0 ] && [ "$STOP_HOOK_ACTIVE" = "true" ]; then - exit 0 -fi - +ASSISTANT_MESSAGE=$(printf '%s' "$PAYLOAD" | jq -r ' + if has("lastAssistantMessage") then + if ((.lastAssistantMessage | type) == "string") then .lastAssistantMessage else error("lastAssistantMessage") end + elif has("last_assistant_message") then + if ((.last_assistant_message | type) == "string") then .last_assistant_message else error("last_assistant_message") end + else "" + end +' 2>/dev/null) || exit 0 +# Whether this harness's Stop payload can carry the assistant reply at all. A +# payload with the field keeps the strict receipt requirement; a payload that +# cannot deliver it gets the bounded surfaced-once decision stop below, so an +# evidence-less harness can never wedge on an open decision. +ASSISTANT_EVIDENCE=$(printf '%s' "$PAYLOAD" | jq -r ' + has("lastAssistantMessage") or has("last_assistant_message") +' 2>/dev/null) || exit 0 # --- scope precisely to a PRIMARY checkout ---------------------------------- # A genuinely-marked secondmate home runs its OWN primary firstmate session, so # force-INCLUDE it as a guarded primary whether treehouse leased it as a linked @@ -123,6 +154,11 @@ fi # so this exempts them while guarding every real secondmate home. fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 +if [ -n "$ASSISTANT_MESSAGE" ]; then + printf '%s' "$ASSISTANT_MESSAGE" | "$SCRIPT_DIR/fm-attention.sh" --record-visible >/dev/null 2>&1 || true +fi +fm_attention_status "$FM_HOME" + # --- the actual predicate ---------------------------------------------------- # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" @@ -133,21 +169,86 @@ budget_reset() { rm -f "$BUDGET_FILE" 2>/dev/null || true } +# A turn must not end while a captain decision the captain has never been shown +# is sitting open. This is the second half of the meta-blindness fix: the +# watcher-liveness predicate below counts state/*.meta, so a primary whose only +# live work is an unanswered decision reaches every exit path with zero in +# flight and ends silently. +# +block_attention() { + local rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + { + printf '●%s\n' "$rule" + printf "● TURN WOULD END WITHOUT TELLING THE CAPTAIN - %s DECISION(S) ARE WAITING ON HIM\n" \ + "$FM_ATT_DECISIONS" + "$SCRIPT_DIR/fm-attention.sh" --no-mark 2>/dev/null | sed 's/^/● /' + printf '● Relay each one to the captain in plain language before ending this turn:\n' + printf '● the concrete choice, why it matters now, what waiting costs, and your recommendation.\n' + printf '● bin/fm-attention.sh prints exactly that, already captain-safe.\n' + printf '●%s\n' "$rule" + } >&2 + if [ "$ASSISTANT_EVIDENCE" != true ]; then + [ -d "$STATE" ] && printf 'decisions=%s\n' "$FM_ATT_DECISION_DIGEST" > "$STATE/.captain-attention-decisions" 2>/dev/null || true + fi + exit 2 +} + +block_attention_unknown() { + local rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + { + printf '●%s\n' "$rule" + printf '● TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS\n' + "$SCRIPT_DIR/fm-attention.sh" --no-mark 2>/dev/null | sed 's/^/● /' + printf '● Do not report an all-clear until the open decision and wait list can be read.\n' + printf '●%s\n' "$rule" + } >&2 + [ -d "$STATE" ] && printf 'unknown=%s\n' "$FM_ATT_UNKNOWN_DIGEST" > "$STATE/.captain-attention-unknown" 2>/dev/null || true + exit 2 +} + +attention_gate() { + local seen_decisions + [ "$FM_ATTENTION_TURNEND_BLOCK" -eq 1 ] || return 1 + if [ "$FM_ATT_AVAILABLE" = true ]; then + rm -f "$STATE/.captain-attention-unknown" 2>/dev/null || true + if [ "$FM_ATT_DECISIONS" -gt 0 ] && [ "$FM_ATT_DECISIONS_NEW" = true ]; then + [ "$ASSISTANT_EVIDENCE" = true ] && block_attention + seen_decisions=$(sed -n 's/^decisions=//p' "$STATE/.captain-attention-decisions" 2>/dev/null | tail -1 || true) + [ "$FM_ATT_DECISION_DIGEST" = "$seen_decisions" ] || block_attention + else + rm -f "$STATE/.captain-attention-decisions" 2>/dev/null || true + fi + return 0 + fi + [ "${FM_ATT_UNKNOWN:-false}" = true ] || return 0 + [ "${FM_ATT_UNKNOWN_NEW:-true}" = true ] && block_attention_unknown + return 0 +} + +allow_stop() { + attention_gate + exit 0 +} + +if [ "$CLAUDE_MODE" -eq 0 ] && [ "$STOP_HOOK_ACTIVE" = "true" ]; then + allow_stop +fi + fm_supervision_status "$STATE" "$GRACE" if [ "$CLAUDE_MODE" -eq 1 ]; then if [ "$FM_SUP_NEEDED" = false ]; then budget_reset - exit 0 + allow_stop fi else if [ "$FM_SUP_IN_FLIGHT" -eq 0 ]; then budget_reset - exit 0 + allow_stop fi fi if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then budget_reset - exit 0 + allow_stop fi block_stop() { @@ -201,14 +302,14 @@ i=0 while [ "$i" -lt $((SYNC_WAIT_MS / 100)) ]; do if autoarm_owns_recovery; then budget_reset - exit 0 + allow_stop fi sleep 0.1 i=$((i + 1)) done if autoarm_owns_recovery; then budget_reset - exit 0 + allow_stop fi # The auto-arm genuinely failed to establish: re-block, but never past the @@ -227,6 +328,7 @@ fi COUNT=$((COUNT + 1)) if [ "$COUNT" -gt "$BLOCK_BUDGET" ]; then budget_reset + attention_gate if [ "$FM_SUP_IN_FLIGHT" -gt 0 ]; then NEED_DESC="$FM_SUP_IN_FLIGHT task(s) in flight" else diff --git a/docs/architecture.md b/docs/architecture.md index c7f4bb2..0be5b7d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,11 @@ It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns t 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. 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). +Both guards carry a second, independent concern that neither watcher liveness nor in-flight counting can express: whether the captain has been told what is waiting on him. +`bin/fm-attention-lib.sh` derives that set from the backlog and the status event logs and gives it stable, prose-free identities, so the pull guard surfaces a changed set before any in-flight test and the turn-end guard stops a turn that would end on a decision the captain has never been shown. +This is also where in-flight counting stops being the definition of an idle home: a primary holding only an unanswered decision has no metadata to count, so idleness is asserted against the derived set instead. +[`captain-attention.md`](captain-attention.md) owns the contract, and `bin/fm-attention.sh` is the single renderer Firstmate reads and relays into chat. + 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. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. Terminal verbs remain captain-relevant, while a nonterminal progress verb cannot become terminal merely because its prose contains a legacy free-text token such as `merged`; bare legacy free-text lines remain compatible. diff --git a/docs/captain-attention.md b/docs/captain-attention.md new file mode 100644 index 0000000..9de1582 --- /dev/null +++ b/docs/captain-attention.md @@ -0,0 +1,91 @@ +# Captain's call: open decisions and waits + +Firstmate could stop on a decision or an external delay without the captain ever receiving a self-contained explanation of what was needed, why it mattered, or what happened next. +Work sat parked, and the fleet read as permanently waiting for no understandable reason. +This document is the operator reference for the contract that fixes it; each script's header and `--help` own exact flags and mechanics. + +## What the contract covers + +Every open item is exactly one of two things. + +- A **decision** needs the captain's own answer before the work can move. +- A **wait** is a meaningful delay that needs no captain action yet, including a declared external delay or backlog work held on another blocker. + It still owes the captain what is being awaited and when it is next checked. + +A status-derived wait becomes a decision only through an explicit action-required `needs-decision` or `blocked` status transition. +Repetition never changes a routine external or timed wait into a critical alert. + +## Where it comes from + +`bin/fm-attention-lib.sh` is the single owner of the contract. +It derives the open set from home-local records Firstmate already keeps - `data/backlog.md`, `state/*.meta`, and `state/*.status` - and uses `state/.last-watcher-beat` only to estimate a wait's next check. +It stores no item records of its own. +It is therefore not a second status surface, and it is harness-agnostic and runtime-backend-agnostic: it never inspects a pane, an endpoint, or a harness. +Backlog rows are read through `bin/fm-fleet-snapshot.sh --backlog-json`, which remains the one owner of backlog parsing. +If that projection, state directory, metadata record, or status stream cannot be read, the result is unknown, not empty: renderers say that the open decision and wait list could not be determined, never print an all-clear, and never record a captain receipt. +Firstmate follows readable symbolic links for the state directory and task records. +A dangling link makes the set unknown instead of looking empty. + +A backlog row is a decision when it carries a captain hold with a reason and no unresolved blocker, whatever the item's own `kind` says. +The documented way to gate ordinary work on the captain is `tasks-axi hold <id> --reason "<reason>" --kind captain`, which leaves `kind` as `ship`, so the snapshot's narrower `captain_actionable` flag is false for exactly the threads this contract exists for. +A captain hold whose blocker is still open is not answerable yet and stays a wait, so a future-gated hold does not nag the captain now. + +## The durable captain briefing + +A title and a one-line reason cannot carry a decision, so every renderer could only truncate them. +`bin/fm-decision-hold.sh hold` requires `--semantic-revision`, `--choice`, `--why-now`, `--cost-of-waiting`, at least one `--option`, and `--recommend` for every new hold and explicit revision, and stores that plain language in the hold's own durable body. +The privacy-safe semantic revision stays the same for wording-only paraphrases and changes whenever the choice, stakes, waiting cost, options, or recommendation changes substantively. +Supplying the complete briefing rewrites it and preserves unrecognized body lines; supplying no briefing flags leaves an existing complete briefing untouched, so an idempotent retry never erases one. +Legacy decisions with no briefing still render and are marked as incomplete, rather than presenting a raw operational note as if it were plain language or recording a captain receipt. +A partial briefing renders the fields that were written and names the missing ones. + +## Where the captain sees it + +`bin/fm-attention.sh` is the single place firstmate reads the open set from and relays into chat, so no reply has to reconstruct it. +Its default view is plain English with no internal identifiers or vocabulary, so it can be relayed as written; `--brief` is the firstmate-facing form and does carry identifiers, and is read-only. + +The same set also reaches ordinary replies through four integrated paths, so nothing depends on remembering to look: + +- `bin/fm-session-start.sh` prints it as its own read-only digest section, before the supervision block and the context. +- `bin/fm-guard.sh` renders a changed set read-only on every guarded command and at the top of every wake-handling turn, before any in-flight test. +- `bin/fm-turnend-guard.sh` stops a turn that would end while a captain decision the captain has never been shown is open. +- `bin/fm-supervision-instructions.sh` carries a one-line count in the emitted operating block. + +`/bearings` renders the same items in its Captain's Call and Charted Next sections. + +The [inbox board](inbox-board.md) is the other captain-facing surface for the same decisions, and the two are complementary rather than duplicates: this contract owns the derived set, its plain language, and how often firstmate is allowed to interrupt with it, while the board is where the captain answers one in a browser. +They select decisions by the same rule - the captain hold itself, not the item's own `kind` - so the surfaces cannot disagree about what needs the captain. +Keep that rule in step when either side changes it. + +## Surfacing, deduplication, and false alarms + +`state/.captain-attention` records the digest of the set most recently verified in an actual assistant reply by `bin/fm-attention.sh --record-visible`. +Every rendering mode is read-only; only `--record-visible`, fed the assistant message by a turn-end adapter, can record a receipt, and it validates each alert as one associated block with the correct category, headline, and that record's own briefing facts. +Firstmate-facing projections such as the default view, `--brief`, `--json`, `--status`, and `--no-mark` cannot spend the receipt. +Identities carry no prose, so a delay re-reported hourly with new wording stays one item and surfaces once; if that delay clears and later opens again, its generation changes and it surfaces again. +Decision receipts include the explicit semantic revision, so a substantive revision reopens the receipt while a wording-only paraphrase does not. +A generated captain item merges with its origin and key, while a direct item merges only with a same-task status whose explicit key equals the item id; ambiguous records remain separate. +When that decision is transferred to its durable captain item, the current wait remains attached until a terminal work outcome closes the wait portion. +The marker bounds the interrupt only - an open item stays listed until it is answered or clears. + +On a harness whose turn-end payload carries the assistant reply, the turn-end stop remains active until the complete alert appears in an actual captain-visible assistant reply. +On a harness whose payload cannot carry the reply, no receipt can ever validate at turn end, so that stop is bounded by a separate decision marker to one interrupt per changed decision set; the open decision stays on every pull surface until it is answered. +Routine external and timed waits never stop a turn because they need no captain action. +An unknown derivation can also stop a turn once, using a separate unknown marker so a broken projection cannot loop the session and cannot mark a decision set as surfaced. +Read-only calls never reset the unknown marker; an explicitly mutating turn-end or receipt path resets it after a readable derivation, so a later derivation failure is surfaced as a fresh unknown. +`FM_ATTENTION_TURNEND_BLOCK=0` disables that stop without touching the watcher-liveness backstop, and `FM_GUARD_NO_ATTENTION=1` suppresses the guard section. + +## The primary-activity blind spot + +`bin/fm-supervision-lib.sh` counts `state/*.meta`, which exist only after `bin/fm-spawn.sh` runs. +That is an accurate answer to "does a watcher need to be running" and a wrong answer to "is this home idle": a primary holding work itself - an unanswered decision, a standing delay - had nothing to count, so every guard concluded the home was idle and the whole stack went silent. +`fm_attention_home_idle` in `bin/fm-attention-lib.sh` owns the idleness question and adds the derived attention set to those counts, so unaccounted primary work reads as suspicious rather than idle. +It is deliberately kept out of `FM_SUP_NEEDED`: a standing decision needs the captain, not a watcher, and folding it in would demand a live watcher forever on any home with a long-lived open decision. + +## Compatibility + +Every supported primary harness and runtime backend was reviewed; see [supervision verification](verification/supervision.md#captains-call-decisions-and-waits) for the review and its evidence. + +## Verification + +The focused regression entry point is `tests/fm-attention.test.sh`. diff --git a/docs/configuration.md b/docs/configuration.md index d9a06bf..55e68c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -392,6 +392,7 @@ CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-s FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands +FM_GUARD_NO_ATTENTION=0 # suppress fm-guard.sh's captain's-call section for a caller that prints it itself (docs/captain-attention.md) FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically FM_POLL=15 # seconds between watcher poll cycles FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle @@ -416,6 +417,7 @@ FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and 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 FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm rewake outcome counts as this event epoch's owned recovery FM_CLAUDE_TURNEND_BLOCK_BUDGET=3 # consecutive --claude guard re-blocks before a degraded allow; safely below Claude Code's 8-block override +FM_ATTENTION_TURNEND_BLOCK=1 # set to 0 to disable the turn-end captain-decision stop without touching the watcher-liveness backstop (docs/captain-attention.md) FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure diff --git a/docs/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md index 234055a..693ec19 100644 --- a/docs/decision-hold-lifecycle.md +++ b/docs/decision-hold-lifecycle.md @@ -13,6 +13,13 @@ The `hold` subcommand maps an originating work id and stable decision key to `<o It creates a kind `captain` backlog item when absent and invokes `tasks-axi hold <id> --reason <reason> --kind captain` on every retry. It rejects an identity collision, a changed title, and attempts to reopen an already resolved identity. +The same subcommand requires the durable captain briefing that makes the decision answerable: an explicit semantic revision, the concrete choice, why it matters now, what waiting costs, at least one option, and a recommendation, each on one line. +The grammar is owned by `bin/fm-attention-lib.sh` and written into the hold body, so the briefing lives and dies with the hold rather than in a side file. +The semantic revision is a privacy-safe slug that stays stable across wording-only paraphrases and changes when the decision's substance changes. +Supplying the complete briefing rewrites the block and preserves unrecognized body lines through the full quoted-scalar escape format; supplying none leaves an existing complete briefing untouched, so an idempotent retry cannot erase one. +`resolve` replaces the body with the resolution record, which clears the briefing along with the hold. +See [`captain-attention.md`](captain-attention.md) for how the result reaches the captain. + The `complete` subcommand unions the reviewed keys into `decision_keys=` and appends `decisions_reviewed=1` while originating task metadata is live. A post-teardown visual review can complete against the surviving report and durable holds without recreating volatile task metadata. It accepts `--none` as an explicit semantic inventory result, not as inferred absence. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index e399e48..a4bebaf 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -61,6 +61,10 @@ "source": "docs/calm-mode-feasibility.md", "target": "docs/calm.md" }, + { + "source": "docs/captain-attention.md", + "target": "docs/verification/supervision.md" + }, { "source": "docs/sessionstart-nudge.md", "target": "docs/verification/supervision.md" @@ -211,6 +215,10 @@ "path": "docs/calm.md", "audience": "operator-current" }, + { + "path": "docs/captain-attention.md", + "audience": "operator-current" + }, { "path": "docs/cd-guard.md", "audience": "maintainer-architecture" diff --git a/docs/inbox-board.md b/docs/inbox-board.md index cea5aa5..a3945b9 100644 --- a/docs/inbox-board.md +++ b/docs/inbox-board.md @@ -17,8 +17,10 @@ Serve the board with this, not a bare `lavish-axi` call, so answers actually rea Every unblocked queued item with `hold_kind == "captain"` and a hold reason renders as a decision card, even when the backlog item's own `kind` is still `ship`. That deliberate selection keeps `tasks-axi hold --kind captain` threads visible when the snapshot's narrower `captain_actionable` flag would hide them. +[`captain-attention.md`](captain-attention.md) selects decisions by the same rule and owns the plain-language contract and the interrupt budget behind them; keep the two in step. Each card leads with plain English: the question, what the captain is actually choosing, why it is a question, firstmate's recommendation, an optional research link, and a collapsed technical section. -The plain-English text is not derivable from backlog metadata, so it comes from an optional captain-private cards file (`data/inbox-cards.md` by default; see `fm-inbox-view.sh --help` for the format). +Card text comes from an optional captain-private cards file (`data/inbox-cards.md` by default; see `fm-inbox-view.sh --help` for the format), which stays the richer surface: it also carries a research link and a collapsed technical section. +The plainer explanation a decision cannot go without is now recorded durably on the hold itself by `bin/fm-decision-hold.sh hold`, so a decision raised without a card is no longer wordless. A decision with no card still renders and is marked as not yet written, rather than presenting a raw hold note as if it were plain English. An item that is firstmate's own assumption rather than a choice the captain made is flagged as such on the card. diff --git a/docs/scripts.md b/docs/scripts.md index fa7bda3..a6af48c 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -7,22 +7,24 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | Script | Purpose | | ------------------------ | ------------------------------------------------------------------------------------ | -| `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest | +| `fm-session-start.sh` | Compose the single ordered session-start digest | | `fm-sessionstart-nudge.sh` | Print the native session-start hook nudge when the primary has not already run the digest | | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | -| `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | +| `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON and its backlog-only projection | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-tasklist-view.sh` | Render the fleet snapshot as a live read-only task-list board | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | +| `fm-attention.sh` | Print every open captain decision and meaningful wait in captain-safe plain language | +| `fm-attention-lib.sh` | Shared single owner of the captain-attention derivation, identities, briefings, and receipts (docs/captain-attention.md) | | `fm-inbox-view.sh` | Generate the read-only captain decision-and-review board HTML | | `fm-inbox-render.py` | Render the inbox-board HTML from snapshot, card, full-text, and PR-state inputs | | `fm-inbox-arm.sh` | Write and register the captain-inbox answer relay | | `fm-inbox-serve.sh` | Generate, arm, serve, print, and verify the captain inbox board link | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | -| `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | +| `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions, with their captain briefing | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | @@ -31,7 +33,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, proven-isolated `--jobs`, coverage guard, timing/JSON | | `fm-test-isolation-proof.sh` | Concurrent isolation proof and proven-isolated candidate set owner | | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section | -| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness | +| `fm-guard.sh` | Warn on primary-checkout tangles, changed captain attention, queued wakes, and stale watcher liveness | | `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) | diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 8ee750d..62a8231 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -25,8 +25,8 @@ An unmarked checkout or invalid marker falls through to the git-dir check. That check keeps crewmate and scout linked worktrees inert because their git dir differs from their git common dir. It also requires `AGENTS.md`, `bin/`, and the effective state directory. -For an in-scope primary, the guard counts in-flight work from `state/*.meta`. -The default cross-harness mode exits silently with no work in flight. +For an in-scope primary, the supervision predicate counts in-flight work from `state/*.meta`. +The default cross-harness mode allows with no work in flight, then still passes through the captain-attention stop below. Claude's `--claude` mode also treats `state/x-watch.check.sh` as supervision need, so X-mode relay polling remains guarded without an in-flight task. Otherwise it calls `fm_watcher_healthy <state-dir> <watch-path> [grace-seconds] [home]` from `bin/fm-wake-lib.sh`, the same identity-matched lock and fresh-beacon check used by `bin/fm-watch-arm.sh`. A stale beacon blocks even when a watcher pid is live. @@ -36,30 +36,46 @@ A fresh leftover beacon blocks when the lock is missing, dead, or identity-misma `FM_GUARD_GRACE` controls beacon freshness and defaults to 300 seconds. If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot safely read loop-guard fields. +## The second stop: an unsurfaced captain decision + +The supervision predicate above counts `state/*.meta`, so a primary whose only live work is an unanswered captain decision would otherwise reach every allow path with zero in flight and end silently. +The guard therefore carries a second, independent stop, checked only on the paths where the supervision predicate already allows, so the two never stack and the watcher alarm keeps priority. +It fires when a captain decision is open that has not appeared under the correct headline and category with its complete briefing in an actual assistant reply. +The banner's internal rendering is read-only; the turn-end adapter passes the assistant message back to `bin/fm-attention.sh --record-visible`, which is the only captain-receipt writer. +When the Stop payload cannot carry the assistant message at all, no receipt can ever validate on that path, so the decision stop is bounded by `state/.captain-attention-decisions` - the same surfaced-once mechanism as the unknown marker: one block per changed decision set, then the turn may end while the pull surfaces keep the open decision visible. +If the captain-attention set cannot be derived, the allow path stops once with an explicit unknown-state banner and does not record a captain receipt; after a readable derivation, a later failure is treated as a fresh unknown. +Waits never fire it. +`FM_ATTENTION_TURNEND_BLOCK=0` disables it without touching the supervision backstop. +[`captain-attention.md`](captain-attention.md) owns the contract, the set, and the surfacing rules. + +Each passive adapter selects its follow-up headline from the guard's own banner, so a captain decision is never announced as a supervision lapse. + ## Harness integrations - Claude registers two `Stop` hooks in `.claude/settings.json`, both anchored through `CLAUDE_PROJECT_DIR`: `bin/fm-turnend-guard.sh --claude`, and `bin/fm-claude-stop-autoarm.sh` with `asyncRewake: true` and `timeout: 28800`. - Codex registers a `Stop` hook in `.codex/hooks.json`, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard. -- OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2. -- Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. +- OpenCode observes the assistant message stream, listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, then always evaluates the shared attention gate and calls `client.session.promptAsync` when it returns 2. +- Pi records the last assistant reply on `agent_end`, evaluates the shared gate on every `agent_settled`, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` when it returns 2. - Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and delegates capability selection to `bin/fm-turnend-guard-grok.sh`. The tracked Claude Stop entries are inert when `GROK_AGENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. Claude and Codex can block a Stop directly with exit status 2 and stderr. Both payloads carry `stop_hook_active`. -In the default Codex mode, a true value lets the second stop finish after one forced continuation. +In the default Codex mode, a true value still passes through the attention gate. +A payload that carries the assistant reply keeps the strict receipt requirement there; the Claude and Codex Stop payloads carry no assistant reply, so their decision stop is the bounded surfaced-once form above rather than a receipt-cleared block. Claude runs the guard with `--claude`, which ignores `stop_hook_active` and cooperates with the Stop-owned auto-arm. Claude Code sets `stop_hook_active=true` on every stop after any stop-hook continuation, including `asyncRewake` rewakes, which re-opened the 2026-07-21 blind window under the default one-shot behavior. The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 milliseconds) and allows the stop when the watcher is healthy, `state/.claude-autoarm.lock` has a live owner, or `state/.claude-autoarm-epoch` contains a fresh rewake outcome. When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_BUDGET` times (default 3, below Claude's 8-block override), then allows degraded with a visible `systemMessage`. +Every Claude allow path still passes through the captain-attention stop before it exits. Any allow resets the budget. OpenCode, Pi, and pi-signed expose passive callbacks for this purpose. -Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks. +Their adapters fail open at the hook boundary to protect the user session and keep routine watcher recovery bounded, while a captain-attention follow-up is evaluated again until its reply contains the required alert. The generated prompts use the canonical `turn-end-guard` kind after the U+2063 `FIRSTMATE_OP: ` prefix, so Ahoy does not treat them as captain messages. -Each passive adapter owns a loop latch. -Pi keeps the latch across internal tool turns and clears it only when the generated follow-up settles or delivery fails. +Each passive adapter owns a loop latch for routine watcher recovery. +Pi keeps the latch across internal tool turns and clears it when the generated follow-up settles or delivery fails, but still evaluates captain attention on that settlement. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`. Grok makes exactly one typed capability decision from each running Stop payload. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index d5f3e54..ef0d94e 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -175,6 +175,46 @@ FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh FM_GROK_STOP_LIVE_E2E=1 FM_GROK_NATIVE_BIN="$native_grok" FM_GROK_LEGACY_BIN="$pre_native_grok" tests/fm-grok-stop-live-e2e.test.sh ``` +## Captain's call: decisions and waits + +Review date: 2026-07-30. + +The captain-attention contract adds two cross-harness delivery guards: a pull banner from `bin/fm-guard.sh`, which reaches every harness through ordinary tool output, and a push turn-end stop from `bin/fm-turnend-guard.sh`, which reaches each harness through that harness's existing Stop adapter. +Every supported primary harness was reviewed against its own adapter rather than against the adapters active in the current fleet. + +| Primary harness | Stop adapter | Result for the captain's-call stop | +| --- | --- | --- | +| Claude | `.claude/settings.json` runs `bin/fm-turnend-guard.sh --claude` | Exit 2 with stderr blocks and shows the banner. The stop runs only on paths where the watcher predicate already allowed, and it never writes `state/.turnend-claude-blocks`, so the Claude block budget and the Stop-owned auto-arm cooperation are unchanged. | +| Codex | `.codex/hooks.json` pipes the payload into the shared script | Exit 2 blocks with the banner, unchanged mechanism. | +| Grok | `bin/fm-turnend-guard-grok.sh` | A native payload delegates exit 0 or 2 and stderr directly. The pre-native bounded resume now selects its headline from the guard's own banner, so a captain decision is no longer announced as a supervision lapse. | +| OpenCode | `.opencode/plugins/fm-primary-turnend-guard.js`, passive `session.idle` | Watcher coordination runs first, then every idle evaluates the shared attention gate with the observed assistant reply; routine recovery stays bounded. | +| Pi and pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts`, passive `agent_settled` | Every settled reply evaluates the shared gate with evidence captured on `agent_end`; routine recovery stays bounded. | +| Kimi | `bin/fm-kimi-turnend-hook.sh` | Not applicable: Kimi is a verified crew harness only. `bin/fm-supervision-instructions.sh` resolves no Kimi primary block, and the Kimi Stop hook only touches a crew task's turn-end marker. There is no primary integration surface to change. | +| Unknown | `docs/supervision-protocols/unknown.md` | The pull banner still reaches the session through tool output; the push stop exits 2, which an unverified harness may ignore. Pull-side coverage is unaffected. | + +Every runtime backend - tmux, herdr, zellij, orca, cmux, and codex-app - is not applicable, and the integration surface was inspected rather than assumed. +`bin/fm-attention-lib.sh` reads only home-local backlog and state inputs and calls no backend function. +Backlog rows come from `bin/fm-fleet-snapshot.sh --backlog-json`, which returns before `task_json_lines`, the only backend-touching part of that script, so no endpoint or session-provider call happens on this path. +The one derived value that could have varied by backend is a wait's next-check time; it is computed from `state/.last-watcher-beat`, which `bin/fm-watch.sh` touches every poll on every backend, and the cadence constant owned by `bin/fm-classify-lib.sh`. + +The captain-facing behavior and the primary-activity blind spot are covered deterministically: + +```sh +bash tests/fm-attention.test.sh +``` + +The suite covers complete briefing creation, full escape preservation, semantic receipt revisions, combined keyed alerts, read-only marker behavior, actual assistant-delivery receipts, the bounded surfaced-once decision stop on an evidence-less Stop payload, wait timing, resolution, readable linked state, dangling-link unknown states, and the primary-activity blind spot. + +Current entry points: + +```sh +tests/fm-attention.test.sh +tests/fm-turnend-guard.test.sh +tests/fm-guard-stale-banner.test.sh +tests/fm-decision-hold-lifecycle.test.sh +tests/fm-fleet-snapshot-view.test.sh +``` + ## 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-attention.test.sh b/tests/fm-attention.test.sh new file mode 100755 index 0000000..8e3fba3 --- /dev/null +++ b/tests/fm-attention.test.sh @@ -0,0 +1,1297 @@ +#!/usr/bin/env bash +# Regression tests for the captain-attention contract: open decisions and +# declared waits must reach the captain in plain language, only captain-visible +# delivery can record a receipt, and every item must clear when it resolves. +# +# The reproduction these tests are built from is the real task-board / fork-sync +# case: a piece of work parked behind a fork synchronization the captain had to +# approve, re-reported hourly as a routine delay, while the captain-facing +# surfaces showed a truncated fragment of an unrelated note and the supervision +# stack - which counts only spawned-task metadata - reported an idle home. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ATTENTION="$ROOT/bin/fm-attention.sh" +GUARD="$ROOT/bin/fm-guard.sh" +TURNEND="$ROOT/bin/fm-turnend-guard.sh" +HOLD="$ROOT/bin/fm-decision-hold.sh" +SNAPSHOT="$ROOT/bin/fm-fleet-snapshot.sh" +TMP_ROOT=$(fm_test_tmproot fm-attention) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +make_home() { # <name> + local home="$TMP_ROOT/$1" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/bin" + [ ! -f "$ROOT/.tasks.toml" ] || cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight + +## Queued + +## Done +EOF + printf '%s\n' "$home" +} + +# A home shaped like a real primary checkout, so the turn-end guard's scoping +# check treats it as one. The secondmate marker is the supported force-include +# for a home that is not a plain clone of this repo. +make_primary_home() { # <name> + local home + home=$(make_home "$1") + printf 'sm-attention-test\n' > "$home/.fm-secondmate-home" + : > "$home/AGENTS.md" + printf '%s\n' "$home" +} + +attention() { # <home> <args...> + local home=$1 + shift + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$ATTENTION" "$@" +} + +run_guard() { # <home> + local home=$1 + FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" "$GUARD" 2>&1 +} + +run_turnend() { # <home> [<assistant-message>] + local home=$1 message=${2:-} + jq -cn --arg message "$message" \ + '{stop_hook_active:false,session_id:"attention-test",last_assistant_message:$message}' \ + | FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" "$TURNEND" 2>&1 +} + +run_turnend_claude() { # <home> [<stop-hook-active>] [<assistant-message>] + local home=$1 active=${2:-false} message=${3:-} + jq -cn --argjson active "$active" --arg message "$message" \ + '{stop_hook_active:$active,session_id:"attention-test",last_assistant_message:$message}' \ + | CLAUDECODE=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" "$TURNEND" --claude 2>&1 +} + +# A Stop payload from a harness whose hook cannot deliver the assistant reply at +# all: the evidence field is absent, not empty. +run_turnend_no_evidence() { # <home> + local home=$1 + jq -cn '{stop_hook_active:false,session_id:"attention-test"}' \ + | FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" "$TURNEND" 2>&1 +} + +record_visible() { # <home> + local home=$1 message + message=$(attention "$home" --no-mark) + printf '%s' "$message" | attention "$home" --record-visible +} + +add_row() { # <home> <section> <line> + local home=$1 section=$2 line=$3 + FM_TEST_SECTION="## $section" FM_TEST_ROW="$line" awk ' + BEGIN { + section = ENVIRON["FM_TEST_SECTION"] + row = ENVIRON["FM_TEST_ROW"] + } + { print } + $0 == section { print row } + ' "$home/data/backlog.md" > "$home/data/backlog.md.tmp" + mv "$home/data/backlog.md.tmp" "$home/data/backlog.md" +} + +failing_snapshot() { # <home> + local home=$1 + local bin="$home/bin/failing-snapshot" + printf '#!/usr/bin/env bash\nexit 42\n' > "$bin" + chmod +x "$bin" + printf '%s\n' "$bin" +} + +add_unsurfaced_decision() { # <home> + local home=$1 + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the worker installs (repo: sample) (kind: captain) (since 2026-07-28) (hold: the machine needs approval before workers run there) (hold-kind: captain) + Captain briefing v1: + Semantic revision: worker-installs-v1 + Choice: Approve the worker installs now, or leave the machine unavailable. + Why now: The queued worker setup cannot proceed without the approval. + If this waits: The worker setup and its dependent work remain stopped. + Option: Approve the installs now. + Option: Leave the machine unavailable and reroute the work. + Recommended: Approve the installs now so the queued setup can proceed.' +} + +add_unsupervised_work() { # <home> + local home=$1 + fm_write_meta "$home/state/task1.meta" \ + "window=fixture:fm-task1" "project=$home/projects/sample" "kind=ship" +} + +# --- the recorded explanation must survive intact --------------------------- +# +# The captain-facing text was truncated at its first comma before it ever +# reached a renderer, because the backlog reader treated commas as metadata +# separators inside a free-text hold reason. Everything else here is worthless +# while that is true, so it is asserted first and directly on the parser. +test_hold_reason_keeps_its_full_text() { + local home reason parsed + home=$(make_home reason-text) + reason='Confirm trusted, saved-affinity, and discovery soft targets before implementation.' + add_row "$home" Queued \ + "- [ ] sample-decision-slots - Choose the slot mix (repo: sample) (kind: captain) (since 2026-07-28) (hold: $reason) (hold-kind: captain)" + parsed=$(FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" FM_STATE_OVERRIDE="$home/state" \ + "$SNAPSHOT" --backlog-json | jq -r '.records[0].hold_reason') + [ "$parsed" = "$reason" ] || fail "hold reason was truncated: $parsed" + + # The comma-separated metadata group form must still parse, so the fix cannot + # be a blanket relaxation. + home=$(make_home reason-groups) + add_row "$home" Queued \ + '- [ ] sample-grouped - Grouped metadata (repo: sample, kind: captain, priority: 2) (hold: a, b) (hold-kind: captain)' + parsed=$(FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" FM_STATE_OVERRIDE="$home/state" \ + "$SNAPSHOT" --backlog-json | jq -r '.records[0] | "\(.repo)|\(.kind)|\(.priority)|\(.hold_reason)"') + [ "$parsed" = 'sample|captain|2|a, b' ] || fail "comma-separated metadata group regressed: $parsed" + pass "a recorded captain explanation keeps its full text, and grouped metadata still parses" +} + +# --- creation: the fork-sync decision, rendered concretely ------------------- +test_briefed_decision_renders_concretely() { + local home out + command -v tasks-axi >/dev/null 2>&1 || { pass "skip-ish: tasks-axi absent, briefed-decision creation not exercised"; return 0; } + home=$(make_home fork-sync) + fm_write_meta "$home/state/fork-sync.meta" \ + "window=fixture:fm-fork-sync" "project=$home/projects/firstmate" "kind=ship" + printf 'working: auditing the fork topology\n' > "$home/state/fork-sync.status" + + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync \ + --title 'Sync the fork main branch with the author upstream' \ + --reason 'The task board cannot be rebased or re-validated until the fork main matches upstream' \ + --repo firstmate \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Sync the fork main from the author upstream now, or keep it frozen and rebase the task board onto the current fork main.' \ + --why-now 'The task board is the last thing before the live view ships and cannot be validated against a stale fork main.' \ + --cost-of-waiting 'The task board stays parked and nothing else will move it.' \ + --option 'Sync the fork main from upstream now, then rebase and re-validate.' \ + --option 'Leave the fork frozen and rebase the board onto the current fork main.' \ + --recommend 'Sync the fork main from upstream now; it is the smaller change and unblocks everything downstream.' \ + >/dev/null || fail "could not register the briefed captain decision" + + out=$(attention "$home" --no-mark) + assert_contains "$out" 'Sync the fork main branch with the author upstream' "the decision headline is missing" + assert_contains "$out" 'The choice:' "the concrete choice is missing" + assert_contains "$out" 'Why it matters now:' "the reason it matters now is missing" + assert_contains "$out" 'If this waits:' "the consequence of waiting is missing" + assert_contains "$out" 'Options:' "the options are missing" + assert_contains "$out" 'Recommended:' "the recommendation is missing" + assert_contains "$out" 'Sync the fork main from upstream now, then rebase and re-validate.' "an option body is missing" + assert_not_contains "$out" 'fork-sync-decision-fork-main-sync' "the captain view must not carry internal identifiers" + pass "a briefed captain decision renders the choice, the stakes, the options, and a recommendation" +} + +test_new_decision_requires_a_complete_briefing() { + local home out status + command -v tasks-axi >/dev/null 2>&1 || { pass "skip-ish: tasks-axi absent, complete-briefing validation not exercised"; return 0; } + home=$(make_home complete-required) + fm_write_meta "$home/state/fork-sync.meta" \ + "window=fixture:fm-fork-sync" "project=$home/projects/firstmate" "kind=ship" + printf 'working: auditing\n' > "$home/state/fork-sync.status" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync \ + --title 'Sync the fork main' --reason 'the board is waiting' --repo firstmate \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Sync now or wait.' --recommend 'Sync now.' 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "an incomplete initial briefing was accepted" + assert_contains "$out" 'why-now must not be empty' "the missing briefing fact was not named" + assert_not_contains "$(attention "$home" --no-mark --status)" 'decisions=1' \ + "an incomplete initial briefing created a live decision" + + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync \ + --title 'Sync the fork main' --reason 'the board is waiting' --repo firstmate \ + --choice 'Sync now or wait.' \ + --why-now 'The board is ready to move.' \ + --cost-of-waiting 'The board remains stopped.' \ + --option 'Sync now.' \ + --recommend 'Sync now.' 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "a complete briefing without a semantic revision was accepted" + assert_contains "$out" 'semantic-revision must be a non-empty privacy-safe slug' \ + "the missing semantic revision was not named" + pass "a new captain decision requires every briefing fact" +} + +# The documented way to gate ordinary work on the captain is +# "tasks-axi hold <id> --reason ... --kind captain", which leaves the item own +# kind as ship. Selecting on the snapshot captain_actionable flag therefore +# missed exactly the threads this contract exists for and rendered the real +# fork-sync case as a routine delay with no captain action attached. +test_a_captain_gated_work_item_is_a_decision_not_a_delay() { + local home out + home=$(make_home captain-gated) + add_row "$home" Queued \ + '- [ ] sample-board - Ship the live task board (repo: sample) (kind: ship) (hold: The fork main must be synced with upstream before this can be rebased) (hold-kind: captain)' + out=$(attention "$home" --no-mark) + assert_contains "$out" '1 decision needs you' \ + "a captain-held ship item must count as a decision" + assert_contains "$out" 'NEEDS YOUR DECISION' "the decision section is missing" + assert_not_contains "$out" 'WAITING ON SOMETHING ELSE' \ + "a captain-held item must not be laundered into a routine delay" + + # A captain hold whose blocker is still open is not answerable yet, so it + # stays a wait; otherwise every future-gated hold would nag the captain now. + home=$(make_home captain-gated-blocked) + add_row "$home" Queued \ + '- [ ] sample-board - Ship the live task board blocked-by: sample-other (repo: sample) (kind: ship) (hold: The fork main must be synced first) (hold-kind: captain)' + out=$(attention "$home" --no-mark) + assert_contains "$out" '0 decisions need you' \ + "a captain hold with an unresolved blocker is not yet answerable" + assert_contains "$out" 'WAITING ON SOMETHING ELSE' "the blocked hold must still be listed as a wait" + pass "a captain-gated work item is a decision, while a still-blocked one waits" +} + +test_unbriefed_decision_is_honest_about_missing_language() { + local home out + home=$(make_home unbriefed) + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the change (repo: sample) (kind: captain) (since 2026-07-28) (hold: an operational note that is not plain language) (hold-kind: captain)' + out=$(attention "$home" --no-mark) + assert_contains "$out" 'No plain-language explanation has been written for this one yet.' \ + "an unbriefed decision must say so rather than dressing up its raw note" + assert_contains "$out" 'an operational note that is not plain language' \ + "an unbriefed decision must still show what was actually recorded" + pass "a decision with no captain briefing renders honestly instead of faking plain language" +} + +test_partial_briefing_renders_recorded_fields_and_names_missing_ones() { + local home out json + home=$(make_home partial-briefing) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight + +## Queued +- [ ] partial-decision - Pick the release path (repo: sample) (kind: captain) (since 2026-07-28) (hold: release path needs captain input) (hold-kind: captain) + Captain briefing v1: + Recommended: Use the staged rollout. + +## Done +EOF + out=$(attention "$home" --no-mark) + assert_contains "$out" 'Recommended:' "a partial briefing must render the field that was written" + assert_contains "$out" 'Use the staged rollout.' "a partial briefing must render the recorded recommendation" + assert_contains "$out" 'Still needs:' "a partial briefing must name missing fields" + assert_contains "$out" 'The choice' "a partial briefing must name the missing choice" + assert_contains "$out" 'Why it matters now' "a partial briefing must name the missing timing/stakes field" + assert_contains "$out" 'If this waits' "a partial briefing must name the missing waiting-cost field" + assert_not_contains "$out" 'No plain-language explanation has been written for this one yet.' \ + "a briefing with recognized fields must not fall back to the no-briefing branch" + json=$(attention "$home" --no-mark --json | jq -r '.[0] | "\(.briefed)|\(.recommendation)|\(.briefing_missing | join(","))"') + assert_contains "$json" 'true|Use the staged rollout.|' "the JSON contract must expose the partial briefing" + + home=$(make_home header-only-briefing) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight + +## Queued +- [ ] header-only - Pick the launch window (repo: sample) (kind: captain) (since 2026-07-28) (hold: launch window needs captain input) (hold-kind: captain) + Captain briefing v1: + +## Done +EOF + out=$(attention "$home" --no-mark) + assert_contains "$out" 'No plain-language explanation has been written for this one yet.' \ + "a briefing header with no recognized fields must still use the no-briefing branch" + assert_not_contains "$out" 'Still needs:' "the no-briefing branch must not claim a partial briefing exists" + pass "a partial captain briefing renders written fields and names missing ones" +} + +test_failed_backlog_projection_is_unknown_not_empty() { + local home snapshot out status + home=$(make_primary_home snapshot-unknown) + snapshot=$(failing_snapshot "$home") + + out=$(FM_ATTENTION_SNAPSHOT_BIN="$snapshot" attention "$home" 2>&1); status=$? + expect_code 3 "$status" "a failed backlog projection must make attention unavailable" + assert_contains "$out" 'could not determine whether anything needs your decision or is waiting' \ + "unknown attention must say the open set could not be determined" + assert_not_contains "$out" 'Nothing needs your decision, and nothing is waiting.' \ + "unknown attention must not render as an all-clear" + assert_absent "$home/state/.captain-attention" \ + "unknown attention must not record the surfaced marker" + + out=$(FM_ATTENTION_SNAPSHOT_BIN="$snapshot" attention "$home" --status 2>&1); status=$? + expect_code 3 "$status" "unknown status must keep attention unavailable" + assert_contains "$out" 'attention=unknown' "unknown status must be explicit" + assert_absent "$home/state/.captain-attention" \ + "unknown status must not record the surfaced marker" + + out=$(FM_ATTENTION_SNAPSHOT_BIN="$snapshot" run_turnend "$home"); status=$? + expect_code 2 "$status" "a turn end must stop once when the attention set is unknown" + assert_contains "$out" 'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS' \ + "unknown turn-end stop must be explicit" + assert_absent "$home/state/.captain-attention" \ + "unknown turn-end stop must not record the surfaced marker" + + out=$(FM_ATTENTION_SNAPSHOT_BIN="$snapshot" run_turnend "$home"); status=$? + expect_code 0 "$status" "a persistent unknown must be bounded to one forced continuation" + [ -z "$out" ] || fail "the bounded unknown turn end produced output: $out" + pass "a failed backlog projection is unknown, not an empty captain call" +} + +test_failed_state_collection_is_unknown_not_empty() { + local home fake_bin metadata_bin enumeration_bin out status + home=$(make_home status-unknown) + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + printf 'paused: waiting for a readable source\n' > "$home/state/task-board.status" + fake_bin="$home/bin/failing-cat" + mkdir -p "$fake_bin" + # shellcheck disable=SC2016 # Literal program text for the generated fake cat; $1/$@ must not expand here. + printf '#!/usr/bin/env bash\ncase "$1" in *.status) exit 42 ;; esac\nexec /bin/cat "$@"\n' > "$fake_bin/cat" + chmod +x "$fake_bin/cat" + + PATH="$fake_bin:$PATH" bash -c '. "$1"; fm_attention_status_rows "$2"' \ + _ "$ROOT/bin/fm-attention-lib.sh" "$home/state" >/dev/null 2>&1 + status=$? + [ "$status" -ne 0 ] || fail "an unreadable status stream was accepted as an empty collection" + + metadata_bin="$home/bin/failing-metadata-cat" + mkdir -p "$metadata_bin" + # shellcheck disable=SC2016 # Literal program text for the generated fake cat; $1/$@ must not expand here. + printf '#!/usr/bin/env bash\ncase "$1" in *.meta) exit 42 ;; esac\nexec /bin/cat "$@"\n' > "$metadata_bin/cat" + chmod +x "$metadata_bin/cat" + PATH="$metadata_bin:$PATH" bash -c '. "$1"; fm_attention_status_rows "$2"' \ + _ "$ROOT/bin/fm-attention-lib.sh" "$home/state" >/dev/null 2>&1 + status=$? + [ "$status" -ne 0 ] || fail "an unreadable metadata record was accepted with the default task kind" + + enumeration_bin="$home/bin/failing-state-enumeration" + mkdir -p "$enumeration_bin" + printf '#!/usr/bin/env bash\nexit 42\n' > "$enumeration_bin/find" + chmod +x "$enumeration_bin/find" + PATH="$enumeration_bin:$PATH" bash -c '. "$1"; fm_attention_status_rows "$2"' \ + _ "$ROOT/bin/fm-attention-lib.sh" "$home/state" >/dev/null 2>&1 + status=$? + [ "$status" -ne 0 ] || fail "a failed state enumeration was accepted as an empty collection" + + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" + bash -c '. "$1"; fm_attention_status_rows "$2"' \ + _ "$ROOT/bin/fm-attention-lib.sh" "$home/state" >/dev/null 2>&1 \ + || fail "readable metadata without a task kind did not use the default" + printf 'kind=\n' >> "$home/state/task-board.meta" + bash -c '. "$1"; fm_attention_status_rows "$2"' \ + _ "$ROOT/bin/fm-attention-lib.sh" "$home/state" >/dev/null 2>&1 + status=$? + [ "$status" -ne 0 ] || fail "an explicitly empty task kind was accepted as an omitted kind" + + out=$(FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" bash -c ' + . "$1" + fm_attention_status_rows() { return 42; } + fm_attention_status "$2" + printf "available=%s unknown=%s json=%s\n" "$FM_ATT_AVAILABLE" "$FM_ATT_UNKNOWN" "$FM_ATT_JSON" + ' _ "$ROOT/bin/fm-attention-lib.sh" "$home") + assert_contains "$out" 'available=false' "a state collection failure left attention available" + assert_contains "$out" 'unknown=true' "a state collection failure did not report unknown" + assert_contains "$out" 'json={"unknown":true}' "a state collection failure rendered a false empty set" + pass "state, metadata, and status collection failures report unknown" +} + +test_linked_state_inputs_follow_targets_and_dangling_links_are_unknown() { + local home json out status + home=$(make_home linked-state) + mv "$home/state" "$home/state-target" + ln -s state-target "$home/state" + fm_write_meta "$home/state-target/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + printf 'needs-decision [key=route]: choose the deployment route\n' \ + > "$home/state-target/task-board.status-source" + ln -s task-board.status-source "$home/state-target/task-board.status" + + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq 'length')" -eq 1 ] \ + || fail "a readable linked state directory did not expose its decision" + printf '%s' "$json" | jq -e '.[0].identity == "decision:task-board:route:1"' >/dev/null \ + || fail "a readable linked status stream did not expose its decision" + + home=$(make_home dangling-state) + rmdir "$home/state" + ln -s missing-state "$home/state" + out=$(attention "$home" --status 2>&1) + status=$? + expect_code 3 "$status" "a dangling state directory must report unknown" + assert_contains "$out" 'attention=unknown' "a dangling state directory rendered a false empty set" + + home=$(make_home dangling-status) + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + ln -s missing-status "$home/state/task-board.status" + out=$(attention "$home" --status 2>&1) + status=$? + expect_code 3 "$status" "a dangling status stream must report unknown" + assert_contains "$out" 'attention=unknown' "a dangling status stream rendered a false empty set" + pass "linked state inputs follow targets and dangling links report unknown" +} + +test_unknown_projection_surfaces_again_after_successful_derivation() { + local home snapshot out status + home=$(make_primary_home snapshot-unknown-reopens) + snapshot=$(failing_snapshot "$home") + + out=$(FM_ATTENTION_SNAPSHOT_BIN="$snapshot" run_turnend "$home"); status=$? + expect_code 2 "$status" "the first unknown projection must stop the turn" + assert_contains "$out" 'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS' \ + "the first unknown projection must surface explicitly" + assert_present "$home/state/.captain-attention-unknown" \ + "the unknown stop must record its bounded marker" + + attention "$home" --status >/dev/null + attention "$home" --json >/dev/null + attention "$home" --no-mark >/dev/null + assert_present "$home/state/.captain-attention-unknown" \ + "read-only attention calls must not reset an unknown marker" + + out=$(run_turnend "$home"); status=$? + expect_code 0 "$status" "a readable projection with nothing open must allow" + [ -z "$out" ] || fail "the readable turn end produced output: $out" + assert_absent "$home/state/.captain-attention-unknown" \ + "a successful derivation must reset the bounded unknown marker" + + out=$(FM_ATTENTION_SNAPSHOT_BIN="$snapshot" run_turnend "$home"); status=$? + expect_code 2 "$status" "a fresh unknown after recovery must stop again" + assert_contains "$out" 'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS' \ + "a fresh unknown after recovery must surface explicitly" + pass "an unknown projection surfaces again after a readable projection" +} + +# --- waits: what is awaited, and when it is next checked --------------------- +test_routine_wait_states_what_it_awaits_and_when_it_is_next_checked() { + local home out + home=$(make_home routine-wait) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + { + printf 'working: rebasing the board\n' + printf 'paused: the fork synchronization has not landed yet, so the board cannot be rebased\n' + } > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + out=$(attention "$home" --no-mark) + assert_contains "$out" 'WAITING ON SOMETHING ELSE' "the waiting section is missing" + assert_contains "$out" 'Live watchable view of the task and priority list' \ + "the wait must name the piece of work, not repeat its note as a heading" + assert_contains "$out" 'Waiting for:' "the wait must say what is being awaited" + assert_contains "$out" 'the fork synchronization has not landed yet' "the awaited thing is missing" + assert_contains "$out" 'Next check: in ' "the wait must say when it is next checked" + + # With no live monitoring there is no honest next-check time, and saying so is + # the point: a delay nothing is watching is worse than one that is. + rm -f "$home/state/.last-watcher-beat" + out=$(attention "$home" --no-mark) + assert_contains "$out" 'nothing is checking this right now' \ + "an unmonitored wait must say that nothing is checking it" + pass "a routine delay states what it awaits and when it is next checked" +} + +test_overdue_monitored_wait_is_due_now() { + local home next + home=$(make_home overdue-wait) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + printf 'paused: waiting for the fork synchronization\n' > "$home/state/task-board.status" + touch -t 202001010000 "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + next=$(FM_PAUSE_RESURFACE_SECS=1 attention "$home" --json | jq -r '.[0].next_check_seconds') + [ "$next" = 0 ] || fail "a monitored overdue wait reported next_check_seconds=$next" + assert_contains "$(FM_PAUSE_RESURFACE_SECS=1 attention "$home")" 'Next check: due now' \ + "a monitored overdue wait must not be reported as unmonitored" + pass "a monitored overdue wait reports its next check as due now" +} + +test_repeated_wait_stays_routine_until_captain_action_is_explicit() { + local home out + home=$(make_home repeated-wait) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + printf 'working: rebasing the board\n' > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + printf 'paused: first recheck unchanged; the fork sync still has not landed\n' >> "$home/state/task-board.status" + printf 'paused: second recheck unchanged; the fork sync still has not landed\n' >> "$home/state/task-board.status" + out=$(attention "$home" --no-mark --status) + assert_contains "$out" 'decisions=0' "two declarations must still be a routine delay" + assert_contains "$out" 'waits=1' "two declarations must still be a routine delay" + + printf 'paused: third recheck unchanged; the fork sync still has not landed\n' >> "$home/state/task-board.status" + out=$(attention "$home" --no-mark --status) + assert_contains "$out" 'decisions=0' "repetition alone must not turn a routine delay into a decision" + assert_contains "$out" 'waits=1' "a repeated external delay must remain a wait" + + printf 'blocked: firstmate must repair the failed synchronization\n' >> "$home/state/task-board.status" + out=$(attention "$home" --no-mark --status) + assert_contains "$out" 'decisions=1' "an action-required blocker must remain a critical decision" + assert_contains "$out" 'waits=0' "the same keyed wait must fold into the action-required blocker" + + printf 'needs-decision: choose whether to keep waiting or reroute the work\n' >> "$home/state/task-board.status" + out=$(attention "$home" --no-mark --status) + assert_contains "$out" 'decisions=1' "an explicit captain-action transition must create a decision" + assert_contains "$out" 'waits=0' "the same keyed wait must fold into the explicit decision" + pass "routine delays stay routine until an explicit action-required transition" +} + +test_same_key_decision_and_wait_render_once_as_combined() { + local home out json + home=$(make_home combined-key) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + { + printf 'needs-decision [key=release]: choose whether to keep the release parked\n' + printf 'paused [key=release]: waiting for legal review\n' + printf 'paused [key=release]: legal review is still pending\n' + printf 'paused [key=release]: waiting for the final legal answer\n' + } > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq 'length')" -eq 1 ] || fail "one keyed issue rendered more than once: $json" + [ "$(printf '%s' "$json" | jq -r '.[0] | "\(.class)|\(.identity)|\(.combined_wait)|\(.redeclares)"')" \ + = 'decision|decision:task-board:release:1|true|3' ] \ + || fail "the keyed decision and wait were not combined: $json" + out=$(attention "$home") + assert_contains "$out" 'choose whether to keep the release parked' "the decision fact disappeared" + assert_contains "$out" 'Waiting for:' "the combined alert omitted its wait" + assert_contains "$out" 'waiting for the final legal answer' "the combined alert omitted the current wait fact" + assert_not_contains "$out" 'WAITING ON SOMETHING ELSE' \ + "the same keyed issue was rendered under a second category" + + printf 'done [key=release]: the worker finished\n' >> "$home/state/task-board.status" + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq -r '.[0] | [.class, (.combined_wait // false), (.awaiting // "")] | join("|")')" \ + = 'decision|false|' ] \ + || fail "terminal work left a stale wait attached to the unresolved decision: $json" + pass "one keyed issue combines once and terminal work closes only its wait portion" +} + +test_direct_captain_hold_and_same_task_status_render_once() { + local home json + home=$(make_home direct-hold-dedup) + add_row "$home" 'In flight' \ + '- [ ] task-board - Choose the task board release path (repo: sample) (kind: ship) (hold: the release path needs captain input) (hold-kind: captain) + Captain briefing v1: + Semantic revision: task-board-release-v1 + Choice: Release the board now, or keep it parked. + Why now: The board is otherwise ready to ship. + If this waits: The board release remains parked. + Option: Release the board now. + Option: Keep the board parked. + Recommended: Release the board now.' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + { + printf 'needs-decision [key=task-board]: choose whether to release the board\n' + printf 'paused [key=task-board]: waiting for final release confirmation\n' + } > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq 'length')" -eq 1 ] \ + || fail "a direct captain hold duplicated its same-task status alert: $json" + [ "$(printf '%s' "$json" | jq -r '.[0] | "\(.identity)|\(.combined_wait)|\(.awaiting)"')" \ + = 'decision:task-board|true|waiting for final release confirmation' ] \ + || fail "the direct captain hold did not retain its same-task status wait: $json" + pass "a direct captain hold and its same-task status render once" +} + +test_ambiguous_direct_hold_keeps_every_keyed_decision_visible() { + local home json identities + home=$(make_home direct-hold-ambiguous) + add_row "$home" 'In flight' \ + '- [ ] task-board - Choose the task board direction (repo: sample) (kind: ship) (hold: the board needs captain input) (hold-kind: captain) + Captain briefing v1: + Semantic revision: task-board-direction-v1 + Choice: Choose the board direction. + Why now: The board is ready for direction. + If this waits: The board remains parked. + Option: Choose a direction now. + Recommended: Choose a direction now.' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + { + printf 'needs-decision [key=route]: choose the board route\n' + printf 'needs-decision [key=access]: choose the board access policy\n' + } > "$home/state/task-board.status" + + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq 'length')" -eq 3 ] \ + || fail "an ambiguous direct hold consumed a distinct keyed decision: $json" + identities=$(printf '%s' "$json" | jq -r '.[].identity') + assert_contains "$identities" 'decision:task-board' "the direct captain item disappeared" + assert_contains "$identities" 'decision:task-board:route:1' "the route decision disappeared" + assert_contains "$identities" 'decision:task-board:access:1' "the access decision disappeared" + pass "an ambiguous direct hold leaves every keyed decision visible" +} + +test_combined_alert_is_receiptable_through_transfer_and_clears_on_terminal_work() { + local home out json status first_wait second_wait + command -v tasks-axi >/dev/null 2>&1 || { pass "skip-ish: tasks-axi absent, combined transfer not exercised"; return 0; } + home=$(make_home combined-transfer) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=secondmate" + { + printf 'needs-decision [key=release]: choose whether to ship after legal review\n' + printf 'paused [key=release]: waiting for the final legal answer\n' + } > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold task-board release \ + --title 'Choose the release path' \ + --reason 'legal review is pending' \ + --repo sample \ + --semantic-revision release-v1 \ + --choice 'Ship after approval, or keep the release parked.' \ + --why-now 'The release is otherwise ready to move.' \ + --cost-of-waiting 'The release remains parked.' \ + --option 'Ship after legal approval.' \ + --option 'Keep the release parked.' \ + --recommend 'Ship after legal approval.' >/dev/null \ + || fail "could not register the combined captain decision" + + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq 'length')" -eq 1 ] || fail "the durable decision duplicated its live status copy: $json" + [ "$(printf '%s' "$json" | jq -r '.[0] | "\(.identity)|\(.briefing_complete)|\(.combined_wait)|\(.awaiting)"')" \ + = 'decision:task-board-decision-release|true|true|waiting for the final legal answer' ] \ + || fail "the durable decision did not retain the combined wait: $json" + out=$(attention "$home") + printf '%s' "$out" | attention "$home" --record-visible >/dev/null + status=$? + expect_code 0 "$status" "a complete combined alert must earn a captain-visible receipt" + + first_wait=$(printf '%s' "$json" | jq -r '.[0].combined_wait_identity') + { + printf 'working [key=release]: legal review resumed\n' + printf 'paused [key=release]: waiting for renewed legal confirmation\n' + } >> "$home/state/task-board.status" + json=$(attention "$home" --json) + second_wait=$(printf '%s' "$json" | jq -r '.[0].combined_wait_identity') + [ "$first_wait" != "$second_wait" ] \ + || fail "a reopened combined wait reused its previous receipt identity: $first_wait" + assert_contains "$second_wait" 'wait:task-board:release:2' \ + "a reopened combined wait must carry a new generation" + assert_contains "$(attention "$home" --status)" 'new=true' \ + "a reopened combined wait must surface after the earlier generation was receipted" + record_visible "$home" + + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" complete task-board release >/dev/null \ + || fail "could not transfer the combined decision" + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq -r '.[0] | "\(.briefing_complete)|\(.combined_wait)|\(.awaiting)"')" \ + = 'true|true|waiting for renewed legal confirmation' ] \ + || fail "the captain-held transfer lost the combined wait: $json" + + printf 'done [key=release]: the reviewed work finished\n' >> "$home/state/task-board.status" + json=$(attention "$home" --json) + [ "$(printf '%s' "$json" | jq -r '.[0] | [.briefing_complete, (.combined_wait // false), (.awaiting // "")] | join("|")')" \ + = 'true|false|' ] \ + || fail "terminal work left stale combined-wait text: $json" + assert_not_contains "$(attention "$home")" 'waiting for renewed legal confirmation' \ + "terminal work must remove the stale combined wait from the captain view" + pass "a combined alert remains receiptable through transfer and terminal work clears its wait" +} + +# --- deduplication ---------------------------------------------------------- +test_identities_ignore_wording_so_repeats_do_not_re_alarm() { + local home first second + home=$(make_home dedup) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + printf 'paused: first recheck unchanged\n' > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + first=$(attention "$home" --no-mark --json | jq -r '.[].identity') + printf 'paused: second recheck unchanged, with entirely different wording\n' >> "$home/state/task-board.status" + second=$(attention "$home" --no-mark --json | jq -r '.[].identity') + [ "$first" = "$second" ] || fail "re-wording a delay changed its identity: $first vs $second" + + # The whole point of a stable identity: the guard interrupts once, not once per + # re-declaration. + assert_contains "$(run_guard "$home")" "CAPTAIN'S CALL CHANGED" "the first changed set must surface" + record_visible "$home" + printf 'paused: third recheck unchanged, worded differently again\n' >> "$home/state/task-board.status" + assert_not_contains "$(run_guard "$home")" "CAPTAIN'S CALL" "a re-worded repeat must not surface again" + pass "attention identities ignore wording, so a repeated delay surfaces once" +} + +test_reopened_status_items_get_new_generation_identity() { + local home first second third out + home=$(make_home wait-generation) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + printf 'paused: first external wait\n' > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + first=$(attention "$home" --no-mark --json | jq -r '.[0].identity') + record_visible "$home" + printf 'working: unblocked for implementation\npaused: second external wait\n' >> "$home/state/task-board.status" + second=$(attention "$home" --no-mark --json | jq -r '.[0].identity') + [ "$first" != "$second" ] || fail "a reopened wait reused its previous identity: $first" + assert_contains "$second" 'wait:task-board:default:2' "a reopened wait must carry a new generation" + assert_contains "$(attention "$home" --no-mark --status)" 'new=true' \ + "a reopened wait must re-surface even if the earlier generation was surfaced" + record_visible "$home" + printf 'paused: same wait with new wording\n' >> "$home/state/task-board.status" + third=$(attention "$home" --no-mark --json | jq -r '.[0].identity') + [ "$second" = "$third" ] || fail "a still-open wait changed identity on rewording: $second vs $third" + out=$(attention "$home" --no-mark --status) + assert_contains "$out" 'new=false' "a still-open wait must stay deduped after rewording" + + home=$(make_home decision-generation) + fm_write_meta "$home/state/api-shape.meta" \ + "window=fixture:fm-api-shape" "project=$home/projects/sample" "kind=ship" + printf 'needs-decision: choose the API shape\n' > "$home/state/api-shape.status" + first=$(attention "$home" --no-mark --json | jq -r '.[0].identity') + printf 'resolved: the first shape was chosen\nneeds-decision: choose the migration shape\n' >> "$home/state/api-shape.status" + second=$(attention "$home" --no-mark --json | jq -r '.[0].identity') + [ "$first" != "$second" ] || fail "a reopened status decision reused its previous identity: $first" + assert_contains "$second" 'decision:api-shape:default:2' \ + "a reopened status decision must carry a new generation" + pass "reopened status decisions and waits carry a new generation identity" +} + +test_brief_form_never_spends_captain_surface_marker() { + local home out status + home=$(make_primary_home brief-no-mark) + add_unsurfaced_decision "$home" + attention "$home" --brief >/dev/null + assert_absent "$home/state/.captain-attention" \ + "the firstmate-facing brief form must not record the surfaced digest" + + out=$(run_turnend "$home"); status=$? + expect_code 2 "$status" "brief output must not satisfy the captain-facing turn-end stop" + assert_contains "$out" 'NEEDS YOUR DECISION' "the turn-end stop must render the captain-safe view" + out=$(run_turnend "$home"); status=$? + expect_code 2 "$status" "an internal turn-end render must not record a captain receipt" + out=$(run_turnend "$home" "$(attention "$home" --no-mark)"); status=$? + expect_code 0 "$status" "the actual captain-visible reply must record the surfaced digest" + [ -z "$out" ] || fail "the captain-visible turn end produced output: $out" + pass "the brief form never spends the captain-facing surface marker" +} + +test_guard_banner_never_spends_captain_receipt() { + local home out status + home=$(make_home guard-captain-safe) + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the change (repo: sample) (kind: captain) (since 2026-07-28) (hold: an operational note that is not plain language) (hold-kind: captain)' + out=$(run_guard "$home") + assert_contains "$out" 'NEEDS YOUR DECISION' "the guard banner must include the captain-safe decision section" + assert_not_contains "$out" 'sample-decision-x' "the guard banner must not use the identifier-only brief form" + status=$(attention "$home" --no-mark --status) + assert_contains "$status" 'new=true' "an internal guard render must leave the captain receipt open" + pass "the guard banner renders the captain-safe view without spending its receipt" +} + +test_only_the_captain_renderer_writes_the_surface_marker() { + local writers + writers=$( + while IFS= read -r file; do + grep -n -- '\.captain-attention' "$file" \ + | grep -v -- '\.captain-attention-unknown' \ + | grep -v -- '\.captain-attention-decisions' \ + | grep -E -- '(^|[[:space:]])>[[:space:]]*"?[^"]*\.captain-attention("|$)|(^|[[:space:]])(tee|mv|cp|touch|rm)[[:space:]].*\.captain-attention' >/dev/null \ + && printf '%s\n' "$file" + done <<EOF +$(grep -RIl -- '\.captain-attention' "$ROOT/bin") +EOF + ) + writers=$(printf '%s\n' "$writers" | sort -u | sed '/^$/d') + [ "$writers" = "$ROOT/bin/fm-attention.sh" ] || fail "surface marker writers escaped the captain renderer: $writers" + if grep -R -- 'fm_attention_mark_surfaced' "$ROOT/bin" >/dev/null 2>&1; then + fail "general-purpose surface marker writer was reintroduced" + fi + pass "only the captain-safe renderer writes the surfaced marker" +} + +test_ordinary_reads_do_not_become_false_alarms() { + local home out + home=$(make_home quiet-reads) + add_unsurfaced_decision "$home" + assert_contains "$(run_guard "$home")" "CAPTAIN'S CALL CHANGED" "the first changed set must surface" + # Reading the set, listing it, and running the guard again are all ordinary + # conductor reads and must be silent. + attention "$home" --no-mark --json >/dev/null + attention "$home" --no-mark --status >/dev/null + out=$(run_guard "$home") + assert_contains "$out" "CAPTAIN'S CALL CHANGED" \ + "an ordinary read must not spend the captain-visible receipt" + record_visible "$home" + out=$(run_guard "$home") + [ -z "$out" ] || fail "rendering the ledger produced a false alarm: $out" + pass "ordinary reads and re-renders never become false alarms" +} + +test_ledger_keeps_showing_an_open_item_after_it_was_surfaced() { + local home out + home=$(make_home persistent) + add_unsurfaced_decision "$home" + record_visible "$home" + out=$(attention "$home" --no-mark) + assert_contains "$out" 'Approve the worker installs' \ + "an open decision must stay listed after it was surfaced; the marker bounds the interrupt, not the ledger" + pass "an open item stays visible until it resolves, even once its interrupt is spent" +} + +# --- resolution ------------------------------------------------------------- +test_resolution_clears_the_item() { + local home out + command -v tasks-axi >/dev/null 2>&1 || { pass "skip-ish: tasks-axi absent, resolution path not exercised"; return 0; } + home=$(make_home resolution) + fm_write_meta "$home/state/fork-sync.meta" \ + "window=fixture:fm-fork-sync" "project=$home/projects/firstmate" "kind=ship" + printf 'working: auditing\n' > "$home/state/fork-sync.status" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync \ + --title 'Sync the fork main branch with the author upstream' \ + --reason 'the board cannot be rebased until the fork main matches upstream' \ + --repo firstmate \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --why-now 'The board is ready to rebase now.' \ + --cost-of-waiting 'The board remains blocked.' \ + --option 'Sync now.' \ + --recommend 'Sync now.' >/dev/null || fail "could not register the decision" + assert_contains "$(attention "$home" --no-mark --status)" 'decisions=1' "the decision should be open" + + ( cd "$home" && tasks-axi add board-rebase 'Rebase and revalidate the task board' --kind ship --repo firstmate >/dev/null \ + && tasks-axi block board-rebase --by fork-sync-decision-fork-main-sync >/dev/null ) \ + || fail "could not create the dependent work" + printf 'Sync the fork main from upstream now.\n' > "$home/decision.txt" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" resolve fork-sync fork-main-sync --decision-file "$home/decision.txt" --routed-to board-rebase >/dev/null \ + || fail "could not resolve the decision" + + assert_contains "$(attention "$home" --no-mark --status)" 'decisions=0' "a resolved decision must clear" + out=$(attention "$home" --no-mark) + assert_not_contains "$out" 'Sync the fork main branch with the author upstream' "a resolved decision must leave the ledger" + pass "a resolved decision clears from the captain view" +} + +test_retry_never_erases_a_written_briefing() { + local home out + command -v tasks-axi >/dev/null 2>&1 || { pass "skip-ish: tasks-axi absent, retry path not exercised"; return 0; } + home=$(make_home retry) + fm_write_meta "$home/state/fork-sync.meta" \ + "window=fixture:fm-fork-sync" "project=$home/projects/firstmate" "kind=ship" + printf 'working: auditing\n' > "$home/state/fork-sync.status" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'a reason' --repo firstmate \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --why-now 'The board is ready to rebase now.' \ + --cost-of-waiting 'The board remains blocked.' \ + --option 'Sync now.' \ + --recommend 'Sync now.' >/dev/null || fail "could not register the decision" + # A plain idempotent retry carries no briefing flags and must leave the written + # briefing alone. + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'a reason' >/dev/null \ + || fail "idempotent retry failed" + out=$(attention "$home" --no-mark --json | jq -r '.[0] | "\(.briefed)|\(.recommendation)"') + [ "$out" = 'true|Sync now.' ] || fail "an idempotent retry damaged the written briefing: $out" + + # Supplying briefing flags updates it. + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'a reason' \ + --semantic-revision fork-main-sync-v2 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --why-now 'The board is ready to rebase now.' \ + --cost-of-waiting 'The board remains blocked.' \ + --option 'Rebase onto the current fork main instead.' \ + --recommend 'Rebase onto the current fork main instead.' >/dev/null || fail "briefing update failed" + out=$(attention "$home" --no-mark --json | jq -r '.[0].recommendation') + [ "$out" = 'Rebase onto the current fork main instead.' ] || fail "briefing update did not take: $out" + pass "an idempotent retry preserves a written briefing and an explicit update replaces it" +} + +test_briefing_revisions_preserve_body_and_reopen_receipt_semantically() { + local home body_file show_file out + command -v tasks-axi >/dev/null 2>&1 || { pass "skip-ish: tasks-axi absent, briefing revision not exercised"; return 0; } + home=$(make_home revision) + fm_write_meta "$home/state/fork-sync.meta" \ + "window=fixture:fm-fork-sync" "project=$home/projects/firstmate" "kind=ship" + printf 'working: auditing\n' > "$home/state/fork-sync.status" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'the board is waiting' --repo firstmate \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --why-now 'The board is ready to rebase now.' \ + --cost-of-waiting 'The board remains blocked.' \ + --option 'Sync now.' \ + --recommend 'Sync now.' >/dev/null || fail "could not register the decision" + + body_file="$home/body-with-escapes" + { + printf 'Origin: fork-sync\nDecision key: fork-main-sync\nState: awaiting captain decision.\n\n' + printf 'Captain briefing v1:\nSemantic revision: fork-main-sync-v1\n' + printf 'Choice: Sync now, or rebase onto the current fork main.\n' + printf 'Why now: The board is ready to rebase now.\nIf this waits: The board remains blocked.\n' + printf 'Option: Sync now.\nRecommended: Sync now.\n' + printf 'Operator note: C:\\fleet\tquoted "yes"\rcontrol=' + printf '\001\000' + printf ':end\n' + } > "$body_file" + (cd "$home" && tasks-axi update fork-sync-decision-fork-main-sync --body-file "$body_file" >/dev/null) \ + || fail "could not seed the escaped body" + + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'the board is waiting' \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --why-now 'The board is ready to rebase now.' \ + --cost-of-waiting 'The board remains blocked.' \ + --option 'Sync now.' \ + --recommend 'Sync now.' >/dev/null || fail "the complete briefing rewrite failed" + show_file="$home/show-after-rewrite" + (cd "$home" && tasks-axi show fork-sync-decision-fork-main-sync --full) > "$show_file" + node -e ' + const fs = require("fs"); + const line = fs.readFileSync(process.argv[1], "utf8").split("\n").find((row) => row.startsWith(" body: ")); + if (!line) process.exit(1); + const body = JSON.parse(line.slice(8)); + if (!body.includes("Operator note: C:\\fleet\tquoted \"yes\"\rcontrol=\u0001\u0000:end")) process.exit(1); + ' "$show_file" || fail "the briefing rewrite corrupted a preserved escaped body line" + + record_visible "$home" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'the same wait in different words' \ + --semantic-revision fork-main-sync-v1 \ + --choice 'Choose between synchronizing now and rebasing on the current fork.' \ + --why-now 'The board can proceed as soon as this direction is settled.' \ + --cost-of-waiting 'Until then, the board cannot advance.' \ + --option 'Synchronize now.' \ + --recommend 'Synchronize now.' >/dev/null || fail "the wording-only briefing update failed" + out=$(attention "$home" --status) + assert_contains "$out" 'decisions_new=false' "a wording-only edit reopened the captain receipt" + + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$HOLD" hold fork-sync fork-main-sync --title 'Sync the fork main' --reason 'the same wait in different words' \ + --semantic-revision fork-main-sync-v2 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --why-now 'The board is ready to rebase now.' \ + --cost-of-waiting 'The board remains blocked.' \ + --option 'Rebase onto the current fork main.' \ + --recommend 'Rebase onto the current fork main.' >/dev/null || fail "the substantive briefing update failed" + out=$(attention "$home" --status) + assert_contains "$out" 'decisions_new=true' "a substantive briefing revision did not reopen the receipt" + pass "briefing revisions preserve escaped body data and reopen receipts only for substance" +} + +test_receipt_validation_keeps_each_alert_facts_with_its_headline() { + local home mixed reply status + home=$(make_home receipt-association) + add_unsurfaced_decision "$home" + add_row "$home" Queued \ + '- [ ] sample-decision-y - Choose the launch window (repo: sample) (kind: captain) (since 2026-07-28) (hold: the release needs a launch window) (hold-kind: captain) + Captain briefing v1: + Semantic revision: launch-window-v1 + Choice: Launch on Tuesday, or wait until Thursday. + Why now: The release plan is ready for a date. + If this waits: The release team cannot schedule the rollout. + Option: Launch on Tuesday. + Option: Launch on Thursday. + Recommended: Launch on Tuesday.' + + mixed="CAPTAIN'S CALL + +NEEDS YOUR DECISION + +1. Approve the worker installs + The choice: + Launch on Tuesday, or wait until Thursday. + Why it matters now: + The release plan is ready for a date. + If this waits: + The release team cannot schedule the rollout. + Options: + - Launch on Tuesday. + - Launch on Thursday. + Recommended: + Launch on Tuesday. + +2. Choose the launch window + The choice: + Approve the worker installs now, or leave the machine unavailable. + Why it matters now: + The queued worker setup cannot proceed without the approval. + If this waits: + The worker setup and its dependent work remain stopped. + Options: + - Approve the installs now. + - Leave the machine unavailable and reroute the work. + Recommended: + Approve the installs now so the queued setup can proceed." + printf '%s' "$mixed" | attention "$home" --record-visible >/dev/null 2>&1 + status=$? + [ "$status" -ne 0 ] || fail "facts swapped between two alert records earned a receipt" + assert_absent "$home/state/.captain-attention" \ + "a cross-record receipt mismatch must not write the captain marker" + + reply=$(attention "$home") + printf '%s' "$reply" | attention "$home" --record-visible >/dev/null + status=$? + expect_code 0 "$status" "the correctly associated alert records must earn a receipt" + pass "captain receipts validate each alert headline with its own required facts" +} + +# --- the primary-activity blind spot ---------------------------------------- +# +# The defect this contract exists to close: supervision counted state/*.meta, so +# a home whose only live work was an unanswered captain decision had nothing to +# count and every guard concluded it was idle. +test_unaccounted_primary_work_is_not_idle() { + local home out + home=$(make_primary_home blind-spot) + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the worker installs (repo: sample) (kind: captain) (since 2026-07-28) (hold: the machine needs approval before workers run there) (hold-kind: captain)' + [ -z "$(ls "$home/state"/*.meta 2>/dev/null)" ] || fail "the blind-spot fixture must have no task metadata" + + # The old supervision predicate is unchanged and still reports an idle-looking + # home; that is exactly why the idleness question has its own owner. + out=$(FM_STATE_OVERRIDE="$home/state" bash -c '. "$1"; fm_supervision_status "$2"; printf "in_flight=%s needed=%s\n" "$FM_SUP_IN_FLIGHT" "$FM_SUP_NEEDED"' \ + _ "$ROOT/bin/fm-supervision-lib.sh" "$home/state") + assert_contains "$out" 'in_flight=0' "the fixture must reproduce the zero-metadata condition" + assert_contains "$out" 'needed=false' "watcher-need must stay unchanged by this contract" + + # The idleness predicate must disagree: this home is holding captain work. + if FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" bash -c \ + '. "$1"; fm_attention_home_idle "$2"' _ "$ROOT/bin/fm-attention-lib.sh" "$home"; then + fail "a home holding an unanswered captain decision reported itself idle" + fi + + # The pull guard must speak even though nothing is in flight; before this it + # returned early and printed nothing at all. + assert_contains "$(run_guard "$home")" "CAPTAIN'S CALL CHANGED" \ + "the guard went silent on a home whose only work is an unanswered decision" + pass "unaccounted primary work reads as suspicious, not idle" +} + +test_turn_end_requires_a_captain_visible_complete_alert() { + local home out status reply + home=$(make_primary_home turnend) + add_unsurfaced_decision "$home" + + out=$(run_turnend "$home"); status=$? + expect_code 2 "$status" "a turn must not end with a captain decision the captain has never seen" + assert_contains "$out" 'TURN WOULD END WITHOUT TELLING THE CAPTAIN' "the stop banner must read as an alarm" + assert_contains "$out" 'bin/fm-attention.sh' "the stop must point at the captain-facing renderer" + + out=$(run_turnend "$home"); status=$? + expect_code 2 "$status" "an internal render must not spend the receipt" + reply=$(attention "$home" --no-mark) + out=$(run_turnend "$home" "${reply/NEEDS YOUR DECISION/WAITING ON SOMETHING ELSE}"); status=$? + expect_code 2 "$status" "a display under the wrong category must not count" + out=$(run_turnend "$home" "${reply/Approve the worker installs/Approve something else}"); status=$? + expect_code 2 "$status" "a display under the wrong headline must not count" + out=$(run_turnend "$home" "$reply"); status=$? + expect_code 0 "$status" "the complete captain-visible alert must satisfy the gate" + [ -z "$out" ] || fail "the captain-visible turn end produced output: $out" + pass "a turn ends only after the complete alert is actually captain-visible" +} + +test_evidence_less_decision_stop_is_bounded() { + local home out status + home=$(make_primary_home turnend-no-evidence) + add_unsurfaced_decision "$home" + + out=$(run_turnend_no_evidence "$home"); status=$? + expect_code 2 "$status" "an evidence-less harness must still stop once on an unsurfaced decision" + assert_contains "$out" 'TURN WOULD END WITHOUT TELLING THE CAPTAIN' \ + "the evidence-less stop must show the captain banner" + assert_present "$home/state/.captain-attention-decisions" \ + "the evidence-less stop must record its bounded marker" + + out=$(run_turnend_no_evidence "$home"); status=$? + expect_code 0 "$status" "an open decision without payload evidence must be bounded to one stop" + [ -z "$out" ] || fail "the bounded evidence-less turn end produced output: $out" + assert_contains "$(attention "$home" --no-mark --status)" 'decisions_new=true' \ + "the bounded stop must not spend the captain receipt" + + add_row "$home" Queued \ + '- [ ] second-decision-y - Pick the deploy window (repo: sample) (kind: captain) (since 2026-07-29) (hold: the deploy window needs captain input) (hold-kind: captain) + Captain briefing v1: + Semantic revision: deploy-window-v1 + Choice: Deploy tonight or hold until the weekend window. + Why now: The release train departs before the next weekend window. + If this waits: The release misses the train and slips a full cycle. + Option: Deploy tonight. + Option: Hold for the weekend window. + Recommended: Deploy tonight so the release makes the train.' + out=$(run_turnend_no_evidence "$home"); status=$? + expect_code 2 "$status" "a changed decision set must re-arm the bounded stop" + out=$(run_turnend_no_evidence "$home"); status=$? + expect_code 0 "$status" "the re-armed stop must stay bounded to one block" + + out=$(run_turnend "$home"); status=$? + expect_code 2 "$status" "a payload that carries the assistant reply must keep the strict receipt requirement" + pass "an evidence-less harness gets one bounded decision stop per changed set" +} + +test_claude_autoarm_allow_paths_still_stop_for_unsurfaced_decisions() { + local home out status + + home=$(make_primary_home claude-autoarm-loop) + add_unsurfaced_decision "$home" + add_unsupervised_work "$home" + printf 'epoch=1 owner_pid=999 outcome=rewake updated_at=%s\n' "$(date +%s)" > "$home/state/.claude-autoarm-epoch" + out=$(run_turnend_claude "$home" false); status=$? + expect_code 2 "$status" "Claude auto-arm sync-loop allow must pass the captain-call gate" + assert_contains "$out" 'TURN WOULD END WITHOUT TELLING THE CAPTAIN' \ + "Claude auto-arm sync-loop allow must not hide an unsurfaced decision" + + home=$(make_primary_home claude-autoarm-post-loop) + add_unsurfaced_decision "$home" + add_unsupervised_work "$home" + printf 'epoch=1 owner_pid=999 outcome=rewake updated_at=%s\n' "$(date +%s)" > "$home/state/.claude-autoarm-epoch" + out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=0 run_turnend_claude "$home" false); status=$? + expect_code 2 "$status" "Claude auto-arm post-loop allow must pass the captain-call gate" + assert_contains "$out" 'TURN WOULD END WITHOUT TELLING THE CAPTAIN' \ + "Claude auto-arm post-loop allow must not hide an unsurfaced decision" + + home=$(make_primary_home claude-budget-attention) + add_unsurfaced_decision "$home" + add_unsupervised_work "$home" + printf 'session=attention-test\ncount=3\n' > "$home/state/.turnend-claude-blocks" + out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=0 run_turnend_claude "$home" true); status=$? + expect_code 2 "$status" "Claude budget-exhausted allow must pass the captain-call gate" + assert_contains "$out" 'TURN WOULD END WITHOUT TELLING THE CAPTAIN' \ + "Claude budget-exhausted allow must not hide an unsurfaced decision" + assert_not_contains "$out" '"systemMessage"' \ + "Claude budget-exhausted allow must not run once an unsurfaced decision blocks" + pass "Claude auto-arm allow paths stop on unsurfaced decisions" +} + +test_declared_waits_never_stop_a_turn() { + local home out status + # A work item waiting on other work, with nothing in flight: the turn-end path + # reaches the captain's-call check with the watcher question already answered. + home=$(make_primary_home wait-turnend) + add_row "$home" Queued \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23) (hold: the fork synchronization has not landed yet) (hold-kind: external)' + assert_contains "$(attention "$home" --no-mark --status)" 'waits=1' "the fixture must have an open wait" + assert_contains "$(attention "$home" --no-mark --status)" 'decisions=0' "the fixture must have no open decision" + out=$(run_turnend "$home"); status=$? + expect_code 0 "$status" "a routine delay must never force a continuation" + [ -z "$out" ] || fail "a routine delay produced turn-end output: $out" + + # And a live declared delay never contributes the captain's-call stop either, + # whatever the independent watcher-liveness backstop decides about it. + home=$(make_primary_home wait-turnend-live) + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + printf 'paused: the fork synchronization has not landed yet\n' > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + assert_contains "$(attention "$home" --no-mark --status)" 'waits=1' "the live fixture must have an open wait" + out=$(run_turnend "$home") + assert_not_contains "$out" 'TURN WOULD END WITHOUT TELLING THE CAPTAIN' \ + "a routine delay must never raise the captain's-call stop" + pass "declared waits surface without ever stopping a turn" +} + +# --- captain-facing language ------------------------------------------------ +test_captain_view_carries_no_internal_vocabulary() { + local home out term + home=$(make_home vocabulary) + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the change (repo: sample) (kind: captain) (since 2026-07-28) (hold: a reason) (hold-kind: captain)' + add_row "$home" 'In flight' \ + '- [ ] task-board - Live watchable view of the task and priority list (repo: sample) (kind: ship) (since 2026-07-23)' + fm_write_meta "$home/state/task-board.meta" \ + "window=fixture:fm-task-board" "project=$home/projects/sample" "kind=ship" + printf 'paused: the fork synchronization has not landed yet\n' > "$home/state/task-board.status" + touch "$home/state/.last-watcher-beat" + + out=$(attention "$home" --no-mark) + # AGENTS.md section 9 forbids these in captain-facing text; the default view is + # written to be relayed as-is, so it must not contain them. + for term in 'hold' 'worktree' 'watcher' 'harness' 'crewmate' 'teardown' 'backlog' 'metadata' 'state/'; do + assert_not_contains "$out" "$term" "the captain view leaked internal vocabulary: $term" + done + # The firstmate-facing brief is where identifiers belong. + assert_contains "$(attention "$home" --no-mark --brief)" 'sample-decision-x' \ + "the brief form must carry identifiers for firstmate's own use" + pass "the captain view is plain language while the brief form keeps identifiers" +} + +test_empty_home_says_so_plainly() { + local home + home=$(make_home empty) + assert_contains "$(attention "$home" --no-mark)" 'Nothing needs your decision, and nothing is waiting.' \ + "an empty home must render an explicit empty state" + assert_contains "$(attention "$home" --no-mark --brief)" "CAPTAIN'S CALL: nothing open." \ + "the brief form needs an explicit empty state too" + pass "an empty captain's call is stated, not omitted" +} + +test_hold_reason_keeps_its_full_text +test_briefed_decision_renders_concretely +test_new_decision_requires_a_complete_briefing +test_a_captain_gated_work_item_is_a_decision_not_a_delay +test_unbriefed_decision_is_honest_about_missing_language +test_partial_briefing_renders_recorded_fields_and_names_missing_ones +test_failed_backlog_projection_is_unknown_not_empty +test_failed_state_collection_is_unknown_not_empty +test_linked_state_inputs_follow_targets_and_dangling_links_are_unknown +test_unknown_projection_surfaces_again_after_successful_derivation +test_routine_wait_states_what_it_awaits_and_when_it_is_next_checked +test_overdue_monitored_wait_is_due_now +test_repeated_wait_stays_routine_until_captain_action_is_explicit +test_same_key_decision_and_wait_render_once_as_combined +test_direct_captain_hold_and_same_task_status_render_once +test_ambiguous_direct_hold_keeps_every_keyed_decision_visible +test_combined_alert_is_receiptable_through_transfer_and_clears_on_terminal_work +test_identities_ignore_wording_so_repeats_do_not_re_alarm +test_reopened_status_items_get_new_generation_identity +test_brief_form_never_spends_captain_surface_marker +test_guard_banner_never_spends_captain_receipt +test_only_the_captain_renderer_writes_the_surface_marker +test_ordinary_reads_do_not_become_false_alarms +test_ledger_keeps_showing_an_open_item_after_it_was_surfaced +test_resolution_clears_the_item +test_retry_never_erases_a_written_briefing +test_briefing_revisions_preserve_body_and_reopen_receipt_semantically +test_receipt_validation_keeps_each_alert_facts_with_its_headline +test_unaccounted_primary_work_is_not_idle +test_turn_end_requires_a_captain_visible_complete_alert +test_evidence_less_decision_stop_is_bounded +test_claude_autoarm_allow_paths_still_stop_for_unsurfaced_decisions +test_declared_waits_never_stop_a_turn +test_captain_view_carries_no_internal_vocabulary +test_empty_home_says_so_plainly diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 54f8e33..649c6a0 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -142,7 +142,7 @@ resolve_permissive_tmux_kill_ref() { # for a tmux-only conformance run the tmux adapter's behavior is what is under # test, and that adapter is extracted from BASE_REF so later non-tmux backend # additions do not change the historical tmux dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-operational-input.sh fm-backend.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-attention-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-operational-input.sh fm-backend.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 0ef84c4..ce54793 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -102,6 +102,16 @@ tasks_in() { # <home> <tasks-axi args...> run_decisions() { # <home> <command args...> local home=$1 shift + if [ "${1:-}" = hold ] && [ "$#" -ge 3 ] \ + && ! grep -qE "^- \\[[ x]\\] ${2}-decision-${3} -" "$home/data/backlog.md"; then + set -- "$@" \ + --semantic-revision "${3}-v1" \ + --choice "Choose how to resolve ${3}." \ + --why-now "The reviewed work cannot proceed until ${3} is decided." \ + --cost-of-waiting "The dependent work remains stopped." \ + --option "Approve the proposed ${3} path." \ + --recommend "Approve the proposed ${3} path." + fi PATH="$home/fakebin:$PATH" REAL_TASKS_AXI="$TASKS_AXI_BIN" \ FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-decision-hold.sh" "$@" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 518df0e..5156460 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -2072,8 +2072,7 @@ SH cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' #!/usr/bin/env bash printf 'guard\n' >> "${FM_GUARD_LOG:?}" -printf 'guard should not run\n' >&2 -exit 2 +exit 0 SH chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-turnend-guard.sh" out=$(ARM_PLUGIN="$arm_plugin" GUARD_PLUGIN="$guard_plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_GUARD_LOG="$guard_log" node 2>&1 <<'EOF' @@ -2109,8 +2108,8 @@ if (!existsSync(process.env.FM_ARM_LOG)) { console.error("watch arm did not run"); process.exit(1); } -if (existsSync(process.env.FM_GUARD_LOG)) { - console.error("turn-end guard ran before the watch arm could establish supervision"); +if (!existsSync(process.env.FM_GUARD_LOG)) { + console.error("turn-end attention gate did not run after watch coordination"); process.exit(1); } if (promptBody) { @@ -2120,9 +2119,9 @@ if (promptBody) { EOF ) status=$? - expect_code 0 "$status" "OpenCode turn-end guard must let the auto-arm plugin establish supervision first" + expect_code 0 "$status" "OpenCode turn-end guard must evaluate attention after auto-arm coordination" [ -z "$out" ] || fail "OpenCode coordination test printed output: $out" - pass "OpenCode watcher plugin coordinates with the turn-end guard" + pass "OpenCode watcher coordination is followed by the attention gate" } test_opencode_healthy_arm_output_does_not_suppress_guard() { diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 2b82165..0cd9ea8 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -107,9 +107,15 @@ install_guard_scripts() { cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" + cp "$ROOT/bin/fm-attention-lib.sh" "$dir/bin/fm-attention-lib.sh" + cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/fm-classify-lib.sh" + cp "$ROOT/bin/fm-fleet-snapshot.sh" "$dir/bin/fm-fleet-snapshot.sh" + cp "$ROOT/bin/fm-backend.sh" "$dir/bin/fm-backend.sh" + cp "$ROOT/bin/fm-ff-lib.sh" "$dir/bin/fm-ff-lib.sh" + cp "$ROOT/bin/fm-attention.sh" "$dir/bin/fm-attention.sh" mkdir -p "$dir/docs" cp -R "$ROOT/docs/supervision-protocols" "$dir/docs/supervision-protocols" - chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-operational-input.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh" + chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-operational-input.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh" "$dir/bin/fm-attention.sh" "$dir/bin/fm-fleet-snapshot.sh" } mark_codex_hook_root() { @@ -811,6 +817,10 @@ test_opencode_plugin_forces_followup() { assert_contains "$content" 'promptAsync' "OpenCode plugin must force a follow-up turn" assert_contains "$content" 'encodeFirstmateOperationalInput' "OpenCode plugin must use the typed operational-input constructor" assert_contains "$content" 'skipNextIdle' "OpenCode plugin must carry a loop guard" + assert_contains "$content" 'message.updated' "OpenCode plugin must observe the assistant reply" + assert_contains "$content" 'last_assistant_message' "OpenCode plugin must pass captain-visible evidence to the shared guard" + assert_contains "$content" 'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS' \ + "OpenCode plugin must classify the explicit unknown state" assert_contains "$content" 'worktree' "OpenCode plugin must anchor the guard from the git worktree path" assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "OpenCode plugin must identify a blind turn as watcher recovery" assert_contains "$content" 'harness recovery instruction below' "OpenCode plugin must delegate recovery action to the shared guard line" @@ -819,23 +829,25 @@ test_opencode_plugin_forces_followup() { } test_opencode_plugin_anchors_guard_to_worktree() { - local plugin parent worktree_dir wrong_dir out status + local plugin parent worktree_dir wrong_dir payload_log out status plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" [ -f "$plugin" ] || fail "tracked OpenCode primary plugin is missing" parent="$TMP_ROOT/opencode-plugin-parent" git init -q "$parent" worktree_dir="$parent/nested/opencode-plugin-worktree" wrong_dir="$TMP_ROOT/opencode-plugin-cwd/subdir" + payload_log="$TMP_ROOT/opencode-plugin-payload.json" mkdir -p "$worktree_dir/bin" "$wrong_dir" cat > "$worktree_dir/bin/fm-turnend-guard.sh" <<'EOF' #!/usr/bin/env bash -cat >/dev/null +cat > "${FM_PAYLOAD_LOG:?}" printf 'guard-fired\n' >&2 exit 2 EOF chmod +x "$worktree_dir/bin/fm-turnend-guard.sh" # Runtime module-format warnings are host noise; this assertion owns plugin output only. - out=$(NODE_NO_WARNINGS=1 PLUGIN="$plugin" DIRECTORY="$wrong_dir" WORKTREE="$worktree_dir" node 2>&1 <<'EOF' + out=$(NODE_NO_WARNINGS=1 PLUGIN="$plugin" DIRECTORY="$wrong_dir" WORKTREE="$worktree_dir" FM_PAYLOAD_LOG="$payload_log" node 2>&1 <<'EOF' +import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const mod = await import(pathToFileURL(process.env.PLUGIN).href); @@ -852,7 +864,18 @@ const hooks = await mod.FmPrimaryTurnendGuard({ directory: process.env.DIRECTORY, worktree: process.env.WORKTREE, }); +await hooks.event({ event: { type: "message.updated", properties: { + info: { role: "assistant", sessionID: "session-test", id: "message-test" }, +} } }); +await hooks.event({ event: { type: "message.part.updated", properties: { + part: { type: "text", sessionID: "session-test", messageID: "message-test", id: "part-test", text: "captain-visible answer" }, +} } }); await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } }); +const payload = JSON.parse(readFileSync(process.env.FM_PAYLOAD_LOG, "utf8")); +if (payload.last_assistant_message !== "captain-visible answer") { + console.error(`missing assistant evidence: ${JSON.stringify(payload)}`); + process.exit(1); +} if (!promptBody.startsWith("\u2063FIRSTMATE_OP: v1 turn-end-guard: ")) { console.error(`untyped operational prompt: ${promptBody}`); process.exit(1); @@ -888,6 +911,10 @@ test_pi_extension_forces_followup() { assert_contains "$content" 'encodeFirstmateOperationalInput' "pi extension must use the typed operational-input constructor" assert_contains "$content" 'deliverAs: "followUp"' "pi extension must queue the follow-up safely" assert_contains "$content" 'guardFollowupActive' "pi extension must carry a logical-run loop guard" + assert_contains "$content" 'agent_end' "pi extension must observe the assistant reply" + assert_contains "$content" 'last_assistant_message' "pi extension must pass captain-visible evidence to the shared guard" + assert_contains "$content" 'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS' \ + "pi extension must classify the explicit unknown state" assert_not_contains "$content" 'skipNextTurnEnd' "pi extension kept the internal-turn loop guard" assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "pi extension must identify a blind turn as watcher recovery" assert_contains "$content" 'harness recovery instruction below' "pi extension must delegate recovery action to the shared guard line" @@ -911,10 +938,10 @@ test_pi_extension_injects_once_per_logical_agent_run() { 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' +cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' #!/usr/bin/env bash -cat >/dev/null -printf 'guard\n' >> "${FM_GUARD_LOG:?}" +cat >> "${FM_GUARD_LOG:?}" +printf '\n' >> "${FM_GUARD_LOG:?}" printf 'logical-run guard fired\n' >&2 exit 2 SH @@ -948,24 +975,34 @@ 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 ended = handlers.get("agent_end"); +if (!ended) throw new Error("agent_end handler was not registered"); +await ended({ messages: [{ role: "assistant", content: [{ type: "text", text: "captain reply one" }] }] }); await settled({ type: "agent_settled" }, {}); 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 ended({ messages: [{ role: "assistant", content: "captain reply two" }] }); await settled({ type: "agent_settled" }, {}); 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; -if (guardRuns !== 2) throw new Error(`guard predicate ran ${guardRuns} times for two logical runs`); +const guardRuns = readFileSync(process.env.FM_GUARD_LOG, "utf8").trim().split("\n").map((row) => JSON.parse(row)); +if (guardRuns.length !== 4) throw new Error(`guard predicate ran ${guardRuns.length} times for two replies and their follow-ups`); +if (guardRuns[0].last_assistant_message !== "captain reply one" || + guardRuns[1].last_assistant_message !== "captain reply one" || + guardRuns[2].last_assistant_message !== "captain reply two" || + guardRuns[3].last_assistant_message !== "captain reply two") { + throw new Error(`assistant evidence was not preserved: ${JSON.stringify(guardRuns)}`); +} EOF ) status=$? expect_code 0 "$status" "Pi guard must inject once for no-tool and multi-tool logical runs" [ -z "$out" ] || fail "Pi logical-run guard test printed output: $out" - pass ".pi primary extension: no-tool and multi-tool runs each inject exactly one guard follow-up" + pass ".pi primary extension: every reply is gated while routine recovery stays bounded" } test_pi_extension_retries_after_followup_delivery_failure() { @@ -1178,13 +1215,17 @@ test_hook_claude_mode_secondmate_reblocks_like_primary() { } test_grok_hook_invokes_adapter() { - local settings command + local settings command adapter settings="$ROOT/.grok/hooks/fm-primary-turnend-guard.json" [ -f "$settings" ] || fail "tracked grok primary hook config is missing" command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") [ -n "$command" ] || fail "Stop hook command is missing from grok primary hook config" assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok hook must anchor from GROK_WORKSPACE_ROOT" assert_contains "$command" 'fm-turnend-guard-grok.sh' "grok hook must invoke the adapter" + adapter=$(cat "$ROOT/bin/fm-turnend-guard-grok.sh") + assert_contains "$adapter" 'last_assistant_message' "grok adapter must preserve captain-visible evidence" + assert_contains "$adapter" 'TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS' \ + "grok adapter must classify the explicit unknown state" pass ".grok primary hook: Stop hook invokes the grok adapter" }