From 20e7db5724ecb07aa7f0fba2a8db3e86c805dcbb Mon Sep 17 00:00:00 2001 From: Flo Hart Date: Tue, 28 Jul 2026 23:34:09 +0200 Subject: [PATCH 01/18] feat(supervision): surface open captain 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 would happen next. Work sat parked and the fleet read as permanently waiting for no understandable reason. Three separate mechanisms dropped that information. A captain decision was recorded as a one-line title plus a one-line reason, so every renderer could only truncate it - and the backlog reader truncated the reason again at its first comma. A declared external delay was excluded from the captain-relevant verb set, which correctly stops wedge-nagging an idle pane but also removed the delay from every captain-facing surface. Supervision counted state/*.meta, so a home whose only live work was an unanswered decision reported as idle and the whole guard stack went silent. Add one derived, read-only contract over state firstmate already keeps durably - the backlog and the status event logs - rather than another status surface. Every open item is a decision or a wait; a wait re-declared past a threshold without clearing becomes a decision, derived from the keyed event fold rather than from its wording. Identities carry no prose, so a delay re-reported hourly stays one item and surfaces once. Record the plain language durably: fm-decision-hold.sh hold gains briefing fields for the concrete choice, why it matters now, what waiting costs, the options, and a recommendation, stored in the hold body and cleared when the hold resolves. A hold without one renders marked as not yet written rather than dressing up a raw operational note. Surface it in one captain-facing place and in ordinary replies: fm-attention.sh renders it already translated; fm-guard.sh surfaces a changed set before any in-flight test, closing the blind spot where it used to return early; fm-turnend-guard.sh stops a turn that would end on a decision the captain has never been shown, once per distinct set and never on a routine delay; and the session-start digest and supervision block carry it. Rendering is surfacing, so an ordinary read that changes nothing is never an alarm. Every supported primary harness and runtime backend is reviewed in docs/verification/supervision.md. Each passive adapter now selects its follow-up headline from the guard's own banner, so a captain decision is no longer announced as a supervision lapse. --- .agents/skills/bearings/SKILL.md | 2 + .../skills/decision-hold-lifecycle/SKILL.md | 4 +- .opencode/plugins/fm-primary-turnend-guard.js | 24 +- .pi/extensions/fm-primary-turnend-guard.ts | 23 +- AGENTS.md | 14 +- README.md | 2 + bin/fm-attention-lib.sh | 453 +++++++++++++++++ bin/fm-attention.sh | 269 ++++++++++ bin/fm-decision-hold.sh | 91 +++- bin/fm-fleet-snapshot.sh | 29 +- bin/fm-guard.sh | 44 +- bin/fm-session-start.sh | 48 +- bin/fm-supervision-instructions.sh | 18 + bin/fm-supervision-lib.sh | 13 + bin/fm-test-run.sh | 11 +- bin/fm-turnend-guard-grok.sh | 12 +- bin/fm-turnend-guard.sh | 57 +++ docs/captain-attention.md | 69 +++ docs/documentation-audiences.json | 4 + docs/scripts.md | 3 +- docs/turnend-guard.md | 11 + docs/verification/supervision.md | 58 +++ tests/fm-attention.test.sh | 470 ++++++++++++++++++ tests/fm-turnend-guard.test.sh | 9 +- 24 files changed, 1710 insertions(+), 28 deletions(-) create mode 100644 bin/fm-attention-lib.sh create mode 100755 bin/fm-attention.sh create mode 100644 docs/captain-attention.md create mode 100644 tests/fm-attention.test.sh diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index b2804c7..f99daa1 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 captain-facing owner of 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; one that stopped clearing on its own is promoted to a decision by that same command and then belongs in 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..1826730 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -17,6 +17,8 @@ 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, the real options where they help, and your recommendation. +A title and a one-line reason cannot carry a decision, so a hold without that briefing reaches the captain marked as not yet written; 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 +31,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..63ad139 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -3,6 +3,26 @@ 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"; + +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" + ); + } + 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; @@ -78,9 +98,7 @@ export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => 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..3dcb88d 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -61,6 +61,25 @@ function runSessionstartNudge(): string { return result.stdout.trim(); } +// 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"; + +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" + ); + } + 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(): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { @@ -148,9 +167,7 @@ export default function (pi: ExtensionAPI) { 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..bdd52ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,7 @@ 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 rendered to a captain-facing surface; bounds the interrupt, never the ledger (bin/fm-attention-lib.sh) .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 +143,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 declared 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` is the single captain-facing renderer and `docs/captain-attention.md` is the operator reference; a locked session records the surfaced set, a read-only session does not. +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 +423,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 single captain-facing owner of every open decision and meaningful wait, 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, the real options where they help, 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..bdaa623 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ 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 delay is collected in one captain-facing place, in plain English with the concrete choice, what waiting costs, and a recommendation; 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 +204,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..44c81a0 --- /dev/null +++ b/bin/fm-attention-lib.sh @@ -0,0 +1,453 @@ +# 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 declared external delay; no captain action, but the captain is +# still owed what is being awaited and when it is next checked. +# A wait that has been re-declared FM_ATTENTION_WAIT_REDECLARES times (default +# 3) without ever resolving is escalated to a decision, because a delay that +# keeps repeating is by definition no longer clearing on its own. That escalation +# is derived from the keyed event fold, never from reading the prose of the +# wait, so it cannot false-positive on wording. +# +# 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, so a standing delay is surfaced +# once rather than every hour. An escalated wait keeps its wait identity and only +# changes class, so escalation itself surfaces exactly once. +# +# SURFACING +# state/.captain-attention records the digest of the set most recently rendered +# to a captain-facing surface: +# attention= +# decisions= +# A digest that differs from the recorded one means the set CHANGED and has not +# been surfaced since. Rendering is surfacing: bin/fm-attention.sh records the +# digest as a side effect of printing, so an ordinary read that changes nothing +# can never become an alarm, and an item that is answered simply drops out of +# the set. 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 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 data/backlog.md and state/*.status and never inspects a pane, an +# endpoint, or a harness. +# +# DEPENDENCIES +# jq is required for the backlog projection. When jq is absent this library +# reports FM_ATT_AVAILABLE=false with zero counts so a guard degrades to its +# previous behavior instead of breaking a fleet operation, matching the existing +# "missing jq -> silent no-op" degrade in bin/fm-turnend-guard.sh. + +_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}" + +# How many times a declared wait may be re-declared before it stops counting as +# routine and becomes a captain decision. +FM_ATTENTION_WAIT_REDECLARES=${FM_ATTENTION_WAIT_REDECLARES:-3} +case "$FM_ATTENTION_WAIT_REDECLARES" in ''|*[!0-9]*|0) FM_ATTENTION_WAIT_REDECLARES=3 ;; esac + +# --- 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: +# 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_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 choice=${1:-} why=${2:-} cost=${3:-} recommend=${4:-} opt + shift 4 2>/dev/null || true + if [ -z "$choice" ] && [ -z "$why" ] && [ -z "$cost" ] && [ -z "$recommend" ] && [ "$#" -eq 0 ]; then + return 0 + fi + printf '%s\n' "$FM_ATTENTION_BRIEF_HEADER" + [ -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 -------------------------------------------------- + +# Count how many times the currently open wait phase for has been +# re-declared in . A non-wait event carrying the same key resets the +# count, so only the CURRENT uninterrupted run of declarations is counted. +fm_attention_wait_redeclares() { # + local f=$1 want=$2 line verb key pause n=0 stripped + [ -f "$f" ] || { printf '0'; return 0; } + pause=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + 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 + [ "$key" = "$want" ] || continue + if [ "$verb" = "$pause" ]; then + n=$((n + 1)) + else + n=0 + fi + done < "$f" + printf '%s' "$n" +} + +# 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 + 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; } + printf '%s' "$(( m + cadence - now ))" +} + +# 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 id status kind last verb key note n next + for meta in "$state"/*.meta; do + [ -f "$meta" ] || continue + id=$(basename "$meta" .meta) + status="$state/$id.status" + [ -f "$status" ] || continue + kind=$(grep '^kind=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true) + [ -n "$kind" ] || kind=ship + if [ "$kind" != secondmate ]; then + last=$(last_status_line "$status") + verb=$(status_line_verb "$last") + case "$verb" in done|failed) continue ;; esac + fi + while IFS=$'\t' read -r key verb note; do + [ -n "$key" ] || continue + printf 'decision\t%s\t%s\t%s\t0\t-1\t%s\n' "$id" "$key" "$verb" "$note" + done < +# 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 || { printf '[]'; return 1; } + backlog_json=$(FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" \ + "$FM_ATTENTION_SNAPSHOT_BIN" --backlog-json 2>/dev/null) \ + || backlog_json='{"records":[]}' + [ -n "$backlog_json" ] || backlog_json='{"records":[]}' + rows=$(fm_attention_status_rows "$state") + printf '%s' "$rows" | jq -Rn \ + --argjson backlog "$backlog_json" \ + --argjson escalate "$FM_ATTENTION_WAIT_REDECLARES" \ + --arg brief_header "$FM_ATTENTION_BRIEF_HEADER" \ + --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:]]+"; "") ] | .[0]) // null; + def fields($lines; $label): + [ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") ]; + def briefing($record): + ($record.body_lines // []) as $lines + | if ($lines | index($brief_header)) == null then + {briefed:false,choice:null,why_now:null,cost_of_waiting:null,options:[],recommendation:null} + else + {briefed:true, + choice:field($lines; $f_choice), + why_now:field($lines; $f_why), + cost_of_waiting:field($lines; $f_cost), + options:fields($lines; $f_option), + recommendation:field($lines; $f_recommend)} + end; + def backlog_decisions: + [ $backlog.records[]? + | select(.structured == true and .captain_actionable == true) + | {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, + next_check_seconds:null} + + briefing(.) ]; + def backlog_waits: + [ $backlog.records[]? + | select(.structured == true + and (.state == "queued" or .state == "in_flight") + and .captain_actionable != true + 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, + next_check_seconds:null, + briefed:false,choice:null,why_now:null,cost_of_waiting:null, + options:[],recommendation:null} ]; + def status_rows: + [ inputs + | select(length > 0) + | split("\t") + | select(length >= 7) + | {class:.[0],id:.[1],key:.[2],verb:.[3], + redeclares:(.[4] | tonumber? // 0), + next_check_seconds:(.[5] | tonumber? // -1), + note:(.[6:] | 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[] + | (.redeclares >= $escalate) as $esc + | {class:(if .class == "wait" and $esc then "decision" else .class end), + identity:(.class + ":" + .id + ":" + .key), + source:(if .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 .class == "wait" then .note else null end), + blocked_by:[], + redeclares:.redeclares, + escalated:(.class == "wait" and $esc), + next_check_seconds:(if .class == "wait" then .next_check_seconds else null end), + briefed:false,choice:null,why_now:null,cost_of_waiting:null, + options:[],recommendation:null} ]; + (backlog_decisions + status_records) 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_status +# Populate, read-only, for the home at $1: +# FM_ATT_AVAILABLE true/false - false when jq is missing (degrade quietly) +# 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 digest over every identity +# FM_ATT_DECISION_DIGEST digest over decision identities 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 +# 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 + 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_NEW=false + FM_ATT_DECISIONS_NEW=false + + if ! command -v jq >/dev/null 2>&1; then + # shellcheck disable=SC2034 # Read by callers after sourcing. + FM_ATT_AVAILABLE=false + return 0 + fi + FM_ATT_JSON=$(fm_attention_json "$home" 2>/dev/null) || FM_ATT_JSON='[]' + [ -n "$FM_ATT_JSON" ] || FM_ATT_JSON='[]' + # 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 ' + "\(length) \([.[] | select(.class == "decision")] | length)", + "--attention--", (.[].identity), + "--decisions--", (.[] | select(.class == "decision") | .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') + 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_mark_surfaced +# Record that this exact set has reached a captain-facing surface. Bounded: one +# two-line file, always overwritten, never appended. +fm_attention_mark_surfaced() { # + local state=$1 att=$2 dec=$3 + [ -d "$state" ] || return 0 + printf 'attention=%s\ndecisions=%s\n' "$att" "$dec" > "$state/.captain-attention" 2>/dev/null || 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_COUNT" -eq 0 ] +} diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh new file mode 100755 index 0000000..68b9577 --- /dev/null +++ b/bin/fm-attention.sh @@ -0,0 +1,269 @@ +#!/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 data/backlog.md and state/*.status. This command +# parses no fleet state of its own and writes nothing except the surfaced-digest +# marker described below. +# +# Every open item is exactly one of two things: +# a decision - your answer is needed before the work can move; +# a wait - a declared external delay, shown with what is being awaited and +# when it is next checked. +# A delay that has been re-reported FM_ATTENTION_WAIT_REDECLARES times without +# clearing is promoted to a decision, because it is no longer resolving on its +# own. That promotion is derived from the event fold, never from the wording. +# +# RENDERING IS SURFACING. Printing (the default view or --brief) records the +# current set's digest in state/.captain-attention, so the guards stop +# interrupting for a set that has already reached a captain-facing surface. An +# ordinary read that changes nothing therefore never becomes a false alarm, and +# an item that is answered simply drops out. --json and --status never mark. +# Open items stay rendered until they are resolved; the marker bounds the +# interrupt, not the ledger. +# +# 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 belongs +# in guard banners and the session-start digest, never in captain chat. +# +# Usage: +# fm-attention.sh captain-facing view (marks surfaced) +# fm-attention.sh --brief one line per item, with identifiers (marks surfaced) +# fm-attention.sh --json the raw record array (read-only) +# fm-attention.sh --status counts and change flags (read-only) +# fm-attention.sh --no-mark render without recording the digest +# fm-attention.sh -h | --help +# +# Exit status is 0 whenever the set could be read; this is a reporting command, +# never a gate. Exit 3 means jq is unavailable, so the set could not be derived. +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 +MARK=1 +while [ "$#" -gt 0 ]; do + case "$1" in + --brief) MODE=brief ;; + --json) MODE=json ;; + --status) MODE=status ;; + --no-mark) MARK=0 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +fm_attention_status "$FM_HOME" +if [ "$FM_ATT_AVAILABLE" != true ]; then + echo "fm-attention: jq is required to read the open decision and wait set" >&2 + exit 3 +fi + +# 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 escalated redeclares next opts + + 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') + escalated=$(field "$i" '.escalated') + redeclares=$(field "$i" '.redeclares') + detail=$(field "$i" '.detail') + printf '\n' + wrap_prefixed "$(printf '%s. ' "$num")" 3 "$headline" + if [ "$escalated" = true ]; then + wrap 3 "This was reported as a routine delay, but it has been re-reported $redeclares times without clearing, so it is no longer resolving on its own." + printf ' The choice: keep waiting on this, or change the plan so it stops holding the work up.\n' + printf ' What was reported:\n' + wrap 5 "$detail" + elif [ "$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) + 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 briefed escalated 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') + briefed=$(field "$i" '.briefed') + escalated=$(field "$i" '.escalated') + mark='' + if [ "$class" = decision ]; then + if [ "$escalated" = true ]; then + mark=' [repeated delay, no longer clearing]' + elif [ "$briefed" != 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 + 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 + [ "$MARK" -eq 1 ] && fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" + ;; + *) + render_view + [ "$MARK" -eq 1 ] && fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" + ;; +esac +exit 0 diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index aeb140a..f0e7ce7 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>] \ +# [--choice <text>] [--why-now <text>] [--cost-of-waiting <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 and must not contain a backslash, so the stored body round-trips +# unambiguously. Supplying any briefing flag rewrites the briefing block and +# preserves unrecognized body lines; supplying none leaves an existing briefing +# untouched, so a plain idempotent retry never erases a written briefing. A hold +# with no briefing still renders and is marked as not yet written, rather than +# presenting a raw operational note as if it were plain language. +# # `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,49 @@ validate_one_line() { # <label> <value> esac } +# A briefing value is stored inside the hold body, which tasks-axi round-trips +# with backslash escapes. Banning the backslash keeps that decoding unambiguous +# without restricting ordinary captain-facing prose. +validate_brief_field() { # <label> <value> + local label=$1 value=$2 + validate_one_line "$label" "$value" + case "$value" in + *\\*) fail "$label must not contain a backslash" ;; + esac +} + +# tasks-axi prints a body as one quoted line with \n and \" escapes; decode it +# back to real lines. Backslashes are banned in briefing values, so the two +# replacements below cannot collide. +decode_body() { # <shown-body> + local b=$1 + b=${b#\"} + b=${b%\"} + printf '%s' "$b" | sed -e 's/\\n/\ +/g' -e 's/\\"/"/g' +} + +# Keep only body lines this script does not own: the base Origin/Decision +# key/State lines and the whole captain-briefing block are rebuilt from +# arguments, everything else a human added is preserved verbatim. +preserved_body_lines() { # <decoded-body> + printf '%s\n' "$1" | awk \ + -v header="$FM_ATTENTION_BRIEF_HEADER" \ + -v choice="$FM_ATTENTION_BRIEF_CHOICE" \ + -v why="$FM_ATTENTION_BRIEF_WHY" \ + -v cost="$FM_ATTENTION_BRIEF_COST" \ + -v option="$FM_ATTENTION_BRIEF_OPTION" \ + -v recommend="$FM_ATTENTION_BRIEF_RECOMMEND" ' + function starts(s, p) { return substr(s, 1, length(p)) == p } + $0 == header { next } + starts($0, choice) || starts($0, why) || starts($0, cost) { next } + starts($0, option) || starts($0, recommend) { next } + starts($0, "Origin: ") || starts($0, "Decision key: ") { next } + $0 == "State: awaiting captain decision." { next } + { print } + ' | sed -e '/^[[:space:]]*$/d' +} + sha256_text() { # <text> if command -v shasum >/dev/null 2>&1; then printf '%s' "$1" | shasum -a 256 | awk '{print $1}' @@ -230,6 +291,8 @@ command_id() { command_hold() { local origin=${1:-} key=${2:-} title='' reason='' repo='' id show state kind existing_title body + local choice='' why='' cost='' recommend='' brief_given=0 brief_block='' preserved='' decoded opt + local -a options=() [ "$#" -ge 2 ] || { usage >&2; exit 2; } shift 2 while [ "$#" -gt 0 ]; do @@ -237,6 +300,11 @@ command_hold() { --title) shift; title=${1:-} ;; --reason) shift; reason=${1:-} ;; --repo) shift; repo=${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 +314,15 @@ 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 + [ -z "$choice" ] || validate_brief_field choice "$choice" + [ -z "$why" ] || validate_brief_field why-now "$why" + [ -z "$cost" ] || validate_brief_field cost-of-waiting "$cost" + [ -z "$recommend" ] || validate_brief_field recommend "$recommend" + for opt in "${options[@]+"${options[@]}"}"; do validate_brief_field option "$opt"; done + brief_block=$(fm_attention_brief_lines "$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,6 +333,17 @@ 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" + # No briefing flags means a plain idempotent retry: never touch the body, so + # an already-written captain briefing cannot be silently erased. + if [ "$brief_given" -eq 1 ]; then + decoded=$(decode_body "$(show_field "$show" body)") + preserved=$(preserved_body_lines "$decoded") + 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") + [ -z "$preserved" ] || body=$(printf '%s\n\n%s' "$body" "$preserved") + tasks_axi update "$id" --body "$body" >/dev/null \ + || fail "could not record the captain briefing on $id" + fi else if [ -z "$repo" ] && [ -f "$STATE/$origin.meta" ]; then repo=$(meta_value "$STATE/$origin.meta" project) @@ -265,6 +353,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..021d8c8 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 declared +# 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,33 @@ 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_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" --brief --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 + # A read-only session must leave the record for the session that owns this + # home, exactly as it leaves the stale-watcher episode marker alone. + [ "$READ_ONLY" -eq 1 ] \ + || fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" + 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-session-start.sh b/bin/fm-session-start.sh index 1abbace..94bb71b 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -35,14 +35,21 @@ # 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 declared 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. A locked session records the +# surfaced digest; a read-only session does not. +# 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 +296,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 +315,27 @@ 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. +# A locked session records the surfaced digest so the guards stop interrupting +# for a set the session has now seen; a read-only session leaves that record to +# the session that owns the home. +subsection "CAPTAIN'S CALL" +if [ "$READ_ONLY" -eq 1 ]; then + ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --brief --no-mark 2>&1) +else + ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --brief 2>&1) +fi +if [ -n "$ATTENTION_OUT" ]; then + printf '%s\n' "$ATTENTION_OUT" +else + printf '(unavailable)\n' +fi +printf 'Relay every open decision and wait to the captain in plain language: bin/fm-attention.sh\n' + +# --- 5. supervision operating instructions ---------------------------------- AFK_PRESENT=0 [ -e "$STATE/.afk" ] && AFK_PRESENT=1 X_MODE_PRESENT=0 @@ -332,7 +362,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 +370,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 +424,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..cbb2fc5 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,15 @@ 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 ;; 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..5b9a3c8 100755 --- a/bin/fm-turnend-guard-grok.sh +++ b/bin/fm-turnend-guard-grok.sh @@ -74,10 +74,20 @@ 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' +# 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.' + ;; +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..da8e91f 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -28,6 +28,16 @@ # primary checkout - the main home or a genuinely marked secondmate home - and # stay a silent, fast no-op inside child task worktrees. # +# 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. It is bounded by +# construction rather than by a budget: it records the surfaced digest before +# blocking, so one distinct set of open decisions costs at most one forced +# continuation, on every harness. +# # Loop-guard, codex/Grok (default) mode: never block 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 @@ -70,6 +80,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 +97,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" @@ -133,20 +149,61 @@ 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. +# +# Strictly one block per DISTINCT decision set, and never a loop: the surfaced +# digest is recorded BEFORE exiting, so the very next turn end sees the same set +# as already surfaced and allows. Identities carry no prose, so re-declaring the +# same wait cannot manufacture a new set. Declared waits deliberately do NOT +# block - they need no captain action, and blocking on them would turn every +# routine delay into an interruption; they surface through bin/fm-guard.sh and +# the session-start digest instead. Any harness whose adapter honors this +# script's exit status gets the behavior; nothing here is harness-specific. +block_attention() { + local rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" + { + 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" --brief --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 + exit 2 +} + +attention_would_block() { + [ "$FM_ATTENTION_TURNEND_BLOCK" -eq 1 ] || return 1 + fm_attention_status "$FM_HOME" + [ "$FM_ATT_AVAILABLE" = true ] || return 1 + [ "$FM_ATT_DECISIONS" -gt 0 ] || return 1 + [ "$FM_ATT_DECISIONS_NEW" = true ] +} + fm_supervision_status "$STATE" "$GRACE" if [ "$CLAUDE_MODE" -eq 1 ]; then if [ "$FM_SUP_NEEDED" = false ]; then budget_reset + attention_would_block && block_attention exit 0 fi else if [ "$FM_SUP_IN_FLIGHT" -eq 0 ]; then budget_reset + attention_would_block && block_attention exit 0 fi fi if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then budget_reset + attention_would_block && block_attention exit 0 fi diff --git a/docs/captain-attention.md b/docs/captain-attention.md new file mode 100644 index 0000000..661cb2a --- /dev/null +++ b/docs/captain-attention.md @@ -0,0 +1,69 @@ +# 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 declared external delay that needs no captain action, but still owes the captain what is being awaited and when it is next checked. + +A wait that has been re-declared past `FM_ATTENTION_WAIT_REDECLARES` (default 3) without ever clearing is promoted to a decision, because a delay that keeps repeating is no longer resolving on its own. +That promotion is derived from the keyed event fold, never from reading the wording of the delay, so it cannot fire on phrasing alone. + +## Where it comes from + +`bin/fm-attention-lib.sh` is the single owner of the contract. +It derives the open set from state firstmate already keeps durably - `data/backlog.md` and `state/*.status` - and stores nothing of its own except the surfaced-digest marker below. +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. + +## 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` accepts `--choice`, `--why-now`, `--cost-of-waiting`, repeatable `--option`, and `--recommend`, and stores that plain language in the hold's own durable body. +Supplying any briefing flag rewrites the briefing and preserves unrecognized body lines; supplying none leaves an existing briefing untouched, so an idempotent retry never erases one. +A decision with no briefing still renders and is marked as not yet written, rather than presenting a raw operational note as if it were plain language. + +## Where the captain sees it + +`bin/fm-attention.sh` is the single captain-facing place. +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. + +The same set also reaches ordinary replies through three integrated paths, so nothing depends on remembering to look: + +- `bin/fm-session-start.sh` prints it as its own digest section, before the supervision block and the context. +- `bin/fm-guard.sh` surfaces a changed set 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. + +## Surfacing, deduplication, and false alarms + +`state/.captain-attention` records the digest of the set most recently rendered to a captain-facing surface. +Rendering is surfacing: printing records the digest as a side effect, so an ordinary read that changes nothing can never become an alarm. +Identities carry no prose, so a delay re-reported hourly with new wording stays one item and surfaces once. +The marker bounds the interrupt only - an open item stays listed until it is answered or clears. + +The turn-end stop is bounded by construction rather than by a budget: it records the surfaced digest before blocking, so one distinct set of open decisions costs at most one forced continuation on any harness. +Declared waits never stop a turn. +`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/documentation-audiences.json b/docs/documentation-audiences.json index e399e48..ccc6744 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -211,6 +211,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/scripts.md b/docs/scripts.md index fa7bda3..b591d06 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -16,13 +16,14 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `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-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 | diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 8ee750d..5170399 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -36,6 +36,17 @@ 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 predicate above counts `state/*.meta`, so a primary whose only live work is an unanswered captain decision reaches every allow path with zero in flight and ends 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 never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it records the surfaced digest before blocking, so one distinct set of open decisions costs at most one forced continuation on any harness. +Declared external delays 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`. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index d5f3e54..002262c 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -175,6 +175,64 @@ 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-28. + +The captain-attention contract adds two captain-facing surfaces: 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` | Exit 2 schedules exactly one bounded TUI follow-up, with the headline selected the same way. | +| Pi and pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts`, passive `agent_settled` | Same passive one-follow-up path and the same headline selection. | +| 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 `data/backlog.md` and `state/*.status` 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 +``` + +Observed output: + +```text +ok - a recorded captain explanation keeps its full text, and grouped metadata still parses +ok - a briefed captain decision renders the choice, the stakes, the options, and a recommendation +ok - a decision with no captain briefing renders honestly instead of faking plain language +ok - a routine delay states what it awaits and when it is next checked +ok - a delay that keeps repeating becomes a captain decision, and progress ends it +ok - attention identities ignore wording, so a repeated delay surfaces once +ok - ordinary reads and re-renders never become false alarms +ok - an open item stays visible until it resolves, even once its interrupt is spent +ok - a resolved decision clears from the captain view +ok - an idempotent retry preserves a written briefing and an explicit update replaces it +ok - unaccounted primary work reads as suspicious, not idle +ok - a turn cannot end on an unsurfaced captain decision, and each set costs one stop +ok - declared waits surface without ever stopping a turn +ok - the captain view is plain language while the brief form keeps identifiers +ok - an empty captain's call is stated, not omitted +``` + +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 100644 index 0000000..5e71e02 --- /dev/null +++ b/tests/fm-attention.test.sh @@ -0,0 +1,470 @@ +#!/usr/bin/env bash +# Regression tests for the captain-attention contract: open decisions and +# declared waits must reach the captain in plain language, exactly once per +# distinct set, and must clear when they resolve. +# +# 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> + local home=$1 + printf '{"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 +} + +add_row() { # <home> <section> <line> + local home=$1 section=$2 line=$3 + awk -v section="## $section" -v row="$line" ' + { print } + $0 == section { print row } + ' "$home/data/backlog.md" > "$home/data/backlog.md.tmp" + mv "$home/data/backlog.md.tmp" "$home/data/backlog.md" +} + +# --- 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 \ + --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_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" +} + +# --- 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_repeated_wait_escalates_to_a_decision() { + local home out + home=$(make_home escalating-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=1' "a delay re-reported past the threshold must become a decision" + out=$(attention "$home" --no-mark) + assert_contains "$out" 're-reported 3 times' \ + "the escalation must say why it stopped counting as routine" + assert_contains "$out" 'keep waiting on this, or change the plan' \ + "the escalation must name the choice it puts to the captain" + + # A resolution of that keyed phase ends the run, so the same work does not stay + # escalated forever once it genuinely moves again. + printf 'working: the fork sync landed; rebasing now\n' >> "$home/state/task-board.status" + out=$(attention "$home" --no-mark --status) + assert_contains "$out" 'decisions=0' "progress must end the escalation" + pass "a delay that keeps repeating becomes a captain decision, and progress ends it" +} + +# --- 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" + 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_ordinary_reads_do_not_become_false_alarms() { + local home out + home=$(make_home quiet-reads) + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the change (repo: sample) (kind: captain) (since 2026-07-28) (hold: a reason) (hold-kind: captain)' + 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") + [ -z "$out" ] || fail "an ordinary read produced a false alarm: $out" + # And rendering must not resurrect the alarm either: rendering IS surfacing. + attention "$home" >/dev/null + 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_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the change (repo: sample) (kind: captain) (since 2026-07-28) (hold: a reason) (hold-kind: captain)' + attention "$home" >/dev/null + out=$(attention "$home" --no-mark) + assert_contains "$out" 'Approve the change' \ + "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 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --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 \ + --choice 'Sync now, or rebase onto the current fork main.' \ + --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' \ + --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" +} + +# --- 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_stops_once_on_an_unsurfaced_decision() { + local home out status + home=$(make_primary_home turnend) + add_row "$home" Queued \ + '- [ ] sample-decision-x - Approve the worker installs (repo: sample) (kind: captain) (since 2026-07-28) (hold: the machine needs approval) (hold-kind: captain)' + + 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" + + # Bounded by construction: the same set costs exactly one forced continuation. + out=$(run_turnend "$home"); status=$? + expect_code 0 "$status" "the same decision set must not stop a second turn" + [ -z "$out" ] || fail "the second turn end produced output: $out" + + # A genuinely new decision is a new set and stops once more. + add_row "$home" Queued \ + '- [ ] sample-decision-y - Approve the second thing (repo: sample) (kind: captain) (since 2026-07-28) (hold: another approval) (hold-kind: captain)' + run_turnend "$home" >/dev/null; status=$? + expect_code 2 "$status" "a genuinely new decision must stop the turn" + run_turnend "$home" >/dev/null; status=$? + expect_code 0 "$status" "the new set must also cost only one continuation" + pass "a turn cannot end on an unsurfaced captain decision, and each set costs one stop" +} + +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_unbriefed_decision_is_honest_about_missing_language +test_routine_wait_states_what_it_awaits_and_when_it_is_next_checked +test_repeated_wait_escalates_to_a_decision +test_identities_ignore_wording_so_repeats_do_not_re_alarm +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_unaccounted_primary_work_is_not_idle +test_turn_end_stops_once_on_an_unsurfaced_decision +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-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 2b82165..09ddf98 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -107,9 +107,16 @@ 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" + # The captain's-call turn-end stop. fm-attention-lib.sh sources only these two + # small pure libraries; the backlog projection it shells out to is optional and + # degrades to an empty set, which is why fm-fleet-snapshot.sh is not installed + # here and a fixture with no backlog reports nothing open. + 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-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" } mark_codex_hook_root() { From 3ac611f6481ba9808dae086cc5f985e441001b5f Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Tue, 28 Jul 2026 23:43:53 +0200 Subject: [PATCH 02/18] docs: route the captain-attention contract from architecture and decision holds Point the maintainer-architecture and decision-hold mechanism surfaces at captain-attention.md rather than restating the contract, and record the durable briefing that fm-decision-hold.sh now stores on a captain hold. --- docs/architecture.md | 5 +++++ docs/decision-hold-lifecycle.md | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index c7f4bb2..ca6916a 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 captain-facing renderer. + 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/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md index 234055a..c927c17 100644 --- a/docs/decision-hold-lifecycle.md +++ b/docs/decision-hold-lifecycle.md @@ -13,6 +13,12 @@ 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 records the durable captain briefing that makes the decision answerable: the concrete choice, why it matters now, what waiting costs, repeatable options, and a recommendation, each one line and free of backslashes so the stored body round-trips unambiguously. +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. +Supplying any briefing flag rewrites the block and preserves unrecognized body lines; supplying none leaves an existing 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. From dd3180f3d5313bee7dd5de9f4879ae3536d27ce1 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 00:02:48 +0200 Subject: [PATCH 03/18] fix(attention): treat a captain-gated work item as a decision The captain inbox board that landed on main carries evidence this contract missed: selecting decisions on the snapshot's captain_actionable flag also requires the backlog item's own kind to be "captain", but the documented way to gate ordinary work on the captain is tasks-axi hold <id> --reason "<reason>" --kind captain which leaves kind as ship. The flag is therefore false for exactly the threads this contract exists for. Reproduced on the real shape of the reported case: a ship item held for the captain rendered as "0 decisions need you, 1 thing is waiting", filed under waiting on something else with "next check: when the work it is waiting on finishes" - the fleet-is-always-waiting symptom, reproduced through the new surface itself. Select on the captain hold instead, whatever the item's kind says, and keep a hold with an unresolved blocker as a wait so a future-gated hold does not nag now. captain_actionable is deliberately left alone: bearings and the secondmate summary consume it with their own meanings and tests. This is also the rule the inbox board already uses, so the two captain-facing surfaces cannot disagree about what needs the captain. Record the relationship in both directions rather than leaving them reading as rival owners: this contract owns the derived set, its plain language, and the interrupt budget; the board is where the captain answers one in a browser. Correct one rationale the parser fix in the previous commit made stale: fm-inbox-view.sh no longer needs tasks-axi because the snapshot truncates a hold reason at its first comma, but it does still need it for the length cap and for the durable briefing, which the snapshot carries no body for. --- AGENTS.md | 4 ++-- bin/fm-attention-lib.sh | 17 +++++++++++++++-- bin/fm-inbox-view.sh | 8 +++++--- docs/captain-attention.md | 10 +++++++++- docs/inbox-board.md | 4 +++- tests/fm-attention.test.sh | 30 ++++++++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bdd52ed..afccc74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai 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. **Captain's call** - every open captain decision and declared 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` is the single captain-facing renderer and `docs/captain-attention.md` is the operator reference; a locked session records the surfaced set, a read-only session does not. + `bin/fm-attention.sh` renders it and `docs/captain-attention.md` is the operator reference; a locked session records the surfaced set, a read-only session does not. 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.). 6. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/<id>.meta`; a bounded tail of each task's `state/<id>.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. @@ -424,7 +424,7 @@ Reach the captain immediately for: - A needed credential or login. Never let the captain discover a stop by noticing that nothing moved. -`bin/fm-attention.sh` is the single captain-facing owner of every open decision and meaningful wait, and it prints them already translated; relay its content, and never send a reply that leaves an open decision unstated. +`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, the real options where they help, 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. diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 44c81a0..6e2bc3d 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -267,9 +267,22 @@ fm_attention_json() { # <fm-home> options:fields($lines; $f_option), recommendation:field($lines; $f_recommend)} 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 <id> --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(.structured == true and .captain_actionable == true) + | select(is_captain_decision) | {class:"decision", identity:("decision:" + .id), source:"captain-hold", @@ -287,7 +300,7 @@ fm_attention_json() { # <fm-home> [ $backlog.records[]? | select(.structured == true and (.state == "queued" or .state == "in_flight") - and .captain_actionable != true + and (is_captain_decision | not) and ((.hold_reason != null and .hold_kind != null) or ((.unresolved_blocker_ids // []) | length) > 0)) | {class:"wait", 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/docs/captain-attention.md b/docs/captain-attention.md index 661cb2a..57e86d0 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -21,6 +21,10 @@ It derives the open set from state firstmate already keeps durably - `data/backl 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. +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. @@ -30,7 +34,7 @@ A decision with no briefing still renders and is marked as not yet written, rath ## Where the captain sees it -`bin/fm-attention.sh` is the single captain-facing place. +`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. The same set also reaches ordinary replies through three integrated paths, so nothing depends on remembering to look: @@ -42,6 +46,10 @@ The same set also reaches ordinary replies through three integrated paths, so no `/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 rendered to a captain-facing surface. 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/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 5e71e02..c395d39 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -140,6 +140,35 @@ test_briefed_decision_renders_concretely() { pass "a briefed captain decision renders the choice, the stakes, the options, and a recommendation" } +# 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) @@ -455,6 +484,7 @@ test_empty_home_says_so_plainly() { test_hold_reason_keeps_its_full_text test_briefed_decision_renders_concretely +test_a_captain_gated_work_item_is_a_decision_not_a_delay test_unbriefed_decision_is_honest_about_missing_language test_routine_wait_states_what_it_awaits_and_when_it_is_next_checked test_repeated_wait_escalates_to_a_decision From 3e4a04ca7762959ceccbb564e98eecc54473bbda Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 00:19:01 +0200 Subject: [PATCH 04/18] no-mistakes(review): Fix captain attention surfacing gaps --- bin/fm-attention-lib.sh | 207 +++++++++++++++++++++++++++---------- bin/fm-attention.sh | 27 +++-- bin/fm-guard.sh | 10 +- bin/fm-session-start.sh | 5 +- bin/fm-turnend-guard.sh | 9 +- docs/captain-attention.md | 9 +- docs/turnend-guard.md | 2 +- tests/fm-attention.test.sh | 120 +++++++++++++++++++++ 8 files changed, 310 insertions(+), 79 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 6e2bc3d..ad54218 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -37,13 +37,14 @@ # IDENTITY AND DEDUPLICATION # Every record carries a stable, TEXT-FREE identity: # decision:<hold-id> a captain-held backlog item -# decision:<task-id>:<key> an open keyed status decision -# wait:<task-id>:<key> an open keyed status wait +# decision:<task-id>:<key>:<generation> an open keyed status decision +# wait:<task-id>:<key>:<generation> an open keyed status wait # wait:<backlog-id> 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, so a standing delay is surfaced -# once rather than every hour. An escalated wait keeps its wait identity and only -# changes class, so escalation itself surfaces exactly once. +# 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. An escalated wait keeps its wait +# identity and only changes class, so escalation itself surfaces exactly once. # # SURFACING # state/.captain-attention records the digest of the set most recently rendered @@ -138,28 +139,6 @@ fm_attention_brief_lines() { # --- status-side collection -------------------------------------------------- -# Count how many times the currently open wait phase for <key> has been -# re-declared in <status-file>. A non-wait event carrying the same key resets the -# count, so only the CURRENT uninterrupted run of declarations is counted. -fm_attention_wait_redeclares() { # <status-file> <key> - local f=$1 want=$2 line verb key pause n=0 stripped - [ -f "$f" ] || { printf '0'; return 0; } - pause=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} - 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 - [ "$key" = "$want" ] || continue - if [ "$verb" = "$pause" ]; then - n=$((n + 1)) - else - n=0 - fi - done < "$f" - printf '%s' "$n" -} - # 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. @@ -188,14 +167,65 @@ fm_attention_wait_next_check() { # <status-file> <state-dir> printf '%s' "$(( m + cadence - now ))" } +_fm_attention_drop_key() { # <open-set> <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 <<EOF +$set +EOF + printf '%s' "$out" +} + +_fm_attention_row_for_key() { # <open-set> <key> + 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 <<EOF +$set +EOF + return 1 +} + +_fm_attention_generation_get() { # <generation-set> <key> + 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 <<EOF +$set +EOF + printf '0' +} + +_fm_attention_generation_set() { # <generation-set> <key> <generation> + 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: -# <class> <task-id> <key> <verb> <redeclares> <next-check-secs> <note> +# <class> <task-id> <key> <generation> <verb> <redeclares> <next-check-secs> <note> # 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() { # <state-dir> - local state=$1 meta id status kind last verb key note n next + local state=$1 meta id status kind last verb key note n next line stripped pause resolve held + local decisions waits decision_gens wait_gens existing gen old_key old_verb old_gen old_n old_note + 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} for meta in "$state"/*.meta; do [ -f "$meta" ] || continue id=$(basename "$meta" .meta) @@ -208,20 +238,75 @@ fm_attention_status_rows() { # <state-dir> verb=$(status_line_verb "$last") case "$verb" in done|failed) continue ;; esac fi - while IFS=$'\t' read -r key verb note; do + decisions='' + waits='' + decision_gens='' + wait_gens='' + 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") + case "$verb" in + needs-decision|blocked) + existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r old_key old_verb gen old_note <<EOF +$existing +EOF + else + gen=$(_fm_attention_generation_get "$decision_gens" "$key") + gen=$((gen + 1)) + decision_gens=$(_fm_attention_generation_set "$decision_gens" "$key" "$gen") + fi + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + decisions="${decisions}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${note}"$'\n' + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + ;; + "$resolve"|"$held") + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + ;; + done|failed|working) + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + ;; + "$pause") + existing=$(_fm_attention_row_for_key "$waits" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r old_key old_verb gen n old_note <<EOF +$existing +EOF + n=$((n + 1)) + else + gen=$(_fm_attention_generation_get "$wait_gens" "$key") + gen=$((gen + 1)) + wait_gens=$(_fm_attention_generation_set "$wait_gens" "$key" "$gen") + n=1 + fi + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + waits="${waits}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${n}"$'\t'"${note}"$'\n' + ;; + esac + done < "$status" + while IFS=$'\t' read -r key verb gen note; do [ -n "$key" ] || continue - printf 'decision\t%s\t%s\t%s\t0\t-1\t%s\n' "$id" "$key" "$verb" "$note" + printf 'decision\t%s\t%s\t%s\t%s\t0\t-1\t%s\n' "$id" "$key" "$gen" "$verb" "$note" done <<EOF -$(status_open_decisions "$status") +$decisions EOF next=$(fm_attention_wait_next_check "$status" "$state") - while IFS=$'\t' read -r key verb note; do + while IFS=$'\t' read -r key verb gen n note; do [ -n "$key" ] || continue - [ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ] || continue - n=$(fm_attention_wait_redeclares "$status" "$key") - printf 'wait\t%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$key" "$verb" "$n" "$next" "$note" + printf 'wait\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$key" "$gen" "$verb" "$n" "$next" "$note" done <<EOF -$(status_open_activities "$status") +$waits EOF done return 0 @@ -255,17 +340,35 @@ fm_attention_json() { # <fm-home> ([ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") ] | .[0]) // null; def fields($lines; $label): [ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") ]; + def empty_briefing: + {briefed:false,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 - {briefed:false,choice:null,why_now:null,cost_of_waiting:null,options:[],recommendation:null} + empty_briefing else - {briefed:true, - choice:field($lines; $f_choice), - why_now:field($lines; $f_why), - cost_of_waiting:field($lines; $f_cost), - options:fields($lines; $f_option), - recommendation:field($lines; $f_recommend)} + (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, + 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 @@ -316,16 +419,16 @@ fm_attention_json() { # <fm-home> escalated:false, next_check_seconds:null, briefed:false,choice:null,why_now:null,cost_of_waiting:null, - options:[],recommendation:null} ]; + options:[],recommendation:null,briefing_missing:[]} ]; def status_rows: [ inputs | select(length > 0) | split("\t") - | select(length >= 7) - | {class:.[0],id:.[1],key:.[2],verb:.[3], - redeclares:(.[4] | tonumber? // 0), - next_check_seconds:(.[5] | tonumber? // -1), - note:(.[6:] | join("\t"))} ]; + | select(length >= 8) + | {class:.[0],id:.[1],key:.[2],generation:.[3],verb:.[4], + redeclares:(.[5] | tonumber? // 0), + next_check_seconds:(.[6] | tonumber? // -1), + note:(.[7:] | 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, @@ -336,7 +439,7 @@ fm_attention_json() { # <fm-home> [ status_rows[] | (.redeclares >= $escalate) as $esc | {class:(if .class == "wait" and $esc then "decision" else .class end), - identity:(.class + ":" + .id + ":" + .key), + identity:(.class + ":" + .id + ":" + .key + ":" + .generation), source:(if .class == "wait" then "declared-wait" else "status-decision" end), id:.id, key:.key, @@ -348,7 +451,7 @@ fm_attention_json() { # <fm-home> escalated:(.class == "wait" and $esc), next_check_seconds:(if .class == "wait" then .next_check_seconds else null end), briefed:false,choice:null,why_now:null,cost_of_waiting:null, - options:[],recommendation:null} ]; + options:[],recommendation:null,briefing_missing:[]} ]; (backlog_decisions + status_records) 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 diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh index 68b9577..8e9b843 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -14,22 +14,22 @@ # clearing is promoted to a decision, because it is no longer resolving on its # own. That promotion is derived from the event fold, never from the wording. # -# RENDERING IS SURFACING. Printing (the default view or --brief) records the -# current set's digest in state/.captain-attention, so the guards stop +# RENDERING IS SURFACING. Printing the default view records the current set's +# digest in state/.captain-attention, so the guards stop # interrupting for a set that has already reached a captain-facing surface. An # ordinary read that changes nothing therefore never becomes a false alarm, and -# an item that is answered simply drops out. --json and --status never mark. -# Open items stay rendered until they are resolved; the marker bounds the +# an item that is answered simply drops out. --brief, --json, and --status never +# mark. Open items stay rendered until they are resolved; the marker bounds the # interrupt, not the ledger. # # 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 belongs -# in guard banners and the session-start digest, never in captain chat. +# --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 (marks surfaced) -# fm-attention.sh --brief one line per item, with identifiers (marks surfaced) +# 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 --no-mark render without recording the digest @@ -139,7 +139,7 @@ field() { # <index> <jq-path> 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 escalated redeclares next opts + local class headline choice why cost recommend detail awaiting briefed escalated redeclares next opts missing printf "CAPTAIN'S CALL\n" if [ "$total" -eq 0 ]; then @@ -188,6 +188,16 @@ $opts EOF fi [ -z "$recommend" ] || { printf ' Recommended:\n'; wrap 5 "$recommend"; } + missing=$(printf '%s' "$FM_ATT_JSON" | jq -r --argjson i "$i" '.[$i].briefing_missing[]?' 2>/dev/null) + if [ -n "$missing" ]; then + printf ' Still needs:\n' + while IFS= read -r missing; do + [ -n "$missing" ] || continue + wrap_prefixed ' - ' 7 "$missing" + done <<EOF +$missing +EOF + fi else printf ' No plain-language explanation has been written for this one yet.\n' printf ' What was recorded:\n' @@ -259,7 +269,6 @@ case "$MODE" in ;; brief) render_brief - [ "$MARK" -eq 1 ] && fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" ;; *) render_view diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 021d8c8..f7c7b29 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -167,14 +167,14 @@ if [ "$FM_GUARD_NO_ATTENTION" != 1 ]; then 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" --brief --no-mark 2>/dev/null | sed 's/^/● /' + if [ "$READ_ONLY" -eq 1 ]; then + "$SCRIPT_DIR/fm-attention.sh" --no-mark 2>/dev/null | sed 's/^/● /' + else + "$SCRIPT_DIR/fm-attention.sh" 2>/dev/null | sed 's/^/● /' + fi printf '● Relay every open decision and wait to the captain in plain language in your next reply.\n' printf '●%s\n' "$arule" } >&2 - # A read-only session must leave the record for the session that owns this - # home, exactly as it leaves the stale-watcher episode marker alone. - [ "$READ_ONLY" -eq 1 ] \ - || fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" fi fi diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 94bb71b..d78070b 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -324,16 +324,15 @@ fi # the session that owns the home. subsection "CAPTAIN'S CALL" if [ "$READ_ONLY" -eq 1 ]; then - ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --brief --no-mark 2>&1) + ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --no-mark 2>&1) else - ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --brief 2>&1) + ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" 2>&1) fi if [ -n "$ATTENTION_OUT" ]; then printf '%s\n' "$ATTENTION_OUT" else printf '(unavailable)\n' fi -printf 'Relay every open decision and wait to the captain in plain language: bin/fm-attention.sh\n' # --- 5. supervision operating instructions ---------------------------------- AFK_PRESENT=0 diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index da8e91f..fdb6a80 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -34,9 +34,9 @@ # 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. It is bounded by -# construction rather than by a budget: it records the surfaced digest before -# blocking, so one distinct set of open decisions costs at most one forced -# continuation, on every harness. +# construction rather than by a budget: it renders the captain-facing view before +# blocking, and that render records the surfaced digest, so one distinct set of +# open decisions costs at most one forced continuation, on every harness. # # Loop-guard, codex/Grok (default) mode: never block twice in the same turn. # Codex uses stop_hook_active and Grok uses stopHookActive; typed camel-case @@ -165,12 +165,11 @@ budget_reset() { # script's exit status gets the behavior; nothing here is harness-specific. block_attention() { local rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' - fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" { 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" --brief --no-mark 2>/dev/null | sed 's/^/● /' + "$SCRIPT_DIR/fm-attention.sh" 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' diff --git a/docs/captain-attention.md b/docs/captain-attention.md index 57e86d0..5795177 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -31,11 +31,12 @@ A title and a one-line reason cannot carry a decision, so every renderer could o `bin/fm-decision-hold.sh hold` accepts `--choice`, `--why-now`, `--cost-of-waiting`, repeatable `--option`, and `--recommend`, and stores that plain language in the hold's own durable body. Supplying any briefing flag rewrites the briefing and preserves unrecognized body lines; supplying none leaves an existing briefing untouched, so an idempotent retry never erases one. A decision with no briefing still renders and is marked as not yet written, rather than presenting a raw operational note as if it were plain language. +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. +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 three integrated paths, so nothing depends on remembering to look: @@ -53,11 +54,11 @@ 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 rendered to a captain-facing surface. -Rendering is surfacing: printing records the digest as a side effect, so an ordinary read that changes nothing can never become an alarm. -Identities carry no prose, so a delay re-reported hourly with new wording stays one item and surfaces once. +Rendering the default captain-facing view is surfacing: printing it records the digest as a side effect, so an ordinary read that changes nothing can never become an alarm. +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. The marker bounds the interrupt only - an open item stays listed until it is answered or clears. -The turn-end stop is bounded by construction rather than by a budget: it records the surfaced digest before blocking, so one distinct set of open decisions costs at most one forced continuation on any harness. +The turn-end stop is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. Declared waits never stop a turn. `FM_ATTENTION_TURNEND_BLOCK=0` disables that stop without touching the watcher-liveness backstop, and `FM_GUARD_NO_ATTENTION=1` suppresses the guard section. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 5170399..23eb2ec 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -40,7 +40,7 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s The predicate above counts `state/*.meta`, so a primary whose only live work is an unanswered captain decision reaches every allow path with zero in flight and ends 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 never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it records the surfaced digest before blocking, so one distinct set of open decisions costs at most one forced continuation on any harness. +It fires when a captain decision is open that has never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. Declared external delays 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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index c395d39..a5c75c6 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -182,6 +182,52 @@ test_unbriefed_decision_is_honest_about_missing_language() { 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" +} + # --- 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 @@ -270,6 +316,76 @@ test_identities_ignore_wording_so_repeats_do_not_re_alarm() { 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') + attention "$home" >/dev/null + 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" + attention "$home" >/dev/null + 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') + attention "$home" >/dev/null + 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_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)' + 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 0 "$status" "the captain-safe turn-end render must record the surfaced digest" + [ -z "$out" ] || fail "the second turn end produced output: $out" + pass "the brief form never spends the captain-facing surface marker" +} + +test_guard_banner_uses_captain_safe_rendering_to_mark() { + 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=false' "the guard must let the captain-safe render spend the marker" + pass "the guard banner renders the captain-safe view and records that surface" +} + test_ordinary_reads_do_not_become_false_alarms() { local home out home=$(make_home quiet-reads) @@ -486,9 +602,13 @@ test_hold_reason_keeps_its_full_text test_briefed_decision_renders_concretely 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_routine_wait_states_what_it_awaits_and_when_it_is_next_checked test_repeated_wait_escalates_to_a_decision 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_uses_captain_safe_rendering_to_mark test_ordinary_reads_do_not_become_false_alarms test_ledger_keeps_showing_an_open_item_after_it_was_surfaced test_resolution_clears_the_item From d72c14336ed905ffe6e653ccd7353dc8721030f0 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 00:37:28 +0200 Subject: [PATCH 05/18] no-mistakes(review): Harden captain attention surfacing --- bin/fm-attention-lib.sh | 77 +++++++++++++-------- bin/fm-attention.sh | 40 +++++++++-- bin/fm-guard.sh | 11 ++- bin/fm-session-start.sh | 9 +-- bin/fm-turnend-guard.sh | 69 ++++++++++++------- docs/captain-attention.md | 9 ++- docs/turnend-guard.md | 2 + tests/fm-attention.test.sh | 119 ++++++++++++++++++++++++++++++++- tests/fm-turnend-guard.test.sh | 9 ++- 9 files changed, 272 insertions(+), 73 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index ad54218..248de42 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -48,15 +48,16 @@ # # SURFACING # state/.captain-attention records the digest of the set most recently rendered -# to a captain-facing surface: +# to a captain-facing surface by bin/fm-attention.sh: # attention=<digest over every identity> # decisions=<digest over decision identities only> # A digest that differs from the recorded one means the set CHANGED and has not # been surfaced since. Rendering is surfacing: bin/fm-attention.sh records the -# digest as a side effect of printing, so an ordinary read that changes nothing -# can never become an alarm, and an item that is answered simply drops out of -# the set. The marker never suppresses the ledger itself - an open item stays -# rendered until it is resolved; the marker only bounds the INTERRUPT. +# digest as a side effect of printing its captain-facing view, so an ordinary +# read that changes nothing can never become an alarm, and an item that is +# answered simply drops out of the set. 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 renderer (pull, single place) @@ -69,10 +70,9 @@ # endpoint, or a harness. # # DEPENDENCIES -# jq is required for the backlog projection. When jq is absent this library -# reports FM_ATT_AVAILABLE=false with zero counts so a guard degrades to its -# previous behavior instead of breaking a fleet operation, matching the existing -# "missing jq -> silent no-op" degrade in bin/fm-turnend-guard.sh. +# jq and a successful backlog projection 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="." @@ -321,11 +321,11 @@ fm_attention_json() { # <fm-home> 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 || { printf '[]'; return 1; } + 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) \ - || backlog_json='{"records":[]}' - [ -n "$backlog_json" ] || backlog_json='{"records":[]}' + "$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") printf '%s' "$rows" | jq -Rn \ --argjson backlog "$backlog_json" \ @@ -477,7 +477,9 @@ _fm_attention_digest_of() { # <newline-separated identities> # fm_attention_status <fm-home> # Populate, read-only, for the home at $1: -# FM_ATT_AVAILABLE true/false - false when jq is missing (degrade quietly) +# 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_ERROR human-readable unknown-state reason # FM_ATT_JSON the record array # FM_ATT_COUNT total open records # FM_ATT_DECISIONS open records needing the captain @@ -486,28 +488,41 @@ _fm_attention_digest_of() { # <newline-separated identities> # FM_ATT_DECISION_DIGEST digest over decision identities 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() { # <fm-home> - local home=$1 state seen_att seen_dec ids='' dec_ids='' summary + local home=$1 state seen_att seen_dec seen_unknown 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 + FM_ATT_UNKNOWN=false + FM_ATT_ERROR='' 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 + FM_ATT_UNKNOWN_NEW=false - if ! command -v jq >/dev/null 2>&1; then - # shellcheck disable=SC2034 # Read by callers after sourcing. + if ! command -v jq >/dev/null 2>&1 || ! FM_ATT_JSON=$(fm_attention_json "$home" 2>/dev/null) || [ -z "$FM_ATT_JSON" ]; then FM_ATT_AVAILABLE=false + FM_ATT_UNKNOWN=true + FM_ATT_ERROR='The open decision and wait list could not be determined.' + 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) + [ "$FM_ATT_UNKNOWN_DIGEST" = "$seen_unknown" ] || FM_ATT_UNKNOWN_NEW=true return 0 fi - FM_ATT_JSON=$(fm_attention_json "$home" 2>/dev/null) || FM_ATT_JSON='[]' - [ -n "$FM_ATT_JSON" ] || FM_ATT_JSON='[]' # 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 ' @@ -522,6 +537,19 @@ fm_attention_status() { # <fm-home> 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_ATT_AVAILABLE=false + FM_ATT_UNKNOWN=true + FM_ATT_ERROR='The open decision and wait list could not be determined.' + 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) + [ "$FM_ATT_UNKNOWN_DIGEST" = "$seen_unknown" ] || FM_ATT_UNKNOWN_NEW=true + return 0 fi # shellcheck disable=SC2034 # Read by callers after sourcing. FM_ATT_WAITS=$((FM_ATT_COUNT - FM_ATT_DECISIONS)) @@ -537,16 +565,6 @@ fm_attention_status() { # <fm-home> return 0 } -# fm_attention_mark_surfaced <state-dir> <attention-digest> <decision-digest> -# Record that this exact set has reached a captain-facing surface. Bounded: one -# two-line file, always overwritten, never appended. -fm_attention_mark_surfaced() { # <state-dir> <attention-digest> <decision-digest> - local state=$1 att=$2 dec=$3 - [ -d "$state" ] || return 0 - printf 'attention=%s\ndecisions=%s\n' "$att" "$dec" > "$state/.captain-attention" 2>/dev/null || true - return 0 -} - # fm_attention_home_idle <fm-home> [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. @@ -565,5 +583,6 @@ fm_attention_home_idle() { # <fm-home> [grace] 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 index 8e9b843..c36223b 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -3,8 +3,7 @@ # # Renders the captain-attention contract owned by bin/fm-attention-lib.sh, which # derives the open set from data/backlog.md and state/*.status. This command -# parses no fleet state of its own and writes nothing except the surfaced-digest -# marker described below. +# 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; @@ -36,7 +35,7 @@ # fm-attention.sh -h | --help # # Exit status is 0 whenever the set could be read; this is a reporting command, -# never a gate. Exit 3 means jq is unavailable, so the set could not be derived. +# never a gate. Exit 3 means the complete set could not be derived. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -71,8 +70,38 @@ while [ "$#" -gt 0 ]; do done fm_attention_status "$FM_HOME" + +mark_surfaced() { + [ -d "$STATE" ] || return 0 + printf 'attention=%s\ndecisions=%s\n' "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" > "$STATE/.captain-attention" 2>/dev/null || true + return 0 +} + +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 - echo "fm-attention: jq is required to read the open decision and wait set" >&2 + 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 @@ -144,6 +173,7 @@ render_view() { printf "CAPTAIN'S CALL\n" if [ "$total" -eq 0 ]; then printf 'Nothing needs your decision, and nothing is waiting.\n' + [ "$MARK" -eq 1 ] && mark_surfaced return 0 fi printf '%s you. %s waiting.\n' \ @@ -225,6 +255,7 @@ EOF done fi printf '\nEverything above stays listed here until it is answered or clears.\n' + [ "$MARK" -eq 1 ] && mark_surfaced } render_brief() { @@ -272,7 +303,6 @@ case "$MODE" in ;; *) render_view - [ "$MARK" -eq 1 ] && fm_attention_mark_surfaced "$STATE" "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" ;; esac exit 0 diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index f7c7b29..efa5724 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -161,7 +161,16 @@ fi # 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_NEW" = true ] && [ "$FM_ATT_COUNT" -gt 0 ]; then + 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" diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index d78070b..f2918d6 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -40,7 +40,8 @@ # block and the context because it is this turn's second # work queue and must be seen before the first # captain-facing reply. A locked session records the -# surfaced digest; a read-only session does not. +# surfaced digest when the full set can be read; a +# read-only session does not. # 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, @@ -319,9 +320,9 @@ fi # 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. -# A locked session records the surfaced digest so the guards stop interrupting -# for a set the session has now seen; a read-only session leaves that record to -# the session that owns the home. +# A locked session records the surfaced digest only when the full set can be +# read, so the guards stop interrupting for a set the session has now seen; a +# read-only session leaves that record to the session that owns the home. subsection "CAPTAIN'S CALL" if [ "$READ_ONLY" -eq 1 ]; then ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --no-mark 2>&1) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index fdb6a80..5fb2701 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -121,10 +121,6 @@ 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 - # --- 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 @@ -155,14 +151,15 @@ budget_reset() { # live work is an unanswered decision reaches every exit path with zero in # flight and ends silently. # -# Strictly one block per DISTINCT decision set, and never a loop: the surfaced -# digest is recorded BEFORE exiting, so the very next turn end sees the same set -# as already surfaced and allows. Identities carry no prose, so re-declaring the -# same wait cannot manufacture a new set. Declared waits deliberately do NOT -# block - they need no captain action, and blocking on them would turn every -# routine delay into an interruption; they surface through bin/fm-guard.sh and -# the session-start digest instead. Any harness whose adapter honors this -# script's exit status gets the behavior; nothing here is harness-specific. +# Strictly one block per DISTINCT decision set, and never a loop: the +# captain-facing render records the surfaced digest before exiting, so the very +# next turn end sees the same set as already surfaced and allows. Identities carry +# no prose, so re-declaring the same wait cannot manufacture a new set. Declared +# waits deliberately do NOT block - they need no captain action, and blocking on +# them would turn every routine delay into an interruption; they surface through +# bin/fm-guard.sh and the session-start digest instead. Any harness whose adapter +# honors this script's exit status gets the behavior; nothing here is +# harness-specific. block_attention() { local rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' { @@ -178,32 +175,55 @@ block_attention() { exit 2 } -attention_would_block() { +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() { [ "$FM_ATTENTION_TURNEND_BLOCK" -eq 1 ] || return 1 fm_attention_status "$FM_HOME" - [ "$FM_ATT_AVAILABLE" = true ] || return 1 - [ "$FM_ATT_DECISIONS" -gt 0 ] || return 1 - [ "$FM_ATT_DECISIONS_NEW" = true ] + if [ "$FM_ATT_AVAILABLE" = true ]; then + [ "$FM_ATT_DECISIONS" -gt 0 ] && [ "$FM_ATT_DECISIONS_NEW" = true ] && block_attention + 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 - attention_would_block && block_attention - exit 0 + allow_stop fi else if [ "$FM_SUP_IN_FLIGHT" -eq 0 ]; then budget_reset - attention_would_block && block_attention - exit 0 + allow_stop fi fi if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then budget_reset - attention_would_block && block_attention - exit 0 + allow_stop fi block_stop() { @@ -257,14 +277,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 @@ -283,6 +303,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/captain-attention.md b/docs/captain-attention.md index 5795177..756601e 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -17,9 +17,10 @@ That promotion is derived from the keyed event fold, never from reading the word ## Where it comes from `bin/fm-attention-lib.sh` is the single owner of the contract. -It derives the open set from state firstmate already keeps durably - `data/backlog.md` and `state/*.status` - and stores nothing of its own except the surfaced-digest marker below. +It derives the open set from state firstmate already keeps durably - `data/backlog.md` and `state/*.status` - and 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 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 the surfaced digest. 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. @@ -53,13 +54,15 @@ 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 rendered to a captain-facing surface. -Rendering the default captain-facing view is surfacing: printing it records the digest as a side effect, so an ordinary read that changes nothing can never become an alarm. +`state/.captain-attention` records the digest of the set most recently rendered by the default captain-facing view in `bin/fm-attention.sh`. +Rendering that view is surfacing: after printing it, the renderer records the digest as a side effect, so an ordinary read that changes nothing can never become an alarm. +Firstmate-facing projections such as `--brief`, `--json`, `--status`, and `--no-mark` are read-only. 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. The marker bounds the interrupt only - an open item stays listed until it is answered or clears. The turn-end stop is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. Declared waits never stop a turn. +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. `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 diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 23eb2ec..9d616ae 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -41,6 +41,7 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s The predicate above counts `state/*.meta`, so a primary whose only live work is an unanswered captain decision reaches every allow path with zero in flight and ends 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 never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. +If the captain-attention set cannot be derived, the allow path stops once with an explicit unknown-state banner and does not record the surfaced digest. Declared external delays 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. @@ -64,6 +65,7 @@ Claude runs the guard with `--claude`, which ignores `stop_hook_active` and coop 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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index a5c75c6..889fd4a 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -70,6 +70,13 @@ run_turnend() { # <home> FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" "$TURNEND" 2>&1 } +run_turnend_claude() { # <home> [<stop-hook-active>] + local home=$1 active=${2:-false} + printf '{"stop_hook_active":%s,"session_id":"attention-test"}' "$active" \ + | 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 +} + add_row() { # <home> <section> <line> local home=$1 section=$2 line=$3 awk -v section="## $section" -v row="$line" ' @@ -79,6 +86,25 @@ add_row() { # <home> <section> <line> mv "$home/data/backlog.md.tmp" "$home/data/backlog.md" } +failing_snapshot() { # <home> + local home=$1 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)' +} + +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 @@ -228,6 +254,39 @@ EOF 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" +} + # --- 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 @@ -386,6 +445,26 @@ test_guard_banner_uses_captain_safe_rendering_to_mark() { pass "the guard banner renders the captain-safe view and records that surface" } +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 -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) @@ -511,8 +590,7 @@ test_unaccounted_primary_work_is_not_idle() { test_turn_end_stops_once_on_an_unsurfaced_decision() { local home out status home=$(make_primary_home turnend) - add_row "$home" Queued \ - '- [ ] sample-decision-x - Approve the worker installs (repo: sample) (kind: captain) (since 2026-07-28) (hold: the machine needs approval) (hold-kind: captain)' + 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" @@ -534,6 +612,40 @@ test_turn_end_stops_once_on_an_unsurfaced_decision() { pass "a turn cannot end on an unsurfaced captain decision, and each set costs one stop" } +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 @@ -603,18 +715,21 @@ test_briefed_decision_renders_concretely 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_routine_wait_states_what_it_awaits_and_when_it_is_next_checked test_repeated_wait_escalates_to_a_decision 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_uses_captain_safe_rendering_to_mark +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_unaccounted_primary_work_is_not_idle test_turn_end_stops_once_on_an_unsurfaced_decision +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-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 09ddf98..d2406b3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -107,16 +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" - # The captain's-call turn-end stop. fm-attention-lib.sh sources only these two - # small pure libraries; the backlog projection it shells out to is optional and - # degrades to an empty set, which is why fm-fleet-snapshot.sh is not installed - # here and a fixture with no backlog reports nothing open. 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" "$dir/bin/fm-attention.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() { From 4e89852911fc235e0968d02a5de2cb956552ed49 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 00:46:28 +0200 Subject: [PATCH 06/18] no-mistakes(review): Reset unknown attention marker after recovery --- bin/fm-attention-lib.sh | 3 ++- docs/captain-attention.md | 1 + docs/turnend-guard.md | 2 +- tests/fm-attention.test.sh | 26 ++++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 248de42..faceae5 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -476,7 +476,7 @@ _fm_attention_digest_of() { # <newline-separated identities> } # fm_attention_status <fm-home> -# Populate, read-only, for the home at $1: +# 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_ERROR human-readable unknown-state reason @@ -558,6 +558,7 @@ fm_attention_status() { # <fm-home> 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) + rm -f "$state/.captain-attention-unknown" 2>/dev/null || 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. diff --git a/docs/captain-attention.md b/docs/captain-attention.md index 756601e..667417e 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -63,6 +63,7 @@ The marker bounds the interrupt only - an open item stays listed until it is ans The turn-end stop is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. Declared waits never stop a turn. 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. +That unknown marker resets as soon as the set is readable again, 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 diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 9d616ae..b113402 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -41,7 +41,7 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s The predicate above counts `state/*.meta`, so a primary whose only live work is an unanswered captain decision reaches every allow path with zero in flight and ends 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 never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. -If the captain-attention set cannot be derived, the allow path stops once with an explicit unknown-state banner and does not record the surfaced digest. +If the captain-attention set cannot be derived, the allow path stops once with an explicit unknown-state banner and does not record the surfaced digest; after a readable derivation, a later failure is treated as a fresh unknown. Declared external delays 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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 889fd4a..4c48f5c 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -287,6 +287,31 @@ test_failed_backlog_projection_is_unknown_not_empty() { pass "a failed backlog projection is unknown, not an empty captain call" } +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" + + 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 @@ -716,6 +741,7 @@ 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_unknown_projection_surfaces_again_after_successful_derivation test_routine_wait_states_what_it_awaits_and_when_it_is_next_checked test_repeated_wait_escalates_to_a_decision test_identities_ignore_wording_so_repeats_do_not_re_alarm From ba0092bc694f3c45059fd86759c65f146d26fd44 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 00:57:44 +0200 Subject: [PATCH 07/18] no-mistakes(document): Refresh captain attention docs --- AGENTS.md | 2 +- README.md | 3 ++- bin/fm-attention-lib.sh | 16 +++++++++------- bin/fm-attention.sh | 11 ++++++----- bin/fm-guard.sh | 2 +- bin/fm-session-start.sh | 2 +- docs/captain-attention.md | 9 +++++---- docs/scripts.md | 4 ++-- docs/turnend-guard.md | 8 ++++---- 9 files changed, 31 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afccc74..a6b1680 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,7 @@ 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. **Captain's call** - every open captain decision and declared wait, printed as this turn's second work queue before the context and the supervision block, so the first captain-facing reply carries them. +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 and `docs/captain-attention.md` is the operator reference; a locked session records the surfaced set, a read-only session does not. 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.). diff --git a/README.md b/README.md index bdaa623..c6ac867 100644 --- a/README.md +++ b/README.md @@ -48,7 +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 delay is collected in one captain-facing place, in plain English with the concrete choice, what waiting costs, and a recommendation; 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. +- **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. diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index faceae5..2073504 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -26,13 +26,15 @@ # 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 declared external delay; no captain action, but the captain is -# still owed what is being awaited and when it is next checked. -# A wait that has been re-declared FM_ATTENTION_WAIT_REDECLARES times (default -# 3) without ever resolving is escalated to a decision, because a delay that -# keeps repeating is by definition no longer clearing on its own. That escalation -# is derived from the keyed event fold, never from reading the prose of the -# wait, so it cannot false-positive on wording. +# 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. +# A status-log wait that has been re-declared FM_ATTENTION_WAIT_REDECLARES times +# (default 3) without ever resolving is escalated to a decision, because a delay +# that keeps repeating is by definition no longer clearing on its own. That +# escalation is derived from the keyed event fold, never from reading the prose +# of the wait, so it cannot false-positive on wording. # # IDENTITY AND DEDUPLICATION # Every record carries a stable, TEXT-FREE identity: diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh index c36223b..cb25f0c 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -7,11 +7,12 @@ # # Every open item is exactly one of two things: # a decision - your answer is needed before the work can move; -# a wait - a declared external delay, shown with what is being awaited and -# when it is next checked. -# A delay that has been re-reported FM_ATTENTION_WAIT_REDECLARES times without -# clearing is promoted to a decision, because it is no longer resolving on its -# own. That promotion is derived from the event fold, never from the wording. +# a wait - a meaningful delay that needs no captain action yet, shown with +# what is being awaited and when it is next checked. +# A status-log wait that has been re-reported FM_ATTENTION_WAIT_REDECLARES times +# without clearing is promoted to a decision, because it is no longer resolving +# on its own. That promotion is derived from the event fold, never from the +# wording. # # RENDERING IS SURFACING. Printing the default view records the current set's # digest in state/.captain-attention, so the guards stop diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index efa5724..46f51d2 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -6,7 +6,7 @@ # 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 declared +# 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. diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index f2918d6..02a9ab8 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -35,7 +35,7 @@ # also run only when locked. # 3. wake-drain - mutates the durable wake queue, so it also only runs # when locked. -# 4. captain's call - every open captain decision and declared wait +# 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 diff --git a/docs/captain-attention.md b/docs/captain-attention.md index 667417e..b624657 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -9,9 +9,10 @@ This document is the operator reference for the contract that fixes it; each scr 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 declared external delay that needs no captain action, but still owes the captain what is being awaited and when it is next checked. +- 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 wait that has been re-declared past `FM_ATTENTION_WAIT_REDECLARES` (default 3) without ever clearing is promoted to a decision, because a delay that keeps repeating is no longer resolving on its own. +A status-log wait that has been re-declared at least `FM_ATTENTION_WAIT_REDECLARES` times (default 3) without ever clearing is promoted to a decision, because a delay that keeps repeating is no longer resolving on its own. That promotion is derived from the keyed event fold, never from reading the wording of the delay, so it cannot fire on phrasing alone. ## Where it comes from @@ -39,7 +40,7 @@ A partial briefing renders the fields that were written and names the missing on `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 three integrated paths, so nothing depends on remembering to look: +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 digest section, before the supervision block and the context. - `bin/fm-guard.sh` surfaces a changed set on every guarded command and at the top of every wake-handling turn, before any in-flight test. @@ -61,7 +62,7 @@ Identities carry no prose, so a delay re-reported hourly with new wording stays The marker bounds the interrupt only - an open item stays listed until it is answered or clears. The turn-end stop is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. -Declared waits never stop a turn. +Waits never stop a turn. 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. That unknown marker resets as soon as the set is readable again, 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. diff --git a/docs/scripts.md b/docs/scripts.md index b591d06..7c4197b 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -12,7 +12,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `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` | @@ -32,7 +32,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 b113402..37c7795 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. @@ -38,11 +38,11 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s ## The second stop: an unsurfaced captain decision -The predicate above counts `state/*.meta`, so a primary whose only live work is an unanswered captain decision reaches every allow path with zero in flight and ends silently. +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 never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. If the captain-attention set cannot be derived, the allow path stops once with an explicit unknown-state banner and does not record the surfaced digest; after a readable derivation, a later failure is treated as a fresh unknown. -Declared external delays never fire it. +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. From 2844dd268776a0a79d7a8e225ba12b76cbdb4e57 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 01:01:25 +0200 Subject: [PATCH 08/18] no-mistakes(lint): Fix attention lint warnings --- bin/fm-attention-lib.sh | 9 ++++++--- tests/fm-attention.test.sh | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 2073504..2653a59 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -224,7 +224,7 @@ _fm_attention_generation_set() { # <generation-set> <key> <generation> # origin_open_decisions skips them. fm_attention_status_rows() { # <state-dir> local state=$1 meta id status kind last verb key note n next line stripped pause resolve held - local decisions waits decision_gens wait_gens existing gen old_key old_verb old_gen old_n old_note + local decisions waits decision_gens wait_gens existing gen 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} @@ -254,7 +254,7 @@ fm_attention_status_rows() { # <state-dir> needs-decision|blocked) existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r old_key old_verb gen old_note <<EOF + IFS=$'\t' read -r _ _ gen _ <<EOF $existing EOF else @@ -281,7 +281,7 @@ EOF "$pause") existing=$(_fm_attention_row_for_key "$waits" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r old_key old_verb gen n old_note <<EOF + IFS=$'\t' read -r _ _ gen n _ <<EOF $existing EOF n=$((n + 1)) @@ -541,7 +541,9 @@ fm_attention_status() { # <fm-home> dec_ids=$(printf '%s\n' "$summary" | awk '/^--decisions--$/{s=1;next} s') else FM_ATT_AVAILABLE=false + # shellcheck disable=SC2034 # Read by callers after sourcing. FM_ATT_UNKNOWN=true + # shellcheck disable=SC2034 # Read by callers after sourcing. FM_ATT_ERROR='The open decision and wait list could not be determined.' FM_ATT_JSON='{"unknown":true}' FM_ATT_DIGEST=unknown @@ -550,6 +552,7 @@ fm_attention_status() { # <fm-home> 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 return 0 fi diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 4c48f5c..57e91c7 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -87,7 +87,8 @@ add_row() { # <home> <section> <line> } failing_snapshot() { # <home> - local home=$1 bin="$home/bin/failing-snapshot" + 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" From 5fe83692ee2adfbb07f46d6f95b2202c65801a64 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 01:26:39 +0200 Subject: [PATCH 09/18] fix(test): give the old-bin conformance fixture its attention dependency tests/fm-backend.test.sh builds a synthetic old bin/ from historical copies of the refactored entrypoints plus CURRENT copies of their siblings. Two of those siblings, fm-guard.sh and fm-decision-hold.sh, now source fm-attention-lib.sh, which was not in the sibling list, so under set -eu the source aborted, the unresolved-decision gate could not run, and the old fm-teardown.sh scout case was refused: fm-guard.sh: line 53: .../fm-attention-lib.sh: No such file or directory fm-guard.sh: line 164: FM_ATT_AVAILABLE: unbound variable REFUSED: scout task teardownconform1 has not passed the unresolved-decision completion gate. Add the dependency to OLD_BIN_UNCHANGED_SIBLINGS. Also close the selection gap that let this reach CI. bin/fm-attention-lib.sh mapped only to watcher-wake-lock, snapshot-bearings, session-bootstrap, and pure-contract-unit, so no local run ever selected the backend-dispatch family that owns this fixture. Map the attention files to backend-dispatch as well, so the suite that consumes them is selected when they change. The same fixture shape already required this dependency in tests/fm-turnend-guard.test.sh; this was the second instance. --- bin/fm-test-run.sh | 4 ++++ tests/fm-backend.test.sh | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index cbb2fc5..b3ae180 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -686,6 +686,10 @@ families_for_changed_path() { 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/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" From a9615decf52c511634f133a59839610a225cec55 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Wed, 29 Jul 2026 21:41:01 +0200 Subject: [PATCH 10/18] =?UTF-8?q?no-mistakes(review):=20Captain,=20all=20f?= =?UTF-8?q?ixes=20are=20applied;=20I=E2=80=99m=20running=20the=20single=20?= =?UTF-8?q?focused=20verification=20across=20the=20four=20touched=20contra?= =?UTF-8?q?cts=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/decision-hold-lifecycle/SKILL.md | 4 +- .opencode/plugins/fm-primary-turnend-guard.js | 58 +++- .pi/extensions/fm-primary-turnend-guard.ts | 44 +++- AGENTS.md | 2 +- bin/fm-attention-lib.sh | 170 +++++++++--- bin/fm-attention.sh | 72 +++-- bin/fm-decision-hold.sh | 127 +++++---- bin/fm-guard.sh | 6 +- bin/fm-session-start.sh | 9 +- bin/fm-turnend-guard-grok.sh | 14 +- bin/fm-turnend-guard.sh | 38 +-- docs/captain-attention.md | 26 +- docs/decision-hold-lifecycle.md | 4 +- docs/turnend-guard.md | 17 +- docs/verification/supervision.md | 24 +- tests/fm-attention.test.sh | 248 ++++++++++++++---- tests/fm-decision-hold-lifecycle.test.sh | 9 + tests/fm-pi-watch-extension.test.sh | 11 +- tests/fm-turnend-guard.test.sh | 55 +++- 19 files changed, 673 insertions(+), 265 deletions(-) diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 1826730..9ae4678 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -17,8 +17,8 @@ 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, the real options where they help, and your recommendation. -A title and a one-line reason cannot carry a decision, so a hold without that briefing reaches the captain marked as not yet written; the briefing is what makes the decision answerable without further investigation. +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. +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. diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js index 63ad139..cc963b7 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -8,6 +8,7 @@ import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; // 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)) { @@ -16,6 +17,12 @@ function turnEndPrefix(stderr) { "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" @@ -26,6 +33,7 @@ function turnEndPrefix(stderr) { const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; let skipNextIdle = false; +const assistantMessages = new Map(); function runProcess(command, args, input = "") { return new Promise((resolve) => { @@ -62,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 }) => { @@ -79,20 +117,24 @@ 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( diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 3dcb88d..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"; @@ -66,6 +67,7 @@ function runSessionstartNudge(): string { // 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)) { @@ -74,13 +76,19 @@ function turnEndPrefix(stderr: string): string { "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(): Promise<{ code: number; stderr: string }> { +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"], @@ -91,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 @@ -154,14 +176,28 @@ 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 { diff --git a/AGENTS.md b/AGENTS.md index a6b1680..255e410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -425,7 +425,7 @@ Reach the captain immediately for: 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, the real options where they help, and your recommendation; a wait must say what is being awaited and when it is next checked. +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. diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 2653a59..e267436 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -49,17 +49,16 @@ # identity and only changes class, so escalation itself surfaces exactly once. # # SURFACING -# state/.captain-attention records the digest of the set most recently rendered -# to a captain-facing surface by bin/fm-attention.sh: -# attention=<digest over every identity> -# decisions=<digest over decision identities only> +# 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=<digest over every record> +# decisions=<digest over decision records> # A digest that differs from the recorded one means the set CHANGED and has not -# been surfaced since. Rendering is surfacing: bin/fm-attention.sh records the -# digest as a side effect of printing its captain-facing view, so an ordinary -# read that changes nothing can never become an alarm, and an item that is -# answered simply drops out of the set. The marker never suppresses the ledger -# itself - an open item stays rendered until it is resolved; the marker only -# bounds the INTERRUPT. +# been shown since. Rendering is read-only. The explicit receipt path validates +# categories, headlines, and complete briefings before writing either digest. +# Decision digests include substantive briefing fields, 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 renderer (pull, single place) @@ -145,7 +144,7 @@ fm_attention_brief_lines() { # 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() { # <status-file> <state-dir> - local f=$1 state=$2 cadence m now beat age grace + 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} @@ -166,7 +165,9 @@ fm_attention_wait_next_check() { # <status-file> <state-dir> fi m=$(fm_sup_stat_mtime "$f") [ -n "$m" ] || { printf '%s' "$cadence"; return 0; } - printf '%s' "$(( m + cadence - now ))" + remaining=$((m + cadence - now)) + [ "$remaining" -ge 0 ] || remaining=0 + printf '%s' "$remaining" } _fm_attention_drop_key() { # <open-set> <key> @@ -224,7 +225,7 @@ _fm_attention_generation_set() { # <generation-set> <key> <generation> # origin_open_decisions skips them. fm_attention_status_rows() { # <state-dir> local state=$1 meta id status kind last verb key note n next line stripped pause resolve held - local decisions waits decision_gens wait_gens existing gen + local decisions waits decision_gens wait_gens existing gen dverb dnote dn dwait wverb wgen wn wnote decision_next 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} @@ -250,21 +251,32 @@ fm_attention_status_rows() { # <state-dir> verb=$(status_line_verb "$line") key=$(_fm_decision_key "$line") || continue note=$(status_line_note "$line") + note=${note//$'\t'/ } case "$verb" in needs-decision|blocked) existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r _ _ gen _ <<EOF + IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF $existing EOF else gen=$(_fm_attention_generation_get "$decision_gens" "$key") gen=$((gen + 1)) decision_gens=$(_fm_attention_generation_set "$decision_gens" "$key" "$gen") + dn=0 + dwait='' + fi + existing=$(_fm_attention_row_for_key "$waits" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ wverb wgen wn wnote <<EOF +$existing +EOF + dn=$wn + dwait=$wnote fi decisions=$(_fm_attention_drop_key "$decisions" "$key") [ -n "$decisions" ] && decisions="${decisions}"$'\n' - decisions="${decisions}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${note}"$'\n' + decisions="${decisions}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${note}"$'\t'"${dn}"$'\t'"${dwait}"$'\n' waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' ;; @@ -277,11 +289,35 @@ EOF done|failed|working) waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' + if [ "$verb" = working ]; then + existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF +$existing +EOF + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t0\t\n' + fi + fi ;; "$pause") + existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ dverb gen dnote n dwait <<EOF +$existing +EOF + n=$((n + 1)) + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t'"${n}"$'\t'"${note}"$'\n' + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + continue + fi existing=$(_fm_attention_row_for_key "$waits" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r _ _ gen n _ <<EOF + IFS=$'\t' read -r _ wverb gen n wnote <<EOF $existing EOF n=$((n + 1)) @@ -297,16 +333,20 @@ EOF ;; esac done < "$status" - while IFS=$'\t' read -r key verb gen note; do + next=$(fm_attention_wait_next_check "$status" "$state") + while IFS=$'\t' read -r key verb gen note n wnote; do [ -n "$key" ] || continue - printf 'decision\t%s\t%s\t%s\t%s\t0\t-1\t%s\n' "$id" "$key" "$gen" "$verb" "$note" + decision_next=$next + [ "$n" -gt 0 ] || decision_next=-1 + printf 'decision\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$key" "$gen" "$verb" "$n" "$decision_next" "$note" "$wnote" done <<EOF $decisions EOF - next=$(fm_attention_wait_next_check "$status" "$state") while IFS=$'\t' read -r key verb gen n note; do [ -n "$key" ] || continue - printf 'wait\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$key" "$gen" "$verb" "$n" "$next" "$note" + printf 'wait\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$key" "$gen" "$verb" "$n" "$next" "$note" "$note" done <<EOF $waits EOF @@ -339,11 +379,11 @@ fm_attention_json() { # <fm-home> --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:]]+"; "") ] | .[0]) // null; + ([ $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:]]+"; "") ]; + [ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") | select(length > 0) ]; def empty_briefing: - {briefed:false,choice:null,why_now:null,cost_of_waiting:null,options:[],recommendation:null,briefing_missing:[]}; + {briefed:false,briefing_complete:false,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 @@ -358,6 +398,7 @@ fm_attention_json() { # <fm-home> empty_briefing else {briefed:true, + briefing_complete:($choice != null and $why != null and $cost != null and ($options | length) > 0 and $recommend != null), choice:$choice, why_now:$why, cost_of_waiting:$cost, @@ -420,17 +461,18 @@ fm_attention_json() { # <fm-home> redeclares:0, escalated:false, next_check_seconds:null, - briefed:false,choice:null,why_now:null,cost_of_waiting:null, + briefed:false,briefing_complete:false,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 >= 8) + | select(length >= 9) | {class:.[0],id:.[1],key:.[2],generation:.[3],verb:.[4], redeclares:(.[5] | tonumber? // 0), next_check_seconds:(.[6] | tonumber? // -1), - note:(.[7:] | join("\t"))} ]; + note:.[7], + awaiting:(.[8:] | 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, @@ -447,12 +489,13 @@ fm_attention_json() { # <fm-home> key:.key, headline:(work_title(.id) // (.note | if . == "" then .id else . end)), detail:.note, - awaiting:(if .class == "wait" then .note else null end), + awaiting:(if .awaiting == "" then null else .awaiting end), blocked_by:[], redeclares:.redeclares, escalated:(.class == "wait" and $esc), - next_check_seconds:(if .class == "wait" then .next_check_seconds else null end), - briefed:false,choice:null,why_now:null,cost_of_waiting:null, + combined_wait:(.class == "decision" and .awaiting != ""), + next_check_seconds:(if .class == "wait" or .awaiting != "" then .next_check_seconds else null end), + briefed:false,briefing_complete:false,choice:null,why_now:null,cost_of_waiting:null, options:[],recommendation:null,briefing_missing:[]} ]; (backlog_decisions + status_records) as $primary # A live declared wait supersedes the backlog-level wait row for the same @@ -465,7 +508,7 @@ fm_attention_json() { # <fm-home> ' } -_fm_attention_digest_of() { # <newline-separated identities> +_fm_attention_digest_of() { # <newline-separated receipt records> local text=$1 [ -n "$text" ] || { printf 'empty'; return 0; } if command -v shasum >/dev/null 2>&1; then @@ -477,6 +520,56 @@ _fm_attention_digest_of() { # <newline-separated identities> fi } +_fm_attention_normalize_message() { + tr '\n\r\t' ' ' | awk '{$1=$1; print}' +} + +fm_attention_message_covers() { # <message> <all|decisions> + local message=$1 scope=${2:-all} required expected normalized_message normalized_expected + normalized_message=$(printf '%s' "$message" | _fm_attention_normalize_message) + required=$(printf '%s' "$FM_ATT_JSON" | jq -r --arg scope "$scope" ' + def decision_requirements: + if .escalated == true then + ["NEEDS YOUR DECISION", .headline, + "The choice:", "keep waiting on this, or change the plan so it stops holding the work up.", + "What was reported:", .detail] + elif .briefing_complete == true then + ["NEEDS YOUR DECISION", .headline, + "The choice:", .choice, + "Why it matters now:", .why_now, + "If this waits:", .cost_of_waiting, + "Options:"] + .options + ["Recommended:", .recommendation] + else + ["__FIRSTMATE_INCOMPLETE_BRIEFING__"] + end + + (if .combined_wait == true then ["Waiting for:", .awaiting, "Next check:"] else [] end); + def wait_requirements: + ["WAITING ON SOMETHING ELSE", .headline, "Waiting for:", .awaiting, "Next check:"]; + if length == 0 then + ["Nothing needs your decision, and nothing is waiting."] + else + [ .[] + | select($scope == "all" or .class == "decision") + | if .class == "decision" then decision_requirements else wait_requirements end + | .[] ] + | unique + end + | .[] + ' 2>/dev/null) || return 1 + while IFS= read -r expected; do + [ -n "$expected" ] || continue + [ "$expected" != "__FIRSTMATE_INCOMPLETE_BRIEFING__" ] || return 1 + normalized_expected=$(printf '%s' "$expected" | _fm_attention_normalize_message) + case "$normalized_message" in + *"$normalized_expected"*) ;; + *) return 1 ;; + esac + done <<EOF +$required +EOF + return 0 +} + # fm_attention_status <fm-home> # Populate for the home at $1: # FM_ATT_AVAILABLE true/false - false when the complete set is unavailable @@ -486,8 +579,8 @@ _fm_attention_digest_of() { # <newline-separated identities> # FM_ATT_COUNT total open records # FM_ATT_DECISIONS open records needing the captain # FM_ATT_WAITS open declared delays -# FM_ATT_DIGEST digest over every identity -# FM_ATT_DECISION_DIGEST digest over decision identities only +# 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 @@ -528,9 +621,17 @@ fm_attention_status() { # <fm-home> # 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 receipt_identity: + if .class == "decision" then + {identity,class,headline,choice,why_now,cost_of_waiting,options,recommendation, + escalated,combined_wait} + | @json + else + .identity + end; "\(length) \([.[] | select(.class == "decision")] | length)", - "--attention--", (.[].identity), - "--decisions--", (.[] | select(.class == "decision") | .identity)' 2>/dev/null) || summary='' + "--attention--", (.[] | receipt_identity), + "--decisions--", (.[] | select(.class == "decision") | receipt_identity)' 2>/dev/null) || summary='' if [ -n "$summary" ]; then FM_ATT_COUNT=${summary%%$'\n'*} FM_ATT_DECISIONS=${FM_ATT_COUNT#* } @@ -563,7 +664,6 @@ fm_attention_status() { # <fm-home> 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) - rm -f "$state/.captain-attention-unknown" 2>/dev/null || 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. diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh index cb25f0c..28ebe83 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -14,13 +14,9 @@ # on its own. That promotion is derived from the event fold, never from the # wording. # -# RENDERING IS SURFACING. Printing the default view records the current set's -# digest in state/.captain-attention, so the guards stop -# interrupting for a set that has already reached a captain-facing surface. An -# ordinary read that changes nothing therefore never becomes a false alarm, and -# an item that is answered simply drops out. --brief, --json, and --status never -# mark. Open items stay rendered until they are resolved; the marker bounds the -# interrupt, not the ledger. +# 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. @@ -28,15 +24,17 @@ # read-only diagnostic, never a captain-facing surface. # # Usage: -# fm-attention.sh captain-facing view (marks surfaced) +# 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 --no-mark render without recording the digest +# 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 set could be read; this is a reporting command, -# never a gate. Exit 3 means the complete set could not be derived. +# 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)" @@ -57,13 +55,13 @@ usage() { } MODE=view -MARK=1 while [ "$#" -gt 0 ]; do case "$1" in --brief) MODE=brief ;; --json) MODE=json ;; --status) MODE=status ;; - --no-mark) MARK=0 ;; + --record-visible) MODE=record-visible ;; + --no-mark) ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; esac @@ -72,12 +70,6 @@ done fm_attention_status "$FM_HOME" -mark_surfaced() { - [ -d "$STATE" ] || return 0 - printf 'attention=%s\ndecisions=%s\n' "$FM_ATT_DIGEST" "$FM_ATT_DECISION_DIGEST" > "$STATE/.captain-attention" 2>/dev/null || true - return 0 -} - render_unknown() { printf "CAPTAIN'S CALL\n" printf 'I could not determine whether anything needs your decision or is waiting.\n' @@ -106,6 +98,26 @@ if [ "$FM_ATT_AVAILABLE" != true ]; then 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_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() { # <indent-spaces> <text> @@ -169,12 +181,11 @@ field() { # <index> <jq-path> 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 escalated redeclares next opts missing + local class headline choice why cost recommend detail awaiting briefed escalated redeclares next opts missing combined printf "CAPTAIN'S CALL\n" if [ "$total" -eq 0 ]; then printf 'Nothing needs your decision, and nothing is waiting.\n' - [ "$MARK" -eq 1 ] && mark_surfaced return 0 fi printf '%s you. %s waiting.\n' \ @@ -193,6 +204,7 @@ render_view() { escalated=$(field "$i" '.escalated') redeclares=$(field "$i" '.redeclares') detail=$(field "$i" '.detail') + combined=$(field "$i" '.combined_wait') printf '\n' wrap_prefixed "$(printf '%s. ' "$num")" 3 "$headline" if [ "$escalated" = true ]; then @@ -234,6 +246,13 @@ EOF printf ' What was recorded:\n' wrap 5 "$detail" fi + if [ "$combined" = true ]; then + awaiting=$(field "$i" '.awaiting') + next=$(printf '%s' "$FM_ATT_JSON" | jq -r --argjson i "$i" '.[$i].next_check_seconds // "null"' 2>/dev/null) + printf ' Waiting for:\n' + wrap 5 "$awaiting" + printf ' Next check: %s\n' "$(next_check_phrase "$next")" + fi i=$((i + 1)) done fi @@ -256,11 +275,10 @@ EOF done fi printf '\nEverything above stays listed here until it is answered or clears.\n' - [ "$MARK" -eq 1 ] && mark_surfaced } render_brief() { - local total=$FM_ATT_COUNT i class id headline briefed escalated next mark + local total=$FM_ATT_COUNT i class id headline complete escalated next mark if [ "$total" -eq 0 ]; then printf "CAPTAIN'S CALL: nothing open.\n" return 0 @@ -272,13 +290,13 @@ render_brief() { class=$(field "$i" '.class') id=$(field "$i" '.id') headline=$(field "$i" '.headline') - briefed=$(field "$i" '.briefed') + complete=$(field "$i" '.briefing_complete') escalated=$(field "$i" '.escalated') mark='' if [ "$class" = decision ]; then if [ "$escalated" = true ]; then mark=' [repeated delay, no longer clearing]' - elif [ "$briefed" != true ]; then + elif [ "$complete" != true ]; then mark=' [no captain briefing recorded - add one with bin/fm-decision-hold.sh hold]' fi else @@ -292,6 +310,10 @@ render_brief() { } case "$MODE" in + record-visible) + record_visible + exit $? + ;; json) printf '%s\n' "$FM_ATT_JSON" ;; diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index f0e7ce7..f03a5e1 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -20,8 +20,8 @@ # fm-decision-hold.sh id <origin-id> <decision-key> # fm-decision-hold.sh hold <origin-id> <decision-key> \ # --title <title> --reason <reason> [--repo <repo>] \ -# [--choice <text>] [--why-now <text>] [--cost-of-waiting <text>] \ -# [--option <text>]... [--recommend <text>] +# --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> \ @@ -33,12 +33,9 @@ # 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 and must not contain a backslash, so the stored body round-trips -# unambiguously. Supplying any briefing flag rewrites the briefing block and -# preserves unrecognized body lines; supplying none leaves an existing briefing -# untouched, so a plain idempotent retry never erases a written briefing. A hold -# with no briefing still renders and is marked as not yet written, rather than -# presenting a raw operational note as if it were plain language. +# one line. New holds and explicit briefing revisions require every field. +# 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 @@ -98,47 +95,65 @@ validate_one_line() { # <label> <value> esac } -# A briefing value is stored inside the hold body, which tasks-axi round-trips -# with backslash escapes. Banning the backslash keeps that decoding unambiguous -# without restricting ordinary captain-facing prose. validate_brief_field() { # <label> <value> local label=$1 value=$2 validate_one_line "$label" "$value" - case "$value" in - *\\*) fail "$label must not contain a backslash" ;; - esac } -# tasks-axi prints a body as one quoted line with \n and \" escapes; decode it -# back to real lines. Backslashes are banned in briefing values, so the two -# replacements below cannot collide. -decode_body() { # <shown-body> - local b=$1 - b=${b#\"} - b=${b%\"} - printf '%s' "$b" | sed -e 's/\\n/\ -/g' -e 's/\\"/"/g' +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 } -# Keep only body lines this script does not own: the base Origin/Decision -# key/State lines and the whole captain-briefing block are rebuilt from -# arguments, everything else a human added is preserved verbatim. -preserved_body_lines() { # <decoded-body> - printf '%s\n' "$1" | awk \ - -v header="$FM_ATTENTION_BRIEF_HEADER" \ - -v choice="$FM_ATTENTION_BRIEF_CHOICE" \ - -v why="$FM_ATTENTION_BRIEF_WHY" \ - -v cost="$FM_ATTENTION_BRIEF_COST" \ - -v option="$FM_ATTENTION_BRIEF_OPTION" \ - -v recommend="$FM_ATTENTION_BRIEF_RECOMMEND" ' - function starts(s, p) { return substr(s, 1, length(p)) == p } - $0 == header { next } - starts($0, choice) || starts($0, why) || starts($0, cost) { next } - starts($0, option) || starts($0, recommend) { next } - starts($0, "Origin: ") || starts($0, "Decision key: ") { next } - $0 == "State: awaiting captain decision." { next } - { print } - ' | sed -e '/^[[:space:]]*$/d' +briefing_complete_body() { # <encoded-body> + printf '%s' "$1" | node -e ' + const fs = require("fs"); + const body = JSON.parse(fs.readFileSync(0, "utf8")); + if (typeof body !== "string") process.exit(1); + const lines = body.split("\n"); + const value = (label) => lines.some((line) => line.startsWith(`${label} `) && line.slice(label.length + 1).length > 0); + if (!lines.includes(process.argv[1])) process.exit(1); + if (!value(process.argv[2]) || !value(process.argv[3]) || !value(process.argv[4]) || + !value(process.argv[5]) || !value(process.argv[6])) process.exit(1); + ' "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_CHOICE" "$FM_ATTENTION_BRIEF_WHY" \ + "$FM_ATTENTION_BRIEF_COST" "$FM_ATTENTION_BRIEF_OPTION" "$FM_ATTENTION_BRIEF_RECOMMEND" +} + +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" | FM_DECISION_PREFIX="$prefix" node -e ' + const fs = require("fs"); + const body = JSON.parse(fs.readFileSync(0, "utf8")); + if (typeof body !== "string") process.exit(1); + const labels = process.argv.slice(2); + const owned = (line) => + line === labels[0] || + labels.slice(1).some((label) => line.startsWith(label)) || + line.startsWith("Origin: ") || + line.startsWith("Decision key: ") || + line === "State: awaiting captain decision."; + const preserved = body.split("\n").filter((line) => !owned(line)).join("\n"); + const output = process.env.FM_DECISION_PREFIX + (preserved.length > 0 ? `\n${preserved}` : ""); + fs.writeFileSync(process.argv[1], output); + ' "$body_file" "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_CHOICE" \ + "$FM_ATTENTION_BRIEF_WHY" "$FM_ATTENTION_BRIEF_COST" "$FM_ATTENTION_BRIEF_OPTION" \ + "$FM_ATTENTION_BRIEF_RECOMMEND"; then + rm -f "$body_file" + fail "could not decode the existing captain decision body" + fi + printf '%s\n' "$body_file" } sha256_text() { # <text> @@ -163,6 +178,7 @@ tasks_axi() { require_tasks_axi() { fm_tasks_axi_compatible || fail "compatible tasks-axi is required" + command -v node >/dev/null 2>&1 || fail "node is required" tasks-axi hold --help 2>&1 | grep -F -- '--kind captain' >/dev/null \ || fail "tasks-axi does not expose the captain-hold contract" } @@ -291,7 +307,7 @@ command_id() { command_hold() { local origin=${1:-} key=${2:-} title='' reason='' repo='' id show state kind existing_title body - local choice='' why='' cost='' recommend='' brief_given=0 brief_block='' preserved='' decoded opt + local choice='' why='' cost='' recommend='' brief_given=0 brief_block='' existing_body body_file local -a options=() [ "$#" -ge 2 ] || { usage >&2; exit 2; } shift 2 @@ -315,11 +331,8 @@ command_hold() { 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 - [ -z "$choice" ] || validate_brief_field choice "$choice" - [ -z "$why" ] || validate_brief_field why-now "$why" - [ -z "$cost" ] || validate_brief_field cost-of-waiting "$cost" - [ -z "$recommend" ] || validate_brief_field recommend "$recommend" - for opt in "${options[@]+"${options[@]}"}"; do validate_brief_field option "$opt"; done + validate_complete_briefing "$choice" "$why" "$cost" "$recommend" \ + "${options[@]+"${options[@]}"}" brief_block=$(fm_attention_brief_lines "$choice" "$why" "$cost" "$recommend" \ "${options[@]+"${options[@]}"}") fi @@ -333,18 +346,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" - # No briefing flags means a plain idempotent retry: never touch the body, so - # an already-written captain briefing cannot be silently erased. + existing_body=$(show_field "$show" body) if [ "$brief_given" -eq 1 ]; then - decoded=$(decode_body "$(show_field "$show" body)") - preserved=$(preserved_body_lines "$decoded") 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") - [ -z "$preserved" ] || body=$(printf '%s\n\n%s' "$body" "$preserved") - tasks_axi update "$id" --body "$body" >/dev/null \ - || fail "could not record the captain briefing on $id" + 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%/} diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 46f51d2..2df0017 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -176,11 +176,7 @@ if [ "$FM_GUARD_NO_ATTENTION" != 1 ]; then 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" - if [ "$READ_ONLY" -eq 1 ]; then - "$SCRIPT_DIR/fm-attention.sh" --no-mark 2>/dev/null | sed 's/^/● /' - else - "$SCRIPT_DIR/fm-attention.sh" 2>/dev/null | sed 's/^/● /' - fi + "$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 diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 02a9ab8..c57b4b9 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -320,15 +320,8 @@ fi # 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. -# A locked session records the surfaced digest only when the full set can be -# read, so the guards stop interrupting for a set the session has now seen; a -# read-only session leaves that record to the session that owns the home. subsection "CAPTAIN'S CALL" -if [ "$READ_ONLY" -eq 1 ]; then - ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --no-mark 2>&1) -else - ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" 2>&1) -fi +ATTENTION_OUT=$("$SCRIPT_DIR/fm-attention.sh" --no-mark 2>&1) if [ -n "$ATTENTION_OUT" ]; then printf '%s\n' "$ATTENTION_OUT" else diff --git a/bin/fm-turnend-guard-grok.sh b/bin/fm-turnend-guard-grok.sh index 5b9a3c8..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,6 +74,13 @@ 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 @@ -83,6 +90,9 @@ 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" diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 5fb2701..a9c2c0b 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -33,15 +33,15 @@ # 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. It is bounded by -# construction rather than by a budget: it renders the captain-facing view before -# blocking, and that render records the surfaced digest, so one distinct set of -# open decisions costs at most one forced continuation, on every harness. +# 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. # -# Loop-guard, codex/Grok (default) mode: never block twice in the same turn. +# 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 - @@ -121,6 +121,14 @@ STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r ' else false end ' 2>/dev/null) || exit 0 +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 # --- 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 @@ -135,6 +143,11 @@ STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r ' # 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" @@ -151,22 +164,13 @@ budget_reset() { # live work is an unanswered decision reaches every exit path with zero in # flight and ends silently. # -# Strictly one block per DISTINCT decision set, and never a loop: the -# captain-facing render records the surfaced digest before exiting, so the very -# next turn end sees the same set as already surfaced and allows. Identities carry -# no prose, so re-declaring the same wait cannot manufacture a new set. Declared -# waits deliberately do NOT block - they need no captain action, and blocking on -# them would turn every routine delay into an interruption; they surface through -# bin/fm-guard.sh and the session-start digest instead. Any harness whose adapter -# honors this script's exit status gets the behavior; nothing here is -# harness-specific. 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" 2>/dev/null | sed 's/^/● /' + "$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' @@ -190,8 +194,8 @@ block_attention_unknown() { attention_gate() { [ "$FM_ATTENTION_TURNEND_BLOCK" -eq 1 ] || return 1 - fm_attention_status "$FM_HOME" if [ "$FM_ATT_AVAILABLE" = true ]; then + rm -f "$STATE/.captain-attention-unknown" 2>/dev/null || true [ "$FM_ATT_DECISIONS" -gt 0 ] && [ "$FM_ATT_DECISIONS_NEW" = true ] && block_attention return 0 fi diff --git a/docs/captain-attention.md b/docs/captain-attention.md index b624657..b9a3b2e 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -21,7 +21,7 @@ That promotion is derived from the keyed event fold, never from reading the word It derives the open set from state firstmate already keeps durably - `data/backlog.md` and `state/*.status` - and 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 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 the surfaced digest. +If that projection 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. 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. @@ -30,9 +30,9 @@ A captain hold whose blocker is still open is not answerable yet and stays a wai ## 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` accepts `--choice`, `--why-now`, `--cost-of-waiting`, repeatable `--option`, and `--recommend`, and stores that plain language in the hold's own durable body. -Supplying any briefing flag rewrites the briefing and preserves unrecognized body lines; supplying none leaves an existing briefing untouched, so an idempotent retry never erases one. -A decision with no briefing still renders and is marked as not yet written, rather than presenting a raw operational note as if it were plain language. +`bin/fm-decision-hold.sh hold` requires `--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. +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 @@ -42,8 +42,8 @@ Its default view is plain English with no internal identifiers or vocabulary, so 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 digest section, before the supervision block and the context. -- `bin/fm-guard.sh` surfaces a changed set on every guarded command and at the top of every wake-handling turn, before any in-flight test. +- `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. @@ -55,16 +55,18 @@ 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 rendered by the default captain-facing view in `bin/fm-attention.sh`. -Rendering that view is surfacing: after printing it, the renderer records the digest as a side effect, so an ordinary read that changes nothing can never become an alarm. -Firstmate-facing projections such as `--brief`, `--json`, `--status`, and `--no-mark` are read-only. +`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 requires the correct category, headline, and every briefing fact. +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 semantic briefing fields, so changing the choice, stakes, waiting cost, options, or recommendation reopens the receipt while a wording-only operational note does not. +A keyed status decision and wait are folded into one combined decision alert. The marker bounds the interrupt only - an open item stays listed until it is answered or clears. -The turn-end stop is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. -Waits never stop a turn. +The turn-end stop remains active until the complete alert appears in an actual captain-visible assistant reply. +Routine external and timed waits never stop a turn because they need no captain action; a repeated wait promoted to a decision does. 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. -That unknown marker resets as soon as the set is readable again, so a later derivation failure is surfaced as a fresh unknown. +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 diff --git a/docs/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md index c927c17..9410094 100644 --- a/docs/decision-hold-lifecycle.md +++ b/docs/decision-hold-lifecycle.md @@ -13,9 +13,9 @@ 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 records the durable captain briefing that makes the decision answerable: the concrete choice, why it matters now, what waiting costs, repeatable options, and a recommendation, each one line and free of backslashes so the stored body round-trips unambiguously. +The same subcommand requires the durable captain briefing that makes the decision answerable: 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. -Supplying any briefing flag rewrites the block and preserves unrecognized body lines; supplying none leaves an existing briefing untouched, so an idempotent retry cannot erase one. +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. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 37c7795..eacf6f9 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -40,8 +40,9 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s 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 never reached a captain-facing surface, and it is bounded by construction rather than by a budget: it renders the default captain-facing view before blocking, and that render records the surfaced digest, so one distinct set of open decisions costs at most one forced continuation on any harness. -If the captain-attention set cannot be derived, the allow path stops once with an explicit unknown-state banner and does not record the surfaced digest; after a readable derivation, a later failure is treated as a fresh unknown. +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. +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. @@ -52,14 +53,14 @@ Each passive adapter selects its follow-up headline from the guard's own banner, - 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, so a continuation cannot end without a validated captain receipt. 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. @@ -69,10 +70,10 @@ Every Claude allow path still passes through the captain-attention stop before i 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 002262c..c3f5ceb 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -187,8 +187,8 @@ Every supported primary harness was reviewed against its own adapter rather than | 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` | Exit 2 schedules exactly one bounded TUI follow-up, with the headline selected the same way. | -| Pi and pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts`, passive `agent_settled` | Same passive one-follow-up path and the same headline selection. | +| 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. | @@ -203,25 +203,7 @@ The captain-facing behavior and the primary-activity blind spot are covered dete bash tests/fm-attention.test.sh ``` -Observed output: - -```text -ok - a recorded captain explanation keeps its full text, and grouped metadata still parses -ok - a briefed captain decision renders the choice, the stakes, the options, and a recommendation -ok - a decision with no captain briefing renders honestly instead of faking plain language -ok - a routine delay states what it awaits and when it is next checked -ok - a delay that keeps repeating becomes a captain decision, and progress ends it -ok - attention identities ignore wording, so a repeated delay surfaces once -ok - ordinary reads and re-renders never become false alarms -ok - an open item stays visible until it resolves, even once its interrupt is spent -ok - a resolved decision clears from the captain view -ok - an idempotent retry preserves a written briefing and an explicit update replaces it -ok - unaccounted primary work reads as suspicious, not idle -ok - a turn cannot end on an unsurfaced captain decision, and each set costs one stop -ok - declared waits surface without ever stopping a turn -ok - the captain view is plain language while the brief form keeps identifiers -ok - an empty captain's call is stated, not omitted -``` +The suite covers complete briefing creation, full escape preservation, semantic receipt revisions, combined keyed alerts, read-only marker behavior, actual assistant-delivery receipts, wait timing, resolution, and the primary-activity blind spot. Current entry points: diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 57e91c7..4638928 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Regression tests for the captain-attention contract: open decisions and -# declared waits must reach the captain in plain language, exactly once per -# distinct set, and must clear when they resolve. +# 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 @@ -63,20 +63,28 @@ run_guard() { # <home> FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" "$GUARD" 2>&1 } -run_turnend() { # <home> - local home=$1 - printf '{"stop_hook_active":false,"session_id":"attention-test"}' \ +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>] - local home=$1 active=${2:-false} - printf '{"stop_hook_active":%s,"session_id":"attention-test"}' "$active" \ +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 } +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 awk -v section="## $section" -v row="$line" ' @@ -97,7 +105,14 @@ failing_snapshot() { # <home> 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)' + '- [ ] 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: + 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> @@ -167,6 +182,25 @@ test_briefed_decision_renders_concretely() { 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 \ + --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" + 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 @@ -300,6 +334,12 @@ test_unknown_projection_surfaces_again_after_successful_derivation() { 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" @@ -344,6 +384,23 @@ test_routine_wait_states_what_it_awaits_and_when_it_is_next_checked() { 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_escalates_to_a_decision() { local home out home=$(make_home escalating-wait) @@ -377,6 +434,34 @@ test_repeated_wait_escalates_to_a_decision() { pass "a delay that keeps repeating becomes a captain decision, and progress ends it" } +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=ship" + { + 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" + pass "one keyed issue renders once as a combined decision and wait" +} + # --- deduplication ---------------------------------------------------------- test_identities_ignore_wording_so_repeats_do_not_re_alarm() { local home first second @@ -396,6 +481,7 @@ test_identities_ignore_wording_so_repeats_do_not_re_alarm() { # 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" @@ -412,14 +498,14 @@ test_reopened_status_items_get_new_generation_identity() { touch "$home/state/.last-watcher-beat" first=$(attention "$home" --no-mark --json | jq -r '.[0].identity') - attention "$home" >/dev/null + 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" - attention "$home" >/dev/null + 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" @@ -431,7 +517,6 @@ test_reopened_status_items_get_new_generation_identity() { "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') - attention "$home" >/dev/null 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" @@ -443,8 +528,7 @@ test_reopened_status_items_get_new_generation_identity() { test_brief_form_never_spends_captain_surface_marker() { local home out status home=$(make_primary_home brief-no-mark) - 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)' + 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" @@ -453,12 +537,14 @@ test_brief_form_never_spends_captain_surface_marker() { 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 0 "$status" "the captain-safe turn-end render must record the surfaced digest" - [ -z "$out" ] || fail "the second turn end produced output: $out" + 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_uses_captain_safe_rendering_to_mark() { +test_guard_banner_never_spends_captain_receipt() { local home out status home=$(make_home guard-captain-safe) add_row "$home" Queued \ @@ -467,8 +553,8 @@ test_guard_banner_uses_captain_safe_rendering_to_mark() { 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=false' "the guard must let the captain-safe render spend the marker" - pass "the guard banner renders the captain-safe view and records that surface" + 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() { @@ -494,17 +580,16 @@ EOF test_ordinary_reads_do_not_become_false_alarms() { local home out home=$(make_home quiet-reads) - 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_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") - [ -z "$out" ] || fail "an ordinary read produced a false alarm: $out" - # And rendering must not resurrect the alarm either: rendering IS surfacing. - attention "$home" >/dev/null + 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" @@ -513,11 +598,10 @@ test_ordinary_reads_do_not_become_false_alarms() { test_ledger_keeps_showing_an_open_item_after_it_was_surfaced() { local home out home=$(make_home persistent) - add_row "$home" Queued \ - '- [ ] sample-decision-x - Approve the change (repo: sample) (kind: captain) (since 2026-07-28) (hold: a reason) (hold-kind: captain)' - attention "$home" >/dev/null + add_unsurfaced_decision "$home" + record_visible "$home" out=$(attention "$home" --no-mark) - assert_contains "$out" 'Approve the change' \ + 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" } @@ -536,6 +620,9 @@ test_resolution_clears_the_item() { --reason 'the board cannot be rebased until the fork main matches upstream' \ --repo firstmate \ --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" @@ -563,6 +650,9 @@ test_retry_never_erases_a_written_briefing() { 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 \ --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. @@ -575,12 +665,80 @@ test_retry_never_erases_a_written_briefing() { # 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' \ + --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 \ + --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:\nChoice: 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' \ + --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' \ + >/dev/null || fail "the wording-only retry 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' \ + --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" +} + # --- the primary-activity blind spot ---------------------------------------- # # The defect this contract exists to close: supervision counted state/*.meta, so @@ -613,8 +771,8 @@ test_unaccounted_primary_work_is_not_idle() { pass "unaccounted primary work reads as suspicious, not idle" } -test_turn_end_stops_once_on_an_unsurfaced_decision() { - local home out status +test_turn_end_requires_a_captain_visible_complete_alert() { + local home out status reply home=$(make_primary_home turnend) add_unsurfaced_decision "$home" @@ -623,19 +781,17 @@ test_turn_end_stops_once_on_an_unsurfaced_decision() { 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" - # Bounded by construction: the same set costs exactly one forced continuation. out=$(run_turnend "$home"); status=$? - expect_code 0 "$status" "the same decision set must not stop a second turn" - [ -z "$out" ] || fail "the second turn end produced output: $out" - - # A genuinely new decision is a new set and stops once more. - add_row "$home" Queued \ - '- [ ] sample-decision-y - Approve the second thing (repo: sample) (kind: captain) (since 2026-07-28) (hold: another approval) (hold-kind: captain)' - run_turnend "$home" >/dev/null; status=$? - expect_code 2 "$status" "a genuinely new decision must stop the turn" - run_turnend "$home" >/dev/null; status=$? - expect_code 0 "$status" "the new set must also cost only one continuation" - pass "a turn cannot end on an unsurfaced captain decision, and each set costs one stop" + 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_claude_autoarm_allow_paths_still_stop_for_unsurfaced_decisions() { @@ -738,24 +894,28 @@ test_empty_home_says_so_plainly() { 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_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_escalates_to_a_decision +test_same_key_decision_and_wait_render_once_as_combined 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_uses_captain_safe_rendering_to_mark +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_unaccounted_primary_work_is_not_idle -test_turn_end_stops_once_on_an_unsurfaced_decision +test_turn_end_requires_a_captain_visible_complete_alert test_claude_autoarm_allow_paths_still_stop_for_unsurfaced_decisions test_declared_waits_never_stop_a_turn test_captain_view_carries_no_internal_vocabulary diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 0ef84c4..312bf0a 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -102,6 +102,15 @@ 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 -- "$@" \ + --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 d2406b3..0cd9ea8 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -817,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" @@ -825,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); @@ -858,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); @@ -894,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" @@ -917,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 @@ -954,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() { @@ -1184,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" } From efcd098431701594eff59f8d9595a634eda1b31d Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Thu, 30 Jul 2026 00:12:40 +0200 Subject: [PATCH 11/18] no-mistakes(review): Harden captain attention receipt and wait lifecycle --- .../skills/decision-hold-lifecycle/SKILL.md | 1 + bin/fm-attention-lib.sh | 239 ++++++++++++------ bin/fm-attention.sh | 26 +- bin/fm-decision-hold.sh | 28 +- docs/captain-attention.md | 14 +- docs/decision-hold-lifecycle.md | 3 +- tests/fm-attention.test.sh | 193 ++++++++++++-- tests/fm-decision-hold-lifecycle.test.sh | 1 + 8 files changed, 375 insertions(+), 130 deletions(-) diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 9ae4678..22f3d66 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -18,6 +18,7 @@ Every unresolved decision that belongs to the captain and is discovered while pr 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. diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index e267436..c748033 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -30,11 +30,9 @@ # 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. -# A status-log wait that has been re-declared FM_ATTENTION_WAIT_REDECLARES times -# (default 3) without ever resolving is escalated to a decision, because a delay -# that keeps repeating is by definition no longer clearing on its own. That -# escalation is derived from the keyed event fold, never from reading the prose -# of the wait, so it cannot false-positive on wording. +# Only an explicit `needs-decision` status transition can turn a wait into a +# captain decision. Repetition and firstmate-actionable blockers never change +# who must act. # # IDENTITY AND DEDUPLICATION # Every record carries a stable, TEXT-FREE identity: @@ -45,8 +43,7 @@ # 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. An escalated wait keeps its wait -# identity and only changes class, so escalation itself surfaces exactly once. +# later opens again, the generation changes. # # SURFACING # state/.captain-attention records the digest of the set most recently verified @@ -56,9 +53,10 @@ # 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 include substantive briefing fields, 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. +# 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 renderer (pull, single place) @@ -89,11 +87,6 @@ _FM_ATTENTION_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null) # 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}" -# How many times a declared wait may be re-declared before it stops counting as -# routine and becomes a captain decision. -FM_ATTENTION_WAIT_REDECLARES=${FM_ATTENTION_WAIT_REDECLARES:-3} -case "$FM_ATTENTION_WAIT_REDECLARES" in ''|*[!0-9]*|0) FM_ATTENTION_WAIT_REDECLARES=3 ;; esac - # --- durable captain briefing grammar --------------------------------------- # # The plain language a captain needs is not derivable from backlog metadata, so @@ -101,6 +94,7 @@ case "$FM_ATTENTION_WAIT_REDECLARES" in ''|*[!0-9]*|0) FM_ATTENTION_WAIT_REDECLA # library reads it back. One writer, one reader, one grammar, stated here. # # Captain briefing v1: +# Semantic revision: <privacy-safe slug> # Choice: <one line> # Why now: <one line> # If this waits: <one line> @@ -112,23 +106,25 @@ case "$FM_ATTENTION_WAIT_REDECLARES" in ''|*[!0-9]*|0) FM_ATTENTION_WAIT_REDECLA # 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 <choice> <why-now> <cost> <recommend> [option...] +# fm_attention_brief_lines <semantic-revision> <choice> <why-now> <cost> <recommend> [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 choice=${1:-} why=${2:-} cost=${3:-} recommend=${4:-} opt - shift 4 2>/dev/null || true - if [ -z "$choice" ] && [ -z "$why" ] && [ -z "$cost" ] && [ -z "$recommend" ] && [ "$#" -eq 0 ]; then + 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" @@ -253,7 +249,7 @@ fm_attention_status_rows() { # <state-dir> note=$(status_line_note "$line") note=${note//$'\t'/ } case "$verb" in - needs-decision|blocked) + needs-decision) existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) if [ -n "$existing" ]; then IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF @@ -280,28 +276,44 @@ EOF waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' ;; - "$resolve"|"$held") + "$resolve") decisions=$(_fm_attention_drop_key "$decisions" "$key") [ -n "$decisions" ] && decisions="${decisions}"$'\n' waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' ;; - done|failed|working) + "$held") + existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' - if [ "$verb" = working ]; then - existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) - if [ -n "$existing" ]; then - IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF $existing EOF - decisions=$(_fm_attention_drop_key "$decisions" "$key") - [ -n "$decisions" ] && decisions="${decisions}"$'\n' - decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t0\t\n' + if [ "$dn" -gt 0 ] && [ -n "$dwait" ]; then + gen=$(_fm_attention_generation_get "$wait_gens" "$key") + gen=$((gen + 1)) + wait_gens=$(_fm_attention_generation_set "$wait_gens" "$key" "$gen") + waits="${waits}${key}"$'\t'"${pause}"$'\t'"${gen}"$'\t'"${dn}"$'\t'"${dwait}"$'\n' fi fi + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + ;; + done|failed|working) + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF +$existing +EOF + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t0\t\n' + fi ;; - "$pause") + blocked|"$pause") existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) if [ -n "$existing" ]; then IFS=$'\t' read -r _ dverb gen dnote n dwait <<EOF @@ -371,8 +383,8 @@ fm_attention_json() { # <fm-home> rows=$(fm_attention_status_rows "$state") printf '%s' "$rows" | jq -Rn \ --argjson backlog "$backlog_json" \ - --argjson escalate "$FM_ATTENTION_WAIT_REDECLARES" \ --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" \ @@ -383,13 +395,14 @@ fm_attention_json() { # <fm-home> def fields($lines; $label): [ $lines[] | select(startswith($label)) | ltrimstr($label) | sub("^[[:space:]]+"; "") | select(length > 0) ]; def empty_briefing: - {briefed:false,briefing_complete:false,choice:null,why_now:null,cost_of_waiting:null,options:[],recommendation:null,briefing_missing:[]}; + {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_choice)) as $choice + (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 @@ -398,7 +411,8 @@ fm_attention_json() { # <fm-home> empty_briefing else {briefed:true, - briefing_complete:($choice != null and $why != null and $cost != null and ($options | length) > 0 and $recommend != null), + 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, @@ -440,6 +454,7 @@ fm_attention_json() { # <fm-home> blocked_by:[], redeclares:0, escalated:false, + combined_wait:false, next_check_seconds:null} + briefing(.) ]; def backlog_waits: @@ -460,8 +475,9 @@ fm_attention_json() { # <fm-home> blocked_by:(.unresolved_blocker_ids // []), redeclares:0, escalated:false, + combined_wait:false, next_check_seconds:null, - briefed:false,briefing_complete:false,choice:null,why_now:null,cost_of_waiting: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 @@ -481,10 +497,12 @@ fm_attention_json() { # <fm-home> ([ $backlog.records[]? | select(.structured == true and .id == $id) | .title ] | .[0]) // null; def status_records: [ status_rows[] - | (.redeclares >= $escalate) as $esc - | {class:(if .class == "wait" and $esc then "decision" else .class end), + | {class:.class, identity:(.class + ":" + .id + ":" + .key + ":" + .generation), - source:(if .class == "wait" then "declared-wait" else "status-decision" end), + 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)), @@ -492,12 +510,36 @@ fm_attention_json() { # <fm-home> awaiting:(if .awaiting == "" then null else .awaiting end), blocked_by:[], redeclares:.redeclares, - escalated:(.class == "wait" and $esc), + escalated:false, combined_wait:(.class == "decision" and .awaiting != ""), next_check_seconds:(if .class == "wait" or .awaiting != "" then .next_check_seconds else null end), - briefed:false,briefing_complete:false,choice:null,why_now:null,cost_of_waiting:null, + briefed:false,briefing_complete:false,semantic_revision:null,choice:null,why_now:null,cost_of_waiting:null, options:[],recommendation:null,briefing_missing:[]} ]; - (backlog_decisions + status_records) as $primary + (backlog_decisions) as $backlog_decisions + | (status_records) as $status_records + | [ $backlog_decisions[] as $decision + | ([ $status_records[] + | select((.id + "-decision-" + .key) == $decision.id) ] | .[0]) as $live + | if $live == null then + $decision + elif (($live.combined_wait == true or $live.class == "wait") and $live.awaiting != null) then + $decision + { + combined_wait:true, + awaiting:$live.awaiting, + redeclares:$live.redeclares, + next_check_seconds:$live.next_check_seconds + } + else + $decision + end + ] as $merged_decisions + | [ $status_records[] + | . as $status + | select(($backlog_decisions + | any(.id == ($status.id + "-decision-" + $status.key))) + | 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 @@ -525,43 +567,90 @@ _fm_attention_normalize_message() { } fm_attention_message_covers() { # <message> <all|decisions> - local message=$1 scope=${2:-all} required expected normalized_message normalized_expected + 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 decision_requirements: - if .escalated == true then - ["NEEDS YOUR DECISION", .headline, - "The choice:", "keep waiting on this, or change the plan so it stops holding the work up.", - "What was reported:", .detail] - elif .briefing_complete == true then - ["NEEDS YOUR DECISION", .headline, - "The choice:", .choice, - "Why it matters now:", .why_now, - "If this waits:", .cost_of_waiting, - "Options:"] + .options + ["Recommended:", .recommendation] + 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 - + (if .combined_wait == true then ["Waiting for:", .awaiting, "Next check:"] else [] end); - def wait_requirements: - ["WAITING ON SOMETHING ELSE", .headline, "Waiting for:", .awaiting, "Next check:"]; + "__FIRSTMATE_INCOMPLETE_BRIEFING__" + end; + def wait_block: + [.headline, "Waiting for:", (.awaiting // ""), "Next check:"] + | map(norm) + | join(" "); if length == 0 then - ["Nothing needs your decision, and nothing is waiting."] + "empty\tNothing needs your decision, and nothing is waiting." else - [ .[] - | select($scope == "all" or .class == "decision") - | if .class == "decision" then decision_requirements else wait_requirements end - | .[] ] - | unique + .[] + | 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= read -r expected; do + 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 "$normalized_message" in - *"$normalized_expected"*) ;; + 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 <<EOF @@ -621,17 +710,21 @@ fm_attention_status() { # <fm-home> # 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 receipt_identity: + 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 - {identity,class,headline,choice,why_now,cost_of_waiting,options,recommendation, - escalated,combined_wait} + {decision:decision_receipt_identity,combined_wait:(.combined_wait == true)} | @json else .identity end; "\(length) \([.[] | select(.class == "decision")] | length)", - "--attention--", (.[] | receipt_identity), - "--decisions--", (.[] | select(.class == "decision") | receipt_identity)' 2>/dev/null) || summary='' + "--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#* } diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh index 28ebe83..107c8d5 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -9,10 +9,8 @@ # 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. -# A status-log wait that has been re-reported FM_ATTENTION_WAIT_REDECLARES times -# without clearing is promoted to a decision, because it is no longer resolving -# on its own. That promotion is derived from the event fold, never from the -# wording. +# Only an explicit `needs-decision` status transition changes a 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 @@ -107,7 +105,7 @@ record_visible() { seen_att=$FM_ATT_DIGEST wrote=1 fi - if fm_attention_message_covers "$message" decisions; then + if [ "$FM_ATT_DECISIONS" -gt 0 ] && fm_attention_message_covers "$message" decisions; then seen_dec=$FM_ATT_DECISION_DIGEST wrote=1 fi @@ -181,7 +179,7 @@ field() { # <index> <jq-path> 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 escalated redeclares next opts missing combined + local class headline choice why cost recommend detail awaiting briefed next opts missing combined printf "CAPTAIN'S CALL\n" if [ "$total" -eq 0 ]; then @@ -201,18 +199,11 @@ render_view() { num=$((num + 1)) headline=$(field "$i" '.headline') briefed=$(field "$i" '.briefed') - escalated=$(field "$i" '.escalated') - redeclares=$(field "$i" '.redeclares') detail=$(field "$i" '.detail') combined=$(field "$i" '.combined_wait') printf '\n' wrap_prefixed "$(printf '%s. ' "$num")" 3 "$headline" - if [ "$escalated" = true ]; then - wrap 3 "This was reported as a routine delay, but it has been re-reported $redeclares times without clearing, so it is no longer resolving on its own." - printf ' The choice: keep waiting on this, or change the plan so it stops holding the work up.\n' - printf ' What was reported:\n' - wrap 5 "$detail" - elif [ "$briefed" = true ]; then + if [ "$briefed" = true ]; then choice=$(field "$i" '.choice') why=$(field "$i" '.why_now') cost=$(field "$i" '.cost_of_waiting') @@ -278,7 +269,7 @@ EOF } render_brief() { - local total=$FM_ATT_COUNT i class id headline complete escalated next mark + 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 @@ -291,12 +282,9 @@ render_brief() { id=$(field "$i" '.id') headline=$(field "$i" '.headline') complete=$(field "$i" '.briefing_complete') - escalated=$(field "$i" '.escalated') mark='' if [ "$class" = decision ]; then - if [ "$escalated" = true ]; then - mark=' [repeated delay, no longer clearing]' - elif [ "$complete" != true ]; then + if [ "$complete" != true ]; then mark=' [no captain briefing recorded - add one with bin/fm-decision-hold.sh hold]' fi else diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index f03a5e1..b148ac4 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -20,6 +20,7 @@ # fm-decision-hold.sh id <origin-id> <decision-key> # fm-decision-hold.sh hold <origin-id> <decision-key> \ # --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>...) @@ -33,9 +34,11 @@ # 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. -# Supplying briefing flags rewrites the briefing block and preserves unrecognized -# body lines; supplying none leaves an existing complete briefing untouched. +# 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 @@ -125,9 +128,10 @@ briefing_complete_body() { # <encoded-body> const value = (label) => lines.some((line) => line.startsWith(`${label} `) && line.slice(label.length + 1).length > 0); if (!lines.includes(process.argv[1])) process.exit(1); if (!value(process.argv[2]) || !value(process.argv[3]) || !value(process.argv[4]) || - !value(process.argv[5]) || !value(process.argv[6])) process.exit(1); - ' "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_CHOICE" "$FM_ATTENTION_BRIEF_WHY" \ - "$FM_ATTENTION_BRIEF_COST" "$FM_ATTENTION_BRIEF_OPTION" "$FM_ATTENTION_BRIEF_RECOMMEND" + !value(process.argv[5]) || !value(process.argv[6]) || !value(process.argv[7])) process.exit(1); + ' "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_REVISION" "$FM_ATTENTION_BRIEF_CHOICE" \ + "$FM_ATTENTION_BRIEF_WHY" "$FM_ATTENTION_BRIEF_COST" "$FM_ATTENTION_BRIEF_OPTION" \ + "$FM_ATTENTION_BRIEF_RECOMMEND" } write_updated_body_file() { # <encoded-body> <replacement-prefix> @@ -147,9 +151,9 @@ write_updated_body_file() { # <encoded-body> <replacement-prefix> const preserved = body.split("\n").filter((line) => !owned(line)).join("\n"); const output = process.env.FM_DECISION_PREFIX + (preserved.length > 0 ? `\n${preserved}` : ""); fs.writeFileSync(process.argv[1], output); - ' "$body_file" "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_CHOICE" \ - "$FM_ATTENTION_BRIEF_WHY" "$FM_ATTENTION_BRIEF_COST" "$FM_ATTENTION_BRIEF_OPTION" \ - "$FM_ATTENTION_BRIEF_RECOMMEND"; then + ' "$body_file" "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_REVISION" \ + "$FM_ATTENTION_BRIEF_CHOICE" "$FM_ATTENTION_BRIEF_WHY" "$FM_ATTENTION_BRIEF_COST" \ + "$FM_ATTENTION_BRIEF_OPTION" "$FM_ATTENTION_BRIEF_RECOMMEND"; then rm -f "$body_file" fail "could not decode the existing captain decision body" fi @@ -307,7 +311,7 @@ command_id() { command_hold() { local origin=${1:-} key=${2:-} title='' reason='' repo='' id show state kind existing_title body - local choice='' why='' cost='' recommend='' brief_given=0 brief_block='' existing_body body_file + 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 @@ -316,6 +320,7 @@ 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 ;; @@ -333,7 +338,8 @@ command_hold() { if [ "$brief_given" -eq 1 ]; then validate_complete_briefing "$choice" "$why" "$cost" "$recommend" \ "${options[@]+"${options[@]}"}" - brief_block=$(fm_attention_brief_lines "$choice" "$why" "$cost" "$recommend" \ + validate_slug semantic-revision "$semantic_revision" + brief_block=$(fm_attention_brief_lines "$semantic_revision" "$choice" "$why" "$cost" "$recommend" \ "${options[@]+"${options[@]}"}") fi require_tasks_axi diff --git a/docs/captain-attention.md b/docs/captain-attention.md index b9a3b2e..1ca77d7 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -12,8 +12,8 @@ Every open item is exactly one of two things. - 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-log wait that has been re-declared at least `FM_ATTENTION_WAIT_REDECLARES` times (default 3) without ever clearing is promoted to a decision, because a delay that keeps repeating is no longer resolving on its own. -That promotion is derived from the keyed event fold, never from reading the wording of the delay, so it cannot fire on phrasing alone. +A wait becomes a decision only through an explicit `needs-decision` status transition. +Repetition never changes a routine external or timed wait into a critical alert. ## Where it comes from @@ -30,7 +30,8 @@ A captain hold whose blocker is still open is not answerable yet and stays a wai ## 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 `--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. +`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. @@ -56,15 +57,16 @@ 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 requires the correct category, headline, and every briefing fact. +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 semantic briefing fields, so changing the choice, stakes, waiting cost, options, or recommendation reopens the receipt while a wording-only operational note does not. +Decision receipts include the explicit semantic revision, so a substantive revision reopens the receipt while a wording-only paraphrase does not. A keyed status decision and wait are folded into one combined decision alert. +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. The turn-end stop remains active until the complete alert appears in an actual captain-visible assistant reply. -Routine external and timed waits never stop a turn because they need no captain action; a repeated wait promoted to a decision does. +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. diff --git a/docs/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md index 9410094..693ec19 100644 --- a/docs/decision-hold-lifecycle.md +++ b/docs/decision-hold-lifecycle.md @@ -13,8 +13,9 @@ 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: the concrete choice, why it matters now, what waiting costs, at least one option, and a recommendation, each on one line. +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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 4638928..ae1b097 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -87,7 +87,11 @@ record_visible() { # <home> add_row() { # <home> <section> <line> local home=$1 section=$2 line=$3 - awk -v section="## $section" -v row="$line" ' + 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" @@ -107,6 +111,7 @@ add_unsurfaced_decision() { # <home> 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. @@ -162,6 +167,7 @@ test_briefed_decision_renders_concretely() { --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.' \ @@ -192,12 +198,26 @@ test_new_decision_requires_a_complete_briefing() { 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" } @@ -401,9 +421,9 @@ test_overdue_monitored_wait_is_due_now() { pass "a monitored overdue wait reports its next check as due now" } -test_repeated_wait_escalates_to_a_decision() { +test_repeated_wait_stays_routine_until_captain_action_is_explicit() { local home out - home=$(make_home escalating-wait) + 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" \ @@ -419,19 +439,19 @@ test_repeated_wait_escalates_to_a_decision() { 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=1' "a delay re-reported past the threshold must become a decision" - out=$(attention "$home" --no-mark) - assert_contains "$out" 're-reported 3 times' \ - "the escalation must say why it stopped counting as routine" - assert_contains "$out" 'keep waiting on this, or change the plan' \ - "the escalation must name the choice it puts to the captain" - - # A resolution of that keyed phase ends the run, so the same work does not stay - # escalated forever once it genuinely moves again. - printf 'working: the fork sync landed; rebasing now\n' >> "$home/state/task-board.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=0' "progress must end the escalation" - pass "a delay that keeps repeating becomes a captain decision, and progress ends it" + assert_contains "$out" 'decisions=0' "a firstmate-actionable blocker must not become a captain decision" + assert_contains "$out" 'waits=1' "a firstmate-actionable blocker must remain a visible delay" + + 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 "a routine delay becomes critical only through an explicit captain-action transition" } test_same_key_decision_and_wait_render_once_as_combined() { @@ -440,7 +460,7 @@ test_same_key_decision_and_wait_render_once_as_combined() { 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" + "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' @@ -459,7 +479,69 @@ test_same_key_decision_and_wait_render_once_as_combined() { 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" - pass "one keyed issue renders once as a combined decision and wait" + + 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_combined_alert_is_receiptable_through_transfer_and_clears_on_terminal_work() { + local home out json status + 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" + + 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 the final legal answer' ] \ + || 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 the final legal answer' \ + "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 ---------------------------------------------------------- @@ -619,6 +701,7 @@ test_resolution_clears_the_item() { --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.' \ @@ -649,6 +732,7 @@ test_retry_never_erases_a_written_briefing() { 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.' \ @@ -665,6 +749,7 @@ test_retry_never_erases_a_written_briefing() { # 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.' \ @@ -684,6 +769,7 @@ test_briefing_revisions_preserve_body_and_reopen_receipt_semantically() { 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.' \ @@ -693,7 +779,8 @@ test_briefing_revisions_preserve_body_and_reopen_receipt_semantically() { 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:\nChoice: Sync now, or rebase onto the current fork main.\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=' @@ -705,6 +792,7 @@ test_briefing_revisions_preserve_body_and_reopen_receipt_semantically() { 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.' \ @@ -723,12 +811,18 @@ test_briefing_revisions_preserve_body_and_reopen_receipt_semantically() { 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' \ - >/dev/null || fail "the wording-only retry failed" + --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.' \ @@ -739,6 +833,63 @@ test_briefing_revisions_preserve_body_and_reopen_receipt_semantically() { 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 @@ -902,8 +1053,9 @@ test_failed_backlog_projection_is_unknown_not_empty 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_escalates_to_a_decision +test_repeated_wait_stays_routine_until_captain_action_is_explicit test_same_key_decision_and_wait_render_once_as_combined +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 @@ -914,6 +1066,7 @@ 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_claude_autoarm_allow_paths_still_stop_for_unsurfaced_decisions diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 312bf0a..ce54793 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -105,6 +105,7 @@ run_decisions() { # <home> <command args...> 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." \ From 95118a1a1b82e29bda937a9ab007d89500f64c32 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Thu, 30 Jul 2026 00:29:18 +0200 Subject: [PATCH 12/18] no-mistakes(review): Captain, harden attention identity and lifecycle handling --- bin/fm-attention-lib.sh | 164 ++++++++++++++++++++++--------------- bin/fm-attention.sh | 4 +- bin/fm-classify-lib.sh | 21 +++-- docs/captain-attention.md | 4 +- tests/fm-attention.test.sh | 87 ++++++++++++++++++-- 5 files changed, 196 insertions(+), 84 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index c748033..d499fbe 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -30,8 +30,8 @@ # 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 `needs-decision` status transition can turn a wait into a -# captain decision. Repetition and firstmate-actionable blockers never change +# Only an explicit action-required `needs-decision` or `blocked` status +# transition can turn a wait into a captain decision. Repetition never changes # who must act. # # IDENTITY AND DEDUPLICATION @@ -214,14 +214,14 @@ _fm_attention_generation_set() { # <generation-set> <key> <generation> } # Emit one TAB-separated status-derived row per open decision or wait: -# <class> <task-id> <key> <generation> <verb> <redeclares> <next-check-secs> <note> +# <class> <task-id> <key> <generation> <verb> <redeclares> <wait-generation> <next-check-secs> <note> # 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() { # <state-dir> local state=$1 meta id status kind last verb key note n next line stripped pause resolve held - local decisions waits decision_gens wait_gens existing gen dverb dnote dn dwait wverb wgen wn wnote decision_next + local decisions waits decision_gens wait_gens existing gen dverb dnote dn dwgen dwait wverb 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} @@ -241,6 +241,7 @@ fm_attention_status_rows() { # <state-dir> waits='' decision_gens='' wait_gens='' + status_text=$(cat "$status" 2>/dev/null) || return 1 while IFS= read -r line || [ -n "$line" ]; do stripped=${line//[[:space:]]/} [ -n "$stripped" ] || continue @@ -248,34 +249,37 @@ fm_attention_status_rows() { # <state-dir> key=$(_fm_decision_key "$line") || continue note=$(status_line_note "$line") note=${note//$'\t'/ } - case "$verb" in - needs-decision) - existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) - if [ -n "$existing" ]; then - IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF + 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 <<EOF $existing EOF - else - gen=$(_fm_attention_generation_get "$decision_gens" "$key") - gen=$((gen + 1)) - decision_gens=$(_fm_attention_generation_set "$decision_gens" "$key" "$gen") - dn=0 - dwait='' - fi - existing=$(_fm_attention_row_for_key "$waits" "$key" || true) - if [ -n "$existing" ]; then - IFS=$'\t' read -r _ wverb wgen wn wnote <<EOF + else + gen=$(_fm_attention_generation_get "$decision_gens" "$key") + gen=$((gen + 1)) + decision_gens=$(_fm_attention_generation_set "$decision_gens" "$key" "$gen") + dn=0 + dwgen=0 + dwait='' + fi + existing=$(_fm_attention_row_for_key "$waits" "$key" || true) + if [ -n "$existing" ]; then + IFS=$'\t' read -r _ wverb wgen wn wnote <<EOF $existing EOF - dn=$wn - dwait=$wnote - fi - decisions=$(_fm_attention_drop_key "$decisions" "$key") - [ -n "$decisions" ] && decisions="${decisions}"$'\n' - decisions="${decisions}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${note}"$'\t'"${dn}"$'\t'"${dwait}"$'\n' - waits=$(_fm_attention_drop_key "$waits" "$key") - [ -n "$waits" ] && waits="${waits}"$'\n' - ;; + dn=$wn + dwgen=$wgen + dwait=$wnote + fi + decisions=$(_fm_attention_drop_key "$decisions" "$key") + [ -n "$decisions" ] && decisions="${decisions}"$'\n' + decisions="${decisions}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${note}"$'\t'"${dn}"$'\t'"${dwgen}"$'\t'"${dwait}"$'\n' + waits=$(_fm_attention_drop_key "$waits" "$key") + [ -n "$waits" ] && waits="${waits}"$'\n' + continue + fi + case "$verb" in "$resolve") decisions=$(_fm_attention_drop_key "$decisions" "$key") [ -n "$decisions" ] && decisions="${decisions}"$'\n' @@ -287,14 +291,11 @@ EOF waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' if [ -n "$existing" ]; then - IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF + IFS=$'\t' read -r _ dverb gen dnote dn dwgen dwait <<EOF $existing EOF if [ "$dn" -gt 0 ] && [ -n "$dwait" ]; then - gen=$(_fm_attention_generation_get "$wait_gens" "$key") - gen=$((gen + 1)) - wait_gens=$(_fm_attention_generation_set "$wait_gens" "$key" "$gen") - waits="${waits}${key}"$'\t'"${pause}"$'\t'"${gen}"$'\t'"${dn}"$'\t'"${dwait}"$'\n' + waits="${waits}${key}"$'\t'"${pause}"$'\t'"${dwgen}"$'\t'"${dn}"$'\t'"${dwait}"$'\n' fi fi decisions=$(_fm_attention_drop_key "$decisions" "$key") @@ -305,24 +306,29 @@ EOF [ -n "$waits" ] && waits="${waits}"$'\n' existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r _ dverb gen dnote dn dwait <<EOF + IFS=$'\t' read -r _ dverb gen dnote dn dwgen dwait <<EOF $existing EOF decisions=$(_fm_attention_drop_key "$decisions" "$key") [ -n "$decisions" ] && decisions="${decisions}"$'\n' - decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t0\t\n' + decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t0\t0\t\n' fi ;; - blocked|"$pause") + "$pause") existing=$(_fm_attention_row_for_key "$decisions" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r _ dverb gen dnote n dwait <<EOF + IFS=$'\t' read -r _ dverb gen dnote n dwgen dwait <<EOF $existing EOF + if [ "$n" -eq 0 ]; then + dwgen=$(_fm_attention_generation_get "$wait_gens" "$key") + dwgen=$((dwgen + 1)) + wait_gens=$(_fm_attention_generation_set "$wait_gens" "$key" "$dwgen") + fi n=$((n + 1)) decisions=$(_fm_attention_drop_key "$decisions" "$key") [ -n "$decisions" ] && decisions="${decisions}"$'\n' - decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t'"${n}"$'\t'"${note}"$'\n' + decisions="${decisions}${key}"$'\t'"${dverb}"$'\t'"${gen}"$'\t'"${dnote}"$'\t'"${n}"$'\t'"${dwgen}"$'\t'"${note}"$'\n' waits=$(_fm_attention_drop_key "$waits" "$key") [ -n "$waits" ] && waits="${waits}"$'\n' continue @@ -344,21 +350,23 @@ EOF waits="${waits}${key}"$'\t'"${verb}"$'\t'"${gen}"$'\t'"${n}"$'\t'"${note}"$'\n' ;; esac - done < "$status" + done <<EOF +$status_text +EOF next=$(fm_attention_wait_next_check "$status" "$state") - while IFS=$'\t' read -r key verb gen note n wnote; do + while IFS=$'\t' read -r key verb gen note n dwgen wnote; do [ -n "$key" ] || continue decision_next=$next [ "$n" -gt 0 ] || decision_next=-1 - printf 'decision\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$id" "$key" "$gen" "$verb" "$n" "$decision_next" "$note" "$wnote" + printf 'decision\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$key" "$gen" "$verb" "$n" "$dwgen" "$decision_next" "$note" "$wnote" done <<EOF $decisions EOF while IFS=$'\t' read -r key verb gen n note; do [ -n "$key" ] || continue - printf 'wait\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$id" "$key" "$gen" "$verb" "$n" "$next" "$note" "$note" + printf 'wait\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$key" "$gen" "$verb" "$n" "$gen" "$next" "$note" "$note" done <<EOF $waits EOF @@ -380,7 +388,7 @@ fm_attention_json() { # <fm-home> "$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") + rows=$(fm_attention_status_rows "$state") || return 1 printf '%s' "$rows" | jq -Rn \ --argjson backlog "$backlog_json" \ --arg brief_header "$FM_ATTENTION_BRIEF_HEADER" \ @@ -455,6 +463,7 @@ fm_attention_json() { # <fm-home> redeclares:0, escalated:false, combined_wait:false, + combined_wait_identity:null, next_check_seconds:null} + briefing(.) ]; def backlog_waits: @@ -476,6 +485,7 @@ fm_attention_json() { # <fm-home> 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:[]} ]; @@ -483,12 +493,13 @@ fm_attention_json() { # <fm-home> [ inputs | select(length > 0) | split("\t") - | select(length >= 9) + | select(length >= 10) | {class:.[0],id:.[1],key:.[2],generation:.[3],verb:.[4], redeclares:(.[5] | tonumber? // 0), - next_check_seconds:(.[6] | tonumber? // -1), - note:.[7], - awaiting:(.[8:] | join("\t"))} ]; + 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, @@ -512,31 +523,48 @@ fm_attention_json() { # <fm-home> 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 - | [ $backlog_decisions[] as $decision - | ([ $status_records[] - | select((.id + "-decision-" + .key) == $decision.id) ] | .[0]) as $live - | if $live == null then - $decision - elif (($live.combined_wait == true or $live.class == "wait") and $live.awaiting != null) then - $decision + { - combined_wait:true, - awaiting:$live.awaiting, - redeclares:$live.redeclares, - next_check_seconds:$live.next_check_seconds - } - else - $decision - end - ] as $merged_decisions + | 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 (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(($backlog_decisions - | any(.id == ($status.id + "-decision-" + $status.key))) + | select(($decision_associations + | any(.status_identity == $status.identity)) | not) ] as $unmatched_status | ($merged_decisions + $unmatched_status) as $primary @@ -717,7 +745,7 @@ fm_attention_status() { # <fm-home> | @json; def attention_receipt_identity: if .class == "decision" then - {decision:decision_receipt_identity,combined_wait:(.combined_wait == true)} + {decision:decision_receipt_identity,combined_wait_identity:(.combined_wait_identity // null)} | @json else .identity diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh index 107c8d5..7245c1a 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -9,8 +9,8 @@ # 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 `needs-decision` status transition changes a wait into a -# captain decision. +# Only an explicit action-required `needs-decision` or `blocked` status +# transition changes a 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 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() { # <status-verb> + 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 "<key>\t<verb>\t<summary>" 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() { # <status-file> [ -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/docs/captain-attention.md b/docs/captain-attention.md index 1ca77d7..5a3ef77 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -12,7 +12,7 @@ Every open item is exactly one of two things. - 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 wait becomes a decision only through an explicit `needs-decision` status transition. +A 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 @@ -61,7 +61,7 @@ Every rendering mode is read-only; only `--record-visible`, fed the assistant me 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 keyed status decision and wait are folded into one combined decision alert. +A direct or generated captain item and its matching keyed status decision or wait are folded into one combined decision alert. 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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index ae1b097..a5eac48 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -342,6 +342,34 @@ test_failed_backlog_projection_is_unknown_not_empty() { pass "a failed backlog projection is unknown, not an empty captain call" } +test_failed_status_collection_is_unknown_not_empty() { + local home fake_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" + printf '#!/usr/bin/env bash\nexit 42\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" + + 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 status collection failure left attention available" + assert_contains "$out" 'unknown=true' "a status collection failure did not report unknown" + assert_contains "$out" 'json={"unknown":true}' "a status collection failure rendered a false empty set" + pass "a status collection failure reports unknown instead of an empty captain call" +} + test_unknown_projection_surfaces_again_after_successful_derivation() { local home snapshot out status home=$(make_primary_home snapshot-unknown-reopens) @@ -444,14 +472,14 @@ test_repeated_wait_stays_routine_until_captain_action_is_explicit() { printf 'blocked: firstmate must repair the failed synchronization\n' >> "$home/state/task-board.status" out=$(attention "$home" --no-mark --status) - assert_contains "$out" 'decisions=0' "a firstmate-actionable blocker must not become a captain decision" - assert_contains "$out" 'waits=1' "a firstmate-actionable blocker must remain a visible delay" + 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 "a routine delay becomes critical only through an explicit captain-action transition" + pass "routine delays stay routine until an explicit action-required transition" } test_same_key_decision_and_wait_render_once_as_combined() { @@ -488,8 +516,38 @@ test_same_key_decision_and_wait_render_once_as_combined() { 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=release]: choose whether to release the board\n' + printf 'paused [key=release]: 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_combined_alert_is_receiptable_through_transfer_and_clears_on_terminal_work() { - local home out json status + 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' \ @@ -526,12 +584,27 @@ test_combined_alert_is_receiptable_through_transfer_and_clears_on_terminal_work( 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 the final legal answer' ] \ + = '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" @@ -539,7 +612,7 @@ test_combined_alert_is_receiptable_through_transfer_and_clears_on_terminal_work( [ "$(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 the final legal answer' \ + 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" } @@ -1050,11 +1123,13 @@ 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_status_collection_is_unknown_not_empty 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_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 From 1e2c61e4af6f936fde41c9b11fa99c4e5bfdff63 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Thu, 30 Jul 2026 00:41:40 +0200 Subject: [PATCH 13/18] no-mistakes(review): Captain, require explicit attention associations and readable state --- bin/fm-attention-lib.sh | 42 +++++++++++++++----- docs/captain-attention.md | 4 +- tests/fm-attention.test.sh | 79 +++++++++++++++++++++++++++++++++----- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index d499fbe..63bc2c0 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -69,9 +69,10 @@ # endpoint, or a harness. # # DEPENDENCIES -# jq and a successful backlog projection 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. +# 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="." @@ -220,18 +221,35 @@ _fm_attention_generation_set() { # <generation-set> <key> <generation> # hold by bin/fm-decision-hold.sh. Terminal tasks are skipped for the same reason # origin_open_decisions skips them. fm_attention_status_rows() { # <state-dir> - local state=$1 meta id status kind last verb key note n next line stripped pause resolve held + 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 wverb 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} - for meta in "$state"/*.meta; do - [ -f "$meta" ] || continue + [ -e "$state" ] || return 0 + [ -d "$state" ] && [ -r "$state" ] && [ -x "$state" ] || return 1 + meta_paths=$(find "$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" [ -f "$status" ] || continue - kind=$(grep '^kind=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true) - [ -n "$kind" ] || kind=ship + 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 <<EOF +$meta_text +EOF + if [ "$kind_seen" -eq 0 ]; then + kind=ship + else + [ -n "$kind" ] || return 1 + fi if [ "$kind" != secondmate ]; then last=$(last_status_line "$status") verb=$(status_line_verb "$last") @@ -370,7 +388,9 @@ EOF done <<EOF $waits EOF - done + done <<EOF +$meta_paths +EOF return 0 } @@ -541,7 +561,9 @@ fm_attention_json() { # <fm-home> $backlog_decisions | any(synthetic_match(.; $status)); def matches_decision($decision; $status): synthetic_match($decision; $status) - or ($decision.id == $status.id and (has_synthetic_owner($status) | not)); + 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), diff --git a/docs/captain-attention.md b/docs/captain-attention.md index 5a3ef77..527502f 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -21,7 +21,7 @@ Repetition never changes a routine external or timed wait into a critical alert. It derives the open set from state firstmate already keeps durably - `data/backlog.md` and `state/*.status` - and 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 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. +If that projection or an existing 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. 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. @@ -61,7 +61,7 @@ Every rendering mode is read-only; only `--record-visible`, fed the assistant me 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 direct or generated captain item and its matching keyed status decision or wait are folded into one combined decision alert. +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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index a5eac48..4ca5847 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -342,15 +342,15 @@ test_failed_backlog_projection_is_unknown_not_empty() { pass "a failed backlog projection is unknown, not an empty captain call" } -test_failed_status_collection_is_unknown_not_empty() { - local home fake_bin out status +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" - printf '#!/usr/bin/env bash\nexit 42\n' > "$fake_bin/cat" + 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"' \ @@ -358,16 +358,45 @@ test_failed_status_collection_is_unknown_not_empty() { 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" + 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 status collection failure left attention available" - assert_contains "$out" 'unknown=true' "a status collection failure did not report unknown" - assert_contains "$out" 'json={"unknown":true}' "a status collection failure rendered a false empty set" - pass "a status collection failure reports unknown instead of an empty captain call" + 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_unknown_projection_surfaces_again_after_successful_derivation() { @@ -532,8 +561,8 @@ test_direct_captain_hold_and_same_task_status_render_once() { 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 release the board\n' - printf 'paused [key=release]: waiting for final release confirmation\n' + 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" @@ -546,6 +575,35 @@ test_direct_captain_hold_and_same_task_status_render_once() { 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; } @@ -1123,13 +1181,14 @@ 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_status_collection_is_unknown_not_empty +test_failed_state_collection_is_unknown_not_empty 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 From 89f39a5cf676676188a15e1e5961c6b433d60152 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Thu, 30 Jul 2026 00:49:48 +0200 Subject: [PATCH 14/18] no-mistakes(review): Captain, follow linked state and reject dangling inputs --- bin/fm-attention-lib.sh | 13 ++++++++++--- tests/fm-attention.test.sh | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 63bc2c0..b47fb6b 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -226,16 +226,23 @@ fm_attention_status_rows() { # <state-dir> 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} - [ -e "$state" ] || return 0 + if [ ! -e "$state" ]; then + [ -L "$state" ] && return 1 + return 0 + fi [ -d "$state" ] && [ -r "$state" ] && [ -x "$state" ] || return 1 - meta_paths=$(find "$state" -type d ! -path "$state" -prune -o -name '*.meta' -print 2>/dev/null) || 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" - [ -f "$status" ] || continue + 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 diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 4ca5847..291fb3b 100644 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -399,6 +399,42 @@ test_failed_state_collection_is_unknown_not_empty() { 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) @@ -1182,6 +1218,7 @@ 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 From 5313774cbfff355db10b9dbacdb4043671742f48 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Thu, 30 Jul 2026 01:08:23 +0200 Subject: [PATCH 15/18] no-mistakes(document): Align captain-attention docs with final receipt behavior --- .agents/skills/bearings/SKILL.md | 4 ++-- AGENTS.md | 4 ++-- bin/fm-attention-lib.sh | 8 ++++---- bin/fm-attention.sh | 4 ++-- bin/fm-session-start.sh | 6 +++--- docs/architecture.md | 2 +- docs/captain-attention.md | 9 ++++++--- docs/scripts.md | 2 +- docs/verification/supervision.md | 8 ++++---- 9 files changed, 25 insertions(+), 22 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index f99daa1..916b306 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -25,9 +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 <id>`; never infer current state from a raw `tail` of `state/<id>.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 captain-facing owner of 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. + 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; one that stopped clearing on its own is promoted to a decision by that same command and then belongs in Captain's Call. + 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.md b/AGENTS.md index 255e410..99686c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ 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: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload .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 rendered to a captain-facing surface; bounds the interrupt, never the ledger (bin/fm-attention-lib.sh) + .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) .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 @@ -144,7 +144,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai 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. **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 and `docs/captain-attention.md` is the operator reference; a locked session records the surfaced set, a read-only session does not. + `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.). 6. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/<id>.meta`; a bounded tail of each task's `state/<id>.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. diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index b47fb6b..33d2fcd 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -31,8 +31,8 @@ # 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 wait into a captain decision. Repetition never changes -# who must act. +# 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: @@ -59,13 +59,13 @@ # INTERRUPT. # # CONSUMERS -# bin/fm-attention.sh the captain-facing renderer (pull, single place) +# 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 data/backlog.md and state/*.status and never inspects a pane, an +# reads only home-local backlog and state records and never inspects a pane, an # endpoint, or a harness. # # DEPENDENCIES diff --git a/bin/fm-attention.sh b/bin/fm-attention.sh index 7245c1a..71992c2 100755 --- a/bin/fm-attention.sh +++ b/bin/fm-attention.sh @@ -2,7 +2,7 @@ # 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 data/backlog.md and state/*.status. This command +# 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: @@ -10,7 +10,7 @@ # 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 wait into a captain decision. +# 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 diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index c57b4b9..7d3ca6b 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -39,9 +39,9 @@ # (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. A locked session records the -# surfaced digest when the full set can be read; a -# read-only session does not. +# 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, diff --git a/docs/architecture.md b/docs/architecture.md index ca6916a..0be5b7d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ The guard covers the main primary and genuinely marked secondmate homes, exempts 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 captain-facing renderer. +[`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. diff --git a/docs/captain-attention.md b/docs/captain-attention.md index 527502f..4122343 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -12,16 +12,19 @@ Every open item is exactly one of two things. - 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 wait becomes a decision only through an explicit action-required `needs-decision` or `blocked` status transition. +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 state firstmate already keeps durably - `data/backlog.md` and `state/*.status` - and stores no item records of its own. +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 or an existing 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. +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. diff --git a/docs/scripts.md b/docs/scripts.md index 7c4197b..34fff73 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -7,7 +7,7 @@ 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 | diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index c3f5ceb..20b7204 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -177,9 +177,9 @@ FM_GROK_STOP_LIVE_E2E=1 FM_GROK_NATIVE_BIN="$native_grok" FM_GROK_LEGACY_BIN="$p ## Captain's call: decisions and waits -Review date: 2026-07-28. +Review date: 2026-07-30. -The captain-attention contract adds two captain-facing surfaces: 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. +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 | @@ -193,7 +193,7 @@ Every supported primary harness was reviewed against its own adapter rather than | 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 `data/backlog.md` and `state/*.status` and calls no backend function. +`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`. @@ -203,7 +203,7 @@ The captain-facing behavior and the primary-activity blind spot are covered dete 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, wait timing, resolution, and the primary-activity blind spot. +The suite covers complete briefing creation, full escape preservation, semantic receipt revisions, combined keyed alerts, read-only marker behavior, actual assistant-delivery receipts, wait timing, resolution, readable linked state, dangling-link unknown states, and the primary-activity blind spot. Current entry points: From 45c29fdf707f225c07f1f0885f40f4ee3be3cc56 Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Tue, 4 Aug 2026 22:46:41 +0200 Subject: [PATCH 16/18] no-mistakes(review): Bound evidence-less captain-decision stops; port decision-hold JSON helpers to jq --- bin/fm-attention-lib.sh | 51 ++++++++++++++-------------- bin/fm-decision-hold.sh | 68 +++++++++++++++++++++----------------- bin/fm-turnend-guard.sh | 25 ++++++++++++-- docs/captain-attention.md | 3 +- docs/turnend-guard.md | 4 ++- tests/fm-attention.test.sh | 49 +++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 60 deletions(-) mode change 100644 => 100755 tests/fm-attention.test.sh diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index 33d2fcd..f53c197 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -716,6 +716,28 @@ EOF return 0 } +# The one owner of the unknown-state result: every derivation failure inside +# fm_attention_status reports the same unavailable set and unknown-marker +# freshness through here, so the failure branches cannot drift apart. +_fm_attention_mark_unknown() { # <state-dir> + 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 + # shellcheck disable=SC2034 # Read by callers after sourcing. + FM_ATT_ERROR='The open decision and wait list could not be determined.' + 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 <fm-home> # Populate for the home at $1: # FM_ATT_AVAILABLE true/false - false when the complete set is unavailable @@ -733,7 +755,7 @@ EOF # FM_ATT_UNKNOWN_NEW true when the same unknown has not been surfaced # Always returns 0. fm_attention_status() { # <fm-home> - local home=$1 state seen_att seen_dec seen_unknown ids='' dec_ids='' summary + 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 @@ -751,17 +773,7 @@ fm_attention_status() { # <fm-home> 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_ATT_AVAILABLE=false - FM_ATT_UNKNOWN=true - FM_ATT_ERROR='The open decision and wait list could not be determined.' - 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) - [ "$FM_ATT_UNKNOWN_DIGEST" = "$seen_unknown" ] || FM_ATT_UNKNOWN_NEW=true + _fm_attention_mark_unknown "$state" return 0 fi # One jq pass for counts and both identity lists; this runs on every guarded @@ -791,20 +803,7 @@ fm_attention_status() { # <fm-home> 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_ATT_AVAILABLE=false - # shellcheck disable=SC2034 # Read by callers after sourcing. - FM_ATT_UNKNOWN=true - # shellcheck disable=SC2034 # Read by callers after sourcing. - FM_ATT_ERROR='The open decision and wait list could not be determined.' - 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_mark_unknown "$state" return 0 fi # shellcheck disable=SC2034 # Read by callers after sourcing. diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index b148ac4..087e634 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -120,40 +120,48 @@ validate_complete_briefing() { } briefing_complete_body() { # <encoded-body> - printf '%s' "$1" | node -e ' - const fs = require("fs"); - const body = JSON.parse(fs.readFileSync(0, "utf8")); - if (typeof body !== "string") process.exit(1); - const lines = body.split("\n"); - const value = (label) => lines.some((line) => line.startsWith(`${label} `) && line.slice(label.length + 1).length > 0); - if (!lines.includes(process.argv[1])) process.exit(1); - if (!value(process.argv[2]) || !value(process.argv[3]) || !value(process.argv[4]) || - !value(process.argv[5]) || !value(process.argv[6]) || !value(process.argv[7])) process.exit(1); - ' "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_REVISION" "$FM_ATTENTION_BRIEF_CHOICE" \ - "$FM_ATTENTION_BRIEF_WHY" "$FM_ATTENTION_BRIEF_COST" "$FM_ATTENTION_BRIEF_OPTION" \ - "$FM_ATTENTION_BRIEF_RECOMMEND" + 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" | FM_DECISION_PREFIX="$prefix" node -e ' - const fs = require("fs"); - const body = JSON.parse(fs.readFileSync(0, "utf8")); - if (typeof body !== "string") process.exit(1); - const labels = process.argv.slice(2); - const owned = (line) => - line === labels[0] || - labels.slice(1).some((label) => line.startsWith(label)) || - line.startsWith("Origin: ") || - line.startsWith("Decision key: ") || - line === "State: awaiting captain decision."; - const preserved = body.split("\n").filter((line) => !owned(line)).join("\n"); - const output = process.env.FM_DECISION_PREFIX + (preserved.length > 0 ? `\n${preserved}` : ""); - fs.writeFileSync(process.argv[1], output); - ' "$body_file" "$FM_ATTENTION_BRIEF_HEADER" "$FM_ATTENTION_BRIEF_REVISION" \ - "$FM_ATTENTION_BRIEF_CHOICE" "$FM_ATTENTION_BRIEF_WHY" "$FM_ATTENTION_BRIEF_COST" \ - "$FM_ATTENTION_BRIEF_OPTION" "$FM_ATTENTION_BRIEF_RECOMMEND"; then + 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 @@ -182,7 +190,7 @@ tasks_axi() { require_tasks_axi() { fm_tasks_axi_compatible || fail "compatible tasks-axi is required" - command -v node >/dev/null 2>&1 || fail "node 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" } diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index a9c2c0b..2cb533a 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -35,7 +35,11 @@ # 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. +# 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 @@ -129,6 +133,13 @@ ASSISTANT_MESSAGE=$(printf '%s' "$PAYLOAD" | jq -r ' 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 @@ -176,6 +187,9 @@ block_attention() { 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 } @@ -193,10 +207,17 @@ block_attention_unknown() { } 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 - [ "$FM_ATT_DECISIONS" -gt 0 ] && [ "$FM_ATT_DECISIONS_NEW" = true ] && block_attention + 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 diff --git a/docs/captain-attention.md b/docs/captain-attention.md index 4122343..9de1582 100644 --- a/docs/captain-attention.md +++ b/docs/captain-attention.md @@ -68,7 +68,8 @@ A generated captain item merges with its origin and key, while a direct item mer 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. -The turn-end stop remains active until the complete alert appears in an actual captain-visible assistant reply. +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. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index eacf6f9..62a8231 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -42,6 +42,7 @@ The supervision predicate above counts `state/*.meta`, so a primary whose only l 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. @@ -60,7 +61,8 @@ Each passive adapter selects its follow-up headline from the guard's own banner, 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 still passes through the attention gate, so a continuation cannot end without a validated captain receipt. +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. diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh old mode 100644 new mode 100755 index 291fb3b..361576b --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -79,6 +79,15 @@ run_turnend_claude() { # <home> [<stop-hook-active>] [<assistant-message>] 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) @@ -812,6 +821,7 @@ test_only_the_captain_renderer_writes_the_surface_marker() { 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 @@ -1112,6 +1122,44 @@ test_turn_end_requires_a_captain_visible_complete_alert() { 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 @@ -1240,6 +1288,7 @@ 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 From 5c1fc008a8621a179dd066f480580ddb798093ec Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Tue, 4 Aug 2026 23:12:06 +0200 Subject: [PATCH 17/18] no-mistakes(document): Align inventories with captain-attention markers, knobs, and library --- AGENTS.md | 1 + docs/configuration.md | 2 ++ docs/documentation-audiences.json | 4 ++++ docs/scripts.md | 1 + docs/verification/supervision.md | 2 +- 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 99686c7..0eb3b24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,7 @@ state/ volatile runtime signals; gitignored .wake-queue durable queued wakes: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload .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 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/documentation-audiences.json b/docs/documentation-audiences.json index ccc6744..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" diff --git a/docs/scripts.md b/docs/scripts.md index 34fff73..a6af48c 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -17,6 +17,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `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 | diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 20b7204..ef0d94e 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -203,7 +203,7 @@ The captain-facing behavior and the primary-activity blind spot are covered dete 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, wait timing, resolution, readable linked state, dangling-link unknown states, and the primary-activity blind spot. +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: From 50856345fe385e96c157700efc775680c3edca0e Mon Sep 17 00:00:00 2001 From: Flo Hart <florian.eberhart.z@gmail.com> Date: Tue, 4 Aug 2026 23:47:51 +0200 Subject: [PATCH 18/18] no-mistakes(lint): fm-attention: resolve shellcheck SC2034/SC2016 findings --- bin/fm-attention-lib.sh | 12 +++++------- tests/fm-attention.test.sh | 2 ++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/fm-attention-lib.sh b/bin/fm-attention-lib.sh index f53c197..5971dcb 100644 --- a/bin/fm-attention-lib.sh +++ b/bin/fm-attention-lib.sh @@ -222,7 +222,7 @@ _fm_attention_generation_set() { # <generation-set> <key> <generation> # origin_open_decisions skips them. fm_attention_status_rows() { # <state-dir> 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 wverb wgen wn wnote decision_next status_text + 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} @@ -290,7 +290,7 @@ EOF fi existing=$(_fm_attention_row_for_key "$waits" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r _ wverb wgen wn wnote <<EOF + IFS=$'\t' read -r _ _ wgen wn wnote <<EOF $existing EOF dn=$wn @@ -360,7 +360,7 @@ EOF fi existing=$(_fm_attention_row_for_key "$waits" "$key" || true) if [ -n "$existing" ]; then - IFS=$'\t' read -r _ wverb gen n wnote <<EOF + IFS=$'\t' read -r _ _ gen n wnote <<EOF $existing EOF n=$((n + 1)) @@ -725,8 +725,6 @@ _fm_attention_mark_unknown() { # <state-dir> FM_ATT_AVAILABLE=false # shellcheck disable=SC2034 # Read by callers after sourcing. FM_ATT_UNKNOWN=true - # shellcheck disable=SC2034 # Read by callers after sourcing. - FM_ATT_ERROR='The open decision and wait list could not be determined.' FM_ATT_JSON='{"unknown":true}' FM_ATT_DIGEST=unknown FM_ATT_DECISION_DIGEST=unknown @@ -742,7 +740,6 @@ _fm_attention_mark_unknown() { # <state-dir> # 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_ERROR human-readable unknown-state reason # FM_ATT_JSON the record array # FM_ATT_COUNT total open records # FM_ATT_DECISIONS open records needing the captain @@ -759,8 +756,8 @@ fm_attention_status() { # <fm-home> 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_ERROR='' FM_ATT_JSON='[]' FM_ATT_COUNT=0 FM_ATT_DECISIONS=0 @@ -770,6 +767,7 @@ fm_attention_status() { # <fm-home> 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 diff --git a/tests/fm-attention.test.sh b/tests/fm-attention.test.sh index 361576b..8e3fba3 100755 --- a/tests/fm-attention.test.sh +++ b/tests/fm-attention.test.sh @@ -359,6 +359,7 @@ test_failed_state_collection_is_unknown_not_empty() { 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" @@ -369,6 +370,7 @@ test_failed_state_collection_is_unknown_not_empty() { 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"' \