From 4ac5e34067fb355ee760947f0a83fe40abbaaae2 Mon Sep 17 00:00:00 2001 From: Flo Hart Date: Tue, 28 Jul 2026 23:34:09 +0200 Subject: [PATCH 1/8] 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 23f7d34..121f4a6 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -15,13 +15,14 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `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 14014fe8cfec304a0835523596ffee1b8fa710af 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 2/8] 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 d1bcb83..d6a492a 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 a4f53d5139dcde071933d966d39ad44f22d367f0 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 3/8] 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 9243291dbb867b58cb71a30daa0f0bf7ed1d4f2c 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 4/8] 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 406210b983a84e877c8231290fbe820315fd8afd 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 5/8] 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 cb967faba43bacd17318897798c9d3d9e82f3965 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 6/8] 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 6faae6b5230ab1df7bd9b8fafe09850dec5c393e 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 7/8] 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 121f4a6..d7197ed 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-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 | @@ -31,7 +31,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 e609cd49bd5d4050f9eede70b229f791a7e5ba91 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 8/8] 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"