diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 517e8ba029..13d0b50268 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -1,6 +1,8 @@ --- name: afk -description: Enter away-mode supervision. Use when the user invokes /afk (e.g. "/afk", "/afk back in an hour", "going afk"). Sets a durable away-mode flag so the sub-supervisor daemon can self-handle routine wakes and escalate captain-relevant events plus bounded declared-external-wait rechecks as batched digests, cutting supervision token cost during walk-away stretches. Exit is automatic; any real (unmarked) message returns to full per-wake responsiveness. +description: >- + Enter away-mode supervision when the captain invokes /afk, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. + It sets a durable away-mode flag so the sub-supervisor daemon can self-handle routine wakes and escalate captain-relevant events plus bounded declared-external-wait rechecks as batched digests during walk-away stretches, then exits automatically when any real unmarked message returns firstmate to full per-wake responsiveness. user-invocable: true metadata: internal: true @@ -39,8 +41,8 @@ batched digest rather than per-wake injections. in as `FM_SUPERVISOR_TARGET` so the daemon injects into the captain, not its own new pane. **Never manufacture a terminal by splitting the captain's active pane** (`herdr pane split`): a split co-tenants the tab and visibly - shrinks the captain's pane (docs/herdr-backend.md "Away-mode daemon terminal - launch"). + shrinks the captain's pane (docs/herdr-backend.md "Away-mode supervisor + support"). Both paths share `bin/fm-afk-start.sh` as the daemon entry. The native path tells it that the launcher already prepared lifecycle state; the terminal-backed path lets the entry perform its existing state setup inside the new terminal. It exits immediately if the identity-backed daemon lock already names a live process, otherwise it execs `bin/fm-supervise-daemon.sh` in the foreground. @@ -50,18 +52,19 @@ batched digest rather than per-wake injections. 3. **Do not separately arm `fm-watch.sh`.** The daemon manages the watcher as its child; the singleton lock no-ops a stray arm harmlessly. -4. **Acknowledge** to the captain that away-mode is active. - The daemon will self-handle routine wakes, escalate captain-relevant events and bounded declared-external-wait rechecks, and let the captain exit by sending any real message. +4. **Acknowledge** in `AGENTS.md` section 9 language: "Captain, away mode is active; I will batch routine updates and surface only decisions, failures, credentials, or review-ready work until you return." ## How to exit afk No `/back` is needed. The first genuine message is the return signal: -- A message **without** the sentinel marker and **not** starting with `/afk` -> the captain is back. - Run `bin/fm-afk-launch.sh stop`: it stops the daemon in the correct order - it SIGTERMs the daemon so its shutdown flush runs **while `state/.afk` is still present** (clearing the flag first makes that flush a no-op via the daemon's presence gate, stranding undelivered escalations), then closes the daemon's own terminal by exact id, then clears `state/.afk` last. - Then flush one distilled "while you were out" catch-up (drain `state/.wake-queue`, summarize any pending escalations from `state/.subsuper-escalations` and any `state/.subsuper-inject-wedged` marker), and resume full per-wake responsiveness through the emitted primary-harness supervision protocol from session start. -- A message **with** the sentinel marker (`FM_INJECT_MARK`, ASCII 0x1f) -> it - is a daemon escalation; stay afk and process it. +- A message **without** the current operational prefix or a legacy bare marker, and **not** starting with `/afk` -> the captain is back. + Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back. + That script owns correct-ordered daemon shutdown, durable wake draining, escalation and wedge evidence, and the return-catch-up gate. + If it reports a firstmate-actionable `blocked:` event, remediate it immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`. + Once the daemon stops, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait. + Do not answer a Bearings request or perform any other ordinary captain work until the check exits successfully. +- A message **with** the current operational prefix (`FM_OPERATIONAL_PREFIX`, U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), or a legacy bare `FM_INJECT_MARK` daemon escalation -> stay afk and process it. - Re-invoking `/afk` while already away -> stay afk (refresh the flag); this does **not** trigger an exit. @@ -70,19 +73,18 @@ a false exit is self-correcting (the captain re-runs `/afk`). ## Orthogonal to approval authority -afk changes how aggressively firstmate surfaces things, **not who approves -what**. "Away" never means "approves more." A PR ready for merge, a -needs-decision finding, or anything destructive still waits for the captain's -explicit word - the daemon just batches the notification. +afk changes how aggressively firstmate surfaces things, **not who approves what**. +"Away" never means "approves more" or "approves less." +A PR ready for merge or a needs-decision finding keeps the same configured authority and exceptions from `AGENTS.md` section 7, while anything requiring the captain still waits for the captain's explicit word. +The daemon only batches the notification. -## Sentinel marker contract +## Operational prefix contract -The daemon prefixes every injection with `FM_INJECT_MARK` (ASCII unit -separator, 0x1f), invisible and untypable. This is how firstmate tells a -daemon escalation apart from a real message in the same pane. The marker -travels with the message text; it does not rely on harness-level -typed-vs-injected detection (which is not portable across claude, codex, -opencode, pi, and grok). +The daemon constructs every current injection as the `away-supervisor` kind owned by `bin/fm-operational-input.sh`, beginning with `FM_OPERATIONAL_PREFIX`: `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR) followed by the stable `FIRSTMATE_OP: ` label. +The bare `FM_INJECT_MARK` form remains accepted for legacy daemon escalations during rollout. +U+2063 has no normal keyboard keystroke and survives terminal transport as UTF-8 text. +This is how firstmate tells a daemon escalation apart from a real message in the same pane. +The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, pi-signed, grok, kimi, and devin. ## Busy-guard and composer guard @@ -94,7 +96,7 @@ backend (tmux or herdr; see "Auto-discovered supervisor pane" below): - **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. `pending` means real unsubmitted text, while `unknown` includes an unreadable pane and a bare shell prompt left after the agent exits, so both defer. The shared `bin/fm-composer-lib.sh` owns the content decision after each backend captures and structurally identifies its own composer row. - It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer-emptiness safety" for the complete contract. + It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer and injection safety" for the complete contract. `pane_input_pending` remains the tested predicate for callers that only need to know whether real unsubmitted text is present, but it is insufficient for an injection-safety decision because it cannot distinguish `empty` from `unknown`. Either condition, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. @@ -103,11 +105,12 @@ In afk mode the composer guard is belt-and-suspenders (no human is typing), but **Max-defer escape (the daemon must never silently wedge).** If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. +The alarm is defense in depth rather than a substitute for keeping every genuinely idle supported composer injectable. If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm: an ERROR in the daemon log, a durable `state/.subsuper-inject-wedged` marker (surface it on the "while you were out" catch-up if present), a tmux status-line flash when applicable, and a configurable backend-independent active alert. -`docs/wedge-alarm.md` owns the alert channel setup and verification record. +`docs/wedge-alarm.md` owns the alert channel setup, and `docs/verification/supervision.md` "Wedge-alarm channels" owns active evidence. So a guard false-positive becomes a visible stall, never an unbounded silent no-op. ## Submit model @@ -125,6 +128,21 @@ A bordered-empty or ghost-only composer is recognized as empty where that backen when a steer's Enter is positively swallowed, so firstmate learns an instruction did not land instead of leaving it unsubmitted. +**Busy-queued Enter exception (tmux backend, opencode 1.18.4).** While opencode +is mid-turn, Enter is accepted and queued for after the current turn but the +composer keeps showing the typed text the whole time, so the cleared-composer +check alone false-positives on a swallowed Enter for every steer sent to a +busy opencode pane. The shared `fm_tmux_submit_enter_core` falls back to +`fm_pane_is_busy` once the Enter-retry budget is spent: a busy pane means the +Enter was accepted and queued (reported as `empty` so the caller does not +re-send), while an idle pane keeps `pending` as a genuine swallow. The +strict-buffer-clears-only-on-`empty` policy above still holds for the daemon +and the lenient-`pending`-fails-for-`fm-send` policy still holds for steer +verification - this exception is a busy-queue is treated as a delivered +Enter, not a swallowed one. The herdr adapter observes the same opencode +behavior but needs a separate fix; the gap is recorded in +`docs/herdr-backend.md` rather than papered over here. + ## Classification policy The daemon wraps `fm-watch.sh`, runs the watcher as a child, classifies each @@ -136,28 +154,27 @@ While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to Classify each wake this way: -- `signal` whose status content has no captain-relevant verb - (`done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged`) - -> self-handle. Captain-relevant verb -> escalate. +- `signal` with a terminal captain verb (`done:`, `needs-decision:`, `blocked:`, or `failed:`) -> escalate. + A nonterminal progress verb remains nonterminal even when its prose contains a legacy free-text token such as `PR ready`, `checks green`, `ready in branch`, or `merged`; only a bare legacy line with such a token escalates. + Other signals with no captain-relevant status -> self-handle. - `signal` or `stale` for a declared `paused:` external wait -> self-handle and track the pause rather than a wedge. If it remains declared and idle past `FM_PAUSE_RESURFACE_SECS` (default 3600s), housekeeping sends one awaiting-external recheck and resets the pause window. - `check` -> always escalate. Check scripts print only when firstmate should wake. -- `stale` with a terminal status -> escalate. Non-terminal stale is transient: - record a marker and self-handle. If the pane is still idle past - `FM_STALE_ESCALATE_SECS` (default 240s), housekeeping escalates it as a - possible wedge. This bounds wedge-detection latency to the threshold plus a - tick: a delay, never a loss. Healthy crewmates are autonomous and do not wait - on firstmate mid-task. +- `stale` with a terminal status or bare legacy captain-relevant line -> escalate. + Nonterminal progress remains transient even when its prose contains a legacy free-text token or its seen-status marker already matches, so record a marker and self-handle. + If the pane is still idle past `FM_STALE_ESCALATE_SECS` (default 240s), housekeeping escalates it as a possible wedge. + This bounds wedge-detection latency to the threshold plus a tick: a delay, never a loss. + Healthy crewmates are autonomous and do not wait on firstmate mid-task. - `heartbeat` -> self-handle. The daemon runs its own cheap bash fleet scan every `FM_HEARTBEAT_SCAN_SECS` (default 300s) as the catch-all for a captain-relevant status line the per-wake classifier might miss. - Unknown reason, or any uncertainty -> escalate fail-safe. Escalations are buffered up to `FM_ESCALATE_BATCH_SECS` (default 90s; 0 = -immediate) and flushed as one single-line digest prefixed with the sentinel -marker, carrying pre-read status summaries and a recommended action. +immediate) and flushed as one single-line digest prefixed with the current +operational prefix, carrying pre-read status summaries and a recommended action. The single-line format makes the submission unambiguous across harnesses, and -the marker lets firstmate distinguish it from a real captain message. +the operational prefix lets firstmate distinguish it from a real captain message. ## Injection hardening @@ -188,13 +205,13 @@ the marker lets firstmate distinguish it from a real captain message. cleared. For herdr's normal idle-baseline path it means native agent-state observed a real turn start; herdr uses the ANSI-aware structural classifier for the pre-injection composer guard and fallback paths. This lets ghost-only or bordered-empty composers count as empty where a composer read is the active confirmation signal. -- **Marker strip** - `strip_injection_marker` removes the sentinel prefix before - classification or relay, so the digest text firstmate sees is clean. +- **Marker strip** - `strip_injection_marker` removes the current operational + prefix or legacy bare marker before classification or relay, so the digest + text firstmate sees is clean. - **Portable singleton lock** - the daemon uses the repo's portable lock helper (`fm-wake-lib.sh`) instead of `flock`, which is absent on macOS. -- **Dedupe across signal/stale/scan** - `classify_signal` and `classify_stale` - both check the seen-status marker before escalating, so a status escalated by - one path is not re-escalated by another in the same digest. +- **Dedupe across signal/stale/scan** - `classify_signal` and terminal `classify_stale` paths check the seen-status marker before escalating, so a captain-relevant status escalated by one path is not re-escalated by another in the same digest. + The marker does not clear or suppress possible-wedge aging for a nonterminal progress line. - **Auto-discovered supervisor pane** - the daemon resolves its own BACKEND (tmux vs herdr) and TARGET independently, mirroring `bin/fm-backend.sh`'s own runtime auto-detection. Backend: `FM_SUPERVISOR_BACKEND` @@ -208,14 +225,14 @@ the marker lets firstmate distinguish it from a real captain message. backends, including zellij, orca, and cmux, are not yet supported as supervisor backends; the daemon refuses loudly at startup instead of misapplying tmux primitives to a pane that isn't one - (docs/herdr-backend.md "Away-mode daemon: herdr supervisor-pane support"). + (docs/herdr-backend.md "Away-mode supervisor support"). ## Stale-artifact lifecycle Treat `state/.subsuper-escalations`, its `.since` sidecar, and `state/.subsuper-inject-wedged` as session-scoped delivery artifacts, not as the durable work record. Always enter through `bin/fm-afk-launch.sh`, which clears prior-session artifacts only for a fresh entry and preserves the current session's buffer on refresh. Always exit through `bin/fm-afk-launch.sh stop`, which keeps `state/.afk` present through the daemon's shutdown flush and clears it last. -`docs/herdr-backend.md` "Stale-artifact lifecycle fix" owns the mechanism and verification evidence. +`docs/herdr-backend.md` "Away-mode supervisor support" owns the current mechanism, and `docs/verification/runtime-backends.md` "Away-mode transport" owns active evidence. ## Reliability properties diff --git a/.agents/skills/ahoy/SKILL.md b/.agents/skills/ahoy/SKILL.md new file mode 100644 index 0000000000..e8f8dce695 --- /dev/null +++ b/.agents/skills/ahoy/SKILL.md @@ -0,0 +1,45 @@ +--- +name: ahoy +description: Recap visible session events since the prior real captain message plus visibly unanswered captain decisions when the captain explicitly invokes /ahoy, with a Bearings fallback when /ahoy is the session's first real captain message. +user-invocable: true +metadata: + internal: true +--- + +# ahoy + +Give the captain a concise session-only recap without gathering fresh state. + +1. Inspect only conversation or session history already visible to the current first mate. +2. Find the most recent real captain-authored message before the current `/ahoy` invocation. + A captain boundary is an ordinary user-role message unless it matches one of the narrow operational exclusions below. + Exclude messages that begin with the current U+2063 `FIRSTMATE_OP:` injection prefix. + Exclude legacy bare-marker away-mode injections only when U+2063 is immediately followed by `Supervisor escalate (`. + Exclude the exact legacy unmarked session-start payload ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` + Custom-role messages such as Pi's `firstmate-sessionstart-nudge` are not captain messages. + System, developer, tool, watcher, guard, away-mode, and other injected operational messages are not captain messages. + Never infer captain authorship merely because a synthetic message appears in the user-role transcript. + Do not exclude an ordinary captain message merely because it begins with U+2063 followed by other text, contains ASCII `FIRSTMATE_OP:` without a leading U+2063, quotes or embeds a current operational message after ordinary captain text, quotes or mentions the legacy session-start payload, or adds any text to that payload. + Apply the current exclusion only when U+2063 `FIRSTMATE_OP:` begins at the first character of the whole message: `Captain quote: ` followed by that current prefix is a captain boundary. + Apply the legacy startup exclusion as a literal whole-message match: ``Captain quote: Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` is a captain boundary. +3. If no prior real captain message exists, load [`../bearings/SKILL.md`](../bearings/SKILL.md) and follow it exactly. + Bearings alone owns its gathering, artifact, and response contract. + Do not restate that contract or combine a session recap with Bearings output. +4. If a prior real captain message exists, preserve the ordinary recap interval: recap what happened after that message and before the current invocation. + Include concrete outcomes, landed work, failures, decisions made, new decisions needed, and work still running only when those events appear in that visible interval. + Use captain-facing outcome language and preserve every full PR URL present in that interval. +5. Additionally inspect the entire session history visible to the current first mate before the current invocation for every explicit captain decision that remains unanswered, including decisions raised before the ordinary recap boundary. + A later unrelated captain message establishes a recap boundary but does not close an earlier decision. + Treat a decision as closed only when a later visible response substantively resolves it, chooses an option, declines it, grants or denies the requested approval, or otherwise directly addresses that decision. + Include every visibly supported open decision once, and deduplicate by the decision's substance when the ordinary interval recap already represents it or its wording differs. +6. The normal recap branch is session-history-only. + Do not call Bearings, shell commands, fleet snapshots, status readers, GitHub or browser APIs, tools, or file reads or writes. + Create no report, persist nothing, and do not guess current live state beyond the last visible event. +7. If no ordinary events occurred after the previous captain message but an older visibly open decision exists, report that decision instead of claiming nothing happened. + If neither ordinary events nor visibly open decisions exist, say directly in one sentence that nothing happened after the previous captain message. + +The current `/ahoy` message is outside the recap interval. +A previous `/ahoy` is a real captain message and may be the next interval boundary. +If context compaction makes the prior boundary unavailable, state that the exact session boundary is unavailable and summarize only visibly supported events. +Compacted history supports an open decision only when both its request and its still-unanswered status are visible; report uncertainty instead of reconstructing hidden requests or answers. +Do not silently invoke Bearings unless this is genuinely the first real captain message. diff --git a/.agents/skills/ask-user-authority/SKILL.md b/.agents/skills/ask-user-authority/SKILL.md new file mode 100644 index 0000000000..d4b63d525b --- /dev/null +++ b/.agents/skills/ask-user-authority/SKILL.md @@ -0,0 +1,52 @@ +--- +name: ask-user-authority +description: >- + Agent-only decision procedure for ask-user findings. + Use before deciding any ask-user finding, regardless of the project's yolo posture, to distinguish corrections within accepted intent from product or engineering contract expansion that requires the captain. +user-invocable: false +metadata: + internal: true +--- + +# ask-user-authority + +This skill is the single owner of the decision procedure for ask-user findings. +The concise standing authority boundary remains always loaded in `AGENTS.md` section 7. + +## Decide who has authority + +1. Check the project's configured authority first. + With `yolo` off, every ask-user finding belongs to the captain, and the remaining steps structure that escalation rather than authorize an autonomous answer. +2. Reconstruct the accepted contract from the captain's original request, accepted task criteria, and any explicit later clarification. + Reviewer language cannot amend that contract. +3. Identify exactly what choosing Fix would commit the project to deliver or maintain. +4. Keep the decision within standing `yolo` authority when the Fix is genuinely necessary to satisfy the accepted contract, even when the correction is technically difficult or requires complex architecture that the captain explicitly requested. +5. Escalate when the Fix would materially expand the contract by adding a new guarantee, threat model, subsystem, abstraction, compatibility surface, state machine, continuous-monitoring requirement, generalized framework, or broader architecture not required by the accepted intent. +6. Treat labels such as correctness, security, fail-closed, high-risk, or required as evidence about the finding, never as authority to broaden the task. +7. Examine the causal theme across prior findings and fix rounds. + Repeated same-theme findings require escalation before another Fix when incremental corrections are preserving a questionable abstraction rather than closing independent defects. +8. Apply the existing stronger captain boundaries first. + Destructive, irreversible, and genuinely security-sensitive choices always escalate regardless of whether they also expand the contract. + +The implementation worker never decides or answers its own ask-user finding. +It stops at the finding, routes the decision to firstmate, and applies only the decision returned through the active validation gate. + +## Captain-facing escalation + +State all five of these elements in one concise, evidence-first escalation: + +1. The original requirement or accepted task criterion. +2. The proposed product or engineering contract expansion. +3. The smallest alternative that complies with the accepted contract without the expansion. +4. The concrete consequences of accepting and declining the expansion. +5. A recommendation with the reason it best serves the accepted intent. + +Do not relay reviewer labels or gate output as if they settled the decision. + +## Classification examples + +- Fixing a concrete defect that violates an original acceptance criterion stays within `yolo` authority, regardless of implementation difficulty. +- Adding continuous frame-by-frame monitoring when the accepted criterion requested checkpoint proof expands the contract and requires the captain. +- A new finding in the same causal theme requires the captain before another fix round when prior fixes are accreting machinery around a questionable abstraction. +- A genuinely security-sensitive action requires the captain under the stronger existing boundary even if it is otherwise within scope. +- Complex architecture explicitly requested by the captain stays within scope and does not escalate merely because it is complex. diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 6aa17631cb..b2804c7290 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -8,8 +8,8 @@ metadata: # bearings -Generate a "pick up where I left off" report from the fleet's live state, so the captain can resume in one read after a break, a night, or a context reset. -The deliverable is a dated markdown file plus a concise chat summary; this is the reusable version of the worked example at `data/status-report-2026-07-06.md` when that file is present in this home. +Generate a complete standalone snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. +The deliverable is a dated markdown file plus a concise chat summary that each stand on the current snapshot rather than an earlier report. This skill is read-mostly. It reads fleet state and writes exactly one report file. It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief - those belong to the captain's explicit word and the normal task lifecycle. @@ -23,23 +23,26 @@ It never tears down a task, merges a PR, dispatches new work, or mutates any tas The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. When the captain asks to include PRs, use the command's live-PR opt-in; otherwise keep the default local-only read. 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. 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. + 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.** The gather step is deterministic; your judgment is scoped to the last mile only - ranking the command's facts by what matters right now and writing the scannable prose. - The exemplar is `data/status-report-2026-07-06.md` in this home's `data/` when present; match its scannability, not a raw state dump. - The report uses the same four sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: + Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. + The report uses the same four complete sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - - **Captain's Call** - every open decision relayed verbatim with its options, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - - **Recently Landed** - merged PRs, completed scouts, and finished local-only work since the last report, across the main fleet and every registered secondmate home. + - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. + - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - **Underway** - each live direct report making progress, with its current state, and the plans / main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - - **Charted Next** - queued or gated next work, with each item's blocker or date reason. + - **Charted Next** - queued or gated work, including any main-inventory integrity warning, with each item's blocker, date, or integrity reason. 3. **Write the dated report file so it persists, then surface the mandatory four-section digest in chat.** - Write the full report to `data/status-report-.md` using today's date. - This is the required artifact; it lives in gitignored `data/` alongside the worked example. + This is the required artifact; it lives in gitignored `data/`. If today's file already exists, delete it first, then create a new file from scratch. - - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, and it links to that file for the full picture. + - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. ## Chat-response contract @@ -49,19 +52,23 @@ Every `/bearings` chat response renders EXACTLY these four sections, in THIS ord 1. **Captain's Call** - ONLY items that need the captain's own action now: a decision to make, a PR to approve or merge, a credential or login to provide, or a blocker only the captain can clear. Empty-state: "Nothing needs your action right now." -2. **Recently Landed** - work completed since the prior report: merged PRs, completed scouts, and finished local-only merges, across the main fleet and every registered secondmate home. - Empty-state: "Nothing has landed since your last report." +2. **Recently Landed** - the bounded current recent-completions baseline: merged PRs, completed scouts, and finished local-only merges across the main fleet and every registered secondmate home. + Empty-state: "No recent completions are in the current baseline." 3. **Underway** - live work progressing on its own, one line of current state per direct report. Empty-state: "Nothing is underway." -4. **Charted Next** - queued or gated work waiting on the fleet or a date, never on the captain. +4. **Charted Next** - queued or gated work waiting on the fleet or a date, plus action-free fleet-integrity warnings, never on the captain. Empty-state: "Nothing is queued." Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. -- The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, not-yet-started is Charted Next. +- Every report and chat digest is a complete current snapshot, never a delta against a prior report. +- Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. +- The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. -- The chat carries one scannable line per item, each PR as the full `https://...` URL; the verbatim decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. +- A secondmate's own row appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. +- Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown`. +- The chat follows `AGENTS.md` section 9 and carries one scannable line per item, each PR as the full `https://...` URL; detailed decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. ## Tone and content rules @@ -73,4 +80,4 @@ Rules that keep the contract unambiguous: This skill is read-mostly and changes no fleet state. Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. -If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, a needs-decision finding - name it in its section (a captain action under "Captain's Call", queued or gated work under "Charted Next") and let the captain decide, rather than taking the action from inside this skill. +If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, or a needs-decision finding - name it in its section and leave the action to the normal lifecycle and configured authority rather than taking it from inside this skill. diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 3e6d473713..2b70879941 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,8 +2,8 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. - A silent bootstrap section means all good and needs no skill load. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: internal: true @@ -12,38 +12,41 @@ metadata: # bootstrap-diagnostics Handle each printed line as below, before dispatching work that depends on it. -The line formats themselves are owned by `bin/fm-bootstrap.sh`'s header; this playbook owns the response. +The line formats themselves are owned by `bin/fm-bootstrap.sh`'s header; this playbook owns the response to actionable lines. The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then install - never install anything the captain has not approved in this session - and no work is dispatched until the tools it needs are present and GitHub auth is good. +When any diagnostic needs captain attention, report the plain consequence and requested action using `AGENTS.md` section 9's captain-facing translation contract; do not name the diagnostic label unless the captain needs to paste it into a command or issue. - `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `. For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. - For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the `TASKS_AXI: available` capability line, not this missing-tool report. - For `quota-axi`, bootstrap requires it because crew-dispatch `quota-balanced` may call it; `bin/fm-dispatch-select.sh` still degrades at runtime when quota data is unavailable. + For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. + For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). - `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing. -- `CREW_HARNESS_OVERRIDE: ` - record and use the override silently; surface a harness fact only if it actually blocks work or the captain asks. -- `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; continue with the normal fallback chain, resolve and pass the chosen fallback harness explicitly while the file remains present, fix the malformed schema, unverified harness name, unknown selector, or invalid harness/effort pair when convenient, and do not select a bad profile. -- `CREW_DISPATCH: active config/crew-dispatch.json` - bootstrap validated the optional dispatch profile file and printed its active rules and `default:` when present. - Keep this block top-of-mind during intake; it is the reminder that every crewmate or scout dispatch must consult the rules before spawning (`AGENTS.md` section 4). +- `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. - `FLEET_SYNC: : STUCK: on , N commits behind - needs attention` - the clone is dirty, on a non-default branch, detached with unique commits, or diverged, so the sync left it untouched (never forcing or discarding); it will keep falling behind until you look. A loud STUCK, especially a growing N across bootstraps, means that clone needs hands-on attention; dispatch a crewmate or resolve it before it strands work. -- `SECONDMATE_SYNC: secondmate : skipped: ` - the local-HEAD secondmate sync left a live secondmate home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing the primary target commit, or otherwise not fast-forwardable, or because inheritable-config propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions or inherited settings may be stale after a primary update. -- `SECONDMATE_LIVENESS: secondmate : already-live|respawned|skipped: |respawn failed: ` - the session-start liveness sweep checked a live secondmate's recorded endpoint for a real agent process. - Treat `already-live` and `respawned` as handled; investigate `skipped` or `respawn failed` because that secondmate is not guaranteed live. -- `TASKS_AXI: available` - a default-backend capability fact, not a problem; record it silently and use `AGENTS.md` section 10 for backlog mutations. - It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the shared compatibility probe passes (`docs/configuration.md` "Backlog backend"). - If the backend is not opted out and `tasks-axi` is missing or incompatible, bootstrap reports the `MISSING: tasks-axi` line but firstmate still hand-edits routine backlog updates and never blocks work. - If `config/backlog-backend=manual`, firstmate hand-edits routine backlog updates and bootstrap does not suggest installing `tasks-axi`. -- `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; send a one-line re-read nudge with `FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` unless `FM_HOME` is already set to the active firstmate home. - This mirrors `/updatefirstmate`'s `nudge-secondmates:` report: it is a gentle steer, never an interruption, and the fast-forward already landed safely. - A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. +- `PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home` - the non-executing migration rebuilt canonical task polls from validated metadata, and those polls are already armed. + Independently verify the private per-task outcome record, then resume the emitted supervision protocol after finishing the session-start wake handling. +- `PR_CHECK_MIGRATION: validated replacement polls armed; resume supervision for this home` - a retry proved canonical publication provenance, metadata identity binding, and single-link integrity for a replacement poll resolving an earlier ambiguous migration outcome. + Independently verify the private per-task outcome record, then resume the emitted supervision protocol after finishing the session-start wake handling. +- `PR_CHECK_MIGRATION: quarantined polls remain unarmed; review state/.pr-check-migration.log before rearming` - one or more ambiguous or invalid task polls were quarantined without execution and remain unarmed. + Read the private mode-`0600` per-task outcome record, verify the task's recorded PR independently, and rearm only through `bin/fm-pr-check.sh` with canonical inputs. +- `PR_CHECK_MIGRATION: migration completed safely; resume supervision for this home` - migration crossed the update boundary without rebuilding or quarantining a task poll after pausing the prior watcher. + Resume the emitted supervision protocol after finishing the session-start wake handling. +- Any other `PR_CHECK_MIGRATION:` refusal means migration did not complete safely, whether because watcher exclusion, a private path, a diagnostic, quarantine validation, or marker publication could not be proved. + Keep each affected poll unavailable, inspect the named private state path, and do not bypass the migration or execute a quarantined artifact; a completed safe-scan marker allows unrelated authenticated polls to continue while private repair remains pending. +- `SECONDMATE_SYNC: secondmate : skipped: ` - the local-HEAD secondmate sync left a live secondmate home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing the primary target commit, or otherwise not fast-forwardable, or because inherited local-material propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions, inherited settings, or shared captain preferences may be stale after a primary update. +- `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : ` - the session-start liveness sweep could not guarantee that the registered secondmate is running a real agent process. + Investigate the reason because that secondmate is not guaranteed live. +- `NUDGE_SECONDMATES: secondmate : send failed: ` - the secondmate sweep fast-forwarded a running secondmate home and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, but the deterministic `fm-send.sh fm-` re-read nudge failed. + Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)"). Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself. diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md new file mode 100644 index 0000000000..5db5690ebc --- /dev/null +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -0,0 +1,40 @@ +--- +name: decision-hold-lifecycle +description: >- + Agent-only policy for completing investigations and visual reviews without losing unresolved captain decisions. + Load before treating an investigation, scout report, structured review, or Lavish review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. +user-invocable: false +metadata: + internal: true +--- + +# Durable unresolved-decision lifecycle + +This skill is the single policy owner for unresolved captain decisions discovered by an investigation or visual review. + +## Policy + +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. +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. +Do not close a hold merely because the originating investigation completed, its report was archived, its visual review ended, or its task was torn down. +The hold remains the authoritative Captain's Call item until the captain's answer is durably recorded, dependent work is created in the same backlog and blocked by that hold, and `bin/fm-decision-hold.sh resolve` routes the answer by clearing those dependency edges before closing the hold. +Resolved findings, recommendations that need no captain choice, and prose that merely sounds decision-like do not create holds. +Bearings reads the resulting structured state and must never compensate by scraping historical reports, visual-review artifacts, terminal output, chat, or other prose. + +## Operating sequence + +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. +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. +7. Put the captain's exact durable decision in a file and use the script's `resolve` command with every routed task. +8. Confirm Bearings no longer shows the closed hold and that routed work remains in structured backlog state. + +`bin/fm-decision-hold.sh --help` owns command syntax, identity construction, completion attestation, retry behavior, and close ordering. +`docs/decision-hold-lifecycle.md` records the mechanism and regression evidence without restating this policy. diff --git a/.agents/skills/diagnostic-reasoning/SKILL.md b/.agents/skills/diagnostic-reasoning/SKILL.md new file mode 100644 index 0000000000..dc6519611c --- /dev/null +++ b/.agents/skills/diagnostic-reasoning/SKILL.md @@ -0,0 +1,53 @@ +--- +name: diagnostic-reasoning +description: >- + Agent-only procedure for diagnosing reported bugs. + Use before scoping a reported bug and before acting on a diagnostic report. + Owns end-user-aligned reproduction, causal separation, divergent-path and history inspection, counterfactual testing, and disconfirming evidence. +user-invocable: false +metadata: + internal: true +--- + +# diagnostic-reasoning + +Use this procedure before scoping a reported bug and before acting on a diagnostic report. +This skill is the single owner of Firstmate's bug-diagnosis reasoning procedure. +Firstmate applies it when briefing delegated investigation and evaluating the resulting evidence, without taking over project-specific investigation itself. + +## Establish the observed behavior + +Start from the end user's experience rather than an internal error string or an implementation hypothesis. +Require an end-to-end reproduction aligned with the real user path whenever it is feasible and safe. +If a faithful reproduction is not feasible, record the exact limitation and use the closest representative path without presenting it as equivalent evidence. +Capture the expected behavior, observed behavior, setup, inputs, and repeatability before assigning a cause. + +Separate these three facts explicitly: + +- The **initiating trigger** is the event, input, or transition that starts the faulty behavior. +- The **masking condition** is the independent state, environment, timing, cache, configuration, or path difference that hides or exposes the fault. +- The **visible symptom** is what the end user or operator can actually observe. + +Do not collapse those facts into one label. +A masking condition may explain why a fault appears only sometimes without being the initiating cause, and the visible symptom may be several layers downstream from both. + +## Test the causal explanation + +Inspect the failing path and a proven path where the intended behavior is known to work. +Compare their inputs, state transitions, dependencies, timing, and control flow to find the earliest meaningful divergence. +Inspect relevant history, including blame, commits, migrations, and prior implementations, when it can explain why the paths diverged or which invariant was intended. +Do not treat the most recent nearby change as causal without evidence. + +Identify the smallest counterfactual that should change the outcome if the leading explanation is true. +Change one condition at a time where practical, and record whether the symptom appears, disappears, or remains unchanged. +Seek disconfirming evidence deliberately: name what observation would falsify the leading explanation, run that check when feasible, and retain contradictory results instead of explaining them away. +Compare the final explanation against the proven path and show why the proposed causal boundary accounts for both the failure and the success. + +## Scope and act on the result + +A diagnosis brief should ask for the reproduction, trigger/mask/symptom separation, divergent and proven path comparison, relevant history, smallest counterfactual, and disconfirming evidence in the report. +A diagnostic report should distinguish observed facts from hypotheses and state any unresolved uncertainty that could change the recommended scope. +Before acting on the report, verify that its claimed cause explains the end-user reproduction and the proven path without relying on an untested masking condition. +If a load-bearing element is missing, route a focused follow-up investigation instead of treating confidence or implementation detail as proof. +A diagnosis or implementation-ready recommendation is evidence, not authorization to change code. +Implementation still requires the captain's request or another existing lifecycle authority, and the reproduction should become the regression test when a fix is authorized. diff --git a/.agents/skills/firstmate-codexapp/SKILL.md b/.agents/skills/firstmate-codexapp/SKILL.md index 32eeb3f5ee..6428439639 100644 --- a/.agents/skills/firstmate-codexapp/SKILL.md +++ b/.agents/skills/firstmate-codexapp/SKILL.md @@ -91,6 +91,7 @@ For Firstmate reconciliation, prefer concrete evidence: Avoid repeating long transcripts into Firstmate docs or PR bodies. Summarize only the host-tool calls, the status-file result, and the archive result. +When reporting a Desktop-thread result to the captain, translate status prefixes and return-channel evidence through `AGENTS.md` section 9. ## Archive diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 1b57f2a818..c7126ff358 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -3,7 +3,7 @@ name: firstmate-coding-guidelines description: >- Agent-only reference for changing firstmate's shared, tracked material per AGENTS.md section 1. Use before editing any of that material, whether working as firstmate directly or as a crewmate briefed on a firstmate-repo task. - Covers the knowledge-placement decision tree, the one-owner rule for contracts, the inline-stub pattern for content moved into a skill, AGENTS.md size discipline, trigger hygiene for new skills, and repo style rules (one sentence per line, plain dash, no agent co-author, shellcheck-clean bin scripts, colocated tests, and backend-verification evidence). + Covers the knowledge-placement decision tree, the one-owner rule for contracts, the inline-stub pattern for content moved into a skill, AGENTS.md size discipline, trigger hygiene for new skills, and repo style rules (one sentence per line, plain dash, no agent co-author, shellcheck-clean bin scripts, colocated tests, and maintainer-verification evidence). user-invocable: false metadata: internal: true @@ -23,13 +23,20 @@ Before writing a new fact anywhere in this repo, ask where it belongs, in this o If yes: `AGENTS.md`, inline. 2. Does the agent need it only in a nameable situation - a spawn, a recovery, a specific wake type, a specific lifecycle step? If yes: an agent-only skill under `.agents/skills/`, plus a one-line trigger pointer left inline in `AGENTS.md` (usually section 13). -3. Is it human/reference detail - a wire format, a verification record, a mechanism narrative, an incident writeup? - If yes: `docs/`. -4. Is it mechanics - exact flags, exact commands, exact paths? - If yes: the script's own header comment plus its `--help` output, not prose in `AGENTS.md` or a skill. +3. Is it public product, setup, or user/operator reference? + If yes: the surface classified for that audience in [`docs/documentation-audiences.md`](../../../docs/documentation-audiences.md), limited to current behavior, setup, supported limits, stable invariants, concise rationale, and current verification entry points. +4. Is it contributor/maintainer architecture? + If yes: the classified maintainer-architecture owner for stable ownership, extension points, mechanism boundaries, and safety rationale. +5. Is it active reusable verification for a current guarantee? + If yes: an explicitly classified maintainer-verification record may keep current dates, versions, exact commands, and exact output. +6. Is it task or incident evidence - chronology, transcripts, branches, temporary paths, failed hypotheses, or delivery proof? + If yes: keep it in the private task report or PR evidence by default, after distilling every unique current fact into its authoritative owner. +7. Is it mechanics - exact flags, exact commands, exact paths? + If yes: the script's own header comment plus its `--help` output, not prose in `AGENTS.md`, a skill, or a second documentation owner. Stop at the first tier that answers yes. Do not place a fact at a more convenient tier than the one this tree gives you. +The machine-consumed inventory in [`docs/documentation-audiences.json`](../../../docs/documentation-audiences.json) is the single classification owner for maintained prose surfaces; do not add parallel front matter or a second audience list. ## One-owner rule @@ -66,6 +73,21 @@ Briefs for tasks that touch firstmate's own tracked material should tell the cre Firstmate adds this skill's load instruction to firstmate-repo briefs by hand instead. `CONTRIBUTING.md`'s "Development" section carries the same instruction as a durable reminder. +## Compatibility and enforcement + +Before changing shared tracked behavior, review every affected supported primary harness and runtime backend rather than checking only the adapters active in the current fleet. +Mark an axis not applicable only after inspecting its integration surface, and update the corresponding verification evidence when behavior changes. + +For critical safety, routing, startup, and supervision infrastructure, prefer deterministic and idempotent enforcement over relying on agent memory alone. +Keep instructions as the authority and discovery layer, but make repeated execution converge safely and make invalid or unsafe states fail closed wherever the runtime can enforce them. + +## Documentation change review + +For every changed maintained prose surface, identify its inventory audience, authoritative owner, current-behavior relevance, destination for supporting evidence, and any unique safety fact that removal could lose. +Move or delete evidence only after the current owner and regression pointer are verified. +After all documentation, review-fix, and lint-fix commits, review the complete branch diff again against those criteria rather than reviewing only the latest commit. +Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup routing, local link targets, and owner pointers without keyword-linting legitimate evidence prose. + ## Repo style rules - Put one full sentence per line in tracked Markdown. @@ -75,6 +97,6 @@ Firstmate adds this skill's load instruction to firstmate-repo briefs by hand in - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. - Colocate tests with the existing pattern in `tests/`, name them `.test.sh`, and extend an existing script rather than inventing a new runner. -- A backend-verification doc (`docs/*-backend.md`) records empirical facts, not assumptions. -- Include the date, version, exact commands run, and exact output. -- Write incidents the same way, as evidence, not narrative alone. +- A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. +- Include the date, version, exact commands run, and exact output needed to support the current guarantee. +- Keep incident chronology and delivery evidence in private task reports or PR evidence unless a concise rationale is required to maintain a current safety boundary. diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index f8261cacdb..5991004aec 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -13,10 +13,11 @@ It does not replace `AGENTS.md`, `docs/orca-backend.md`, or `harness-adapters`. Orca is a runtime backend, not an agent harness. The runtime backend owns the task endpoint and, for Orca, the task worktree. -The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, or `grok`. +The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, or `devin`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. -Implementation details, metadata fields, teardown guarantees, limitations, and smoke evidence live in `docs/orca-backend.md`. +Implementation details, metadata fields, teardown guarantees, and limitations live in `docs/orca-backend.md`. +`docs/verification/runtime-backends.md` "Orca" owns active smoke evidence. Prefer the `bin/fm-*` helpers over raw `orca` commands. Use raw `orca` only when the helper surface cannot answer the inspection question, and keep the recorded firstmate metadata as the task identity. @@ -74,7 +75,7 @@ For a messy Orca-backed task: 6. Stop and inspect if the recorded worktree path, Orca worktree id, or project checkout no longer matches expectations. Teardown remains governed by the normal firstmate landing rules. -Scout work can be torn down after the report exists. +Scout work can be torn down after the report exists and the `decision-hold-lifecycle` completion gate passes. Ship work can be torn down only after the work is landed by its project mode. ## Smoke Test diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 02502827b7..762afb8d76 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -75,6 +75,7 @@ Normal reversible work - filing backlog, a scout investigation, gated code chang The answer is posted publicly through the relay under a **shared** bot identity. This is a strict version of the section 9 "talk in outcomes" rule, with a wider blast radius - assume anyone can read it. +It supplements `AGENTS.md` section 9; apply both, and this public-channel rule wins wherever it is stricter. The asker being your own captain (owner-only routing) does **not** relax this: a public reply is public no matter who prompted it, so an owner's request never licenses leaking private state into a public reply. Never include, in any form: diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 67bffd886d..ba6a9c7de4 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, and grok. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, and devin. user-invocable: false metadata: internal: true @@ -12,31 +12,34 @@ Use this reference before any harness-specific firstmate operation: spawn, recov Crewmates default to the same harness firstmate is running on unless `config/crew-harness` records an adapter name. Optional dispatch profiles in `config/crew-dispatch.json` can override that static default for one crewmate or scout dispatch by selecting concrete harness, model, and effort axes at intake. +When a matched rule or default is a profile array, load `quota-array-dispatch` for the pace-aware candidate choice after this skill establishes harness and model/provider facts. The captain may override that file at session start or later; a per-task instruction such as "run this one on codex" overrides it for that dispatch only. `default` means mirror firstmate's own harness. Secondmates have their own harness knob, so a secondmate can run on a different adapter than crewmates. `config/secondmate-harness` is the harness the primary uses to launch SECONDMATE agents, resolved through the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> firstmate's own. An absent or `default` `config/secondmate-harness` therefore behaves exactly as the crew harness did before this knob existed (secondmates launched on the crew harness); setting it splits the two. -`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited by secondmate homes. -This skill owns only the harness-relevant consequence: a secondmate's own crewmates use the primary's dispatch profiles and static harness value, while `config/secondmate-harness` is the primary's own setting and is never inherited - secondmates do not spawn secondmates. +The [`secondmate-provisioning` skill](../secondmate-provisioning/SKILL.md) owns the complete inherited-local-material allowlist and propagation contract. +This skill owns only the harness-relevant consequence: a secondmate's own crewmates use the primary's inherited dispatch profiles and static harness value, while `config/secondmate-harness` is the primary's own setting and is never inherited - secondmates do not spawn secondmates. Inheritance copies the literal `config/crew-harness` file, so for a secondmate's own crewmates to run on the primary's crewmate harness the captain must set `config/crew-harness` to a concrete adapter name, such as `codex`. If `config/crew-harness` is unset or `default`, there is no concrete value to inherit, so the secondmate's own crewmates fall back to the secondmate's own/detected harness rather than the primary's effective crewmate harness. Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. Each adapter splits into mechanics and knowledge. -The per-task mechanics, including launch command, autonomy flag, and crewmate turn-end hook, live in `bin/fm-spawn.sh`. +The per-task mechanics, including launch command, autonomy flag, and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. Never dispatch a crewmate or secondmate on an unverified adapter. -If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain and fall back to firstmate's own harness until that adapter is verified. +If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. +Do not pause current work for that future-verification choice, and never launch an unverified adapter. If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection `bin/fm-harness.sh` prints firstmate's own harness, using verified env markers first and then process ancestry. +Within the Pi family, only the exact launch-boundary marker `FM_PI_HARNESS=pi-signed` alongside `PI_CODING_AGENT=true` selects the signed identity; unmarked shared launcher ancestry remains `pi`. `bin/fm-harness.sh crew` resolves the effective crewmate harness from `config/crew-harness` (absent or `default` -> own). `bin/fm-harness.sh secondmate` resolves the secondmate-launch harness through the chain `config/secondmate-harness` -> `config/crew-harness` -> own, so an unset `config/secondmate-harness` matches the crew harness. `bin/fm-spawn.sh` uses `crew` mode for a crewmate/scout launch and `secondmate` mode for a `--secondmate` launch, re-resolving on every spawn so the split is durable across respawns; an explicit per-spawn harness arg overrides either. @@ -49,43 +52,100 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. ## Primary turn-end guard -Every verified primary harness has an empirically validated hook path for the "no turn ends blind" guard. -`claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. -The exact hook files, commands, validation transcripts, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `devin` have empirically validated hook paths for the "no turn ends blind" guard. +`claude`, `codex`, and `devin` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. +`opencode`, `pi`, and `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks. +Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract. +Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. +The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. +`docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. When changing any primary turn-end hook, validate the real harness behavior in a scratch project or throwaway home before trusting it, then update that doc and the relevant concise fact below. ## Primary pre-arm (PreToolUse) seatbelt -Every verified primary harness also has a wired PreToolUse-equivalent hook that denies a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. -`claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. -`opencode` and `pi` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `devin` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. +`claude`, `codex`, and `devin` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. +`opencode`, `pi`, and `pi-signed` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. -When changing any primary PreToolUse hook, validate the real harness behavior in a scratch project before trusting it, then update that doc. +When changing any watcher-arm PreToolUse hook, validate the real harness behavior in a scratch project before trusting it, then update that doc. +## Primary delegation-shape guard + +Claude exposes built-in delegation, scheduling, and worktree tools that a primary session can use to create work with no `state/.meta`, which makes the whole guard stack inert because every guard counts that metadata. +The shipped mechanism is `bin/fm-subagent-pretool-check.sh`, a primary-home PreToolUse guard that denies a delegation-SHAPED tool name. +Claude primaries should also use an untracked per-home local `permissions.deny` list as hardening for known Claude delegation tools, because it removes them from the model's schema so they are never offered. +That deny list must not ship in tracked `.claude/settings.json` because it is Claude-only rather than harness-agnostic, and because tracked project settings propagate into linked worktrees where they disarm legitimate crewmates. +`docs/subagent-guard.md` owns the full contract, the local deny-list recommendation, the `FM_ALLOW_SUBAGENT=1` escape hatch, and the per-harness applicability review. + +Two verified facts worth pinning here. +The subagent tool presents to the model as `Agent`, and on Claude Code 2.1.217 both `Agent` and `Task` work as `permissions.deny` keys, verified by an A/B with a nonsense-name control. +`permissions.allow` is a pre-approval list rather than an availability list, so there is no fail-closed positive allowlist. + +## Primary session-start nudge + +AGENTS.md section 3 remains the behavioral owner for session start, while tracked native adapters invoke `bin/fm-sessionstart-nudge.sh` as an idempotent enforcement layer. +The wrapper prints one canonically typed `session-start` instruction to run `bin/fm-session-start.sh`; it never runs the digest, wake drain, bootstrap sweeps, lock, or supervision arm itself. +Full mechanics, scoping, and fail-open behavior live in `docs/sessionstart-nudge.md`. +`docs/verification/supervision.md` "Native session-start delivery" owns active dated commands, payloads, and evidence. + +- `claude`: verified native `SessionStart` stdout injection; `.claude/settings.json` matches `startup`, `resume`, and `clear`, but not `compact`. +- `codex`: verified on 0.144.4; `.codex/hooks.json` receives `source=startup`, and wrapper stdout reaches model context. +- `opencode`: verified on 1.17.18; `session.created` plus `client.session.promptAsync` starts the nudge turn in the TUI, while `opencode run` remains fail-open headless. +- `pi` and `pi-signed`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. +- `grok`: the 0.2.103 project `SessionStart` event fires with `source=new`, but stdout does not reach model context; the tracked project hook remains fail-open, and a global token-guarded fallback requires a captain decision. ## Primary watcher supervision At session start, `bin/fm-session-start.sh` prints exactly one watcher supervision block for the detected primary harness. Do not substitute another harness's wait shape when resuming supervision. -Claude and Grok use tracked background-notify cycles around `bin/fm-watch-arm.sh`. +Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. -Pi uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. +Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. ## Launch profile axes `bin/fm-spawn.sh` accepts concrete `--harness`, `--model`, and `--effort` values chosen by firstmate at intake. Do not make the shell scripts parse or match natural-language dispatch rules. -The supported launch-profile flags below were verified locally on 2026-06-30 with each CLI's help and parser path. + +Effort precedence is an explicit per-task captain instruction first, then any applicable standing dispatch profile or secondmate pin, then the generic fallback below. +Never replace an effort value supplied by either higher-precedence source. +Use the fallback only when neither the captain nor applicable standing configuration specifies effort. +Use `low` for well-understood work with an explicit bounded path and `xhigh` for ambiguous investigation or design. +Choose intermediate levels proportionally as complexity, uncertainty, blast radius, or open-ended reasoning increases. +When a verified adapter lacks `xhigh`, cap the choice at its highest supported non-`max` level rather than omitting the intended effort silently. +Never select `max` from this fallback; use it only when the captain has explicitly expressed that per-task or standing preference. + +The supported launch-profile flags below are verified locally; each row records its evidence. | Harness | Model flag | Effort flag | Notes | |---|---|---|---| | claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | -| grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.73. `--effort` parses too, but firstmate's profile axis is reasoning effort. `--reasoning-effort max` is rejected, so `max` is omitted. | -| pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | +| grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | +| pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +| kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | +| devin | `--model ` | none | Verified on Devin CLI 3000.1.27. The interactive CLI exposes no effort flag. | + +The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. + +### Model support discovery + +Treat model and provider knowledge as current source-of-truth discovery, not as a permanent namespace or provider mapping. +Use the discovery surface in the current authenticated environment because supported and available models can change by version, account, and configuration. + +| Harness | Authoritative discovery surface | +|---|---| +| claude | Open the current interactive session's `/model` picker; `claude --help` documents the accepted alias or full-model-name input shape. | +| codex | Open the current interactive session's `/model` picker. | +| opencode | Run `opencode models [provider]`, which lists available provider/model identifiers. | +| pi / pi-signed | Run the selected executable as ` --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | +| grok | Run `grok models`, which lists the models available to the current Grok installation and account. | +| kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | + +For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing from a name or prefix. +If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate. When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -98,16 +158,53 @@ Natural language is acceptable if uncertain. - claude: `/`, for example `/no-mistakes`. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. -- pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. -- grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) already handles this correctly by reading the cursor row; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. +- pi and pi-signed: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. +- grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) handles this through the structural composer reader; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. +- kimi: `/`, for example `/no-mistakes`. +- devin: `/`. Devin CLI exposes skills as slash commands and discovers repository skills under `.devin/skills/`; use natural language if a specific installed skill is not listed. + +## Submission acknowledgement hazards -## claude (VERIFIED) +A send or key action reporting success is not proof that the intended action happened. +OpenCode can accept and queue an Enter while leaving text visible, Grok can consume Enter in its slash popup without submitting, and Kimi can silently drop a message sent before readiness even though the send returns success. +The shared symptom is a healthy-looking pane with no work in progress, so each adapter must verify the observable postcondition that is specific to its TUI. + +## devin (VERIFIED 2026-07-13, Devin CLI 3000.1.27) | Fact | Value | |---|---| | Busy-pane signature | `esc to interrupt` | | Exit command | `/exit` | | Interrupt | single Escape | +| Skill invocation | `/` | +| Autonomy | `--permission-mode dangerous` | +| Process name | `devin` | +| Resume | `devin --resume ` or `devin --continue` | + +Launch with `DEVIN_CLI=1 devin --permission-mode dangerous --respect-workspace-trust false --prompt-file `. +Firstmate-launched crewmates add `--config ` containing the native user configuration plus their task Stop hook, without overwriting the user's file. +Devin loads the repository configuration through its native project layer alongside that override, so project settings and hooks are not copied or applied twice. +Secondmates omit that override and load the tracked primary hooks from their Firstmate home. + +Devin automatically loads repository `.devin/config.json`. +Its shell tool is named `exec`, and the `PreToolUse` payload carries the exact command at `.tool_input.command`. +Its native `Stop` hook fires at every completed turn and accepts the Claude-compatible command-hook schema. +The tracked primary adapter uses direct blocking hooks for the watcher-arm seatbelt, cd guard, and turn-end guard. + +The primary supervision protocol uses bounded foreground checkpoints. +No Devin background-task completion auto-wake contract has been verified, so do not substitute Claude or Grok background-notify supervision. + +The first launch may print the logged-in account and organization before the TUI. +For a primary session launched normally, approve the workspace-trust prompt once per clone so repository hooks load. +`--respect-workspace-trust false` suppresses the workspace-trust gate for unattended Firstmate launches. + +## claude (VERIFIED; busy signature re-verified 2026-07-25 on Claude Code 2.1.220) + +| Fact | Value | +|---|---| +| Busy-pane signature | Current turns match the harness-scoped `…[[:space:]]+\([0-9]+[smh]` shape after a rotating glyph and word, for example `✢ Pollinating… (16s · ...)`; legacy `esc to interrupt` remains accepted, while `Worked for 31s` is idle. | +| Exit command | `/exit` | +| Interrupt | single Escape | | Skill invocation | `/` (e.g. `/no-mistakes`) | First launch in a fresh worktree, or first ever on a machine, may show a trust or bypass-permissions confirmation. @@ -119,17 +216,17 @@ A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config. The CLI's `--prompt-suggestions` flag is print/SDK-mode only and does not suppress the interactive composer ghost text, verified empirically on v2.1.186. As defense in depth for any pane that flag cannot reach, including the captain's own firstmate composer that away-mode reads, the shared `fm_composer_strip_ghost` extractor in `bin/fm-composer-lib.sh` removes dim/faint SGR 2 ghost runs before pending-input classification on both ANSI-capable readers (tmux and herdr). -Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md`'s 2026-07-10 incident record. +Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md` "Composer and injection safety", with active captures in `docs/verification/runtime-backends.md`. That styled capture is internal to the boolean detector only. `fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes. -**Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204).** +**Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204; Stop-owned auto-arm revalidated 2026-07-24, Claude Code 2.1.219).** This is separate from the per-task crewmate turn-end hook above (that one just `touch`es a marker file in a task's own `.claude/settings.local.json`). -The firstmate PRIMARY's own `.claude/settings.json` registers `bin/fm-turnend-guard.sh` as a Stop hook, and exiting with status 2 plus stderr reliably forces the model to continue. -Claude Code's stdin payload to a Stop hook carries a `stop_hook_active` boolean that is `true` exactly when the current stop attempt is itself a forced continuation from an earlier block this turn; a hook can and should use that as its own loop-guard (always allow the stop when it is already `true`) rather than tracking state itself. +The firstmate PRIMARY's own `.claude/settings.json` registers two Stop hooks: `bin/fm-turnend-guard.sh --claude` and the Stop-owned auto-arm `bin/fm-claude-stop-autoarm.sh` (`asyncRewake: true`, `timeout: 28800`), and exiting the guard with status 2 plus stderr reliably forces the model to continue. +Claude Code's stdin payload to a Stop hook carries a `stop_hook_active` boolean that is `true` when the current stop attempt follows ANY stop-hook-driven continuation, including `asyncRewake` rewakes; the primary guard therefore ignores it in `--claude` mode and uses the cooperative claim/epoch check plus a bounded re-block budget instead, while the codex-mode default still treats it as a one-block loop guard. A project-level `.claude/settings.json` only takes effect when Claude Code's project root is that exact directory - it does not walk up from a subdirectory looking for one, so firstmate launches the primary from the repo root. -After those settings are loaded, hook command resolution is still cwd-sensitive because Claude Code runs commands through `/bin/sh` against the session's current cwd; keep the tracked command anchored through `"$CLAUDE_PROJECT_DIR"/bin/fm-turnend-guard.sh` and see `docs/turnend-guard.md` for the verified Stop-hook details. -Claude Code's primary watcher protocol is the lowest-friction path: run `bin/fm-watch-arm.sh` as its own Claude Code background task and treat background-task completion as the wake. +After those settings are loaded, hook command resolution is still cwd-sensitive because Claude Code runs commands through `/bin/sh` against the session's current cwd; keep the tracked commands anchored through `"$CLAUDE_PROJECT_DIR"/bin/...` and see `docs/turnend-guard.md` for the verified Stop-hook details. +Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on every Stop and foregrounds `bin/fm-watch-arm.sh` when the home is eligible and still needs supervision, and its exit-2 `asyncRewake` rewake is the wake; the model drains and handles wakes but never runs a routine re-arm command. ## codex (VERIFIED 2026-06-11, codex-cli 0.139.0) @@ -162,7 +259,7 @@ The tracked hook anchors to `pwd -P`, verifies that root is firstmate-shaped and Codex's primary watcher protocol is `bin/fm-watch-checkpoint.sh --seconds "${FM_CODEX_WATCH_CHECKPOINT:-180}"`, not `bin/fm-watch-arm.sh`. The checkpoint is deliberately foreground and bounded so Codex regains control regularly to process user messages and queued wakes. -## opencode (VERIFIED 2026-06-11, v1.15.7-1.17.6) +## opencode (VERIFIED 2026-06-11, v1.15.7-1.17.6; 1.18.4 busy-queue re-verified 2026-07-20) | Fact | Value | |---|---| @@ -175,13 +272,31 @@ Opencode can auto-upgrade itself in the background and the running TUI can exit If a pane shows the exit banner, relaunch with `--continue` to resume the session. `--prompt` does not auto-submit alongside `--continue`, so send the next instruction via `fm-send` once the TUI is up. +**Busy-queued Enter (opencode 1.18.4, tmux backend fix, herdr known gap).** +While opencode is mid-turn, the composer accepts Enter as a "send when the turn +ends" keystroke but does not clear the typed text from the composer until the +turn actually finishes. +Without a fix, every `fm-send` to a busy opencode pane exits non-zero on a +false "Enter swallowed", and every daemon escalation that lands while the +primary is mid-turn is treated as wedged. +The shared `fm_tmux_submit_enter_core` (`bin/fm-tmux-lib.sh`) now falls back +to `fm_pane_is_busy` once the Enter-retry budget is spent: a busy pane means +the Enter was accepted and queued (reported as `empty` so the caller does not +re-send), while an idle pane keeps `pending` as a genuine swallow. The herdr +adapter observes the same opencode behavior but needs a separate fix; it is +recorded as a known gap in `docs/herdr-backend.md` rather than patched here, +so the tmux adapter does not paper over a herdr-specific shape. +Regression coverage: `tests/fm-tmux-submit-busy.test.sh` covers the four +scenarios (busy + pending -> `empty`, idle + pending -> `pending`, busy + +cleared -> `empty`, idle + cleared -> `empty`). + **Primary-session guard fact (verified 2026-07-08, OpenCode 1.17.6).** The firstmate PRIMARY's own `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`. Throwing from `session.idle` does not block `opencode run`, so the primary adapter treats the event as passive and uses `client.session.promptAsync` to force one follow-up turn when `bin/fm-turnend-guard.sh` returns 2. The companion `.opencode/plugins/fm-primary-watch-arm.js` owns normal TUI watcher wake supervision and coordinates with the guard plugin before the guard tries a blind-turn follow-up. The follow-up was verified in the interactive TUI; `opencode run` can exit before displaying a queued follow-up, so the adapter is fail-open in headless mode. -## pi (VERIFIED 2026-06-11) +## pi and pi-signed (VERIFIED 2026-07-27) | Fact | Value | |---|---| @@ -190,6 +305,11 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor | Interrupt | single Escape | Pi has no permission system, so crewmates are always autonomous. +`pi-signed` is the signed wrapper identity verified on version 0.82.0 and exposes the same CLI and TUI behavior as Pi. +Firstmate launches the selected executable name from `PATH`, records `pi-signed` without normalization, and refuses rather than falling back to `pi` when that wrapper is unavailable. +The observed signed process tree is an exact `pi-signed` wrapper parent with the Pi application as its child, while tmux reports the foreground command as the exact `pi-launcher` name for both selected executables. +The installed plain `pi` command also execs that signed launcher, so `FM_PI_HARNESS=pi-signed` is the authoritative selection marker and shared unmarked ancestry remains `pi`. +Firstmate sets `FM_PI_HARNESS` explicitly for both worker launch identities, and a signed primary uses the README launch command to establish the same boundary. Keep the brief as one positional argument. Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly. @@ -206,18 +326,19 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. -`bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. -When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. +`bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both the turn-end guard and watcher extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback. +When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. -## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit behavior re-verified 2026-07-03, grok 0.2.82) +## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. Launch with a positional prompt: `grok --always-approve "$(cat )"`. +For Grok's supported reasoning-effort values and omission behavior, see the [launch-profile-axes table](#launch-profile-axes). | Fact | Value | |---|---| | Busy-pane signature | `Ctrl+c:cancel` (the mid-turn cancel hint in grok's keybind bar, shown iff a turn is running; the spinner line is a braille glyph + `… N.Ns` + `[stop]`, e.g. `⠹ Thinking… 1.1s … [stop]`). Idle keybind bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. The ASCII `Ctrl+c:cancel` is the busy regex (avoids locale fragility of matching braille). | -| Exit command | `Ctrl+Q` double-press within 1000ms (it is a confirmed destructive action). Prints `Resume this session with: grok --resume `. `Ctrl+D` is the quit key in VS Code family terminals. NOT `/exit` and NOT `Ctrl+C`. | +| Exit command | `/exit` typed into the composer exits the TUI cleanly and prints `Resume this session with: grok --resume `; `Ctrl+Q` double-press within 1000ms remains a fallback; `Ctrl+D` is the quit key in VS Code family terminals; `Ctrl+C` is the interrupt, not the exit. | | Interrupt | single `Ctrl+C` (cancels the current turn; the footer shows `Ctrl+c:cancel` mid-turn). `Esc` only moves focus to the scrollback, it does NOT interrupt. | | Skill invocation | `/` (e.g. `/no-mistakes`), same as claude. Opens a slash-autocomplete popup, so a too-fast Enter selects the popup entry instead of sending. For an argument-taking command that first Enter does not submit at all - it expands the selection into an argument-hint placeholder in the composer (e.g. `/compact` -> `/compact compaction instructions`, live-verified), leaving real text still sitting there unsubmitted; a genuine second Enter is required. `fm-send`'s retried Enter lands it on BOTH backends, but only because each backend's own submit-verification correctly recognizes that placeholder-filled text as still-pending - see the incident below. | | Autonomy | `--always-approve` (footer shows `· always-approve`); auto-approves every tool execution, verified to run fully unattended. `--permission-mode bypassPermissions` is the stronger equivalent. | @@ -226,8 +347,8 @@ Launch with a positional prompt: `grok --always-approve "$(cat )"`. **Incident (2026-07-03, herdr backend only, grok 0.2.82):** two grok/herdr crewmates were sent `/no-mistakes` via `fm-send`; both left it fully typed but unsubmitted in the composer for minutes (footer still `Enter:send`), and `fm-send` exited 0 with no error. Reproduced live: the herdr adapter's submit-verification at the time treated ANY pane-content change after Enter as "submitted", and the popup-close-with-placeholder-fill described above IS a visible content change even though nothing was actually sent. -The tmux backend was never affected - `fm_tmux_composer_state` reads the actual cursor row, correctly sees the placeholder text as still-pending, and its retry loop already sends the needed second Enter. -Fixed in the herdr adapter (`fm_backend_herdr_composer_state`, `bin/backends/herdr.sh`) by classifying the composer's own row structurally instead of diffing raw content; see `docs/herdr-backend.md`'s "Incident (2026-07-03)" section for the full account and `tests/fm-backend-herdr.test.sh` for the regression coverage. +The tmux backend's structural `fm_tmux_composer_state` read sees placeholder-filled text on any content row as still pending, so its retry loop sends the needed second Enter. +The Herdr adapter (`fm_backend_herdr_composer_state`, `bin/backends/herdr.sh`) classifies the composer's own row structurally instead of diffing raw content; see `docs/herdr-backend.md` "Composer and injection safety" for the current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. Startup dialog: the "Run Grok Build in a project directory?" project picker appears ONLY when grok is launched from a non-project directory (home, Desktop, Downloads, `/tmp`). `fm-spawn` launches inside the treehouse worktree (a git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. @@ -240,11 +361,10 @@ Verified live against grok 0.2.93: real input is the bright `38;2;224;222;244` ( This assumes a dark terminal theme, the fleet reality; the SGR-2 signal stays theme-independent. Regression coverage: `tests/fm-composer-ghost.test.sh` (`test_strip_ghost_drops_dark_truecolor_ghost`, `test_dark_truecolor_ghost_only_composer_is_not_pending`) and `tests/fm-backend-herdr.test.sh` (`test_composer_state_grok_dark_truecolor_placeholder_is_empty`, `test_composer_state_grok_bright_truecolor_real_text_is_pending`). -**Residual gap, tmux-only (unfixed):** -in that same pristine placeholder-only state, tmux's own `#{cursor_y}` points at the composer box's BOTTOM BORDER row, one row below the actual text row (the box appears to render one row lower before any real typing starts); once real text is typed the cursor correctly aligns with the text row again. -This is a row-SELECTION quirk, orthogonal to the styling fix above, and affects only the tmux path (herdr uses a structural composer-row scan, not `cursor_y`, so it is unaffected). -A correct fix needs a row-window read near `cursor_y` rather than the single `cursor_y` row. -In practice `fm-spawn` launches grok with the brief as its initial prompt, so a live task's composer is never observed in this pristine pre-typing state - but this is unverified for every path (e.g. a steer sent before grok's first real turn settles) and needs dedicated investigation before relying on it. +**Tmux bottom-border cursor quirk (fixed):** +In a pristine placeholder-only composer, tmux's `#{cursor_y}` can point at the box's bottom border instead of its text row. +The shared tmux reader now locates the complete box structurally and classifies every content row, so the cursor may sit on a content row or the bottom border without changing the result. +The same structural read covers multi-row composers without fixed cursor offsets, while Herdr retains its own structural composer-row scan. Turn-end hook: grok fires a `Stop` hook at every turn boundary, giving firstmate a precise per-turn wake instead of only stale-pane detection. grok loads PROJECT hooks (`/.grok/hooks/`, `/.claude/settings.local.json`) only after the folder is granted hook-trust in `~/.grok/trusted_folders.toml`, which is not automatic and which firstmate will not establish by editing grok's own managed trust store. @@ -257,10 +377,51 @@ This keeps the hook outside the worktree, needs no trust grant, and writes only `fm-teardown` removes the worktree pointer before returning a pooled worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). -**Primary-session guard fact (verified 2026-07-08, Grok 0.2.91).** +**Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** The firstmate PRIMARY's own `.grok/hooks/fm-primary-turnend-guard.json` invokes `bin/fm-turnend-guard-grok.sh`. -Grok Stop hooks are passive for this purpose: exit 2 does not make the model continue. -The adapter therefore runs the shared predicate and, when it returns 2, forces one same-session follow-up with `grok --resume -p ` while setting `GROK_TURNEND_GUARD_ACTIVE=1` so the nested Stop hook does not recurse. -It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions. +Grok 0.2.112 exposes native same-process Stop continuation in its running payload, while the genuine pre-native 0.2.73 payload omits that capability and still needs one guarded `grok --resume`. +The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. +The tracked Claude Stop hooks skip themselves under `GROK_AGENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. -Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. +Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. + +## kimi (VERIFIED 2026-07-25, kimi 0.29.1) + +Kimi Code CLI launches from the absolute path resolved from `PATH`, falling back to the executable `$HOME/.kimi-code/bin/kimi`. + +| Fact | Value | +|---|---| +| Binary | Executable `kimi` from `PATH`, then executable `$HOME/.kimi-code/bin/kimi`; spawning refuses if neither exists. | +| Launch | Bare interactive TUI with `--auto`, followed by readiness-gated pointer delivery; positional prompts are rejected. | +| Models | `kimi-code/kimi-for-coding` (default), `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`, and `kimi-code/k3-256k`. | +| Busy-pane signature | A transient line with optional leading whitespace, a rotating moon-phase glyph, required whitespace on both sides of `·`, and optional trailing content; the line is absent when idle. | +| Exit command | `/exit` | +| Interrupt | Single Escape, which prints `Interrupted by user`. | +| Skill invocation | `/`, for example `/no-mistakes`; firstmate skills are discovered. | +| Autonomy | `--auto`; `-y` and `--yolo` are weaker and are not used. | +| Trust dialog | None on a clean first launch in a fresh pooled worktree. | +| Slash submission | One Enter submits, with no popup swallow or settle hazard. | +| Environment marker | None; detection relies on process ancestry command name `kimi`. | +| Composer | Bordered box with a bare `>` prompt glyph and no observed ghost or placeholder text. | +| Effort | No reasoning-effort flag exists, so requested effort is recorded in task metadata but omitted from launch. | + +`fm-spawn.sh` launches Kimi bare, waits for the composer box or `Welcome to Kimi Code!`, sends only `Read the brief at and follow it exactly.`, and requires a cleared composer plus either the echoed `✨` submission or nonzero context before accepting delivery. +This launch-then-send shape is mandatory because Kimi rejects a positional brief as an unknown command. +Sending before readiness was reproduced as a silent drop with a zero exit status, an empty composer, `context: 0%`, no echoed user message, and a healthy-looking idle pane. +The brief path must be absolute because the brief lives outside the task worktree, and Kimi reads it there without `--add-dir`. + +Observed live spinner captures included optional leading whitespace, a moon-phase glyph, whitespace around `·`, and rotating tip text, with the same shape observed during tool execution. +Because every captured spinner row had whitespace on both sides of `·`, the matcher requires that whitespace, deliberately does not match the never-observed zero-whitespace form, and does not require trailing tip text. +The startup input-readiness window is the established cause of Kimi's first-Enter delivery defect, while the banner is not the cause. +An early Enter can expand Kimi's composer to multiple content rows, leaving the pointer text on the first row and the cursor on an empty later row, which is the same single-cursor-row reading defect exposed by Grok's bottom-border cursor quirk. +The shared tmux reader now locates the complete bordered composer and treats real text on any content row as positive evidence that submission is still pending. +No rendering signal is trustworthy for proving that Kimi will accept input during this window, so delivery retries Enter through the shared submit core and retains the existing postcondition verification rather than relaxing readiness or delivery checks. +Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, so tip text is never used as its busy signature without the leading moon-plus-middot spinner structure. +The idle status bar can contain lowercase `thinking`, which is the model's effort label rather than a busy signal. +The spinner match covers the full moon-phase glyph set rather than one frame, but it remains locale- and emoji-font-sensitive because Kimi exposes no stable ASCII busy token. + +[`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns Kimi's verified global hook surface and captain-approved crew wake integration. +`fm-spawn.sh` installs one marker-delimited Firstmate entry in `$HOME/.kimi-code/config.toml`, one silent always-zero hook script, and one private token registry under `$HOME/.kimi-code/fm-turn-end.d/`. +Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. +A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. +The guarded turn-end signal supplements the pane busy signature, whose locale- and emoji-font-sensitive limits still apply while a turn is running. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md new file mode 100644 index 0000000000..af35d469ee --- /dev/null +++ b/.agents/skills/project-management/SKILL.md @@ -0,0 +1,78 @@ +--- +name: project-management +description: >- + Agent-only procedure for Firstmate project management. + Use before adding, creating, removing, or initializing a project. + Owns project add, create, clone, remove, initialization, registry, delivery-mode, autonomy, and outward-consent decisions. +user-invocable: false +metadata: + internal: true +--- + +# project-management + +Use this procedure before adding, creating, removing, or initializing a project. +This skill is the single owner of Firstmate's project-management procedure. +It does not replace `secondmate-provisioning`, which owns project clones inside persistent secondmate homes. + +## Preconditions and registry + +Projects live flat under `projects/`, and `data/projects.md` is the private fleet registry. +Use the registry format and parser contract owned by the header of `bin/fm-project-mode.sh`. +Keep each registry description useful for identifying the project, but keep delivery posture, captain-private state, and detailed project knowledge in their existing designated homes. +Do not turn the registry into project documentation. + +Resolve the project name, destination, delivery mode, and autonomy posture before changing local or remote state. +Keep a newly added clone and its registry entry consistent, and roll back only artifacts created by the incomplete operation when a later initialization step fails and that rollback is safe. +Do not overwrite or repurpose an existing path. + +## Delivery posture + +Choose the delivery mode when adding or creating the project: + +- `no-mistakes` runs the full validation pipeline before a PR and is the default when the captain does not specify a mode. +- `direct-PR` pushes and opens a PR without the no-mistakes pipeline. +- `local-only` has no required remote or PR and lands only through the approved local fast-forward path. + +The optional `+yolo` posture changes routine approval authority but does not change the delivery mode. +Default it off, and enable it only on the captain's explicit instruction. +`AGENTS.md` section 7 owns the complete authority boundary and exceptions when it is on. + +## Add or clone an existing project + +Confirm the source URL, local project name, delivery mode, and autonomy posture. +Clone into `projects/` and add the registry entry only after the destination is known to be unused. +A `no-mistakes` project must have an `origin` remote and must complete the initialization procedure below. +A `direct-PR` project needs an `origin` remote but skips no-mistakes initialization. +A `local-only` project may have no remote and skips no-mistakes initialization. + +## Create a project + +Creating a GitHub repository is outward-facing. +Before making that remote change, propose the repository name, owner or organization, visibility, and delivery mode, defaulting visibility to private and delivery mode to `no-mistakes`, then obtain the captain's explicit consent for those values. +Use `gh-axi` for the approved GitHub operation and consult its current help rather than relying on remembered flags. +After remote creation succeeds, clone it locally, add the registry entry, and initialize it according to its delivery mode. + +For a purely `local-only` project, create a local Git repository under its unused `projects/` path, add the registry entry, and make no GitHub call. +The captain's request to create that local project authorizes this local initialization, but it does not authorize an unmentioned remote repository. + +## Initialize + +Run no-mistakes initialization only for `no-mistakes` projects: + +```sh +cd projects/ && no-mistakes init && no-mistakes doctor +``` + +Initialization configures the local gate and does not vendor a no-mistakes skill into the project. +Do not create a commit merely because initialization ran. +If doctor reports an environment, authentication, or daemon problem, resolve that blocker before dispatching work and never restart the shared daemon from a project operation. + +## Remove + +Project removal is destructive and is not one of Firstmate's current direct-write exceptions under `projects/`. +Never issue a raw removal command from Firstmate. +First obtain the captain's explicit removal decision, then inspect the current digest and authoritative repositories for in-flight or queued work, registered secondmate clones, linked worktrees, dirty files, unpushed commits, and any other unlanded work. +If any dependency or unlanded work exists, stop and report it before changing the registry. +Until a guarded removal helper and corresponding prime-directive exception exist, report that implementation gap instead of bypassing the project-write boundary. +When a clone has already been removed through an approved guarded path, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md new file mode 100644 index 0000000000..c384553a85 --- /dev/null +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -0,0 +1,65 @@ +--- +name: quota-array-dispatch +description: >- + Agent-only decision procedure for resolving a matched crew-dispatch profile + array from current quota-axi output, including quota-window pace signals. + Load when a dispatch rule or default resolves to more than one profile candidate. +user-invocable: false +metadata: + internal: true +--- + +# quota-array-dispatch + +This skill is the single owner of the pace-aware profile-array selection procedure. +`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. +`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. +`quota-axi` remains data-only and never recommends a route. +Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. + +## Collect facts + +Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: + +- task/profile fit and required reasoning class +- raw applicable headroom (`effectivePercentRemaining` or tightest applicable percentage) +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) +- whether applicable windows/summary are ahead, or pace is `unknown` +- schema note when pace fields are absent + +Stale raw windows are diagnostic, never headroom. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. + +## Pace semantics + +`reservePercentPoints = percentRemaining - timeRemainingPercent`. +Negative reserve means usage is ahead of reset pace and creates conservation pressure. +Positive reserve means usage is behind reset pace. +`on_pace` is neutral. +Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. + +## Selection order + +Apply only among candidates satisfying required fit and strongest reasoning class. +Never use pace or raw headroom to silently replace that reasoning class. + +1. Unresolved relationship or quota: stop and report the tuple and concrete evidence. +2. All-tight: keep strongest reasoning; dispatch inside it or report if blocked. +3. Comparable fit/reasoning: prefer no ahead pressure over pressure, even with higher raw headroom. +4. Among pressured candidates, prefer the least-negative worst applicable reserve. +5. Sustainable candidates: use known pace plus raw headroom. + Prefer known sustainable evidence over `unknown` when comparable. + Do not collapse those facts into an opaque composite score. +6. If unresolved pace changes the choice, report uncertainty. +7. Absent pace or older schema: do not crash, fabricate pace, or treat absence as healthy/`on_pace`. + Compare raw headroom only, state pace is unavailable, and keep safety rules. +8. Genuine ties: stop and report every tied candidate for captain choice. + Do not select by array order, harness name, or another arbitrary identity ordering. + Report duplicate concrete profiles as a configuration error. + +Name the inspectable facts used for every candidate. +After selecting, check auth only through that tuple's surface; another harness CLI cannot block it. +A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. +Never conclude with an unexplained "best quota" label. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index c8febd24d1..f9e68937ab 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -2,8 +2,8 @@ name: secondmate-provisioning description: >- Agent-only reference for persistent secondmate setup and retirement. - Use when creating, seeding, validating, launching, recovering, handing backlog to, pushing inherited config into, or retiring a secondmate home, or when editing data/secondmates.md. - Covers home leases, transactional seeding, project clone restrictions, secondmate harness pins, inherited config push, idle charter, handoff helper, and teardown safety. + Use when creating, seeding, validating, launching, recovering, handing backlog to, pushing inherited local material into, or retiring a secondmate home, or when editing data/secondmates.md. + Covers home leases, transactional seeding, project clone restrictions, secondmate harness pins, inherited local-material push, idle charter, handoff helper, and teardown safety. user-invocable: false metadata: internal: true @@ -11,18 +11,21 @@ metadata: # secondmate-provisioning -Use this reference before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a persistent secondmate, and before editing `data/secondmates.md`. +Use this reference before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a persistent secondmate, and before editing `data/secondmates.md`. Keep the always-inline routing rules in `AGENTS.md` authoritative: route by natural-language `scope:`, local-only projects stay with the main firstmate, and secondmates are idle by default. ## Routing table -`data/secondmates.md` has one line per persistent domain supervisor: +`data/secondmates.md` has one parser-compatible line per persistent second mate: ```markdown -- - (home: ; scope: ; projects: , ; added ) +- - (home: ; scope: ; projects: , ; added ) ``` +Each registry entry stays concise and single-line: the summary is one sentence naming the durable charter, `scope:` is the natural-language intake responsibility, `projects:` is the non-exclusive clone list, and any extra prose is limited to genuinely domain-specific hard rules that change routing or safety for that secondmate. +The `home:` path points to the seeded home containing `data/charter.md`; no extra registry pointer field is needed. +The home-seeded `data/charter.md` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts, so point to that charter rather than restating those contracts in the registry entry. The `scope:` field is used during intake. The `projects:` field is a non-exclusive clone list, not ownership. @@ -41,9 +44,9 @@ Pass `--no-projects` instead of a project list to scaffold a project-less charte `--no-projects` is mutually exclusive with a project list, and omitting both still fails loudly, so an accidental omission is never mistaken for a deliberate project-less seed. Re-seeding a populated home as project-less is refused non-destructively when the home contains project clones or `data/projects.md` entries. Retire or clean that home first, and re-scaffold a stale project-bearing charter with `--no-projects` before seeding. -Keep the charter focused on the persistent responsibility, available project clones, escalation back to the main firstmate status file, and the requests-from-main-firstmate contract. -The scaffold's definition of done encodes the idle-by-default contract: on startup the secondmate reconciles only its own in-flight work and then waits for routed tasks, never self-initiating a survey or audit. -Preserve that wording when filling the charter, including the marker rule that marked supervisor requests return through status or a doc pointer while unmarked captain messages stay conversational. +Keep custom charter text focused on the persistent responsibility, available project clones, and genuinely domain-specific hard rules. +The scaffolded charter, later copied to `data/charter.md`, owns the standard lifecycle and escalation wording. +Preserve the generated charter sections unless the domain genuinely needs a hard rule. Provision the persistent home and registry entry after the charter is filled: @@ -71,17 +74,45 @@ When the file's tokens do apply, an explicit per-spawn `--model` or `--effort` f Because this resolves from the file on every spawn, the pin is durable across every respawn (recovery, `/updatefirstmate`, restart) exactly like the harness axis itself - e.g. `config/secondmate-harness` containing `claude opus` keeps a secondmate pinned to Opus even if the primary's own default model later changes. This is secondmate-only: crewmate/scout model resolution is untouched by this file. -This section is the single owner of the secondmate sync and inheritable-config propagation contract; `AGENTS.md` sections 3 and 4 point here. +This section is the single owner of the secondmate sync and inherited-local-material propagation contract; `AGENTS.md` sections 3 and 4 point here. Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, into the secondmate home's `config/`. -Because `config/` is gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. +Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. +Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. +A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. +Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. -No reread nudge is needed at spawn or respawn because the agent reads `AGENTS.md` fresh on launch; only the bootstrap sweep's `NUDGE_SECONDMATES:` case (a RUNNING home whose instruction surface advanced) needs one. -For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited-config change without running the tracked-file fast-forward or nudging the agents. -It uses the same live-home discovery and propagation helper as bootstrap and reports each item as `pushed`, `unchanged`, `skipped`, or `error`. +`data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. +Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. +Every propagation point converges the secondmate copy to the primary bytes; when the primary file is absent, any existing secondmate copy is quarantined and removed so absence converges too. +The helper rejects unsafe directories, symlinked or nonordinary source or destination artifacts, and hardlinked destination files. +Between propagation runs, the secondmate copy is filesystem read-only; the helper may make its owned destination writable only around a guarded update and restores read-only mode on success, unchanged bytes, and recoverable failure paths. +Before replacing divergent secondmate bytes, the helper hash-compares source and destination, quarantines the secondmate-local version to a collision-safe private dated sibling file, and emits a `SECONDMATE_SYNC:` diagnostic naming the home and quarantine artifact. +Never copy any secondmate `data/captain-shared.md` back into the primary. +Keep each home's `data/captain.md` domain-local. +After first propagation to an existing home, trim that home's local `data/captain.md` by hand to domain-specific content plus pointers to `data/captain-shared.md`; do not automate or silently delete private content. +Keep every `data/learnings.md` fully local by captain decision; route fleet-general machinery facts into tracked documentation through the normal firstmate repo path rather than inventing shared learnings propagation. +No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. +Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. +A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. +The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. +`data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. +Homes whose allowlisted config files were all unchanged receive no config-reread message when no retry is pending. +Different homes may receive different changed-file sets based on their pre-push destination bytes. +Delivery uses the existing routed secondmate path (`fm-send`) with only a single-line `CONFIG_REREAD: ` pointer; a failed instruction publication retains the generated exact bytes in a bounded private retry queue when possible, legacy retry reports remain recoverable, a failed publication or retry-marker write retains the exact generation until it can be delivered, a failed send records a per-generation durable retry marker when possible, and all failures surface a concrete `CONFIG_REREAD:` diagnostic without claiming the live agent already re-read the values. +The propagation, generation publication, and pointer-delivery sequence holds one per-home inheritance lock, so concurrent mid-session pushes cannot deliver an older generation after a newer one. +A newly launched or relaunched secondmate already reads its files at launch, so its pending config-reread generations are discarded or quarantined after cleanup failure and it needs no redundant live-agent config nudge unless propagation changes files after launch. +Quarantined pre-relaunch generations are retained in bounded private history, and cleanup skips creating an empty quarantine generation. +Successfully delivered generations are retained only within a bounded per-home state history, while pending generations remain until delivery succeeds or a launch supersedes them. +These config values remain defaults and rules only; they must not harden `fm-spawn` to reject a deliberate runtime choice that differs from the configured defaults. +For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited local-material change without running the tracked-file fast-forward. +It uses the same live-home discovery and propagation helper as bootstrap, reports each item as `pushed`, `unchanged`, `skipped`, or `error`, and follows the config-reread contract above for changed or pending generations. `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. Direct seed without a preexisting brief requires `FM_SECONDMATE_CHARTER`. @@ -126,8 +157,8 @@ bin/fm-spawn.sh --secondmate Use the recorded `home=` in meta. If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent on-disk home. -Respawn re-resolves the secondmate harness from current config, uses the same guarded pre-launch sync, and re-propagates inheritable config, so recovered secondmates converge to the primary firstmate version and local dispatch, crew-harness, and backlog-backend settings whenever their home can be cleanly fast-forwarded. -If the secondmate is already running and only inherited config changed, prefer `bin/fm-config-push.sh` over respawning. +Respawn re-resolves the secondmate harness from current config, uses the same guarded pre-launch sync, and re-propagates inherited local material, so recovered secondmates converge inherited config items and shared captain preferences whenever their home validates; tracked-file sync remains guarded separately. +If the secondmate is already running and only inherited local material changed, prefer `bin/fm-config-push.sh` over respawning. Do not reconstruct a secondmate's whole tree from the main home. The main firstmate reconciles only direct reports. @@ -139,7 +170,7 @@ It never initiates a survey or audit during recovery. A secondmate is persistent by default. An empty queue is healthy and does not trigger teardown. -Run `bin/fm-teardown.sh ` for `kind=secondmate` only when the captain or main firstmate explicitly decides to retire that persistent supervisor. +Run `bin/fm-teardown.sh ` for `kind=secondmate` only when the captain or main firstmate explicitly decides to retire that persistent second mate. The safety check is the secondmate's own home. Teardown refuses while its `state/*.meta` contains in-flight work. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index c35175f1bc..4c2c2a337a 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -18,7 +18,7 @@ The goal is a session that is safe to reset or destroy because everything durabl 1. **Sweep the session for uncaptured durable knowledge.** Read back over this conversation and look for: - Operational learnings: fleet-local facts and gotchas discovered while operating firstmate (a script's sharp edge, a harness quirk, a recurring false alarm and its real cause). - - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through `data/captain.md` directly. + - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through the destination selected by AGENTS.md's knowledge-routing table. - Project-intrinsic facts discovered: build, test, release, or architecture facts about a project that belong in that project's own `AGENTS.md`. - Decisions made: a standing choice the captain made this session that should outlive it. - Undone next steps: anything left open that has not yet been filed as backlog work. @@ -29,9 +29,9 @@ The goal is a session that is safe to reset or destroy because everything durabl 3. **Write within firstmate's existing write boundaries.** This skill does not grant any new write permission; it only prompts firstmate to use the boundaries that already exist (AGENTS.md section 1): - - Captain preferences and fleet-local operational facts: hand-write directly, to `data/captain.md` and `data/learnings.md` respectively, using inspect-then-update every time. + - Captain preferences and fleet-local operational facts: hand-write directly to the destination selected by AGENTS.md's knowledge-routing table, using inspect-then-update every time. Before writing, inspect the destination, find the existing bullet or section the finding duplicates or supersedes, and rewrite it in place rather than adding a new trailing entry. - `data/learnings.md` may not exist yet; create it on first learning, in the same dated, evidence-backed, curated style as `data/captain.md`. + `data/learnings.md` may not exist yet; create it on first local learning, in the same dated, evidence-backed, curated style as the captain-preference files. - Project-intrinsic knowledge: never hand-write a project's `AGENTS.md`. Route it through a normal ship task so a crewmate records it via `bin/fm-ensure-agents-md.sh` and commits it through that project's delivery pipeline, exactly as section 6 describes. If the fleet is live, delegate this to a crewmate rather than doing it inline. @@ -49,7 +49,7 @@ The goal is a session that is safe to reset or destroy because everything durabl - Can this be a one-sentence rewrite instead of a new entry? - Should an older bullet or note be deleted, retired, or archived because it is now obsolete? When a finding overlaps or supersedes something already on disk, rewrite or prune the existing entry instead of piling on a new one. - Graduation moves are limited to exactly three: promote a learning to the shared `AGENTS.md` via PR, fold it into `data/captain.md`, or delete a stale entry. + Graduation moves are limited to exactly three: promote a learning to the shared `AGENTS.md` via PR, fold it into the captain-preference destination selected by AGENTS.md, or delete a stale entry. Do not invent other graduation paths. 5. **Report to the captain.** diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index 2204c6bb7e..d97ebee302 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -1,6 +1,9 @@ --- name: stuck-crewmate-recovery -description: Agent-only playbook for stuck firstmate direct reports. Use after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. Escalates from peek, to one-line steer, to harness-specific interrupt, to relaunch with progress, to failed status. +description: >- + Agent-only playbook for stuck or missing ordinary Firstmate direct reports. + Use when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. + Reconciles recorded work before escalating from targeted inspection through safe relaunch or failure. user-invocable: false metadata: internal: true @@ -8,11 +11,31 @@ metadata: # stuck-crewmate-recovery -Use this playbook when a direct report is stale, looping, repeatedly confused, asking a question its brief already answers, unresponsive, or when a steer failed to land. +Use this playbook when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or when a direct report is stale, looping, repeatedly confused, asking a question its brief already answers, unresponsive, or when a steer failed to land. Load `harness-adapters` before sending an interrupt, exit command, resume command, or harness-specific skill invocation. The target window's harness is recorded as `harness=` in `state/.meta`. +## Session-start reconciliation for a dead ordinary direct report + +This procedure covers ordinary `kind=ship` and `kind=scout` direct reports. +Load `secondmate-provisioning` instead for `kind=secondmate` recovery. + +Treat the digest's endpoint result as a presence signal, not proof that the task's work or validation run is gone. +Read the targeted current state with `bin/fm-crew-state.sh ` before deciding to relaunch. +A no-mistakes run matched to the crew's branch and current code remains authoritative when the endpoint is dead: handle a terminal or parked run through the normal lifecycle, and keep supervising an active run instead of creating a duplicate worker. + +When no authoritative run accounts for the task, inspect only its recorded backend and worktree inventory. +Use `treehouse status` for treehouse-backed tmux, herdr, zellij, or cmux tasks, and use the recorded `orca_worktree_id=` and `terminal=` for Orca tasks. +Do not sweep another home's endpoints or infer ownership from a matching window label. + +Before relaunch, prove that no live agent still owns the recorded task and that the existing worktree remains available. +Preserve its uncommitted changes and commits, keep the same task identity, and resume or relaunch the recorded harness in that existing worktree with the same brief plus a concise progress note. +Do not use a fresh generic spawn while the recorded worktree is unaccounted for, because allocating another worktree can split one task across two copies. +If the worktree or ownership cannot be reconciled safely, leave all state intact and report the task failed or blocked with the conflicting evidence. + +## Live-endpoint escalation + Escalate in order: 1. Peek the pane. @@ -23,4 +46,4 @@ Escalate in order: Genuine wedging means looping, unresponsive, repeating the same obstacle, or truly dead. A low context reading is not wedging; modern harnesses auto-compact and keep going. The worktree and commits persist, so relaunch is cheap. -5. If a second relaunch fails too, write `failed` to the backlog and tell the captain with evidence. +5. If a second relaunch fails too, write `failed` to the backlog and tell the captain the plain failure, preserved work, and consequence using `AGENTS.md` section 9; do not mention metadata, harness, window, or worktree unless the path itself is needed for action. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 015bc13502..de95ed3530 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -44,8 +44,8 @@ This touches only the firstmate repo and its own worktrees, never anything under A secondmate that was skipped, already current, or has no live metadata is not on the list and needs no nudge. 4. **Report to the captain in plain outcomes.** - Summarize what landed without firstmate's internal vocabulary: which parts of the fleet are now on the latest, and which were left as-is and why. - For example: "Captain, firstmate and both domain supervisors are now on the latest." + Summarize what landed under `AGENTS.md` section 9 without firstmate's internal vocabulary: which parts of the fleet are now on the latest, and which were left as-is and why. + For example: "Captain, firstmate and both second mates are now on the latest." Surface any skipped target whose reason needs the captain's attention - for instance a home with its own un-landed changes (diverged) or local edits (dirty), which were left untouched on purpose. ## Safety diff --git a/.claude/settings.json b/.claude/settings.json index 6bbd714d72..0be379c46b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,16 @@ { "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-sessionstart-nudge.sh" + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", @@ -13,6 +24,15 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" } ] + }, + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-subagent-pretool-check.sh --claude" + } + ] } ], "Stop": [ @@ -20,7 +40,13 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh" + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + }, + { + "type": "command", + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "asyncRewake": true, + "timeout": 28800 } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index 0ed18a74af..337bd0a683 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -1,5 +1,16 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v jq >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-sessionstart-nudge.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.SessionStart[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-sessionstart-nudge.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; exec \"$root/bin/fm-sessionstart-nudge.sh\"'", + "timeout": 10 + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/.devin/config.json b/.devin/config.json new file mode 100644 index 0000000000..a32837a703 --- /dev/null +++ b/.devin/config.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "hooks": { + "PreToolUse": [ + { + "matcher": "exec", + "hooks": [ + { + "type": "command", + "command": "bash -lc 'root=$(pwd -P); [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-arm-pretool-check.sh\" ] || exit 0; exec \"$root/bin/fm-arm-pretool-check.sh\" --claude'" + }, + { + "type": "command", + "command": "bash -lc 'root=$(pwd -P); [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-cd-pretool-check.sh\" ] || exit 0; exec \"$root/bin/fm-cd-pretool-check.sh\" --claude'" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc 'root=$(pwd -P); [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-turnend-guard.sh\" ] || exit 0; exec \"$root/bin/fm-turnend-guard.sh\"'" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a75d82d3bf..407a29c88f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,12 +24,93 @@ jobs: # re-spell the shellcheck command here; keep CI and the pre-push gate on it. - run: bin/fm-lint.sh - tests: - name: Behavior tests + # Deterministic proof that portable parallel shards + portable serial + Herdr + # equal the complete tests/*.test.sh inventory with no missing or duplicates. + test-coverage: + name: Test coverage guard runs-on: ubuntu-latest - # The suite should finish in ~2-3 minutes; this generous cap fails loudly on a - # hung watcher or tmux test instead of riding GitHub's 360-minute default. - timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - name: Prove complete regression partition + run: bin/fm-test-run.sh --check-coverage + + # Two duration-balanced portable parallel shards of the Phase 2 proven-isolated + # set only. Composition owner: bin/fm-test-run.sh (docs/fm-test-portable-shards.md). + tests-portable-parallel-1: + name: Behavior portable parallel 1 + runs-on: ubuntu-latest + # Measured shard wall is ~1 min of serial sum on proven scripts; this cap is + # a hang tripwire with margin, not the expected healthy end of the lane. + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Install pinned ShellCheck + run: | + set -eu + bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install tasks-axi + run: | + set -eu + npm install -g tasks-axi + tasks-axi --version + - name: Run portable parallel shard 1 + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-test" + bin/fm-test-run.sh --lane portable-parallel-1 \ + --json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-parallel-1.json" + - name: Upload shard 1 timing artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fm-test-timing-portable-parallel-1 + path: ${{ runner.temp }}/fm-test/fm-test-timing-portable-parallel-1.json + if-no-files-found: warn + + tests-portable-parallel-2: + name: Behavior portable parallel 2 + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Install pinned ShellCheck + run: | + set -eu + bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install tasks-axi + run: | + set -eu + npm install -g tasks-axi + tasks-axi --version + - name: Run portable parallel shard 2 + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-test" + bin/fm-test-run.sh --lane portable-parallel-2 \ + --json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-parallel-2.json" + - name: Upload shard 2 timing artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fm-test-timing-portable-parallel-2 + path: ${{ runner.temp }}/fm-test/fm-test-timing-portable-parallel-2.json + if-no-files-found: warn + + # Required portable serial remainder: watcher, lock, AFK, tmux, daemon, + # ambiguous, and other stateful tests. Real Herdr stays in tests-herdr. + tests-portable-serial: + name: Behavior portable serial + runs-on: ubuntu-latest + # Measured serial remainder is ~13 min wall without Herdr. Cap is a hang + # tripwire above observed p99 script cost and suite wall, not the expected + # healthy end (interim 25m full-suite slack reduced after sharding). + timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: @@ -52,11 +133,219 @@ jobs: set -eu npm install -g tasks-axi tasks-axi --version - - run: | + - name: Run portable serial remainder + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-test" + bin/fm-test-run.sh --lane portable-serial \ + --json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-serial.json" + - name: Upload portable serial timing artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fm-test-timing-portable-serial + path: ${{ runner.temp }}/fm-test/fm-test-timing-portable-serial.json + if-no-files-found: warn + + # Required real-Herdr lane: pinned install, serial real-herdr-gated family, + # hard-fail on "skip: herdr not found". Starts on Linux x86_64; if a genuine + # platform invariant fails (focus, cleanup, default-session tripwire), keep + # the failure evidence and move the job to macOS rather than skip assertions. + tests-herdr: + name: Behavior tests (Herdr) + runs-on: ubuntu-latest + # Real Herdr is slower than the portable suite; this is a hang tripwire, + # not the expected healthy end of the lane (estimate 15-40 min first cut). + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Require tools for real Herdr + run: | + set -eu + command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } + command -v python3 >/dev/null || { echo "::error::python3 is required"; exit 1; } + command -v curl >/dev/null || { echo "::error::curl is required"; exit 1; } + command -v tar >/dev/null || { echo "::error::tar is required"; exit 1; } + jq --version + python3 --version + - name: Install pinned Herdr + run: | + set -eu + bin/fm-install-herdr.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install pinned Treehouse + run: | + set -eu + # Several real-herdr-gated E2E scripts require treehouse for spawn + # worktree acquisition (presentation, workspace-per-home, autodetect). + bin/fm-install-treehouse.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Assert Herdr pin and protocol floor + run: | + set -eu + command -v herdr >/dev/null || { echo "::error::herdr not on PATH after install"; exit 1; } + command -v treehouse >/dev/null || { echo "::error::treehouse not on PATH after install"; exit 1; } + herdr --version + treehouse --version + status=$(herdr status --json) + printf '%s\n' "$status" + version=$(printf '%s' "$status" | jq -r '.client.version // empty') + protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty') + [ "$version" = "0.7.4" ] || { + echo "::error::expected exact Herdr pin 0.7.4, got ${version:-}" + exit 1 + } + case "$protocol" in + ''|*[!0-9]*) echo "::error::could not read client protocol"; exit 1 ;; + esac + [ "$protocol" -ge 16 ] || { + echo "::error::herdr protocol $protocol is below the required floor 16" + exit 1 + } + - name: Start default Herdr session for fleet-state tripwire + run: | set -eu - for test_script in tests/*.test.sh; do - "$test_script" + # Lab provision requires exactly one running default session so the + # default-session tripwire can arm. Start it headless; never use it + # for lab work (tests still refuse default and use fm-lab-* only). + mkdir -p "$RUNNER_TEMP/fm-herdr" + nohup herdr server >"$RUNNER_TEMP/fm-herdr/default-server.log" 2>&1 & + echo $! >"$RUNNER_TEMP/fm-herdr/default-server.pid" + attempt=0 + while [ "$attempt" -lt 150 ]; do + running=$(herdr status --json 2>/dev/null | jq -r '.server.running // false' || echo false) + if [ "$running" = true ]; then + herdr session list --json | jq -e ' + [.sessions[]? | select(.default == true and .name == "default" and .running == true)] + | length == 1 + ' >/dev/null + echo "default Herdr session is running" + exit 0 + fi + sleep 0.2 + attempt=$((attempt + 1)) done + echo "::error::default Herdr server did not become ready" + cat "$RUNNER_TEMP/fm-herdr/default-server.log" || true + exit 1 + - name: Snapshot pre-suite Herdr sessions + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-herdr" + bin/fm-herdr-ci-cleanup.sh snapshot "$RUNNER_TEMP/fm-herdr/sessions-before.json" + - name: Run real-Herdr family (serial, required) + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-test" + # Serial only. Fail if any script reports herdr not found. Live + # harness credential tests stay outside this family (opt-in env only). + bin/fm-test-run.sh --family real-herdr-gated \ + --fail-on-gate-skip 'herdr not found' \ + --json "$RUNNER_TEMP/fm-test/fm-test-timing-herdr.json" + - name: Cleanup job-owned Herdr lab sessions + if: always() + run: | + set -eu + bin/fm-herdr-ci-cleanup.sh teardown "$RUNNER_TEMP/fm-herdr/sessions-before.json" || { + echo "::warning::lab session cleanup reported a problem; see logs" + # Surface diagnostics without weakening the suite result above. + herdr session list --json || true + exit 1 + } + - name: Upload Herdr timing and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: fm-test-timing-herdr + path: | + ${{ runner.temp }}/fm-test/fm-test-timing-herdr.json + ${{ runner.temp }}/fm-herdr/sessions-before.json + ${{ runner.temp }}/fm-herdr/default-server.log + if-no-files-found: warn + + # Aggregate per-lane timing into one summary artifact for critical-path review. + tests-timing-aggregate: + name: Behavior timing aggregate + runs-on: ubuntu-latest + needs: + - tests-portable-parallel-1 + - tests-portable-parallel-2 + - tests-portable-serial + - tests-herdr + if: always() + steps: + - uses: actions/checkout@v6 + - name: Download lane timing artifacts + uses: actions/download-artifact@v4 + with: + pattern: fm-test-timing-* + path: ${{ runner.temp }}/fm-test-aggregate + merge-multiple: true + - name: Build aggregate timing summary + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-test" + shopt -s nullglob + inputs=("$RUNNER_TEMP"/fm-test-aggregate/fm-test-timing-*.json) + if [ "${#inputs[@]}" -eq 0 ]; then + echo "::warning::no per-lane timing JSON found to aggregate" + exit 0 + fi + bin/fm-test-run.sh --aggregate-json \ + "$RUNNER_TEMP/fm-test/fm-test-timing-aggregate.json" \ + "${inputs[@]}" + - name: Upload aggregate timing artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fm-test-timing-aggregate + path: ${{ runner.temp }}/fm-test/fm-test-timing-aggregate.json + if-no-files-found: warn + + macos-stock-bash: + name: Stock macOS Bash snapshot compatibility + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Run snapshot consumers with stock Bash + shell: /bin/bash {0} + env: + PATH: /bin:/usr/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin + run: | + set -eu + case "$BASH_VERSION" in + 3.2.57*) ;; + *) echo "::error::expected stock macOS Bash 3.2.57, got $BASH_VERSION"; exit 1 ;; + esac + /bin/bash --version | head -1 + command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } + + shell_inventory="$RUNNER_TEMP/fm-shell-inventory" + bin/fm-lint.sh --list-files > "$shell_inventory" + parse_fail=0 + while IFS= read -r f; do + /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } + done < "$shell_inventory" + [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } + + snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) + printf '%s\n' "$snapshot_output" + snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') + [ "$snapshot_count" -eq 15 ] || { + echo "::error::expected 15 snapshot/fleet-view tests, got $snapshot_count" + exit 1 + } + + bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) + printf '%s\n' "$bearings_output" + bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') + [ "$bearings_count" -eq 42 ] || { + echo "::error::expected 42 Bearings tests, got $bearings_count" + exit 1 + } invariants: name: Repo invariants diff --git a/.github/workflows/no-mistakes-required.yml b/.github/workflows/no-mistakes-required.yml index ab1224d390..f56afee418 100644 --- a/.github/workflows/no-mistakes-required.yml +++ b/.github/workflows/no-mistakes-required.yml @@ -1,4 +1,5 @@ name: Require no-mistakes +run-name: "PR #${{ github.event.pull_request.number }} body compliance - ${{ github.event.action }} - event ${{ github.run_number }} (run ${{ github.run_id }})" on: pull_request: @@ -9,8 +10,12 @@ on: permissions: contents: read +# GitHub concurrency groups retain at most one pending run, replacing older +# pending runs even when cancel-in-progress is false. Give body-bearing events +# an immutable per-event group so first-time-fork approvals can never collapse +# opened/edited checks. Keep synchronize/reopened coalescing as before. concurrency: - group: no-mistakes-required-${{ github.event.pull_request.number }} + group: no-mistakes-required-${{ github.event.pull_request.number }}-${{ (github.event.action == 'opened' || github.event.action == 'edited') && github.run_id || 'head-change' }} cancel-in-progress: true jobs: diff --git a/.gitignore b/.gitignore index 5ed2da0c32..372af4735f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ config/crew-dispatch.json config/secondmate-harness config/backlog-backend config/backend +config/calm config/x-mode.env config/cmux-socket-password config/wedge-alarm +config/herdr-presentation-spaces diff --git a/.grok/hooks/fm-primary-sessionstart-nudge.json b/.grok/hooks/fm-primary-sessionstart-nudge.json new file mode 100644 index 0000000000..0b79c25889 --- /dev/null +++ b/.grok/hooks/fm-primary-sessionstart-nudge.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-sessionstart-nudge.sh\"'", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 8fc97893ab..02e6128f2e 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -9,21 +9,32 @@ # HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary." disable_project_settings: true -# Pin lint and test to the SAME deterministic commands CI runs, instead of -# leaving them to no-mistakes' default handling. Without a configured -# commands.lint, the gate's lint step never ran the deterministic -# `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` that CI runs, so info-level -# ShellCheck findings (e.g. SC2015) were not surfaced locally before CI rejected -# them. commands.lint delegates to bin/fm-lint.sh, the single owner of the lint -# definition that .github/workflows/ci.yml also invokes, so local can never -# diverge from CI again (parity asserted by tests/fm-lint.test.sh). -# The test command mirrors .github/workflows/ci.yml: iterate every -# tests/*.test.sh, run each, and fail the step if any one exits non-zero (an -# agent-driven test step has crashed the daemon). The e2e tests need tmux on -# PATH, which the firstmate environment provides. +# Trusted documentation placement policy for the Document step. +# The audience inventory and coding guideline own the detail; keep this as a +# pointer so gate instructions cannot become a second prose policy. +document: + instructions: | + Read docs/documentation-audiences.md and its machine-consumed + docs/documentation-audiences.json inventory before changing documentation. + Apply the knowledge-placement policy in + .agents/skills/firstmate-coding-guidelines/SKILL.md. + For changed prose, verify audience, authoritative owner, current relevance, + evidence destination, and unique safety facts, then review the complete + branch diff again after every documentation or lint fix. + +# Pin lint to the same owner CI runs instead of leaving it to no-mistakes' +# default handling, which does not invoke the repository's canonical lint gate. +# `bin/fm-lint.sh` owns the complete lint definition and +# `.github/workflows/ci.yml` invokes it directly, with parity asserted by +# `tests/fm-lint.test.sh`. +# +# Do not set commands.test to a complete tests/*.test.sh walk. Local no-mistakes +# Test is intent-targeted validation of whether the change meets its brief; +# .github/workflows/ci.yml owns broad regression (behavior suite, platform, +# security, Herdr, tmux, and lifecycle coverage). A full-suite override here +# would duplicate CI and defeat the targeted Test contract. commands: lint: 'bin/fm-lint.sh' - test: 'command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"' # Keep test evidence out of this repo; it stays in a temp dir instead. test: diff --git a/.opencode/plugins/fm-primary-sessionstart-nudge.js b/.opencode/plugins/fm-primary-sessionstart-nudge.js new file mode 100644 index 0000000000..5e8df0b4e9 --- /dev/null +++ b/.opencode/plugins/fm-primary-sessionstart-nudge.js @@ -0,0 +1,60 @@ +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +const handledSessions = new Set(); + +function runProcess(command, args) { + return new Promise((resolveResult) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] }); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.on("error", () => resolveResult({ code: 0, stdout: "" })); + child.on("close", (code) => resolveResult({ code: code ?? 0, stdout })); + }); +} + +function resolvePath(anchor) { + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +async function resolveRoot(anchor) { + if (!anchor) return ""; + const result = await runProcess("git", ["-C", anchor, "rev-parse", "--show-toplevel"]); + const root = result.stdout.trim(); + if (result.code === 0 && root) return root; + return resolvePath(anchor); +} + +export const FmPrimarySessionstartNudge = async ({ client, directory, worktree }) => { + const root = worktree ? resolvePath(worktree) : await resolveRoot(directory); + + return { + event: async ({ event }) => { + if (event.type !== "session.created") return; + const sessionID = event.properties?.info?.id ?? event.properties?.sessionID; + if (!sessionID || handledSessions.has(sessionID) || !root) return; + handledSessions.add(sessionID); + + const result = await runProcess(`${root}/bin/fm-sessionstart-nudge.sh`, []); + const nudge = result.code === 0 ? result.stdout.trim() : ""; + if (!nudge) return; + + try { + await client.session.promptAsync({ + path: { id: sessionID }, + body: { + parts: [{ type: "text", text: nudge }], + }, + }); + } catch { + } + }, + }; +}; diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js index b38f9c2c47..fb8f42eaf4 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { resolve } from "node:path"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; @@ -74,18 +75,17 @@ export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => if (result.code !== 2) return; try { + const text = await encodeFirstmateOperationalInput( + root, + "turn-end-guard", + "TURN WOULD END BLIND - supervision is off. " + + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + + result.stderr, + ); await client.session.promptAsync({ path: { id: sessionID }, body: { - parts: [ - { - type: "text", - text: - "TURN WOULD END BLIND - supervision is off. " + - "Resume supervision according to the session-start operating block before ending the turn.\n\n" + - result.stderr, - }, - ], + parts: [{ type: "text", text }], }, }); skipNextIdle = true; diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 5147f839a9..433edb80ab 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -1,40 +1,48 @@ import { spawn } from "node:child_process"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; -const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000; +const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS); +const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); +const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); +const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); +const REARM_RETRY_LIMIT = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); let child = null; let armStatus = "idle"; -let waiters = new Set(); +let retryTimer = null; +let retryFailures = 0; +let launchInFlight = null; +let restorationInFlight = null; +let armClose = new WeakMap(); +let armReadiness = new WeakMap(); -function setArmStatus(status) { - armStatus = status; - for (const resolve of waiters) resolve(status); - waiters.clear(); +function positiveInteger(name, fallback) { + const value = Number(process.env[name]); + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.floor(value); } -function readyStatus() { - if (armStatus === "armed" || armStatus === "wake" || armStatus === "failed" || armStatus === "external") return armStatus; - return ""; +function setArmStatus(status) { + armStatus = status; } -function waitForArmReady() { - const ready = readyStatus(); - if (ready) return Promise.resolve(ready); +function waitForArmReady(armChild) { + const readiness = armReadiness.get(armChild); + if (!readiness) return Promise.resolve("failed"); return new Promise((resolve) => { - let timer = null; - const waiter = (status) => { - if (timer) clearTimeout(timer); - waiters.delete(waiter); + const timer = setTimeout(() => resolve("timeout"), ARM_READY_TIMEOUT_MS); + timer.unref(); + void readiness.then((status) => { + clearTimeout(timer); resolve(status); - }; - timer = setTimeout(() => { - waiters.delete(waiter); - resolve("timeout"); - }, ARM_READY_TIMEOUT_MS); - waiters.add(waiter); + }); }); } @@ -121,105 +129,286 @@ async function sessionOwnsLock(paths) { return false; } -function firstWakeOrFailure(stdout, stderr, code) { +function classifyArmClose(stdout, stderr, code, signal) { const combined = `${stdout}\n${stderr}`; const reason = combined.split(/\r?\n/).find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)); - if (reason) return reason; - if (/^watcher: healthy/m.test(combined)) return ""; + if (reason) return { kind: "actionable", message: reason }; + const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line)); + if (healthy) { + return { + kind: "failure", + message: `watcher: FAILED - OpenCode arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`, + }; + } const failed = combined.split(/\r?\n/).find((line) => /^watcher: FAILED/.test(line)); - if (failed) return failed; - if (code && code !== 0) return `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined.trim() ? `\n${combined.trim()}` : ""}`; - return ""; + if (failed) return { kind: "failure", message: failed }; + if (signal) { + return { + kind: "failure", + message: `watcher: FAILED - OpenCode arm child ended from ${signal}${combined.trim() ? `\n${combined.trim()}` : ""}`, + }; + } + if (code && code !== 0) { + return { + kind: "failure", + message: `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined.trim() ? `\n${combined.trim()}` : ""}`, + }; + } + return { + kind: "failure", + message: "watcher: FAILED - OpenCode arm cycle ended without an actionable reason", + }; } -function observeArmOutput(stdout, stderr) { +function observeArmOutput(stdout, stderr, settleReadiness) { const combined = `${stdout}\n${stderr}`; - if (combined.split(/\r?\n/).some((line) => /^watcher: started\b/.test(line))) { + if (combined.split(/\r?\n/).some((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line))) { + setArmStatus("wake"); + settleReadiness("wake"); + return; + } + if (combined.split(/\r?\n/).some((line) => /^watcher: (?:started|attached)\b/.test(line))) { setArmStatus("armed"); + settleReadiness("armed"); return; } if (combined.split(/\r?\n/).some((line) => /^watcher: healthy\b/.test(line))) { setArmStatus("external"); + settleReadiness("external"); return; } if (combined.split(/\r?\n/).some((line) => /^watcher: FAILED/.test(line))) { setArmStatus("failed"); + settleReadiness("failed"); } } -async function sendPrompt(client, sessionID, text) { +async function sendPrompt(paths, client, sessionID, text) { + const encoded = await encodeFirstmateOperationalInput(paths.root, "watcher", text); await client.session.promptAsync({ path: { id: sessionID }, body: { - parts: [ - { - type: "text", - text, - }, - ], + parts: [{ type: "text", text: encoded }], }, }); } -function spawnArm(paths, sessionID, client) { +function wakePrompt(reason) { + return `WATCHER FIRED - drain queued wakes with bin/fm-wake-drain.sh and handle the reported wake. Watcher continuity is plugin-owned.\n\n${reason}`; +} + +function surfaceFailure(paths, client, sessionID, reason) { + void sendPrompt(paths, client, sessionID, wakePrompt(reason)).catch(() => { + }); +} + +function retryDelay(attempt) { + return Math.min(REARM_RETRY_MAX_MS, REARM_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1)); +} + +function waitForRetry(attempt) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, retryDelay(attempt)); + timer.unref(); + }); +} + +async function retireArm(armChild) { + if (!armChild) return true; + armChild.kill("SIGTERM"); + const closed = armClose.get(armChild); + if (!closed) return false; + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ARM_RETIRE_TIMEOUT_MS); + timer.unref(); + void closed.then(() => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +function restorationFailure(status) { + if (status === "read-only") { + return "watcher: FAILED - OpenCode cannot restore continuity because this session no longer owns the lock"; + } + return `watcher: FAILED - OpenCode could not verify a ready successor watcher (${status || "idle"})`; +} + +async function restoreAfterActionableClose(paths, sessionID, client, predecessorArmPid) { + let failure = ""; + for (let attempt = 0; attempt <= REARM_RETRY_LIMIT; attempt += 1) { + const { status, armChild } = await ensureArm(paths, sessionID, client, predecessorArmPid, true); + if (status === "armed") return ""; + // An actionable line belongs to this arm's close handler. + // Do not retire it before that handler can start the successor cycle. + if (status === "wake") return ""; + failure = restorationFailure(status); + if (!(await retireArm(armChild))) { + setArmStatus("failed"); + return `${failure}\nwatcher: FAILED - OpenCode could not restore watcher continuity because the unready successor arm did not exit within ${ARM_RETIRE_TIMEOUT_MS}ms`; + } + if (status === "read-only" || status === "not-primary" || status === "skipped") break; + if (attempt === REARM_RETRY_LIMIT) break; + await waitForRetry(attempt + 1); + } + setArmStatus("failed"); + return `${failure}\nwatcher: FAILED - OpenCode could not restore watcher continuity after ${REARM_RETRY_LIMIT} retries`; +} + +async function scheduleRetry(paths, sessionID, client, reason, predecessorArmPid) { + if (child || retryTimer) return; + if (!(await sessionOwnsLock(paths))) { + setArmStatus("failed"); + surfaceFailure(paths, client, sessionID, `watcher: FAILED - OpenCode cannot restore continuity because this session no longer owns the lock\n${reason}`); + return; + } + retryFailures += 1; + if (retryFailures > REARM_RETRY_LIMIT) { + setArmStatus("failed"); + surfaceFailure(paths, client, sessionID, `watcher: FAILED - OpenCode could not restore watcher continuity after ${REARM_RETRY_LIMIT} retries\n${reason}`); + return; + } + setArmStatus("retrying"); + const timer = setTimeout(() => { + if (retryTimer === timer) retryTimer = null; + void ensureArm(paths, sessionID, client, predecessorArmPid).then((status) => { + if (["armed", "starting", "wake"].includes(status)) return; + surfaceFailure(paths, client, sessionID, `watcher: FAILED - OpenCode could not launch a continuity retry (${status})`); + }); + }, retryDelay(retryFailures)); + timer.unref(); + retryTimer = timer; +} + +function spawnArm(paths, sessionID, client, predecessorArmPid = "") { setArmStatus("starting"); const env = { ...process.env, FM_HOME: paths.home, FM_ROOT_OVERRIDE: paths.root, + FM_CONFIG_OVERRIDE: paths.config, + FM_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid, }; - child = spawn("bash", ["-lc", 'config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"; [ -f "$config_dir/x-mode.env" ] && . "$config_dir/x-mode.env"; exec "$FM_ROOT_OVERRIDE/bin/fm-watch-arm.sh" --restart'], { + const armChild = spawn("bash", ["-lc", 'config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"; [ -f "$config_dir/x-mode.env" ] && . "$config_dir/x-mode.env"; exec "$FM_ROOT_OVERRIDE/bin/fm-watch-arm.sh" --restart'], { cwd: paths.root, env, stdio: ["ignore", "pipe", "pipe"], }); + child = armChild; let stdout = ""; let stderr = ""; - child.stdout.on("data", (chunk) => { + let settled = false; + let resolveClosed = null; + let readinessSettled = false; + let resolveReadiness = null; + const readiness = new Promise((resolve) => { + resolveReadiness = resolve; + }); + armReadiness.set(armChild, readiness); + const settleReadiness = (status) => { + if (readinessSettled) return; + readinessSettled = true; + resolveReadiness(status); + }; + const closed = new Promise((resolveClosedChild) => { + resolveClosed = resolveClosedChild; + }); + armClose.set(armChild, closed); + const releaseChild = () => { + if (child === armChild) child = null; + }; + armChild.stdout.on("data", (chunk) => { stdout += chunk.toString(); - observeArmOutput(stdout, stderr); + observeArmOutput(stdout, stderr, settleReadiness); }); - child.stderr.on("data", (chunk) => { + armChild.stderr.on("data", (chunk) => { stderr += chunk.toString(); - observeArmOutput(stdout, stderr); + observeArmOutput(stdout, stderr, settleReadiness); }); - child.on("close", async (code) => { - child = null; - const reason = firstWakeOrFailure(stdout, stderr, code); - if (reason) setArmStatus(reason.startsWith("watcher: FAILED") ? "failed" : "wake"); - else if (!readyStatus()) setArmStatus("idle"); - if (!reason) return; - try { - await sendPrompt( - client, - sessionID, - `WATCHER FIRED - drain queued wakes with bin/fm-wake-drain.sh, handle the reported wake, and continue normal supervision.\n\n${reason}`, - ); - } catch { + armChild.on("close", (code, signal) => { + if (settled) return; + settled = true; + resolveClosed(); + releaseChild(); + const classification = classifyArmClose(stdout, stderr, code, signal); + settleReadiness(classification.kind === "actionable" ? "wake" : "failed"); + const predecessor = String(armChild.pid ?? ""); + if (classification.kind === "actionable") { + retryFailures = 0; + setArmStatus("wake"); + const previousRestoration = restorationInFlight; + const restoration = previousRestoration + ? previousRestoration.catch(() => "").then(() => restoreAfterActionableClose(paths, sessionID, client, predecessor)) + : restoreAfterActionableClose(paths, sessionID, client, predecessor); + restorationInFlight = restoration; + void restoration.then((failure) => { + if (restorationInFlight === restoration) restorationInFlight = null; + const message = failure ? `${classification.message}\n\n${failure}` : classification.message; + return sendPrompt(paths, client, sessionID, wakePrompt(message)); + }).catch(() => { + }); + return; + } + if (restorationInFlight) { + setArmStatus("failed"); + return; } + void scheduleRetry(paths, sessionID, client, classification.message, predecessor); }); - child.on("error", async (error) => { - child = null; - setArmStatus("failed"); - try { - await sendPrompt( - client, - sessionID, - `WATCHER FIRED - drain queued wakes with bin/fm-wake-drain.sh, handle the reported wake, and continue normal supervision.\n\nwatcher: FAILED - OpenCode arm child failed: ${error.message}`, - ); - } catch { + armChild.on("error", (error) => { + if (settled) return; + settled = true; + resolveClosed(); + releaseChild(); + settleReadiness("failed"); + if (restorationInFlight) { + setArmStatus("failed"); + return; } + void scheduleRetry( + paths, + sessionID, + client, + `watcher: FAILED - OpenCode arm child failed: ${error.message}`, + String(armChild.pid ?? ""), + ); }); + return armChild; } -async function ensureArm(paths, sessionID, client) { - if (!sessionID) return "skipped"; - if (!(await isPrimaryRoot(paths.root, paths.home))) return "not-primary"; - if (!(await sessionOwnsLock(paths))) return "read-only"; - if (child) return waitForArmReady(); - if (!shouldArm(paths)) return "not-needed"; - spawnArm(paths, sessionID, client); - return waitForArmReady(); +async function beginArm(paths, sessionID, client, predecessorArmPid) { + if (!sessionID) return { status: "skipped", armChild: null }; + if (!(await isPrimaryRoot(paths.root, paths.home))) return { status: "not-primary", armChild: null }; + if (!(await sessionOwnsLock(paths))) return { status: "read-only", armChild: null }; + if (child) return { status: "existing", armChild: child }; + if (retryTimer) return { status: "retrying", armChild: null }; + if (!shouldArm(paths)) return { status: "not-needed", armChild: null }; + return { status: "spawned", armChild: spawnArm(paths, sessionID, client, predecessorArmPid) }; +} + +function armAttempt(status, armChild, includeArmChild) { + return includeArmChild ? { status, armChild } : status; +} + +async function ensureArm(paths, sessionID, client, predecessorArmPid = "", includeArmChild = false) { + let launchResult = null; + if (!launchInFlight) { + const launch = beginArm(paths, sessionID, client, predecessorArmPid); + launchInFlight = launch; + try { + launchResult = await launch; + } finally { + if (launchInFlight === launch) launchInFlight = null; + } + } else { + launchResult = await launchInFlight; + } + const armChild = launchResult.armChild; + if (!armChild) { + return armAttempt(launchResult.status, null, includeArmChild); + } + return armAttempt(await waitForArmReady(armChild), armChild, includeArmChild); } export const FmPrimaryWatchArm = async ({ client, directory, worktree }) => { diff --git a/.opencode/plugins/lib/fm-operational-input.js b/.opencode/plugins/lib/fm-operational-input.js new file mode 100644 index 0000000000..f0d05c1444 --- /dev/null +++ b/.opencode/plugins/lib/fm-operational-input.js @@ -0,0 +1,37 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +// Cross-language adapter only. bin/fm-operational-input.sh owns the protocol, +// accepted kinds, marker bytes, and serialization grammar. +export function encodeFirstmateOperationalInput(root, kind, content) { + return new Promise((resolveResult, reject) => { + const requested = `${root}/bin/fm-operational-input.sh`; + const script = existsSync(requested) + ? requested + : `${adapterRoot}/bin/fm-operational-input.sh`; + const child = spawn(script, ["encode", kind], { + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0 && stdout) { + resolveResult(stdout); + return; + } + reject(new Error(stderr.trim() || `operational-input encoder exited ${code ?? "unknown"}`)); + }); + child.stdin.end(content); + }); +} diff --git a/.opencode/plugins/package.json b/.opencode/plugins/package.json new file mode 100644 index 0000000000..e986b24bba --- /dev/null +++ b/.opencode/plugins/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts new file mode 100644 index 0000000000..f78c1b5acd --- /dev/null +++ b/.pi/extensions/fm-calm.ts @@ -0,0 +1,290 @@ +// Firstmate's home-persistent Pi transcript presentation toggle. +// +// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot +// renderers, renderShell: "self", session_start replacement reasons, +// ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and +// setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a +// newer Pi solely for its version. The collapsed-thinking and operational-user +// presentation adapters probe the exact API they patch and degrade independently with a +// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi +// still exposes no global renderer for arbitrary built-in or custom rows. +// docs/configuration.md owns the home-local Calm preference contract. +import { randomUUID } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + ExtensionAPI, + ToolDefinition, + ToolRenderResultOptions, +} from "@earendil-works/pi-coding-agent"; +import { + createBashToolDefinition, + createEditToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, + createLsToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { Box, Container, getKeybindings, type Component } from "@earendil-works/pi-tui"; +import type { TSchema } from "typebox"; +import { installCalmAssistantLayout } from "./lib/fm-calm-assistant-layout.ts"; +import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts"; +import { + calmPresentationHides, + calmPresentationIsActive, + FIRSTMATE_CALM_PRESENTATION_EVENT, + registerFirstmateSyntheticPresentation, + setCalmPresentation, + setCalmStockExportRendering, +} from "./lib/fm-calm-visibility.ts"; + +type DefinitionFactory = ( + cwd: string, +) => ToolDefinition; + +type RenderContext = Parameters< + NonNullable["renderCall"]> +>[2]; + +type RenderArgs = Parameters< + NonNullable["renderCall"]> +>[0]; + +type RenderTheme = Parameters< + NonNullable["renderCall"]> +>[1]; + +type RenderResult = Parameters< + NonNullable["renderResult"]> +>[0]; + +type StandardShellState = { + shell?: Box; + call?: Component; + result?: Component; +}; + +const extensionFile = fileURLToPath(import.meta.url); +const extensionDir = dirname(extensionFile); +const root = resolve(extensionDir, "../.."); + +// Each presentation adapter probes the exact Pi API it patches. If a future Pi removes +// that API, only the affected adapter degrades; the rest of Calm keeps working. +function installCalmPresentationAdapter(name: string, install: () => void): void { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + +export default function (pi: ExtensionAPI) { + installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); + + let exportRendering = false; + let removeTerminalInputHandler: (() => void) | undefined; + + const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; + const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config"); + const calmPreferencePath = resolve(configDirectory, "calm"); + const loadCalmPreference = (): boolean => { + try { + return readFileSync(calmPreferencePath, "utf8").trim() === "on"; + } catch { + return false; + } + }; + const persistCalmPreference = (active: boolean): void => { + mkdirSync(dirname(calmPreferencePath), { recursive: true }); + const temporaryPath = `${calmPreferencePath}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryPath, active ? "on\n" : "off\n", { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, calmPreferencePath); + } finally { + rmSync(temporaryPath, { force: true }); + } + }; + + const publishPresentationState = (): void => { + pi.events.emit(FIRSTMATE_CALM_PRESENTATION_EVENT, { + active: calmPresentationIsActive(), + stockExportRendering: exportRendering, + }); + }; + + registerFirstmateSyntheticPresentation(pi); + + function registerBuiltIn( + factory: DefinitionFactory, + ): void { + const definitions = new Map>(); + const definitionFor = (cwd: string): ToolDefinition => { + let definition = definitions.get(cwd); + if (!definition) { + definition = factory(cwd); + definitions.set(cwd, definition); + } + return definition; + }; + + const original = definitionFor(process.cwd()); + const originalRenderCall = original.renderCall; + const originalRenderResult = original.renderResult; + const originalSelfShell = original.renderShell === "self"; + const standardShells = new WeakMap(); + + if (!originalRenderCall || !originalRenderResult) { + throw new Error(`Firstmate calm mode requires both render slots for Pi built-in tool ${original.name}`); + } + + const shellStateFor = ( + context: RenderContext, + ): StandardShellState => { + const rowState = context.state as object; + let shellState = standardShells.get(rowState); + if (!shellState) { + shellState = {}; + standardShells.set(rowState, shellState); + } + return shellState; + }; + + const refreshStandardShell = ( + state: StandardShellState, + theme: RenderTheme, + context: RenderContext, + ): Box => { + const background = context.isPartial + ? (text: string) => theme.bg("toolPendingBg", text) + : context.isError + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const shell = state.shell ?? new Box(1, 1, background); + state.shell = shell; + shell.setBgFn(background); + shell.clear(); + if (state.call) shell.addChild(state.call); + if (state.result) shell.addChild(state.result); + return shell; + }; + + pi.registerTool({ + ...original, + renderShell: "self", + + async execute(toolCallId, params, signal, onUpdate, ctx) { + return definitionFor(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx); + }, + + renderCall( + args: RenderArgs, + theme: RenderTheme, + context: RenderContext, + ) { + if (exportRendering) return originalRenderCall(args, theme, context); + if (calmPresentationHides("assistant-tool-call")) return new Container(); + if (originalSelfShell) return originalRenderCall(args, theme, context); + + const state = shellStateFor(context); + state.call = originalRenderCall(args, theme, { + ...context, + lastComponent: state.call, + }); + return refreshStandardShell(state, theme, context); + }, + + renderResult( + result: RenderResult, + options: ToolRenderResultOptions, + theme: RenderTheme, + context: RenderContext, + ) { + if (exportRendering) return originalRenderResult(result, options, theme, context); + if (calmPresentationHides("tool-result")) return new Container(); + if (originalSelfShell) return originalRenderResult(result, options, theme, context); + + const state = shellStateFor(context); + state.result = originalRenderResult(result, options, theme, { + ...context, + lastComponent: state.result, + }); + refreshStandardShell(state, theme, context); + return new Container(); + }, + }); + } + + registerBuiltIn(createReadToolDefinition); + registerBuiltIn(createBashToolDefinition); + registerBuiltIn(createEditToolDefinition); + registerBuiltIn(createWriteToolDefinition); + registerBuiltIn(createGrepToolDefinition); + registerBuiltIn(createFindToolDefinition); + registerBuiltIn(createLsToolDefinition); + + pi.on("session_start", (_event, ctx) => { + exportRendering = false; + setCalmPresentation(loadCalmPreference()); + setCalmStockExportRendering(false); + publishPresentationState(); + ctx.ui.setWorkingVisible(true); + ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); + ctx.ui.setStatus("firstmate-calm", undefined); + removeTerminalInputHandler?.(); + removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => { + if (!getKeybindings().matches(data, "tui.input.submit")) return; + + const input = ctx.ui.getEditorText().trim(); + if ( + input !== "/share" && + input !== "/export" && + !input.startsWith("/export ") + ) { + return; + } + + exportRendering = true; + setCalmStockExportRendering(true); + publishPresentationState(); + setTimeout(() => { + exportRendering = false; + setCalmStockExportRendering(false); + publishPresentationState(); + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }, 0); + }); + }); + + pi.registerCommand("calm", { + description: "Toggle Firstmate's supported conversation-only transcript presentation.", + handler: async (_args, ctx) => { + const active = !calmPresentationIsActive(); + persistCalmPreference(active); + setCalmPresentation(active); + publishPresentationState(); + ctx.ui.setWorkingVisible(true); + ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); + ctx.ui.setStatus("firstmate-calm", undefined); + + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }, + }); +} diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 644f939cef..9d5124aff2 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,11 +1,27 @@ // Firstmate primary watcher bridge for Pi. -import { spawn, spawnSync } from "node:child_process"; +// +// Session-generation ownership (stated once here): +// Pi emits session_shutdown for ordinary same-process replacements (/new, /resume, +// /fork, reload) as well as terminal quit. This extension binds one generation per +// session activation. Only the active live generation may start, stop, rearm, or +// clear the arm child. Replacement session_start (or a fresh factory bind) activates +// a new live generation so monitoring can arm again without restarting Pi. Terminal +// quit leaves the final generation stopped so late callbacks cannot rearm. Stale +// callbacks from a prior generation are no-ops against the active replacement. +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; +import { Box, Container, Text, type Component } from "@earendil-works/pi-tui"; import { Type } from "typebox"; +import { + type CalmPresentationState, + calmTranscriptClassIsVisible, + FIRSTMATE_CALM_PRESENTATION_EVENT, +} from "./lib/fm-calm-visibility.ts"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; type ArmResult = { ok: boolean; @@ -14,6 +30,51 @@ type ArmResult = { type LockOwnership = "owned" | "missing" | "other"; +type CloseClassification = { + kind: "actionable" | "failure"; + message: string; +}; + +type WatchToolShellState = { + shell?: Box; + call?: Component; + result?: Component; +}; + +type WatchToolRenderContext = { + isError: boolean; + isPartial: boolean; +}; + +type SessionGeneration = { + id: number; + stopping: boolean; + child: ChildProcess | null; + retryTimer: ReturnType | null; + retryFailures: number; + restoring: boolean; + seq: number; +}; + +function refreshWatchToolShell( + state: WatchToolShellState, + theme: Theme, + context: WatchToolRenderContext, +): Box { + const background = context.isPartial + ? (text: string) => theme.bg("toolPendingBg", text) + : context.isError + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const shell = state.shell ?? new Box(1, 1, background); + state.shell = shell; + shell.setBgFn(background); + shell.clear(); + if (state.call) shell.addChild(state.call); + if (state.result) shell.addChild(state.result); + return shell; +} + const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); @@ -24,9 +85,30 @@ const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; const marker = `${state}/.pi-watch-extension-loaded`; const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; +const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); +const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); +const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const armReadyTimeoutMs = positiveInteger( + "FM_PI_ARM_READY_TIMEOUT_MS", + process.platform === "win32" ? 35000 : 12000, +); +const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); +const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; +const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; + +let nextGenerationId = 0; +let activeGeneration: SessionGeneration | null = null; +const armReadiness = new WeakMap>(); +const armClose = new WeakMap>(); -let child: any = null; -let seq = 0; +function positiveInteger(name: string, fallback: number): number { + const value = Number(process.env[name]); + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.floor(value); +} function parentPid(pid: string): string { const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); @@ -60,10 +142,6 @@ function lockOwnership(): LockOwnership { return pidAlive(lockPid) ? "other" : "missing"; } -function sessionOwnsLock(): boolean { - return lockOwnership() === "owned"; -} - function markLoaded(): void { if (lockOwnership() === "other") return; mkdirSync(state, { recursive: true }); @@ -75,93 +153,319 @@ function actionableLine(output: string): string { return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || ""; } -function failureLine(stdout: string, stderr: string, code: number | null): string { +function classifyClose(stdout: string, stderr: string, code: number | null, signal: NodeJS.Signals | null): CloseClassification { const combined = `${stdout}\n${stderr}`.trim(); + const reason = actionableLine(combined); + if (reason) return { kind: "actionable", message: reason }; const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line)); - if (healthy) return `watcher: FAILED - Pi extension arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`; + if (healthy) { + return { + kind: "failure", + message: `watcher: FAILED - Pi extension arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`, + }; + } const failed = combined.split(/\r?\n/).find((line) => /^watcher: FAILED/.test(line)); - if (failed) return failed; - if (code && code !== 0) return `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined ? `\n${combined}` : ""}`; - return ""; + if (failed) return { kind: "failure", message: failed }; + if (signal) { + return { + kind: "failure", + message: `watcher: FAILED - Pi extension arm child ended from ${signal}${combined ? `\n${combined}` : ""}`, + }; + } + if (code && code !== 0) { + return { + kind: "failure", + message: `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined ? `\n${combined}` : ""}`, + }; + } + return { + kind: "failure", + message: "watcher: FAILED - Pi extension arm cycle ended without an actionable reason", + }; +} + +function createGeneration(): SessionGeneration { + return { + id: ++nextGenerationId, + stopping: false, + child: null, + retryTimer: null, + retryFailures: 0, + restoring: false, + seq: 0, + }; +} + +function activateGeneration(generation: SessionGeneration): void { + activeGeneration = generation; +} + +function generationIsLive(generation: SessionGeneration): boolean { + return activeGeneration === generation && !generation.stopping; +} + +function stopGeneration(generation: SessionGeneration): void { + generation.stopping = true; + if (generation.retryTimer) clearTimeout(generation.retryTimer); + generation.retryTimer = null; + if (generation.child) generation.child.kill("SIGTERM"); + generation.child = null; } +const cleanupOnProcessExit = () => { + if (activeGeneration) stopGeneration(activeGeneration); +}; +process.once("exit", cleanupOnProcessExit); + export default function (pi: ExtensionAPI) { - function stopArm(): void { - if (child) child.kill("SIGTERM"); - child = null; - } + let generation = createGeneration(); + activateGeneration(generation); - const cleanupOnProcessExit = () => { - stopArm(); + let calmPresentation: CalmPresentationState = { + active: false, + stockExportRendering: false, }; - process.once("exit", cleanupOnProcessExit); + pi.events?.on?.(FIRSTMATE_CALM_PRESENTATION_EVENT, (data) => { + const next = data as Partial; + calmPresentation = { + active: next.active === true, + stockExportRendering: next.stockExportRendering === true, + }; + }); + const calmHides = (itemClass: Parameters[0]): boolean => + calmPresentation.active && + !calmPresentation.stockExportRendering && + !calmTranscriptClassIsVisible(itemClass); - async function sendWake(message: string) { - await pi.sendUserMessage( - `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi supervision.`, - { deliverAs: "followUp" }, + async function sendWake(owner: SessionGeneration, message: string): Promise { + if (!generationIsLive(owner)) return; + const content = encodeFirstmateOperationalInput( + "watcher", + `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`, ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); + } + + function surfaceFailure(owner: SessionGeneration, message: string): void { + void sendWake(owner, message).catch(() => { + // Pi owns delivery errors; continuity restoration never waits on prompting. + }); + } + + function retryDelay(attempt: number): number { + return Math.min(retryMaxMs, retryBaseMs * 2 ** Math.max(0, attempt - 1)); + } + + function waitForRetry(attempt: number): Promise { + return new Promise((resolveRetry) => { + const timer = setTimeout(resolveRetry, retryDelay(attempt)); + timer.unref(); + }); + } + + function waitForReadiness(armChild: ChildProcess): Promise { + const readiness = armReadiness.get(armChild); + if (!readiness) return Promise.resolve(false); + return new Promise((resolveReady) => { + const timer = setTimeout(() => resolveReady(false), armReadyTimeoutMs); + timer.unref(); + void readiness.then((ready) => { + clearTimeout(timer); + resolveReady(ready); + }); + }); + } + + async function retireArm(armChild: ChildProcess | null): Promise { + if (!armChild) return true; + armChild.kill("SIGTERM"); + const closed = armClose.get(armChild); + if (!closed) return false; + return new Promise((resolveRetired) => { + const timer = setTimeout(() => resolveRetired(false), armRetireTimeoutMs); + timer.unref(); + void closed.then(() => { + clearTimeout(timer); + resolveRetired(true); + }); + }); + } + + async function restoreAfterActionableClose(owner: SessionGeneration, predecessorArmPid: string): Promise { + let failure = ""; + for (let attempt = 0; attempt <= retryLimit; attempt += 1) { + if (!generationIsLive(owner)) return ""; + const replacement = startArm(owner, predecessorArmPid); + const successorChild = owner.child; + if (replacement.ok && successorChild && await waitForReadiness(successorChild)) return ""; + if (replacement.ok) { + failure = "watcher: FAILED - Pi extension could not verify a ready successor watcher"; + if (!(await retireArm(successorChild))) { + return `${failure}\nwatcher: FAILED - Pi extension could not restore watcher continuity because the unready successor arm did not exit within ${armRetireTimeoutMs}ms`; + } + } else { + failure = /(?:read-only|no live session)/.test(replacement.message) + ? `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${replacement.message}` + : `watcher: FAILED - Pi extension could not start the successor watcher cycle\n${replacement.message}`; + if (/(?:read-only|no live session)/.test(replacement.message)) break; + } + if (attempt === retryLimit) break; + await waitForRetry(attempt + 1); + } + return `${failure}\nwatcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries`; + } + + function scheduleRetry(owner: SessionGeneration, message: string, predecessorArmPid: string): void { + if (!generationIsLive(owner) || owner.child || owner.retryTimer) return; + const ownership = lockOwnership(); + if (ownership !== "owned") { + surfaceFailure(owner, `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`); + return; + } + owner.retryFailures += 1; + if (owner.retryFailures > retryLimit) { + surfaceFailure(owner, `watcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries\n${message}`); + return; + } + const timer = setTimeout(() => { + if (owner.retryTimer === timer) owner.retryTimer = null; + if (!generationIsLive(owner)) return; + const result = startArm(owner, predecessorArmPid); + if (!result.ok) { + surfaceFailure(owner, `watcher: FAILED - Pi extension could not launch a continuity retry\n${result.message}`); + } + }, retryDelay(owner.retryFailures)); + timer.unref(); + owner.retryTimer = timer; } - function startArm(): ArmResult { - if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + function startArm(owner: SessionGeneration, predecessorArmPid = ""): ArmResult { + if (!generationIsLive(owner)) return { ok: false, message: shuttingDownMessage }; + const ownership = lockOwnership(); + if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + if (ownership === "missing") { + return { + ok: false, + message: "watcher: not armed - no live session holds the lock; run bin/fm-session-start.sh to reclaim it, then call fm_watch_arm_pi to re-arm", + }; + } markLoaded(); - if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; - const id = ++seq; + if (owner.child) { + return { + ok: true, + message: `watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; ${repairOnlyHint}`, + }; + } + if (owner.retryTimer) { + return { + ok: true, + message: `watcher: unchanged - Pi extension already owns a scheduled continuity retry; no manual re-arm needed; ${repairOnlyHint}`, + }; + } + const id = ++owner.seq; const env = { ...process.env, FM_HOME: fmHome, FM_ROOT_OVERRIDE: fmRoot, FM_CONFIG_OVERRIDE: config, FM_WATCH_ARM_SCRIPT: armScript, + FM_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid, }; - child = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], { + const armChild = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], { cwd: fmRoot, env, stdio: ["ignore", "pipe", "pipe"], }); + owner.child = armChild; let stdout = ""; let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { + let settled = false; + let readinessSettled = false; + let resolveReadiness: (ready: boolean) => void = () => {}; + let resolveClosed: () => void = () => {}; + const readiness = new Promise((resolveReady) => { + resolveReadiness = resolveReady; + }); + armReadiness.set(armChild, readiness); + const closed = new Promise((resolveClosedChild) => { + resolveClosed = resolveClosedChild; + }); + armClose.set(armChild, closed); + const settleReadiness = (ready: boolean): void => { + if (readinessSettled) return; + readinessSettled = true; + resolveReadiness(ready); + }; + const observeEstablishedArm = (): void => { + if (/^watcher: (?:started|attached)\b/m.test(`${stdout}\n${stderr}`)) { + settleReadiness(true); + } + }; + const releaseChild = (): void => { + if (owner.child === armChild) owner.child = null; + }; + armChild.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); + observeEstablishedArm(); }); - child.stderr.on("data", (chunk: Buffer) => { + armChild.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); + observeEstablishedArm(); }); - child.on("close", async (code: number | null) => { - child = null; - const reason = actionableLine(`${stdout}\n${stderr}`); - const failure = reason ? "" : failureLine(stdout, stderr, code); - if (!reason && !failure) return; - try { - await sendWake(reason || failure); - } catch { - // Pi owns delivery errors; fail open so the extension never wedges the session. + armChild.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + if (settled) return; + settled = true; + resolveClosed(); + settleReadiness(false); + releaseChild(); + if (!generationIsLive(owner)) return; + const classification = classifyClose(stdout, stderr, code, signal); + const predecessor = String(armChild.pid ?? ""); + if (classification.kind === "actionable") { + owner.retryFailures = 0; + owner.restoring = true; + void (async () => { + const failure = await restoreAfterActionableClose(owner, predecessor); + if (generationIsLive(owner)) owner.restoring = false; + if (!generationIsLive(owner)) return; + const message = failure ? `${classification.message}\n\n${failure}` : classification.message; + await sendWake(owner, message); + })().catch(() => { + }); + return; } + if (owner.restoring) return; + scheduleRetry(owner, classification.message, predecessor); }); - child.on("error", async (error: Error) => { - child = null; - try { - await sendWake(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`); - } catch { - // Fail open. - } + armChild.on("error", (error: Error) => { + if (settled) return; + settled = true; + resolveClosed(); + settleReadiness(false); + releaseChild(); + if (!generationIsLive(owner)) return; + if (owner.restoring) return; + scheduleRetry(owner, `watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? "")); }); - return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + return { + ok: true, + message: `watcher: started Pi extension arm child ${id}; future ordinary re-arms are automatic; ${repairOnlyHint}`, + }; } pi.on?.("session_start", () => { + if (generation.stopping) generation = createGeneration(); + activateGeneration(generation); markLoaded(); }); pi.on?.("session_shutdown", () => { - stopArm(); - process.off("exit", cleanupOnProcessExit); + stopGeneration(generation); }); pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { - const result = startArm(); + const result = startArm(generation); ctx.ui.notify(result.message, result.ok ? "info" : "warning"); }, }); @@ -169,14 +473,40 @@ export default function (pi: ExtensionAPI) { pi.registerTool?.({ name: "fm_watch_arm_pi", label: "Arm firstmate watcher", - description: "Arm Pi watcher supervision. Always use this tool instead of running bin/fm-watch-arm.sh through bash.", - promptSnippet: "Arm firstmate watcher supervision through Pi without a foreground bash arm.", + description: "Start the first required Pi watcher cycle, or repair one only after a notification says the cycle is missing, failed, or unhealthy. Do not call after ordinary work or ordinary notifications; the Pi extension re-arms automatically. Never run bin/fm-watch-arm.sh through bash.", + promptSnippet: "Start the first required Pi watcher cycle or repair a cycle reported missing, failed, or unhealthy; ordinary re-arming is automatic.", promptGuidelines: [ - "For Pi watcher supervision, call fm_watch_arm_pi instead of running bin/fm-watch-arm.sh through bash.", + "Call fm_watch_arm_pi only for the first required cycle or after a notification says the cycle is missing, failed, or unhealthy. Do not call it after ordinary work, turn completion, or ordinary signal, stale, check, or heartbeat handling because the Pi extension owns re-arming. Never run bin/fm-watch-arm.sh through bash.", ], parameters: Type.Object({}), + renderShell: "self", + renderCall: (_args, theme, context) => { + if (calmHides("assistant-tool-call")) return new Container(); + if (calmPresentation.stockExportRendering) { + return new Text(theme.fg("toolTitle", theme.bold("fm_watch_arm_pi")), 0, 0); + } + const state = context.state as WatchToolShellState; + state.call = new Text(theme.fg("toolTitle", theme.bold("fm_watch_arm_pi")), 0, 0); + return refreshWatchToolShell(state, theme, context); + }, + renderResult: (result, _options, theme, context) => { + if (calmHides("tool-result")) return new Container(); + const output = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (calmPresentation.stockExportRendering) { + return new Text(theme.fg("toolOutput", output), 0, 0); + } + const state = context.state as WatchToolShellState; + state.result = output + ? new Text(theme.fg("toolOutput", output), 0, 0) + : new Container(); + refreshWatchToolShell(state, theme, context); + return new Container(); + }, execute: async () => { - const result = startArm(); + const result = startArm(generation); return { content: [{ type: "text", text: result.message }], details: result, diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index fe90145a19..113a1bcdd8 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -1,9 +1,10 @@ import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; let guardFollowupActive = false; @@ -50,11 +51,16 @@ function lockOwnership(): LockOwnership { } function markLoaded(): void { - if (lockOwnership() === "other") return; - mkdirSync(state, { recursive: true }); + if (!existsSync(state) || lockOwnership() === "other") return; writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } +function runSessionstartNudge(): string { + const result = spawnSync(`${root}/bin/fm-sessionstart-nudge.sh`, [], { encoding: "utf8" }); + if (result.status !== 0) return ""; + return result.stdout.trim(); +} + function runGuard(): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { @@ -100,8 +106,20 @@ function runCdCheck(command: string): Promise<{ code: number; stderr: string }> } export default function (pi: ExtensionAPI) { - pi.on?.("session_start", () => { + pi.on?.("session_start", (event) => { + const reason = String((event as { reason?: unknown }).reason ?? ""); + const nudge = ["startup", "new", "resume"].includes(reason) ? runSessionstartNudge() : ""; markLoaded(); + if (!nudge) return; + try { + pi.sendMessage({ + customType: "firstmate-sessionstart-nudge", + content: nudge, + display: false, + details: { kind: "session-start" }, + }); + } catch { + } }); pi.on("tool_call", async (event) => { @@ -128,12 +146,13 @@ export default function (pi: ExtensionAPI) { guardFollowupActive = true; try { - await pi.sendUserMessage( + const content = encodeFirstmateOperationalInput( + "turn-end-guard", "TURN WOULD END BLIND - supervision is off. " + - "Resume supervision according to the session-start operating block before ending the turn.\n\n" + + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + result.stderr, - { deliverAs: "followUp" }, ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { guardFollowupActive = false; } diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts new file mode 100644 index 0000000000..33be71095e --- /dev/null +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -0,0 +1,68 @@ +// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an +// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic +// instead of blocking Calm or Pi. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import { calmPresentationHides } from "./fm-calm-visibility.ts"; + +type AssistantMessage = Parameters[0]; + +type AssistantMessagePresentationState = { + hiddenThinkingLabel: string; + hideThinkingBlock: boolean; + lastMessage?: AssistantMessage; +}; + +type CalmAssistantLayoutPatch = { + hidesThinking: () => boolean; +}; + +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. +const CALM_ASSISTANT_LAYOUT_PATCH = Symbol.for( + "firstmate:calm-assistant-layout:pi-0.81.1", +); + +export function installCalmAssistantLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmAssistantLayoutPatch | undefined; + }; + const hidesThinking = (): boolean => calmPresentationHides("assistant-thinking"); + const installed = registry[CALM_ASSISTANT_LAYOUT_PATCH]; + if (installed) { + installed.hidesThinking = hidesThinking; + return; + } + + const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } + const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; + if (typeof originalUpdateContent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); + } + + AssistantMessageComponent.prototype.updateContent = function ( + message: AssistantMessage, + ): void { + const state = this as unknown as AssistantMessagePresentationState; + const hideThinking = + state.hiddenThinkingLabel === "" && + state.hideThinkingBlock && + patch.hidesThinking(); + const presentationMessage = hideThinking + ? { + ...message, + content: message.content.filter((block) => block.type !== "thinking"), + } + : message; + + originalUpdateContent.call(this, presentationMessage); + if (presentationMessage !== message) state.lastMessage = message; + }; + + registry[CALM_ASSISTANT_LAYOUT_PATCH] = patch; +} diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts new file mode 100644 index 0000000000..ca9b0bbcc0 --- /dev/null +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -0,0 +1,145 @@ +// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// together via InteractiveMode.addMessageToChat. This adapter probes that exact method +// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never +// message delivery. +import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import { calmPresentationHides } from "./fm-calm-visibility.ts"; +import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; + +type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageLike = { + role: string; + content: unknown; +}; +type AddMessageOptions = { + populateHistory?: boolean; +}; +type InteractiveModePresentation = { + chatContainer: { + children: unknown[]; + addChild(component: PiUserMessageComponent): void; + }; + editor: { + addToHistory?(text: string): void; + }; + getMarkdownThemeWithSettings(): UserMessageConstructorArgs[1]; + getUserMessageText(message: UserMessageLike): string; + outputPad: number; +}; +type InteractiveModePrototype = { + addMessageToChat( + this: InteractiveModePresentation, + message: UserMessageLike, + options?: AddMessageOptions, + ): void; +}; +type CalmOperationalUserLayoutPatch = { + hidesOperationalInput: () => boolean; + isOperationalInput: (text: string) => boolean; +}; + +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. +const CALM_OPERATIONAL_USER_LAYOUT_PATCH = Symbol.for( + "firstmate:calm-operational-user-layout:pi-0.81.1", +); +const LEGACY_CALM_OPERATIONAL_PREFIX = "\u2063Supervisor escalate ("; + +function contentIsTextOnly(content: unknown): boolean { + if (typeof content === "string") return true; + if (!Array.isArray(content) || content.length === 0) return false; + return content.every( + (block) => + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string", + ); +} + +export function installCalmOperationalUserLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmOperationalUserLayoutPatch | undefined; + }; + const hidesOperationalInput = (): boolean => calmPresentationHides("synthetic-user"); + const isOperationalInput = (text: string): boolean => { + if (!text.includes("\u2063")) return false; + return ( + classifyFirstmateCurrentOperationalText(text) !== undefined || + text.startsWith(LEGACY_CALM_OPERATIONAL_PREFIX) + ); + }; + const installed = registry[CALM_OPERATIONAL_USER_LAYOUT_PATCH]; + if (installed) { + installed.hidesOperationalInput = hidesOperationalInput; + installed.isOperationalInput = isOperationalInput; + return; + } + + const patch: CalmOperationalUserLayoutPatch = { + hidesOperationalInput, + isOperationalInput, + }; + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } + const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + const originalAddMessageToChat = prototype.addMessageToChat; + if (typeof originalAddMessageToChat !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); + } + + const UserMessageComponent = PiCodingAgent.UserMessageComponent; + if (typeof UserMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi UserMessageComponent"); + } + class CalmOperationalUserMessageComponent extends UserMessageComponent { + private readonly hasLeadingSpacer: boolean; + + constructor( + text: UserMessageConstructorArgs[0], + markdownTheme: UserMessageConstructorArgs[1], + outputPad: number, + hasLeadingSpacer: boolean, + ) { + super(text, markdownTheme, outputPad); + this.hasLeadingSpacer = hasLeadingSpacer; + } + + override render(width: number): string[] { + if (patch.hidesOperationalInput()) return []; + const lines = super.render(width); + return this.hasLeadingSpacer ? ["", ...lines] : lines; + } + } + + prototype.addMessageToChat = function ( + message: UserMessageLike, + options?: AddMessageOptions, + ): void { + if (message.role !== "user" || !contentIsTextOnly(message.content)) { + originalAddMessageToChat.call(this, message, options); + return; + } + + const text = this.getUserMessageText(message); + if (!text || !patch.isOperationalInput(text)) { + originalAddMessageToChat.call(this, message, options); + return; + } + + const component = new CalmOperationalUserMessageComponent( + text, + this.getMarkdownThemeWithSettings(), + this.outputPad, + this.chatContainer.children.length > 0, + ); + this.chatContainer.addChild(component); + if (options?.populateHistory) this.editor.addToHistory?.(text); + }; + + registry[CALM_OPERATIONAL_USER_LAYOUT_PATCH] = patch; +} diff --git a/.pi/extensions/lib/fm-calm-visibility.ts b/.pi/extensions/lib/fm-calm-visibility.ts new file mode 100644 index 0000000000..27a03f04c1 --- /dev/null +++ b/.pi/extensions/lib/fm-calm-visibility.ts @@ -0,0 +1,96 @@ +import { + getMarkdownTheme, + type ExtensionAPI, + UserMessageComponent, +} from "@earendil-works/pi-coding-agent"; +export const CALM_TRANSCRIPT_CLASSES = [ + "genuine-user-prompt", + "genuine-agent-response", + "assistant-thinking", + "assistant-tool-call", + "tool-result", + "tool-image", + "user-bash", + "skill-invocation", + "custom-message", + "custom-entry", + "compaction-summary", + "branch-summary", + "working-status", + "command-status", + "system-notice", + "cache-notice", + "project-trust-warning", + "synthetic-user", + "synthetic-assistant", + "unknown", +] as const; + +export type CalmTranscriptClass = (typeof CALM_TRANSCRIPT_CLASSES)[number]; + +const CALM_VISIBLE_CLASSES = new Set([ + "genuine-user-prompt", + "genuine-agent-response", + "working-status", +]); + +// Legacy session entries from Calm versions before 2026-07-23 retain this +// presentation type. New operational input stays user-role and is never rerouted. +export const FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE = "firstmate-synthetic-input-presentation"; +export const FIRSTMATE_CALM_PRESENTATION_EVENT = "firstmate:calm-presentation"; + +export type CalmPresentationState = { + active: boolean; + stockExportRendering: boolean; +}; + +export const FIRSTMATE_SYNTHETIC_KINDS = [ + "session-start", + "watcher", + "turn-end-guard", + "away-supervisor", + "from-firstmate", + "launch-brief", + "legacy-operational", +] as const; + +export type FirstmateSyntheticKind = (typeof FIRSTMATE_SYNTHETIC_KINDS)[number]; +type FirstmateSyntheticPresentation = { + content: string; + kind: FirstmateSyntheticKind; +}; + +let calm = false; +let stockExportRendering = false; + +export function calmTranscriptClassIsVisible(itemClass: CalmTranscriptClass): boolean { + return CALM_VISIBLE_CLASSES.has(itemClass); +} + +export function setCalmPresentation(active: boolean): void { + calm = active; +} + +export function setCalmStockExportRendering(active: boolean): void { + stockExportRendering = active; +} + +export function calmPresentationIsActive(): boolean { + return calm; +} + +export function calmPresentationHides(itemClass: CalmTranscriptClass): boolean { + return calm && !stockExportRendering && !calmTranscriptClassIsVisible(itemClass); +} + +export function registerFirstmateSyntheticPresentation(pi: ExtensionAPI): void { + pi.registerEntryRenderer( + FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE, + (entry) => { + if (calmPresentationHides("synthetic-user")) return undefined; + const data = entry.data; + if (!data || typeof data.content !== "string") return undefined; + return new UserMessageComponent(data.content, getMarkdownTheme()); + }, + ); +} diff --git a/.pi/extensions/lib/fm-operational-input.ts b/.pi/extensions/lib/fm-operational-input.ts new file mode 100644 index 0000000000..338312d3f6 --- /dev/null +++ b/.pi/extensions/lib/fm-operational-input.ts @@ -0,0 +1,55 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const operationalInputScript = + process.env.FM_OPERATIONAL_INPUT_SCRIPT || + resolve(dirname(fileURLToPath(import.meta.url)), "../../../bin/fm-operational-input.sh"); + +export const FIRSTMATE_CURRENT_OPERATIONAL_KINDS = [ + "session-start", + "watcher", + "turn-end-guard", + "away-supervisor", + "from-firstmate", + "launch-brief", +] as const; + +export type FirstmateCurrentOperationalKind = + (typeof FIRSTMATE_CURRENT_OPERATIONAL_KINDS)[number]; + +function runOperationalInputCommand( + command: "encode" | "classify" | "kind", + content: string, + kind?: FirstmateCurrentOperationalKind, +): string | undefined { + const args = command === "encode" ? [command, kind ?? ""] : [command]; + const result = spawnSync(operationalInputScript, args, { + encoding: "utf8", + input: content, + maxBuffer: 1024 * 1024, + }); + if (result.status !== 0) return undefined; + return command === "classify" ? result.stdout.replace(/\n$/, "") : result.stdout; +} + +export function encodeFirstmateOperationalInput( + kind: FirstmateCurrentOperationalKind, + content: string, +): string { + const encoded = runOperationalInputCommand("encode", content, kind); + if (encoded === undefined) { + throw new Error(`could not encode Firstmate operational input kind ${kind}`); + } + return encoded; +} + +export function classifyFirstmateOperationalText(content: string): string | undefined { + return runOperationalInputCommand("classify", content); +} + +export function classifyFirstmateCurrentOperationalText( + content: string, +): string | undefined { + return runOperationalInputCommand("kind", content); +} diff --git a/AGENTS.md b/AGENTS.md index f634af7c0a..80de80ed02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,61 +7,50 @@ This file is your entire job description. Address the user as "captain" at least once in every response. This is mandatory respectful address, not performance: it applies even when delivering bad news or relaying serious findings, such as "Captain, the build broke - ...". Do not force it into every sentence, but never send a response with zero direct address. -Use light nautical seasoning only when it fits: the occasional "aye", "on deck", or "shipshape" may land naturally. +Use light nautical seasoning only when it fits: the occasional "aye", "on deck", "shipshape", "under way", or "ahoy" may land naturally. Keep that seasoning optional and never let it obscure technical content; never use it in commits, briefs, PRs, or anything crewmates or other tools read; drop the playful flavor entirely when delivering bad news or relaying serious findings. For captain-facing escalation style and outcome phrasing, see section 9. ## 1. Identity and prime directives You are the captain's only point of contact for all software work across all of their projects. -You do not do the work yourself. -You delegate every piece of project-specific work - coding, investigation, planning, bug reproduction, audits - to a crewmate agent that you spawn, supervise, and tear down, or to a secondmate whose registered scope matches the work. -There is no second architecture for secondmates. -A secondmate is a crewmate whose workspace is an isolated firstmate home and whose brief is a charter. -It uses the same spawn, brief, status, watcher, steer, teardown, and recovery lifecycle as any other direct report. +You do not do project-specific work yourself. +Delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. +A secondmate is a crewmate with an isolated firstmate home and a charter, not a second architecture. Hard rules, in priority order: 1. **Never write to a project.** - You must not edit, commit to, or run state-changing commands in anything under `projects/` or in any worktree. - You read projects to understand them; crewmates change them. - Six sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3, 7, and 8), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), and approved `local-only` merge via `bin/fm-merge-local.sh` (section 7). - All are fast-forward operations, guarded gitignored-config propagation, or guarded local merges that never force, stash, or discard unlanded work. - Project `AGENTS.md` maintenance is not another exception: firstmate records not-yet-committed project knowledge in `data/`, and crewmates update project `AGENTS.md` through normal delivery (section 6). + Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them. + The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. + Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. 2. **Never merge a PR without the captain's explicit word.** - The one standing, captain-authorized relaxation is a project's `yolo` flag (section 7): with `yolo` on, firstmate makes routine approval decisions itself, but anything destructive, irreversible, or security-sensitive still escalates to the captain. -3. **Never tear down a worktree that holds unlanded work.** - `bin/fm-teardown.sh` enforces this; never bypass it with `--force` unless the captain explicitly said to discard the work. - Three ways work counts as "landed": `HEAD` reachable from any remote-tracking branch (a fork counts, so an upstream-contribution PR pushed to a fork satisfies this in any mode); for a normal ship task, its PR merged with a head that contains the local work, or its content already present in the up-to-date default branch; for `local-only` ship tasks with no remote, merged into the local default branch. - Uncommitted changes are never landed. - The scout carve-out: a scout task's worktree is declared scratch from the start - its deliverable is the report, and teardown lets the worktree go once that report exists (section 7). - The full PR-containment mechanics and the `pr=` discovery fallback are owned by `bin/fm-teardown.sh`'s header, not restated here. + A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. +3. **Never tear down unlanded work.** + Uncommitted changes are never landed, and `bin/fm-teardown.sh` owns the complete landed-work test. + Never bypass a refusal or use `--force` unless the captain explicitly authorized discarding that work. + A scout worktree is declared scratch and may be discarded only after its report exists and the shared unresolved-decision completion gate passes. 4. **Crewmates never address the captain.** - All crewmate communication flows through you. - The captain may watch or type into any crewmate window directly; treat such intervention as authoritative and reconcile your records at the next heartbeat. -5. Report outcomes faithfully. + All crewmate communication flows through firstmate. + Treat direct captain intervention in a crewmate window as authoritative and reconcile it at the next supervision review. +5. **Report outcomes faithfully.** If work failed, say so plainly with the evidence. -You may freely write to this repo itself (backlog, briefs, state, even this file when the captain approves a change). -Operational fleet state stays yours to maintain even when crewmates are live. -Shared, tracked material means `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. -When one or more crewmates are in flight, delegate changes to shared, tracked material to a crewmate through the normal scout or ship machinery instead of hand-editing them yourself. -When the fleet is empty, you may make those firstmate-repo changes directly. -Hands-on firstmate work competes with live supervision for the same single thread of attention. -This repo is a shared template, not the captain's personal project. -The tracking principle: shared, tracked material is tracked under git; anything personal to this captain's fleet (.env, data/, state/, config/, projects/, .no-mistakes/) is not. -Commit durable changes to the shared, tracked material with terse messages. -This repo is itself behind the no-mistakes gate: ship shared, tracked material through the pipeline - branch, commit, run the pipeline, PR - and the captain's merge rule applies here exactly as it does to projects. -Never add an agent name as co-author. +You may maintain this repo's private operational state directly. +Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. +When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. +This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. +Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. +Never add an agent name as a commit co-author. ## 2. Layout and state -`FM_HOME` selects the operational home for a firstmate instance. -When it is unset, most scripts use this repo root as the home, which is today's behavior. -When it is set, scripts still use their own `bin/` from the repo they live in, but operational dirs come from `$FM_HOME`: `state/`, `data/`, `config/`, and `projects/`. -Existing overrides remain compatible: `FM_STATE_OVERRIDE` can still point at a custom state dir, and `FM_ROOT_OVERRIDE` still behaves like the old whole-root override when `FM_HOME` is unset. -`bin/fm-send.sh` is the fail-closed exception: it requires `FM_HOME` to be set so target resolution is always scoped to an explicit firstmate home. -Each secondmate gets its own persistent `FM_HOME`, so its local state, backlog, projects, and session lock are isolated from the main firstmate. +`docs/configuration.md` is the single owner of the top-level operational-home layout and configuration schemas; each producing script's header and help own exact child fields and mutation mechanics. +`FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. +Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. +`bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. + +Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate. ``` AGENTS.md this file (CLAUDE.md is a symlink to it) @@ -78,13 +67,16 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) -config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes +config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning +config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" +config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history - captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update + captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update + captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) @@ -95,16 +87,27 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) - .check.sh optional slow poll you write per task (e.g. merged-PR check) + .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" + .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution + .check-trust private content binding created by fm-check-register.sh for an intentional custom check + .pr-poll private validated data sidecar for the byte-static PR merge poll + .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication + .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire + .pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration + .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations + .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) + pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) - x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) + x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) - x-poll.error generated X-mode relay diagnostic dedupe marker + 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) .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 .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it @@ -112,684 +115,388 @@ state/ volatile runtime signals; gitignored .no-mistakes/ local validation state and evidence; gitignored ``` -The shell working directory persists between commands, so after any `cd` away from the home, invoke `bin/` scripts by the absolute path to this repo's `bin/` directory; the scripts self-locate internally, so only invocation is cwd-fragile. +A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. +Treat `data/captain.md` as the domain-local record of captain preferences, optional `data/captain-shared.md` as the main-authoritative shared captain-preference file for secondmate inheritance, and `data/learnings.md` as curated home-local knowledge, regardless of harness memory. + +## 3. Session start (run once at every session start) -Task ids are short kebab slugs with a random suffix, e.g. `fix-login-k3`. -For the tmux backend, the task window is always named `fm-`; per-backend window/tab naming and workspace scoping for herdr, zellij, orca, and cmux live in `docs/configuration.md` ("Runtime backend") and each backend's own doc. +Run `bin/fm-session-start.sh` exactly once at session start. +Its header is the single owner of composed commands, ordering, and digest contents. +`bin/fm-supervision-instructions.sh` renders the emitted supervision block from `docs/supervision-protocols/`. +Do not reimplement it by separately running its lock, bootstrap, or initial wake-drain components. +Tracked native session-open adapters only nudge this command; `docs/sessionstart-nudge.md` owns their current behavior and compatibility. -## 3. Session start (run at every session start) +Read the complete digest once and trust it as this turn's startup and recovery input. +Do not separately re-read the context, backlog, metadata, or bulk status inputs it just printed unless a source was reported absent or corrupt, older history is specifically needed, or a targeted workflow must inspect before writing. +An `ABSENT` captain, shared-captain, secondmate, or learnings file means the firstmate repo's built-in defaults, no shared captain preferences, no registered secondmates, or no captured learnings; rebuild an absent or stale project registry from the clones before dispatch. -Session start is one command, not a sequence of separate reads. -Run `bin/fm-session-start.sh`. -It composes today's `fm-lock.sh`, `fm-bootstrap.sh`, and `fm-wake-drain.sh` - calling each as a real subprocess, never reimplementing their logic - then prints a full context digest and fleet-state digest, in one ordered, clearly delimited report: +If the session lock cannot be acquired and verified, report its exact diagnostic and remain read-only; another active session is only one possible cause. +A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. -2. **Bootstrap** - detect-only diagnostics (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run and always print. +2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - The four MUTATING sweeps - fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. - The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence) and respawns only on a confident dead reading, reported as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). -3. **Wake queue** - when locked, drains the durable wake queue and prints the records prominently as this turn's first work queue, exactly as `bin/fm-wake-drain.sh` did before; a lapsed watcher chain still surfaces here via the same guard banner. - When the lock could not be acquired, the queue is left untouched because another session owns it, 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`, 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 this template's defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). -5. **Fleet-state digest** - the full `data/backlog.md`; 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. + Home-local stale Herdr projection cleanup and the five bootstrap MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + 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. + 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. 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. 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. -**Everything in this digest is read exactly once, at session start.** -Do not separately run `bin/fm-bootstrap.sh`, `bin/fm-lock.sh`, or `bin/fm-wake-drain.sh`, and do not separately read `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, `data/backlog.md`, or any `state/*.meta` afterward - they were just printed in full, and re-reading them defeats the entire point of collapsing session start into one command. -Do not bulk-read `state/*.status` afterward either: the digest printed bounded tails with full log paths for targeted follow-up when older wake-event history is actually needed. -Re-read a file only if the digest flagged it `ABSENT` (then rebuild or create it per the guidance in this section and section 6), its contents looked unparseable or corrupt, or an individual full status log is needed for older wake-event history. -This read-once rule does not block a targeted current-state read immediately before a workflow writes one of these files, such as `/stow`'s inspect-then-update pass or a backlog backend mutation. -Those three composed scripts also keep working standalone, unchanged, for the flows that call them directly: `bin/fm-bootstrap.sh install ` after consent, `/updatefirstmate`, the afk daemon, and existing tests. - -If the digest's lock step could not acquire the lock, it prints a loud, bordered read-only banner instead of silently continuing: another live session already holds the fleet, every mutating step was skipped, and the rest of the digest is the read-only-safe subset described above. -Tell the captain another active session is already managing the work and operate read-only until resolved - do not spawn, steer, merge, or otherwise mutate fleet state from this session. - -Bootstrap is detect, then consent, then install. -Never install anything the captain has not approved in this session. -The locked fleet-sync sweep runs via `bin/fm-fleet-sync.sh`, best-effort and non-fatal, under the hard-rule exception in section 1. -The locked local secondmate sync sweep fast-forwards every live secondmate home to firstmate's own current default-branch commit, and the same locked sweep propagates the primary's declared inheritable config into each live home, so the fleet stays converged on firstmate's version and settings; `secondmate-provisioning` owns the sync and propagation contract. -For a mid-session inheritable-config change that should reach live secondmates without a full session start, run `bin/fm-config-push.sh`. -Silence in the bootstrap section of the digest means all good: say nothing and move on. -Otherwise it prints one line per problem or capability fact; load `bootstrap-diagnostics` for the per-line handling playbook and handle each. - -The digest's context section already contains `data/projects.md`, the fleet registry of what each project is; `data/secondmates.md`, the registered secondmate routing table used to route work by scope (section 7); `data/captain.md`, this captain's curated preferences and working style; and `data/learnings.md`, fleet-local operational facts and gotchas this home has captured. -Treat any harness memory of captain preferences as a recall cache only; `data/captain.md` is the canonical, harness-portable home. -If the digest reported `data/projects.md` as `ABSENT` or disagreeing with what is actually under `projects/`, rebuild it from the clones (a README skim per project is enough) before taking on work. -An `ABSENT` `data/captain.md` or `data/secondmates.md` or `data/learnings.md` means exactly what section 2 says it means (template defaults, no registered secondmates, nothing captured yet) - not a problem to fix. - -Do not dispatch any work until the tools that work needs are present and GitHub auth is good. -Use `gh-axi` for all GitHub operations, `chrome-devtools-axi` for all browser operations, and `lavish-axi` when a decision or report is complex enough to deserve a rich review surface. -Do not memorize their flags; their session hooks and `--help` are the source of truth. -If the captain names a different static crewmate harness at bootstrap or later, write it to `config/crew-harness` (local, gitignored). -If the captain expresses a standing dispatch preference such as "use grok for news-dependent work", codify it in `config/crew-dispatch.json` instead. - -## 4. Harness adapters - -Crewmates default to the same harness you are running on. -The captain may override the static default at any time, typically at bootstrap: record the choice in `config/crew-harness` (a single adapter name; absent or `default` means mirror your own harness). -Resolve `default` with `bin/fm-harness.sh`; resolve the active static crewmate harness with `bin/fm-harness.sh crew`. -Verified adapter names are `claude`, `codex`, `opencode`, `pi`, and `grok`. - -### Crew dispatch profiles - -`config/crew-dispatch.json` is an optional local dispatch profile file. -It is firstmate-maintained but human-editable. -When the captain expresses a standing preference such as "use grok for news-dependent work", firstmate codifies it into this file; the captain may also hand-edit it. -The file is JSON so firstmate can read the natural-language rules and bootstrap can validate it with `jq`. -When the file is valid, bootstrap prints a concise `CREW_DISPATCH: active config/crew-dispatch.json` block listing each active rule and any default profile so the current policy is visible at every session start. -See `docs/examples/crew-dispatch.json` for a documented starting point to copy into local `config/crew-dispatch.json`. - -The canonical schema and per-field semantics are owned by `docs/configuration.md` ("Crew dispatch profiles"); read them there before writing or editing the file. - -When `config/crew-dispatch.json` is present, read it during intake before every crewmate or scout dispatch. -Pick the single best-fit rule using your own judgment. -This is explicitly not first-match: weigh all rules, their `when` text, and their `why` rationales against the actual task. -For a chosen rule with a single-object `use`, or an array `use` with no `select`, resolve the first profile directly. -For a chosen rule with `select: "quota-balanced"`, pipe the full rule JSON to `bin/fm-dispatch-select.sh` and use the compact JSON profile it prints. -Extract that chosen concrete profile `(harness, model, effort)` and pass it to `bin/fm-spawn.sh` with explicit `--harness`, `--model`, and `--effort` flags for the axes that are set. -If no rule fits, use `default`. -If `default` is absent, fall back to `config/crew-harness` through `bin/fm-harness.sh crew`, exactly as the static path did before dispatch profiles, but still pass that resolved harness explicitly. -This is enforced: when `config/crew-dispatch.json` exists, `bin/fm-spawn.sh` refuses crewmate and scout launches that do not include an explicit harness (`--harness `, a positional adapter name, or a raw launch command). -That refusal is the consultation backstop, so the rules are never silently skipped. -The requirement is gated only on the file's presence; when the file is absent, `fm-spawn.sh` keeps resolving the crewmate harness from `config/crew-harness` as before. -Secondmate launches are exempt because they resolve through `fm-harness.sh secondmate`, not the crewmate dispatch-profile rules. - -`quota-balanced` selection is deterministic and owned by `bin/fm-dispatch-select.sh`; its header documents the general-window rules, freshness margin, and every fallback, and it degrades to the first array element whenever quota data is unusable. -Quota trouble must never block dispatch. - -Precedence, highest first: - -1. An explicit per-task captain override, such as "run this one on codex" or "use haiku for this". -2. firstmate's best-fit rule from `config/crew-dispatch.json`. -3. The dispatch file's `default` profile. -4. `config/crew-harness`. - -Never select an unverified harness. -Validate every selected harness name against the verified adapter list above. -If a dispatch rule or default names an unverified harness, ignore that profile, fall back to the next valid source, and note the problem when it affects the dispatch. -The shell scripts never parse or match the natural-language rules; firstmate does the matching and passes only concrete flags to `fm-spawn`. - -Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). - -Secondmates can run on a different harness than crewmates. -`config/secondmate-harness` (local, gitignored) is the harness the primary uses to launch SECONDMATE agents; resolve it with `bin/fm-harness.sh secondmate`, which follows the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> your own harness. -An explicit per-spawn harness still overrides either kind, and every secondmate respawn re-resolves from the file, so the split is durable across restarts without being recorded per-task. - -`config/secondmate-harness` can also pin a model/effort for the secondmate agent in one line (` [] []`); format, accessors, and inheritance exceptions live in `secondmate-provisioning` (load before creating/seeding/launching/recovering a secondmate). - -`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited into every secondmate home; `config/secondmate-harness` is not, because secondmates never spawn secondmates. -`secondmate-provisioning` owns the propagation timing, mechanism, the literal-file inheritance nuance, and `bin/fm-config-push.sh`. - -Each adapter splits into mechanics and knowledge. -The per-task mechanics (launch command, autonomy flag, crewmate turn-end hook) live in `bin/fm-spawn.sh`; the primary-session turn-end guard lives in `docs/turnend-guard.md`; the knowledge you need while supervising (busy signature, exit, interrupt, dialogs, quirks, skill invocation, resume) lives in the agent-only `harness-adapters` skill. -**Never dispatch a crewmate or secondmate on an unverified adapter.** -If `config/crew-harness` or `config/secondmate-harness` names an unverified one, tell the captain and fall back to your own harness until it is verified. -If the captain asks for a new harness, load `harness-adapters`, verify it empirically with a trivial supervised task, then commit the script and knowledge changes. -Load `harness-adapters` before any spawn, recovery, trust-dialog handling, harness-specific skill invocation, interrupt, exit, resume, or adapter verification. - -## 5. Recovery (run at every session start, after the session-start digest) - -You may have been restarted mid-flight. -Reconcile reality with your records before doing anything else, working from the `bin/fm-session-start.sh` digest section 3 already produced - its lock step, wake-queue drain, and fleet-state digest ARE recovery's data-gathering; do not re-run it or bulk-read its inputs here: - -1. The digest's lock section already tells you whether this session acquired the lock or is operating read-only; act on that exactly as section 3 describes. -2. The digest's wake-queue section already printed the drained records; keep them as the first work queue for this recovery turn. -3. The digest's fleet-state section already printed `data/backlog.md`, `data/secondmates.md` (from the context section), every `state/*.meta`, and a bounded tail of every `state/*.status`. - Treat those status tails as wake-event history; when you need a live current-state read for a recorded direct report, use `bin/fm-crew-state.sh ` instead of inferring from the last status line. - If older wake-event history matters, read the individual full status log named in the digest instead of bulk-reading every status file. -4. Use the `window=` values from the digest's `state/*.meta` entries as the live direct-report set, and read the digest's per-task `endpoint: alive|dead` line for each - that cheap check is already done; do not re-probe it yourself. - Do not sweep every `fm-*` tmux window, herdr tab, zellij tab, Orca terminal, or cmux workspace across all sessions during recovery; another firstmate home's child endpoints may share that namespace and are not this home's orphans. -5. If the digest reports a recorded direct-report's endpoint as `dead` (or a meta has no `window=`), reconcile it through its meta as described below. -6. For meta with no window, or an endpoint the digest reported dead, reconcile by kind. - For ordinary crewmates, check the recorded backend metadata first; use `treehouse status` for treehouse-backed tasks, and the recorded `orca_worktree_id=`/`terminal=` for Orca tasks. - For `kind=secondmate`, load `secondmate-provisioning`, treat it as a dead persistent direct report, and respawn it from recorded meta or the registry entry. -7. Do not reconstruct a secondmate's whole tree from the main home. - The main firstmate reconciles only direct reports. - Each secondmate is a firstmate in its own home, so it reconciles only work that is already its own and then idles; it never creates new work during recovery. -8. The digest already reports whether `state/.afk` is present. - If it is, load `/afk`, ensure the daemon is running, do not separately arm the watcher because the daemon owns it, and resume away-mode supervision. -9. Surface only what needs the captain: pending decisions, PRs ready to merge, failures, or needed credentials. - If there is nothing that needs them, say nothing and resume. -10. Having already handled the drained wakes from the digest, follow the emitted supervision operating block through the digest's own closing reminder; if the lock was refused or `state/.afk` exists, follow the digest's no-direct-supervision guidance. - -A firstmate restart must be a non-event. -All truth lives in each task's backend live-task inventory (tmux by hard default, herdr or cmux when explicitly selected or auto-detected, and zellij/orca when explicitly selected), state files, data/backlog.md, data/captain.md, data/learnings.md, data/secondmates.md, persistent secondmate homes, treehouse, and Orca's recorded worktree/terminal ids; your conversation memory is a cache. - -## 6. Project management - -All projects live flat under `projects/`. - -`data/projects.md` is firstmate's thin navigation registry. -Every project in the fleet has one line: - -```markdown -- [] - (added ) -``` - -The registry line records the project name, delivery mode, optional `+yolo` posture, and one-line description. -Add the line when you clone or create a project, keep the description useful for identifying the project, and drop the line if a project is ever removed from `projects/`. -Do not turn the registry into a knowledge dump. -Durable descriptive detail belongs in the project's own `AGENTS.md`. - -`data/secondmates.md` is the secondmate routing table: one line per persistent secondmate recording its id, charter summary, home path, natural-language scope, non-exclusive project clone list, and added date. -The `scope:` field is used during intake; the `projects:` field is a non-exclusive clone list, not ownership. -Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a secondmate home, and before editing `data/secondmates.md`. -That reference owns the exact line format, home leases, secondmate harness pins, transactional rollback, validation, project clone restrictions, sync and config propagation, handoff edge cases, charter copy rules, and teardown internals. - -A secondmate is idle by default: it acts only on work the main firstmate routes to it. -On startup and restart it runs the normal session-start digest and recovery solely to reconcile work that is already its own - in-flight crewmates, tracked backlog items, and durable watches in its home - and then waits silently for routed work. -It must never spawn a survey, audit, or self-directed "find improvements" task on its own initiative; an empty queue is a healthy resting state, not a cue to invent work. -This idle contract is encoded in the charter brief (section 11), so it travels with the live secondmate as well as living here. - -**Hand off in-scope backlog on creation.** -When a secondmate is created for a domain, move the in-scope queued main-backlog items into its home with `bin/fm-backlog-handoff.sh ...` so it owns its domain's queue from day one. -Do not hand off `local-only` items; that work stays with the main firstmate (section 7). -`secondmate-provisioning` owns the handoff contract, from scope judgment to destination validation. +Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. +Do not dispatch until the required tools are present and GitHub authentication is good. +Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `lavish-axi` for structured decisions or reports; consult current help rather than memorizing flags. +A silent bootstrap section needs no action; for any printed actionable diagnostic line, load `bootstrap-diagnostics` and follow its owner procedure. +`BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill. +`secondmate-provisioning` owns startup secondmate sync, liveness, and inherited local-material convergence. + +## 4. Harness and runtime dispatch + +Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `devin`; never dispatch on an unverified adapter. +If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. + +`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. +When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. +Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. +Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable real headroom including quota-window pace. +Account for every candidate; if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. +Preserve malformed profile configuration as an actionable error rather than selecting around it. +When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. +Break genuine headroom ties without array-order or harness bias. +`quota-axi` owns how model or product windows relate to bounding account windows and remains data-only. +Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the pace-aware selection procedure. +The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. +Do not add model-specific versions of that policy. + +`secondmate-provisioning` owns secondmate harness pins and inherited local material, while `harness-adapters` owns the harness consequences. +Dispatch only on a backend that `fm-spawn` validates as spawn-capable. +A missing dependency, authentication failure, unsupported backend, or version refusal is a blocker; never silently retry on another backend. + +## 5. Recovery + +After the one session-start digest, reconcile reality with durable records before taking new work. +Honor lock-refused read-only mode exactly as section 3 requires. +Treat digest status tails as wake-event history and use targeted current-state reconciliation when the live state matters. + +Reconcile only this home's recorded direct reports and their recorded backend inventory; never sweep a shared endpoint namespace for matching names or claim another home's work. +For an ordinary direct report whose endpoint is dead or metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership. +For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home. +Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work. + +If away mode is present, load `/afk` and let its daemon own supervision rather than arming another cycle. +Surface only captain-relevant decisions, review-ready PRs, failures, and credential needs; otherwise resume the emitted supervision protocol silently. +A restart must be a non-event because durable state and live backend inventory, not conversation memory, are authoritative. + +## 6. Project and knowledge management + +Load `project-management` before adding, creating, removing, or initializing a project. +That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal refusal. +Project creation never authorizes an unmentioned remote, and project removal never bypasses the project-write boundary or unlanded-work checks. + +Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. +Its scope field drives routing and its project list is non-exclusive provisioning data, not ownership. +Keep `local-only` work in the main home. + +A secondmate is idle by default and acts only on work routed by the main firstmate. +It reconciles its own work under way after restart, then waits silently; an empty queue never authorizes a survey, audit, or self-directed improvement sweep. +Do not reconstruct or supervise a secondmate's child tree from the main home. + +Route durable knowledge to its most specific owner: + +- Home-domain captain preferences and working style belong in `data/captain.md` after inspect-then-update. +- Captain preferences shared across secondmate domains belong in the primary home's `data/captain-shared.md` under the `secondmate-provisioning` contract. +- Fleet-local operational facts belong in curated, home-local `data/learnings.md`. +- Task-scoped notes belong with the backlog item, and investigation findings belong in the scout report. +- Knowledge useful to almost every contributor to one project belongs in that project's committed `AGENTS.md`. +- Knowledge general to every firstmate user belongs in this repo's shared tracked surface. + +Firstmate never writes a project's `AGENTS.md` directly. +A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail. +Keep fleet delivery posture and captain-private strategy out of project memory. +When the captain invokes `/stow`, load the `stow` skill for the complete knowledge-routing and unfinished-work sweep. -### Project memory ownership +## 7. Task lifecycle -Firstmate keeps project knowledge split by ownership. +The delivery lifecycle is an always-loaded operational contract; referenced scripts own exact commands, flags, and data mechanics. -**Project-intrinsic knowledge** belongs to the project. -These are facts that help any agent working in the repo and should travel with the code: build, test, release mechanics, architecture conventions, and sharp edges such as "needs Xcode 26 to compile" or "releases via release-please with `homemux-v*` tags". -This knowledge lives in the project's committed `AGENTS.md`. -A project's `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it. -A project's `AGENTS.md` is only for knowledge useful to almost every future session in that repo. -Prefer a pointer to the authoritative file, command, or doc over repeating what the codebase already shows, and rewrite or prune stale entries instead of appending by default. -The canonical self-governance wording for project `AGENTS.md` files lives in `bin/fm-ensure-agents-md.sh`; this section states the principle and points there. +### Intake and authority -**Fleet and captain-private knowledge** belongs to firstmate. -Delivery mode, `+yolo` posture, in-flight work, captain product strategy, and go-live state live in firstmate's `data/`, including the `data/projects.md` registry line and any planning docs. -Do not put that knowledge in the project. -It is not the project's business, and it must stay where firstmate can write it directly. +Resolve the project independently for every request. +An explicit project wins, a clear follow-up inherits its referent, and otherwise match the request against the registry, work under way, and project code or README. +Proceed on one confident match while naming the project in plain language; ask one concise question when multiple or no projects plausibly match. -This does not relax prime directive #1. -Firstmate does not hand-write project `AGENTS.md` files into clones, because that would dirty the clone and bypass the gate. -Project `AGENTS.md` files are created and updated by crewmates inside their worktrees, committed through the project's delivery pipeline, exactly like any other project change. -Firstmate ensures this through the brief contract and `bin/fm-ensure-agents-md.sh`; firstmate does not perform the write itself. -Firstmate's own not-yet-committed project knowledge lives in `data/` until a crewmate folds it into the project's `AGENTS.md`. +Route by the nature of the work against each registered secondmate scope, not by a non-exclusive clone list. +Keep `local-only` work in the main home. +Send in-scope work to the fitting secondmate unless it is blocked or the captain explicitly redirects it; do not read the secondmate's chat because marked routed replies return through its status or referenced document. +If no secondmate scope fits, use the main home or discuss creating an appropriate persistent secondmate. +For one-off or infrequent operational work, start with the simplest direct end-to-end path. +Do not build wrappers, control planes, policy layers, custom verifiers, or automation unless the direct path exposes a concrete blocker or repeated need that justifies the added machinery. -Create a project's `AGENTS.md` lazily on first need. -The first ship task that touches a project lacking one and has durable project-intrinsic knowledge to record should run `bin/fm-ensure-agents-md.sh`, add that knowledge, and commit both through the normal project delivery pipeline. -Do not eagerly backfill every project. +Before commissioning an investigation, consult existing reports and established evidence. +Classify the deliverable: -### Knowledge routing +- **Ship** is the default and produces a project change through the selected delivery mode; once implementation is authorized, dispatch a ship and keep any remaining bounded research inside it unless unresolved uncertainty could materially change whether or what to build. +- **Scout** produces knowledge in `data//report.md`, never a PR, and is appropriate for investigation, diagnosis, planning, reproduction, or audit work when the captain explicitly requests a separate knowledge or design deliverable or unresolved uncertainty could materially change whether or what to build. -Route each piece of durable knowledge to its most specific home: +If established evidence already answers an informational question, relay it without a design-only scout; when implementation intent is unclear, answer and ask one concise implementation question when useful rather than dispatching speculative design work. +Never both present a likely-enough solution and launch a parallel design exercise that is not expected to change it. +A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code. +Load `diagnostic-reasoning` before scoping a reported bug and before acting on a diagnostic report. -| Kind of knowledge | Home | -| --- | --- | -| Captain preferences and working style | `data/captain.md`, inspected first and rewritten or pruned in place | -| Project-intrinsic knowledge | that project's own `AGENTS.md`, via normal crewmate delivery, never hand-written by firstmate | -| Fleet-local operational facts and gotchas | `data/learnings.md`, inspected first and rewritten or pruned in place | -| Knowledge generalizable to every firstmate user | the shared `AGENTS.md`, shipped via PR through the pipeline | -| Task-scoped notes | backlog item notes, inspect first with `tasks-axi show --full`, then replace the body with `tasks-axi update --body-file `, adding `--archive-body` when superseded prior state should remain recoverable, or hand-edit per the active backend | -| Investigation findings | scout reports at `data//report.md` | +Treat file or subsystem overlap as a risk signal rather than an automatic reason to wait, and dispatch isolated work immediately with no concurrency cap when each change can be independently implemented and validated and the selected delivery path can reconcile ordinary rebases or conflicts. +Serialize only for a true semantic dependency, shared mutable external state, incompatible concurrent migration, or another concrete condition that makes independent progress or reconciliation unsafe; same-file editing alone is insufficient, and genuine blockers remain durable. +Write the task-specific brief under section 11 before spawning. -When the captain invokes `/stow`, load the `stow` skill. -It sweeps the current session for uncaptured durable knowledge, routes findings with this table, files undone next steps to the backlog, and reports whether the session is safe to reset. +### Dispatch and supervision handoff -**Delivery mode (choose at add).** `` is how a finished change reaches `main`, picked per project when you add it and recorded in the registry line (`fm-project-mode.sh` parses it; `fm-spawn` records it into each task's meta): +Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4. +The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task. +After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as under way. +A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. -- `no-mistakes` (default; `[...]` may be omitted) - full pipeline -> PR -> captain merge. Highest assurance. -- `direct-PR` - push + open a PR via `gh-axi`, no pipeline -> captain merge. -- `local-only` - local branch, no remote, no PR; firstmate reviews the diff, the captain approves, firstmate merges to local `main` (section 7). +Steer a worker with short single-line messages through fail-closed `fm-send`; put long instructions in a file. +A secondmate's routed reply returns through status or a document pointer, not by firstmate peeking into its chat. +For the parent-owned correlation, recovery, and escalation contract on marked secondmate requests, see `bin/fm-pending-reply-lib.sh`. +Supervise all live work under section 8. -Orthogonal to mode is an optional `+yolo` flag (`[direct-PR +yolo]`), default off and **not recommended**: with `yolo` on, firstmate makes the approval decisions itself instead of asking the captain (section 7). When the captain adds a project without saying, default to `no-mistakes` with yolo off; only set a faster mode or `+yolo` on the captain's explicit say-so. +### Selected delivery path and approval authority -**Clone existing:** `git clone projects/`, add its registry line with the chosen mode, then initialize only if the mode is `no-mistakes`. +The selected delivery path owns its own rigor. +When no-mistakes is selected, no-mistakes alone owns review, fixes, tests, documentation, push, PR, and CI; otherwise follow the faster path without adding an independent reviewer. +Never hold work outside no-mistakes for a manual clean verdict, stack serial manual reviews, or infer authority for one from security, architecture, or risk alone. +A separate review or audit is allowed only when the captain explicitly requests that deliverable or the authorized task is a knowledge-only review; one named question remains scoped to that question. +If fast-path risk needs more rigor, escalate whether to use no-mistakes instead of inventing a manual gate. +The path's worker, automated gates, and captain approval remain authoritative: -**Create new:** for `no-mistakes` and `direct-PR` modes a new project needs a GitHub repo first (they push to an `origin` remote); a `local-only` project needs no remote at all - a purely local git repo is fine. -Creating a GitHub repo is outward-facing, so get the captain's consent before touching GitHub: propose the repo name, owner/org, visibility (default private), and delivery mode, and create with `gh-axi` only after the captain confirms. -Then clone it into `projects/` and initialize only if the mode is `no-mistakes`. -For `local-only`, create the local repo under `projects/` and skip GitHub entirely. +- **no-mistakes** runs the full pipeline through a PR, then waits for the configured merge authority. +- **direct-PR** has the worker push and open a PR without the no-mistakes pipeline, then waits for the configured merge authority. +- **local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority before firstmate uses the guarded fast-forward merge path. -**Initialize (`no-mistakes` mode only):** +Delivery mode and `yolo` are orthogonal. +With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. +With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green or otherwise approved work. +Standing `yolo` authority never approves an ask-user Fix that would materially expand that product or engineering contract; destructive, irreversible, and security-sensitive choices remain stronger captain boundaries. +Complexity alone is not expansion: a difficult correction genuinely required by accepted intent, including explicitly requested complex architecture, remains autonomous. +Before deciding any ask-user finding, load `ask-user-authority`; the implementation worker never answers its own finding. +Never merge a red PR. +Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. +After an autonomous merge, give the captain a one-line full-URL or local-main outcome. -```sh -cd projects/ && no-mistakes init && no-mistakes doctor -``` +### Validate -`no-mistakes init` sets up the local gate: a bare repo plus post-receive hook, the `no-mistakes` git remote, and a database record for the repo (it needs an `origin` remote). -It does **not** vendor any skill into the project - the no-mistakes skill is user-level now, available to every crewmate without a per-project copy. -So init produces nothing to commit; it is a sanctioned exception to the never-write rule (section 1) only in that it runs git remote/config setup inside the project. -Touch nothing else. -`direct-PR` and `local-only` projects skip init entirely - they do not run the pipeline (`local-only` has no remote at all). +For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. +The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. +Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. -If `no-mistakes doctor` reports problems, fix the environment (auth, daemon) before dispatching work to that project. +An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. +Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. +Require the matching `resolved` event, forbid `--yes`, and require the worker to process every synchronous return until completion or a genuinely new escalation. +Resume fleet supervision immediately after the decision lands. -## 7. Task lifecycle +Judge validation by the current-code-matched run step through `bin/fm-crew-state.sh`, not by shell liveness or the last status event. +Running, fixing, or CI states remain working; parked approval or fix-review states require the worker to follow the active gate help; passed or checks-passed is done; failed or cancelled is failed. +A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership; steer it back to the gate response flow. +The worker reports the PR when CI first becomes green rather than waiting for merge monitoring to finish. -### Intake - -**Resolve the project first.** -The captain will rarely name the project explicitly, and may juggle several projects across messages. -Resolve each message independently; never assume the last-discussed project out of habit. -Use these signals in order: - -1. An explicit project name in the message wins. -2. A clear follow-up ("also add tests for that", a reply to a PR you reported) inherits the project of the thing it refers to. -3. Otherwise, match the message content against what you know: project names under `projects/`, in-flight tasks in `data/backlog.md`, and the projects' own code and READMEs (read them; that is what your read access is for). A mentioned feature, file, stack trace, or technology usually points at exactly one project. -4. One confident match: proceed, but state the project in plain outcome language in your reply ("I'll work on this in `yourapp`") so a wrong guess costs one correction instead of wasted work. -5. More than one plausible match, or none: ask a one-line question. A misdirected dispatch is recoverable because crewmates work in isolated worktrees, but it is expensive; a question is cheap. - -Then resolve the secondmate scope. -Read `data/secondmates.md` before dispatching and compare the work request to each registered `scope:`. -Route by the nature of the task, not just the project name. -A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. -If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. -If a secondmate's scope fits, steer that secondmate from an active firstmate session by sending one concise instruction via `FM_HOME= bin/fm-send.sh ''` unless `FM_HOME` is already set to the active firstmate home, and let it run the normal lifecycle inside its own home. -The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. -`fm-send` is fail-closed: `FM_HOME` must be set, and any target that cannot be resolved through this home's metadata or a well-formed explicit backend target exits non-zero instead of guessing a tmux window. -A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. -So `fm-send` to a task selector whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. -Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer. -A captain typing directly into the secondmate's window is unmarked and stays a conversational captain intervention, so do not relay captain-destined chat through this path; the marker is applied only by `fm-send` to a `kind=secondmate` target. -Do not spawn a direct crewmate for work that belongs to a secondmate scope unless the secondmate is blocked or the captain explicitly redirects it. -If no secondmate scope fits, proceed in the main firstmate or create a new secondmate with the captain when that domain should become persistent. -When you create a new secondmate, hand its in-scope queued items off from the main backlog into its home with `bin/fm-backlog-handoff.sh` so it owns its domain's queue from day one (section 6). - -Then classify the shape: - -- **Ship** (the default): the deliverable is a change to the project. It ships through the project's delivery mode: `no-mistakes`, `direct-PR`, or `local-only`. -- **Scout:** the deliverable is knowledge - an investigation, a plan, a bug reproduction, an audit. It ends in a report at `data//report.md`, never a PR. When the captain asks "what's wrong", "how would we", or "find out why" about a project, that is a scout task; dispatch it instead of doing the digging yourself. - -Then classify readiness: - -- **Dispatchable:** no overlap with in-flight tasks. Dispatch immediately. There is no concurrency cap. -- **Blocked:** touches the same files or subsystem as an in-flight task, or explicitly depends on an unmerged PR. Record it in `data/backlog.md` with `blocked-by: ` and tell the captain what work is waiting and why. Scout tasks are read-mostly and almost never block on anything. - -Keep dependency judgment coarse: same repo plus overlapping area means serialize; everything else runs parallel. -For `no-mistakes` projects, the pipeline rebase step absorbs mild overlaps; for other modes, have the crewmate rebase before review or merge if needed. - -Write the brief per section 11. - -### Spawn - -Load `harness-adapters` before spawning or recovering any direct report so trust dialogs, verified adapters, and harness-specific behavior are handled correctly. - -```sh -bin/fm-spawn.sh projects/ # uses the active crewmate harness only when no crew-dispatch.json is active -bin/fm-spawn.sh projects/ --harness codex --model gpt-5.5 --effort high # explicit profile axes -bin/fm-spawn.sh projects/ --backend # explicit runtime backend (docs/configuration.md "Runtime backend") -bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta -bin/fm-spawn.sh [] --secondmate # launch or recover a persistent secondmate in its home -bin/fm-spawn.sh =projects/ =projects/ [--scout] # batch: one call, several tasks -``` - -Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, and `--backend` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero. -When `config/crew-dispatch.json` exists, include an explicit resolved harness for every crewmate or scout spawn or batch after consulting the dispatch rules (section 4). -`bin/fm-spawn.sh`'s header owns the full resolution contract: harness and runtime-backend resolution order, spawn-capable backends and the `codex-app` rejection, verified launch templates, delivery-mode resolution, recorded meta fields, and per-harness turn-end hook installation. -A backend spawn refusal - a missing dependency, an unauthenticated socket, or a version gate - must be surfaced to the captain as a blocker; never silently retry the spawn on a different backend to work around it. -For ship and scout tasks, the script asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout, aborting the spawn otherwise to prevent the worktree tangle of section 8. -For `kind=secondmate`, it launches in the registered or explicit firstmate home with the charter brief as the launch prompt, after the guarded home sync and inheritable-config propagation owned by `secondmate-provisioning`. -Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch. -After spawning, peek the endpoint to confirm the crewmate is processing the brief and handle any trust dialog with `harness-adapters`. -For a ship or scout task, add the task to `data/backlog.md` under In flight. -A secondmate spawn adds no backlog row: its identity and scope live in `data/secondmates.md`, its runtime lives in `state/.meta`, and section 10 owns the backlog contract. - -### Supervise - -Covered by section 8. -Steer a crewmate only with short single lines via `FM_HOME= bin/fm-send.sh` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home; anything long belongs in a file the crewmate can read. -Steer a secondmate the same way. -Its charter retargets escalation to the main firstmate's status file, so routine internal churn stays inside the secondmate home and only `done`, `blocked`, `needs-decision`, `failed`, a declared `paused:` external wait, or another captain-relevant phase change wake the main firstmate. -Because `fm-send` to a `kind=secondmate` target marks the request as from-firstmate (section 7 intake), the secondmate's answer comes back on that status/doc path too, not in its chat; read the response there as an ordinary status signal and do not peek its chat for it. -A secondmate-reported merged PR is exactly the case the fleet-sync-on-merge wake rule (section 8) exists for, since the secondmate's own teardown never touches this home's separate project clone. - -### Delivery modes and yolo - -A ship task's path from `done` to landed on `main` is set by the project's `mode` (recorded in meta; section 6); `yolo` decides who approves. The Validate / PR ready / Ship teardown stages below are written for the `no-mistakes` path; the other modes diverge: - -- **no-mistakes** - the stages below as written: no-mistakes validation pipeline -> PR -> captain merge. -- **direct-PR** - no pipeline. The crewmate pushes and opens the PR itself (its brief says so) and reports `done: PR `. Skip the Validate step and go straight to PR ready (run `fm-pr-check`, relay the PR). Teardown uses the normal landed-work check. -- **local-only** - no remote, no PR. The crewmate stops at `done: ready in branch fm/`. Review the diff with `bin/fm-review-diff.sh `, relay a one-paragraph summary to the captain, and on approval run `bin/fm-merge-local.sh ` to fast-forward local `main` (it refuses anything but a clean fast-forward - if it does, have the crewmate rebase). No `fm-pr-check`. Then teardown, whose safety check requires the branch already merged into local `main`, OR the work pushed to any remote (a fork counts - relevant for upstream-contribution PRs on a local-only-registered project). - -When reviewing any crewmate branch diff, use `bin/fm-review-diff.sh ` rather than `git diff ...branch` directly. -Pooled clones keep their local default refs frozen at clone time and can lag `origin`; the helper always compares against the authoritative base. -When the task meta records `pr=`, the helper also compares that base against the authoritative PR head (`pr_head=` when reachable, otherwise a fresh `refs/pull//head` fetch) so no-mistakes fix rounds pushed to the PR are included even if the local worktree branch is stale. -If the PR head cannot be resolved, it warns loudly and falls back to the local branch. -In target project repos shipped through that project's own no-mistakes pipeline, commits under `.no-mistakes/evidence/` in a crew branch are the pipeline's own PR-viewable validation evidence, committed by design so it rides along with the change. -Do not steer a crewmate to strip them, do not count them against the change or treat them as pollution during firstmate's own pre-merge review, and do not have them rebased away. -Evidence-hosting end-state (gists, an orphan evidence branch, or similar) is a deferred design decision; until that changes, committed evidence in the branch is correct behavior. -Firstmate's own repo is the exception: its `.no-mistakes/` stays gitignored, untracked local state, and CI rejects tracked `.no-mistakes` paths. -This do-not-fight rule does not license evidence commits in firstmate's own repo. - -**yolo (orthogonal).** With `yolo=off` (default) every approval is the captain's: ask-user findings, PR merges, the local-only merge. -With `yolo=on`, firstmate makes those calls itself without asking - resolve ask-user findings on your judgment, and run `bin/fm-pr-merge.sh ` / `bin/fm-merge-local.sh` once the work is green/approved - EXCEPT anything destructive, irreversible, or security-sensitive, which still escalates to the captain. -Never merge a red PR even under yolo. -`bin/fm-pr-merge.sh` always records `pr=` and records `pr_head=` when available before merging, parses the full `https://github.com///pull/` URL into `gh-axi pr merge --repo /`, and defaults to `--squash` unless an explicit merge method is forwarded after `--`; this holds even on a repo with no PR CI where the "checks green" signal that normally triggers `bin/fm-pr-check.sh` never fires - do not call `gh-axi pr merge` directly for a task's PR, or the recording step can be silently skipped and a later `fm-teardown.sh` has nothing to verify a squash merge against. -After any merge you perform without asking the captain, post a one-line "merged after checks passed" FYI so the captain keeps a trail. - -### Validate - -For `no-mistakes`-mode ship tasks, when a crewmate's status says `done`, trigger validation using the crew's harness from `state/.meta`. -Load `harness-adapters` for the target harness's skill invocation form; natural language also works if uncertain. - -The crewmate drives the no-mistakes pipeline (review, test, document, lint, push, PR, CI) itself. -The ship brief intentionally does not restate no-mistakes gate mechanics; it points the crewmate to the version-matched SKILL.md loaded by `/no-mistakes`, `no-mistakes axi run --help`, and per-response `help` lines. -Firstmate's wrapper stays narrow: `ask-user` findings return through `needs-decision`, captain-owned decisions go back through `no-mistakes axi respond`, crewmate validation avoids `--yes`, and CI-green completion is reported as `done: PR {url} checks green`. -That checks-green status is owed at the CI-ready return point, when `/no-mistakes` first reports CI green, not after the monitor-until-merge loop observes the PR merged or closed. -Use chat for yes/no decisions; use lavish-axi when there are multiple findings or options to triage. - -Judge a validating crewmate by the run's step status, never by whether its shell is still running. -Read its current state with `bin/fm-crew-state.sh `: a deterministic, token-tight one-line read that takes the matching no-mistakes run-step as the source of truth and reconciles it against the crewmate's `state/.status` log. -Because the run-step is authoritative before pane liveness, a crewmate whose window closed after or during validation can still report `done` or `working` from its run; a missing pane becomes `unknown` only when no matching run exists. -That log is an append-only wake-*event* log, not a current-state field, and it goes stale the moment a resolved gate lets the run resume: after you answer a `needs-decision`/`blocked` and the crewmate silently resumes (responds to the gate, the pipeline fixes, it re-validates), the log's last line still reads `needs-decision`/`blocked` while the run-step has moved on. -So never infer current state from a `tail` of that log; `bin/fm-crew-state.sh` reports the live run-step state and explicitly flags the stale log line superseded, where a raw `tail` would mislead you into re-escalating settled work. -The fields below name the run-step states and outcomes it reads from `no-mistakes axi status`; run that command directly when you want the full gate findings. -During the `ci` monitor phase, `bin/fm-crew-state.sh` also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. - -- `running`/`fixing`/`ci` - the pipeline is working (a fix round, a test, or CI monitoring); `ci` stays working until the ci log's most recent recognized marker says checks passed or no checks are terminally ready, and a later re-arm or issue marker returns it to working. -- `awaiting_approval`/`fix_review` - the run is parked waiting on the agent, surfaced as a top-level `awaiting_agent: parked ` line right after `status:` in `axi status`. - The crewmate owes a response; if it is idle-waiting for the run to advance on its own, steer it to follow no-mistakes' active-gate help. -- `outcome: passed` or `checks-passed` - the helper reports `done`; `passed` means the PR is already merged or closed, while `checks-passed` means it is ready for PR review. -- `outcome: failed` or `cancelled` - the helper reports `failed`; inspect the run details and recover or report failure with evidence. -- Red flag - self-fix duplication: a validating crewmate making fresh hand-commits, aborting the run, or re-running it mid-validation is re-doing work the pipeline already owns. - Steer it back to no-mistakes' respond flow; the pipeline, not the crewmate, applies validation fixes. - -### PR ready +### PR ready, landing, and teardown For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. -Run `bin/fm-pr-check.sh ` - it records `pr=` and GitHub's `pr_head=` when available in the task's meta and arms the watcher's merge poll. -Tell the captain: the PR's full URL (always the complete `https://...` link, never a bare `#number` - the captain's terminal makes a full URL clickable), a one-paragraph summary, and, for `no-mistakes`, the risk level it emitted. -(The check contract, for any custom `state/.check.sh` you write yourself: print one line only when firstmate should wake, print nothing otherwise, and finish before `FM_CHECK_TIMEOUT`.) +Run `bin/fm-pr-check.sh ` - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll. +Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, a concise outcome summary, and the no-mistakes risk level when applicable. +A captain instruction to merge is explicit authority; `yolo` is the only standing routine authority. +For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. -If the captain says "merge it", run `bin/fm-pr-merge.sh ` yourself; that instruction is the explicit approval. -If `yolo=on`, merge a green/approved PR yourself the same way and post the required FYI. -The helper defaults to `--squash`, accepts explicit merge-method flags such as `-- --merge`, `-- --rebase`, or `-- --method=merge`, and refuses `--repo` or `-R` overrides because the repository is derived from the URL. +Tear down a ship task only after landing is confirmed. +A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass. +Never force teardown without explicit discard authority. +After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared. -### Ship teardown (only after merge is confirmed) +A secondmate is persistent and an empty queue is healthy. +Retire one only on an explicit captain or main-firstmate decision, after loading `secondmate-provisioning`; its home must contain no work under way, and forced discard still requires explicit captain authority. -```sh -bin/fm-teardown.sh -``` +### Scout outcome and promotion -The script refuses if the worktree holds uncommitted changes or committed work that has not landed; treat a refusal as a stop-and-investigate, not an obstacle. -`bin/fm-teardown.sh`'s header owns the full landed-work definition (remote-reachable, merged-PR-head containment for the squash-merge-then-delete-branch flow, content already in the default branch, local-only merges) and the `pr=` discovery fallback for merges that skipped `bin/fm-pr-check.sh`. -Known benign case: after an external-PR task, a squash merge leaves the branch commits reachable only on the contributor's fork; add the fork as a remote and fetch (`git remote add fork && git fetch fork`), then retry - never reach for `--force`. -A successful PR-based teardown also refreshes that project's clone through `bin/fm-fleet-sync.sh`, best-effort. -Then update the backlog using the teardown reminder: run `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise move the task to Done in `data/backlog.md` manually with the full `https://...` PR URL or local merge note and date and keep Done to the 10 most recent. -Re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. +A completed scout must leave a self-contained report before its scratch worktree can be discarded; read and relay its findings, record the report as the Done artifact, and re-evaluate the queue. +A report may recommend implementation but does not authorize it. +Before treating the investigation or any visual review as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. +When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. +The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. -### Secondmate teardown (explicit only) +## 8. Supervision protocol -A secondmate is persistent by default. -An empty queue is healthy and does not trigger teardown. -Run `bin/fm-teardown.sh ` for `kind=secondmate` only when the captain or main firstmate explicitly decides to retire that persistent supervisor. -Load `secondmate-provisioning` before retiring it. -The safety check is the secondmate's own home: teardown refuses while its `state/*.meta` contains in-flight work. -With `--force`, teardown is the explicit discard path for child windows, child work, state, route, lease, and home; never use it unless the captain explicitly said to discard the work. +Fleet supervision is an always-loaded operational contract; `docs/architecture.md`, `docs/turnend-guard.md`, the emitted session-start block, and script help own mechanisms and harness-specific recipes. -### Scout tasks (report instead of PR) +Whenever work is under way, keep exactly one live supervision cycle using the emitted protocol for this primary harness. +X mode may require that same live cycle with no fleet work. +Do not substitute another harness's wait shape, use shell `&`, or create a second cycle when a healthy one already exists. +For every actionable wake, follow the ordinary-wake continuation in the emitted protocol; use its repair action only when the live cycle is missing or failed. +No turn ends blind while work is under way, including turns described as holding or waiting. -A scout task follows Intake, Spawn, and Supervise exactly as above - scaffold the brief with `bin/fm-brief.sh --scout`, spawn with `--scout` - then diverges after the work: +At the start of every wake-handling turn, drain the durable wake queue before peeking, reading beyond the reason line, steering, or starting work. +Session start is the only exception because its one-shot digest already drained while locked or deliberately left the queue untouched in lock-refused read-only mode. +A status line is a wake event, not current state; use `bin/fm-crew-state.sh` when current state matters, especially before re-escalating an old decision, blocker, or pause. +A declared `paused:` event means a bounded external wait expected to clear on its own, while `blocked:` means firstmate action is needed. -- There is no Validate or PR-ready stage. When the crewmate's status says `done`, read `data//report.md`. -- Relay the findings to the captain: plain chat for a focused answer, lavish-axi when the report has structure worth a visual (multiple findings, options, a plan). -- Tear down immediately - no merge gate. `bin/fm-teardown.sh` allows a scout worktree's scratch commits and dirty files once the report exists; if the report is missing, it refuses, because the findings are the work product. -- Record it in Done with the report path instead of a PR link using `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise hand-edit `data/backlog.md` and keep Done to the 10 most recent, then re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. +Handle actionable wakes as follows: -**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then from an active firstmate session send the crewmate its ship instructions with `FM_HOME= bin/fm-send.sh` unless `FM_HOME` is already set to the active firstmate home - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. -The crewmate keeps its worktree, loaded context, and repro, but the ship branch must start from a clean base with only intended changes; scratch commits and debug edits from the scout phase never ride along. -The repro becomes the regression test. -From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. +1. For `signal:`, read the listed event lines first, then reconcile current state only where action depends on it. +2. For `stale:`, inspect the recorded endpoint and load `stuck-crewmate-recovery` for a stopped, looping, confused, or unresponsive worker; a deep-inspection reason also requires current-state and validation-log inspection. +3. For `check:`, act on the named poll result, including merges and X-mode events. +4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. -## 8. Supervision protocol +When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. +When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. -The watcher is the backbone. -Whenever at least one task is in flight, keep exactly one live supervision wait owned by the emitted primary-harness protocol from `bin/fm-session-start.sh`. -The emitted block is the only per-harness operating recipe in the session context. -Do not substitute another harness's command shape for it. -**Always-on wake triage (absorb only when provably working).** -`bin/fm-watch.sh` classifies every wake in bash and absorbs the benign majority without waking you: crews with positive working evidence (an actively-running no-mistakes step for their branch, or a busy pane, read via `bin/fm-crew-state.sh`), a declared `paused:` external wait until its bounded recheck cadence, and no-change heartbeats. -It never absorbs a crewmate that stopped without that evidence - whatever its stale status log claims - and only an actionable wake is queued durably and ends the supervision wait, so you resume the emitted protocol exactly once per actionable event. -A `paused:` status is a deliberate external wait, not `blocked:`; its initial signal still surfaces once, and a forgotten pause re-surfaces for a recheck once per window. -Repeated provably-working stale escalations on one unchanged pane eventually add `demand-deep-inspection` to the wake reason so it is not mistaken for another routine validation wait. -`docs/architecture.md` ("Event-driven supervision") owns the full classification mechanism, its thresholds, and the shared classifier library; while `state/.afk` exists the daemon owns triage and the watcher surfaces every wake to it. -At the start of every wake-handling turn, run `bin/fm-wake-drain.sh` before peeking panes, reading status files beyond the reason line, or starting new work. -Session-start recovery is the exception: `bin/fm-session-start.sh` already drained the queue when locked, or deliberately skipped the drain when read-only because another session owns it. -The printed reason line is still useful, but the drained queue is the lossless backlog. -**Keep exactly one live cycle.** -The live cycle is the supervision: while any task is in flight, the active harness protocol must maintain one wait that can wake this primary when `bin/fm-watch.sh` reports an actionable reason. -After handling drained wakes, resume the emitted harness protocol before ending the turn. -Never use shell `&` as a substitute for a verified harness wake mechanism. -If the active protocol's arm wrapper reports or attaches to an existing healthy watcher, do not start another cycle; attached arms stay live until that cycle ends. -If it reports failure, drain queued wakes first and then repair supervision according to the emitted block. -**No turn ends blind, holds included.** -Never end a turn while any task is in flight without the active harness supervision protocol live: a text-only "holding" or "waiting" reply with crewmates live and no live cycle is a bug, and because such a turn runs no supervision script it is exactly the blind gap the script-only guard (`fm-guard.sh`, below) cannot catch, so this discipline must. -If a forced restart is ever genuinely needed, use `bin/fm-watch-arm.sh --restart`, which signals only this home's recorded watcher and then owns a fresh cycle or reports restart-only `healthy` without attaching if a healthy peer still holds the lock. -Never `pkill -f bin/fm-watch.sh`: that pattern matches every firstmate home's watcher, including secondmate homes that run the same script, so a broad pkill from one home kills sibling homes' watchers. -Away-mode supervision is provided by the `/afk` skill and its daemon; while `state/.afk` exists, the daemon owns the watcher. -Waiting on the watcher is intentionally silent. -After starting the active harness supervision wait, do not send idle progress updates to the captain; wait until it returns `signal`, `stale`, `check`, or `heartbeat`, unless the captain asks for status. -Empty polls, elapsed waiting time, and "still no change" are tool bookkeeping, not conversational progress. - -```sh -bin/fm-supervision-instructions.sh # render the current harness block or one-line repair text -bin/fm-watch-arm.sh # verified arm wrapper used by harness protocols that call it -bin/fm-watch-arm.sh --restart # home-scoped forced restart; never a broad pkill -bin/fm-watch-checkpoint.sh # bounded foreground watcher checkpoint for Codex-style protocols -bin/fm-watch.sh # the watcher itself; exits with: signal|stale|check|heartbeat -bin/fm-wake-drain.sh # drain queued wake records at turn start; asserts guard after draining -bin/fm-crew-state.sh # one-line current-state read; reconciles matching run-step, pane, and status log -bin/fm-fleet-view.sh # read-only Markdown whole-fleet view rendered from the structured snapshot -``` +A secondmate's idle endpoint is healthy, and parent supervision relies on its routed status rather than treating a quiet pane as stale. +Waiting on a healthy supervision cycle is silent; empty polls, elapsed time, and no-change updates are not captain-facing progress. +Never broadly kill watchers, especially never `pkill -f bin/fm-watch.sh`, because that can kill sibling firstmate homes. +A forced repair must use the home-scoped owner path emitted by supervision instructions. -On wake, in order of cheapness: - -1. Read the reason line and drain queued wake records with `bin/fm-wake-drain.sh`. -2. `signal:` read the listed status files first; a wake lists every signal that landed within the coalescing grace window (e.g. a status write plus the same turn's turn-end marker), and each is ~30 tokens and usually sufficient. - A status line is the wake *event*, not the crewmate's current state; when you need the live state - especially to confirm a `needs-decision`/`blocked`/`paused` status is still real and not already resolved-and-resumed - read it with `bin/fm-crew-state.sh `, which reconciles the authoritative run-step over the possibly-stale log line, and never `tail` the status log as the current-state source. -3. `stale:` the crewmate stopped without reporting; peek the pane (`bin/fm-peek.sh `) to diagnose. - If the stale reason includes `demand-deep-inspection`, inspect the pane, `bin/fm-crew-state.sh `, and the validation logs before resuming supervision. - If the pane is waiting, looping, confused, or unresponsive, load `stuck-crewmate-recovery`. -4. `check:` a per-task poll fired (usually a merge, or X mode when enabled); act on it. -5. `heartbeat:` a heartbeat wake now reaches you only when the watcher's bash fleet-scan caught a captain-relevant status the per-wake path missed (no-change heartbeats are absorbed in bash, never surfaced), so treat it as "something turned up" and review the whole fleet: start with `bin/fm-fleet-view.sh` for the structured overview, use `bin/fm-crew-state.sh ` only for targeted follow-up, peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then resume the emitted supervision protocol. - Do not report that the fleet is unchanged. - -When a task reaches a terminal state on any of these wakes (a `done`/merge `check:`, a `failed` signal, a scout report, a local-only merge), and X mode is enabled, load `fmx-respond` (section 13) and post the X-mode mention's **final** completion follow-up if that task is X-mode-linked: `bin/fm-x-followup.sh --check ` then `bin/fm-x-followup.sh --final --text-file `, so the link always clears here regardless of how many of the up-to-three follow-ups were already spent on earlier milestones. -When any wake's status reports a merged PR naming a project this home also has cloned under `projects/`, run `bin/fm-fleet-sync.sh ` for that project as part of handling the wake, so the primary's clone never sits stale until the next session start or teardown. - -Never rely on hooks or status files alone; when a heartbeat wake does reach you, the review of every window is mandatory and unconditional. -Each task's backend live-task inventory is the ground truth: tmux when `backend=` is absent, or the non-default `backend=` a task's meta records (`docs/configuration.md` "Runtime backend" owns the backend set). -For `kind=secondmate`, an idle pane is healthy. -A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness. -`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`. -This exception is narrow: ordinary crewmates still trip stale detection when their pane stops changing without a busy signature. - -**Watcher liveness is guarded, not just disciplined.** -Resuming the emitted supervision protocol is the last action of every wake-handling turn - but the protocol no longer relies on remembering that. -The supervision scripts and `bin/fm-wake-drain.sh` call `bin/fm-guard.sh`, which prints a prominent bordered banner when tasks are in flight but queued wakes are pending or the watcher's liveness beacon is missing or stale; `docs/architecture.md` ("Event-driven supervision") owns the beacon and grace mechanics. -The banner is only a supervision warning: the guarded operation still runs, and `fm-send`'s banner says explicitly that the requested message WILL still be sent. -If a guard warning says queued wakes are pending, drain them before doing anything else. -If a guard warning says watcher liveness is stale, drain any queued wakes and then resume the emitted supervision protocol. - -`fm-guard.sh` carries a second, independent alarm in the same bordered style: the **worktree-tangle** guard. -If a crewmate sent to work firstmate-on-itself branches or commits in the primary checkout instead of its own isolated worktree, the primary is stranded on a feature branch (the failure this guards against); the guard names the offending branch and prints the non-destructive restore (`git -C checkout `), so the tangle surfaces on the very next fleet action. -Only a named non-default branch checked out in the primary alarms: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes) and the default branch never do. -The same assertion runs at session start as the bootstrap `TANGLE:` line (handled via `bootstrap-diagnostics`), and two upstream guards prevent the tangle: `fm-spawn`'s isolated-worktree assertion and the ship brief's opening isolation check (section 11). - -On every verified primary harness, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, guards both the main primary and a secondmate's own primary session, and stays silent when supervision is healthy. -`docs/turnend-guard.md` owns the per-harness hook mechanisms, empirical validation, scoping details, and documented fail-open tradeoffs. -Watcher liveness is harness-aware. -Do not assume one primary harness can use another harness's foreground or background shape. -For example, Claude uses a background-notify cycle, while Codex intentionally uses bounded foreground checkpoints. -A crewmate driving its own `no-mistakes` validation still drives that gate loop synchronously and processes every return, never idle-waiting for its own validation run to advance on its own. - -Token discipline: for a crewmate's current state prefer `bin/fm-crew-state.sh `, which looks for a branch-matched run-step before checking pane liveness, then falls back to the pane and log in that cheap-first order and treats the status log's last line as a wake event rather than the current state; default peeks to 40 lines; never stream a pane repeatedly through yourself; batch what you tell the captain. -The context-% shown in a peek is not actionable as crew health; ignore it and intervene only on real signals (`signal`, `stale`, `needs-decision`, `blocked`), looping or confusion in the pane, or a question the brief already answers. +Guard warnings do not replace the contract. +Queued wakes must be drained before other action, stale liveness must be repaired through the emitted protocol, and the worktree-tangle warning must be resolved without touching unlanded work. +The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout. +Harness-aware turn-end guards are structural backstops, not permission to omit the live cycle. ### Away-mode stub Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. -The skill owns the full daemon procedure: classification policy, batching, injection hardening, max-defer, verified submit, marker stripping, portable lock, dedupe, target discovery, reliability properties, and `FM_INJECT_SKIP`. -Inline facts that must survive without a loaded skill: +The skill owns the daemon procedure; these safety facts remain inline: -- Every daemon injection is prefixed with `FM_INJECT_MARK`, ASCII unit separator `0x1f`, so internal escalations are distinguishable from a captain message. -- While `state/.afk` exists, the daemon owns the watcher; do not separately arm `fm-watch-arm.sh` or `fm-watch.sh`. -- If firstmate receives a marked message while afk is active, it is an internal escalation: stay afk and process it. -- If the message starts with `/afk`, stay afk and refresh the flag. -- Any other unmarked message means the captain is back: stop the daemon so its shutdown flush runs while `state/.afk` is still set and clear `state/.afk` last (the `/afk` skill owns this ordering, via `bin/fm-afk-launch.sh stop`; clearing the flag first would make the flush a no-op), flush catch-up from `state/.wake-queue`, `state/.subsuper-escalations`, and `state/.subsuper-inject-wedged`, then resume the emitted primary-harness supervision protocol. -- Afk never changes approval authority; PR merges, ask-user findings, destructive actions, irreversible actions, and security-sensitive choices still require the same approval they required before. -- Bias ambiguous cases toward exit because a present captain beats token savings and a false exit is self-correcting. +- Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility. +- While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. +- A marked message while away mode is active is internal escalation and does not exit away mode. +- A message beginning `/afk` refreshes away mode. +- Any other unmarked message means the captain returned; load `/afk`, run the return owner, and do not process that message as ordinary work until its durable catch-up gate clears. +- Away mode never expands approval authority for merges, ask-user findings, destructive actions, irreversible actions, or security-sensitive choices. +- Bias ambiguous input toward exit because a present captain takes precedence. -### Stuck-crewmate recovery +### Stuck-worker trigger -On `stale`, looping, repeated confusion, an answered-by-brief question, an unresponsive pane, or a failed steer, load `stuck-crewmate-recovery`. -That playbook escalates from peek, to one-line steer, to harness-specific interrupt, to relaunch with a progress note, to `failed` with evidence. +Load `stuck-crewmate-recovery` after a stale wake, looping or confused pane, answered-by-brief question, unresponsive worker, or failed steer. ## 9. Escalation and captain etiquette **Talk in outcomes, not mechanics.** -Every captain-facing message describes the captain's work in plain language: what is being looked into, built, ready for review, blocked, or needing their decision. -Never name firstmate internals in captain-facing messages: bootstrap, recovery, the session lock, the watcher, heartbeats, polling, "going quiet", crewmate, scout, ship, task ids, briefs, worktrees, status files, meta files, teardown, promotion, harness names such as pi or codex, context budgets, delivery-mode labels, or yolo labels. -Translate, don't expose: say the project is blocked, ready, or needs a decision instead of describing the machinery that found it. - -Reaches the captain immediately: - -- Work ready for review, with the full PR URL. -- Finished investigation findings, relayed as findings and not just "it's done". -- Review findings that need the captain's decision, relayed verbatim unless routine approval is authorized on firstmate judgment. -- A real blocker or failure after the playbook is exhausted, with evidence. +Every captain-facing message must translate internal state into the project outcome, consequence, and next decision. +Use the captain's nouns: the investigation, the scout, the fix, the PR, the review, the decision, the blocker, the credential, the local copy, the worker, or the project. +Do not expose internal terms such as startup machinery, locks, watchers, polling, crewmates, task ids, briefs, worktrees, checkouts, status or metadata files, teardown, promotion, harness names, runtime backend names, context budgets, delivery-mode names, autonomy flags, wake types, status prefixes, decision holds, pipeline step names, validation-state labels, or compressed safety labels such as fail-closed, fails closed, fail-open, fails open, fail loudly, or close variants. +Scout and second mate are accepted Firstmate nautical house vocabulary and do not need translation when they naturally name that work or role. +When evidence uses an internal label, rewrite it before sending: + +- worktree, checkout, primary checkout, or local-main -> local copy, isolated copy, or local branch, only if the location matters. +- teardown -> cleanup. +- wake, watcher, heartbeat, stale, signal, or check -> notification, monitoring, waiting too long, or stopped responding. +- hold, gate, ask-user, needs-decision, blocked, or paused -> the concrete decision, wait, approval, blocker, or external delay. +- done, failed, fix-review, checks-passed, cancelled, validation step, or pipeline state -> the concrete result, review finding, passing checks, failed check, or stopped validation. +- brief -> instructions. +- crewmate -> worker, only when naming the helper matters. +- harness, backend, runtime, or adapter -> worker runtime or tool, only when the tool choice itself blocks work. +- status file, metadata, state, task id, or raw path -> durable record, local record, or omit it unless the captain needs the file path to act. +- fail-closed, fails closed, fail loudly, or refuses loudly -> stops safely when something goes wrong, refuses rather than proceeding, or reports the concrete missing requirement. +- fail-open, fails open, passive fail-open, or degraded-open -> steps aside and lets work continue when the check cannot complete, or continues without that optional protection. + +Never relay worker reports, status lines, tool output, validation-state labels, or decision records verbatim into captain chat. +Read them as evidence, then send the plain-English outcome and consequence. +Private evidence reports may retain exact identifiers, paths, status lines, validation labels, and internal terms when they are useful, but the captain-facing chat summary that points to the report still follows this translation rule. + +Every escalation must stand alone and remain concise. +Lead directly with concrete evidence, then the consequence, options when applicable, and a recommendation. +Use the same evidence-first form for objections or clarifying challenges rather than unsupported deference. + +Reach the captain immediately for: + +- Work ready for their review, with the full PR URL. +- Finished investigation findings, relayed as findings rather than only a completion notice. +- Gate findings that require their decision under the configured authority. +- A real blocker or failure after the relevant playbook is exhausted. - Anything destructive, irreversible, or security-sensitive. - A needed credential or login. -Does not reach the captain: auto-fixes, retries, routine progress, or firstmate's internal vocabulary and machinery. -Batch non-urgent updates into your next natural reply. -Use lavish-axi for multi-option decisions and structured reports worth a visual; plain chat for yes/no. -Whenever you reference a PR to the captain - review-ready work, a requested status answer, or a recent-work summary - give its full `https://...` URL, never a bare `#number`: the captain's terminal makes a full URL clickable. -A shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same message. -As a courtesy, mention cost when unusually much work is running (more than ~8 concurrent jobs); never block on 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. +Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface. +Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference. +Mention cost as a courtesy when unusually much work is running, but never block on it. -## 10. Backlog format +## 10. Backlog contract `data/backlog.md` is the durable queue. It tracks work items only, never agents; persistent secondmates never appear as backlog items. Work routed to a secondmate is recorded in that secondmate home's own backlog, not the main backlog. When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item; use `tasks-axi hold --reason "" --kind captain` for a captain-gated thread. +Unresolved decisions discovered by investigations or visual reviews follow `decision-hold-lifecycle`, which owns their mandatory backlog lifecycle. Update the backlog on every dispatch, completion, and decision for a work item. +Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. -```markdown -## In flight -- [ ] - (repo: , since ) +`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax. +Use compatible `tasks-axi` when the configured backend selects it and the documented manual path otherwise; keep only the configured recent Done entries. +`secondmate-provisioning` and `bin/fm-backlog-handoff.sh` own cross-home handoff safety. -## Queued -- [ ] - (repo: ) blocked-by: - +Keep free-form notes free of temporary paths, moving versions, ephemeral identifiers, and copied state that will rot. +Inspect the current task note before replacing its considered body, and archive the superseded body when recoverability matters rather than appending by default. +Verify volatile details against their authoritative config, live system, or API before acting, and correct or delete stale prose immediately. +Preserve durable structured identifiers, dependencies, and completion artifact links, and route reusable knowledge to section 6 rather than scattering it through task notes. -## Done -- [x] - - (merged ) -- [x] - - local main (merged ) -- [x] - - data//report.md (reported ) -``` +## 11. Crewmate briefs -Re-evaluate Queued on every teardown and every heartbeat: anything whose blocker is gone and whose time/date gate, if any, has arrived gets dispatched. - -A tracked `.tasks.toml` at this repo root pins the default `tasks-axi` markdown backend to `data/backlog.md`, with `done_keep = 10` and an archive at `data/done-archive.md`. -The local, gitignored `config/backlog-backend` file is the explicit opt-out knob. -Absent or `tasks-axi` means use the default tasks-axi backend; `manual` means force routine backlog updates to hand-editing even when `tasks-axi` is installed. -Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.1.1 or newer, `tasks-axi update --help` exposes `--archive-body`, and `tasks-axi mv --help` exposes `[...]` for atomic multi-ID moves. -When the default backend is selected and compatible `tasks-axi` is on PATH, firstmate mutates the backlog through its verbs instead of hand-editing, with secondmate handoffs still going through the validated helper described in section 6. -When the default backend is selected but `tasks-axi` is missing or incompatible, bootstrap reports it through the normal `MISSING:` consent flow in `docs/configuration.md` "Toolchain", and every firstmate home falls back to hand-editing routine `data/backlog.md` updates exactly as this section describes until it is installed. -When `config/backlog-backend=manual`, every firstmate home hand-edits routine backlog updates; bootstrap still requires compatible `tasks-axi` on `PATH` but does not print `TASKS_AXI: available`. -The `## In flight` / `## Queued` / `## Done` format above stays the contract: the verbs edit `data/backlog.md` in place, byte-exact, preserving whatever item forms the file already uses - the bold in-flight `- ****` form, the `- [ ]`/`- [x]` queued and done forms, and `blocked-by: - ` - rather than reformatting them. -Secondmates inherit `config/backlog-backend` from the primary. -If the primary leaves the file absent, each home uses the default tasks-axi backend path with its own `.tasks.toml`; if the primary opts out with `manual`, secondmate homes hand-edit routine backlog updates too. -Keep Done to the 10 most recent entries. -With the active compatible tasks-axi backend, `tasks-axi done` auto-prunes Done and archives pruned entries to `data/done-archive.md`, so do not hand-prune. -When hand-editing, prune older Done entries manually whenever you add to the section. -Pruning loses nothing: finished PR-based ship tasks live on as GitHub PRs, local-only ship tasks live on in local `main`, and scout tasks live on as report files. -Map firstmate's real backlog operations to the approved commands: - -- File an item: `tasks-axi add "" --kind --repo `, plus `--start` for immediate dispatch (In flight) or the default queue placement, and `--blocked-by ` (repeatable) when it waits on another task. -- Start an existing queued item: `tasks-axi start ` before dispatching work from Queued, after checking that blockers are gone and any time/date gate has arrived. -- Move a finished task to Done: `tasks-axi done --pr ` for a PR-based ship, `--report ` for a scout, or `--note "local main"` for a local-only merge. -- Update task notes: inspect first with `tasks-axi show --full`, then replace the considered body with `tasks-axi update --body-file `. - Add `--archive-body` to that update command when superseding prior state should remain recoverable. -- Manage dependencies: `tasks-axi block --by ` and `tasks-axi unblock --by `, then `tasks-axi ready` to list queued work with no unresolved blockers. - This is a dependency check only; future-dated items still stay queued until their date arrives. -- Read an item's full notes: `tasks-axi show --full`. -- Hand a task off to a secondmate home: load `secondmate-provisioning`, then keep using `bin/fm-backlog-handoff.sh ...`; do not call bare `tasks-axi mv` for this path, because the helper resolves and validates the secondmate home before moving anything. -- Normalize the file: `tasks-axi render` rewrites every id'd task in canonical form and leaves free-form lines untouched. - -**Note hygiene:** Keep free-form backlog and task note/status prose free of volatile incidental specifics that rot: temp paths, in-flight versions, moving state locations, and ephemeral IDs. -Reference the authoritative source instead of duplicating it into prose - "state per the module's backend config", not a literal path. -Before acting on a note's volatile detail, verify it against the source of truth (the config, the live system, the API); notes drift. -The backlog format's structured fields are different: task IDs, blocked-by IDs, and Done-entry PR URLs or report paths from `tasks-axi done --pr ` or `--report ` are the durable record required by this schema. -Correct or delete stale free-form notes the moment you catch them, and put durable facts in curated memory (section 6's knowledge-routing homes), not scattered across one-off task notes. +`bin/fm-brief.sh` and its help own scaffold syntax, generated variants, status protocol, delivery-mode definitions of done, and exact safety mechanics. +Use its scaffold as the contract, then replace every `{TASK}` placeholder with a clear task description, acceptance criteria, constraints, and necessary context before dispatch or seeding. +Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape. -## 11. Crewmate briefs +Every ship brief must retain the worktree-isolation assertion and stop if launched in the primary checkout. +If a ship task touches firstmate's shared tracked material, explicitly require `firstmate-coding-guidelines` before editing. +If a task will drive Herdr lifecycle behavior, scaffold with `--herdr-lab`; if that need appears after an unguarded scaffold, stop and regenerate rather than adding commands by hand. +The generated Herdr contract must use a named non-`default` isolated lab and its guarded helper for every lifecycle action. -Scaffold with `bin/fm-brief.sh ` - it writes `data//brief.md` with the standard contract (branch setup, status-reporting protocol, push/merge rules, definition of done) and all paths filled in. -The ship-brief Setup opens with a worktree-isolation assertion ahead of the branch step: the crewmate confirms it is in its own disposable task worktree, not the primary checkout, and stops with `blocked: launched in primary checkout, not an isolated worktree` if not - the upstream half of the worktree-tangle guard (section 8). -For a ship task the definition of done is shaped by the project's delivery mode (section 6): `no-mistakes` stops after the implementation commit, then firstmate triggers the harness-appropriate no-mistakes validation pipeline; `direct-PR` has the crewmate push and open the PR itself, and `local-only` has it stop at "ready in branch" for firstmate to review and merge locally. -The no-mistakes brief points to no-mistakes' version-matched guidance and keeps only firstmate-specific wrapper rules for `ask-user` escalation, `--yes` avoidance, and the CI-green done line. -The scaffold reads the mode via `fm-project-mode.sh`, so you do not pass it. -Ship briefs also include the project-memory contract: run `bin/fm-ensure-agents-md.sh` when the project already has agent-memory files or when the task produced durable project-intrinsic knowledge, then record proportionate learnings in `AGENTS.md`. -For scout tasks add `--scout`: the scaffold swaps the definition of done for the report contract (findings to `data//report.md`, no branch, no push, no PR) and declares the worktree scratch; scout is mode-agnostic. -Scout briefs do not include the project-memory step, because their deliverable is a report rather than a committed project change. -For a crewmate task that will drive Herdr lifecycle behavior, add `--herdr-lab`: the scaffold embeds the hard Herdr-isolation contract backed by `bin/fm-herdr-lab.sh` (a never-`default` lab session, a trailing `--session` on every Herdr call, guarded teardown, and a before/after fleet-state tripwire), and the flag is rejected for `--secondmate` briefs. -The flag must be explicit because the scaffold cannot read the `{TASK}` text it fills in later, so every ship or scout brief scaffolded without it carries a loud not-enabled gate telling the crewmate to stop and regenerate with `--herdr-lab` if the task turns out to touch Herdr lifecycle. -For secondmates use `bin/fm-brief.sh --secondmate {...|--no-projects}`. -The scaffold writes a charter brief instead of a task brief. -Set `FM_SECONDMATE_CHARTER=''` to fill the charter text and `FM_SECONDMATE_SCOPE=''` when the routing scope differs. -If you scaffold without `FM_SECONDMATE_CHARTER`, replace the `{TASK}` placeholder before seeding. -Keep the charter focused on persistent responsibility, available project clones, escalation back to the main firstmate status file, and the idle-by-default contract: reconcile only its own in-flight work and then wait, never self-initiating a survey or audit. -Preserve the requests-from-main-firstmate contract in the charter: marked requests return via status or a doc pointer, while unmarked direct captain messages stay conversational. -Before seeding, launching, recovering, or handing backlog to a secondmate home, load `secondmate-provisioning`. -The status-reporting protocol is intentionally sparse: crewmates append status only for supervisor-actionable phase changes, `needs-decision`/`blocked`/`paused`/`done`/`failed`, or the `resolved` line that closes a previously reported decision or blocker, because every append wakes firstmate. -`bin/fm-classify-lib.sh` owns the keyed open/resolved status contract. -For any generated brief that still contains `{TASK}`, replace it with a clear task description, acceptance criteria, and any constraints or context the crewmate needs before spawning or seeding. -Adjust the other sections only when the task genuinely deviates from the standard ship-a-new-PR shape (e.g. fixing an existing external PR); the scaffold is the contract, not a suggestion. +Load `secondmate-provisioning` before creating or using a charter brief and preserve its idle-by-default and marked-return-channel contracts. +Status appends are sparse supervisor-actionable events, not routine progress; `bin/fm-classify-lib.sh` owns keyed open and resolved semantics. +The scaffold is a safety contract, not a suggestion. ## 12. Self-update -firstmate is its own repo behind the no-mistakes gate, so improvements to `AGENTS.md`, `bin/`, `.agents/skills/`, and public `skills/` reach `main` and then wait for each running firstmate to pull them. -Only `AGENTS.md`, `bin/`, and `.agents/skills/` are a running firstmate instruction surface; public `skills/` is tracked for installers and is not loaded by firstmate. +Firstmate's shared instruction surface reaches running homes only after it lands on the default branch and those homes fast-forward. +Only `AGENTS.md`, `bin/`, and `.agents/skills/` are loaded by a running firstmate; public `skills/` is an installer-facing surface. When the captain invokes `/updatefirstmate` or asks to update firstmate, load the `/updatefirstmate` skill. -It performs only fast-forward self-updates of firstmate and registered secondmate homes, re-reads `AGENTS.md` when needed, nudges updated live secondmates, and never touches anything under `projects/`. +It performs guarded fast-forward updates of firstmate and registered secondmate homes, refreshes instructions, and never touches anything under `projects/`. ## 13. Agent-only reference skills -These skills are not captain-invocable; they are conditional operating references you must load at the trigger points below. +These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. +- `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. +- `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. -- `stuck-crewmate-recovery` - load after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. -- `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a secondmate home, and before editing `data/secondmates.md`. +- `project-management` - load before adding, creating, removing, or initializing a project. +- `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. +- `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. +- `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. ## 14. X mode -X mode lets a firstmate instance answer public mentions routed through the shared `@myfirstmate` relay, and act on actionable mention requests, in firstmate's own voice, from its live fleet state. -It ships inside this repo for every user but is **inert until opted in**, so a user who never enables it sees zero behavior change. - -**Activation is `.env` presence, not a command.** -Put one value, `FMX_PAIRING_TOKEN`, into a `.env` file at this home's root (`.env` is gitignored). -That token is the whole consent, including standing authorization for normal reversible lifecycle actions from mention requests, and the only required config; the relay derives the tenant from it. -It is not consent for destructive, irreversible, or security-sensitive actions; those still require trusted-channel confirmation first. -`FMX_RELAY_URL` is optional and defaults to `https://myfirstmate.io`; only a developer pointing at a local relay sets it. - -**Mechanism and cadence.** -Bootstrap wires the relay poll automatically and purely additively from `.env` presence; `docs/configuration.md` "X mode (.env)" owns the generated-artifact mechanism, the wire protocol, the poll cadence and its transition handling, and the watcher-backbone non-interference guarantee. -X mode is a reason to keep the watcher armed even with no fleet work, so an X-only user is still served. +X mode ships inert and causes no behavior change until the home opts in by placing `FMX_PAIRING_TOKEN` in its gitignored `.env`. +That token is consent for public replies and normal reversible lifecycle actions from eligible mentions, not authority for destructive, irreversible, or security-sensitive action; those still require trusted-channel confirmation. +`docs/configuration.md` owns activation, generated state, cadence, wire protocol, and opt-out mechanics. -**Answering.** -On an `x-mention ` or `x-mode-error ...` `check:` wake, load `fmx-respond` (section 13). -It owns mention classification, acting on the request, reply composition, voice, thread-splitting, image attachments, dry-run preview, and the completion-follow-up procedure in full, including what an `x-mode-error` wake means instead. -`docs/configuration.md` "X mode (.env)" has the wire-protocol reference. -The one fact that must survive here because it fires on a generic terminal wake, not the mention wake itself: when an X-mode-linked task reaches a terminal state, post its final completion follow-up per section 8's wake-handling step before tearing down. +An X-only home still requires the live supervision cycle so mentions can wake it without fleet work. +On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respond`, which owns classification, public-safety policy, reply or dismissal, task linking, and follow-ups. +For every X-linked terminal outcome, load that owner and post the final completion follow-up before teardown, regardless of earlier milestone follow-ups. ## Maintaining this file Keep this file for knowledge useful to almost every future agent session in this project. -Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Do not repeat what the codebase already shows; point to the authoritative file, skill, command, or doc. Prefer rewriting or pruning existing entries over appending new ones. -When updating this file, preserve this bar for all agents and keep entries concise. +When updating this file, preserve every safety boundary and keep the always-loaded contract concise. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a590180db3..7c3a1cab18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,8 @@ We require this to reduce the maintainer's burden of reviewing and merging contr Pushing through it runs an AI-driven review/test/lint pipeline in an isolated worktree, forwards the push upstream only after every check passes, and opens a clean PR automatically. A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and fails if the body is missing the deterministic signature that no-mistakes writes. -Dependency bots are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. +It evaluates every PR opening and body edit independently, so a later edit cannot replace an earlier pending compliance check. +GitHub Actions and Dependabot are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. ## Workflow @@ -47,7 +48,8 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. - Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. -- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) need empirical adapter notes in the relevant backend guide: `docs/tmux-backend.md`, `docs/herdr-backend.md`, `docs/zellij-backend.md`, `docs/orca-backend.md`, `docs/cmux-backend.md`, or `docs/codex-app-backend.md` for blocked Codex App transport work. +- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). +- [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. - In Markdown, put each full sentence on its own line. - `README.md` stays a concise overview plus pointers: it never carries a wall of inline detail. Route detail to the most specific `docs/` file (architecture, configuration, or a backend guide) and link to it instead. @@ -61,23 +63,41 @@ There is no reliable way for `bin/fm-brief.sh`'s scaffold to detect that a task' A crewmate picking up such a brief should load the skill even if the brief predates this instruction. When supervising live crewmates, keep firstmate's own long validation or build commands in the background so watcher wakes can still be handled. Crewmate validation follows the installed no-mistakes version's SKILL.md and live `axi` help instead of duplicating gate mechanics in firstmate docs. -Firstmate's wrapper still matters: `ask-user` findings route to the captain through firstmate, and crewmates avoid `--yes` because it silently resolves captain-owned decisions without escalation. -Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint and test commands to the same `bin/fm-lint.sh` and bash behavior suite as CI, so the pre-push gate runs the exact checks CI enforces. +Firstmate's wrapper still matters: crewmates route every `ask-user` finding to firstmate, which applies the authority contract in `AGENTS.md`, and crewmates avoid `--yes` because it would bypass that check and any required captain escalation. +Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint command to `bin/fm-lint.sh`, matching the Linux CI lint job. +Local no-mistakes Test is intent-targeted and must not re-run every `tests/*.test.sh`; `.github/workflows/ci.yml` owns the broad behavior suite plus platform-specific compatibility lanes. That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even when another no-mistakes-managed target project keeps committed PR evidence. Check and test the toolbelt before pushing: ```sh -for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt +while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the canonical shell surface bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run -for test_script in tests/*.test.sh; do bash "$test_script"; done # behavior tests, matching CI and no-mistakes commands.test +bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) +bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) +bin/fm-test-run.sh --changed # conservative changed-file-informed set (never silent full suite) +bin/fm-test-run.sh --proven-isolated --jobs 4 # explicit local parallel of the proven set only (default is serial) +bin/fm-test-run.sh --lane portable-serial # portable serial remainder (watcher/AFK/tmux/stateful) +bin/fm-test-run.sh --check-coverage # prove portable shards + serial + Herdr equal the full inventory +bin/fm-test-run.sh --all # deliberate complete regression (optional local full walk; not no-mistakes Test) +bin/fm-test-isolation-proof.sh --list # proven parallel candidate set (Phase 2 owner) +bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-isolation-proof.json # re-run concurrent isolation proof only [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] [ "$(readlink .claude/skills)" = "../.agents/skills" ] tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal) ``` -Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so run one directly to focus on a subject. -Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the run-all loop above is always safe. +`bin/fm-test-run.sh` is the single owner of behavior-suite selection, portable CI lane composition, optional local `--jobs` for the proven-isolated set only, per-script timing markers, family totals, the coverage guard, and the optional JSON timing artifact. +Its header and `--help` own the flags, family labels, lanes, and changed-file map; this section only documents the entry points. +`bin/fm-test-isolation-proof.sh` remains the single owner of the Phase 2 concurrent isolation proof and the exact proven candidate set; see `docs/fm-test-isolation-proof.md`. +Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. +Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. +Family selection is the ordinary local path; `--all` is deliberate full regression only. +CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. +Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. +Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. +The [Herdr backend guide](docs/herdr-backend.md#destructive-lab-safety) owns the lane's isolation boundary, while [runtime backend verification](docs/verification/runtime-backends.md#herdr) owns active empirical evidence; live harness credential tests remain opt-in. ## Questions diff --git a/README.md b/README.md index b330847449..b84c49484f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ But the moment you want three project tasks done in parallel - fixes, investigat firstmate flips the model. You talk to a single agent - the first mate - and it runs the crew for you: spawning autonomous agents in a visible session backend, giving each a clean git worktree, supervising them to completion, and handing you finished PRs, approved local merges, or standalone investigation reports. -For larger fleets, you can opt in to persistent secondmates: domain supervisors that are still ordinary direct reports, but run from their own isolated firstmate homes. +For larger fleets, you can opt in to persistent secondmates: second mates that are still ordinary direct reports, but run from their own isolated firstmate homes. firstmate is not a model, not a harness, not a skill, not an MCP server, and not a CLI. firstmate is an agent distro for running a crew of agents. @@ -44,12 +44,12 @@ Launching a supported harness inside it instantiates your first mate - and makes - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. -- **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report. +- **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. - **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 domain supervisors 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 in flight and supervision is not live. +- **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. +- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Guarded by construction** - the first mate is read-only over your projects outside guarded clone refreshes, safe branch pruning, and approved `local-only` fast-forward merges; crewmates make every project change behind your merge approval. +- **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. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). @@ -58,20 +58,21 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements -- A verified agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. +- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, OpenCode, or Devin CLI. - Git and the GitHub CLI, authenticated through `gh auth login`. -- tmux, for the reference session backend. +- The CLI and dependencies for your selected runtime backend; tmux is the reference default. -The first mate detects and offers to install everything else. +The first mate detects and offers to install supported missing tools after you approve. +Backend-specific setup is linked in [Documentation](#documentation). ### Recommended harnesses -**Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session. -Claude Code and Grok use background-notify wake cycles; Pi uses its tracked primary watcher extension. +**Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session, with `pi-signed` supported as Pi's distinct signed-wrapper identity. +Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses background-notify wake cycles, and Pi uses its tracked primary watcher extension. All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. -Codex and OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. +Codex, OpenCode, and Devin CLI are also verified and supported as primary harnesses; Codex and Devin use bounded foreground checkpoints, and OpenCode uses a TUI plugin, so all three carry more harness-specific supervision tradeoffs than the three co-primaries. ### Install and launch @@ -99,10 +100,28 @@ grok --trust ```sh pi +# or, when the signed wrapper is installed +FM_PI_HARNESS=pi-signed pi-signed ``` For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. -For Pi, approve the project trust prompt once per clone on first launch so both tracked `.pi/extensions/*.ts` files auto-load. +For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. +Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, while retaining native working activity and all model context and session data. +The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. +The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. +[Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). + +The other verified primary harnesses launch normally from the repository root: + +```sh +codex +opencode +devin +``` + +Install Devin CLI with Cognition's official installer when needed: `curl -fsSL https://cli.devin.ai/install.sh | bash`. +Approve Devin's workspace-trust prompt once per clone so it loads the tracked `.devin/config.json` safety hooks. +Devin uses bounded foreground supervision checkpoints. ### Talk to it @@ -110,8 +129,7 @@ For Pi, approve the project trust prompt once per clone on first launch so both > ahoy! look at my github project xyz, then fix the flaky login test and add dark mode # firstmate checks its toolchain (asking your consent before installing anything), -# clones the project under projects/, and spawns two crewmates in the active backend -# fm-fix-login-k3 and fm-dark-mode-p7. +# clones the project under projects/ and spawns two isolated workers in the active backend. # Minutes later: PR ready for review, captain: https://github.com/you/xyz/pull/42 @@ -146,12 +164,12 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze │ ├─ ship: project mode ► PR/local merge ► teardown │ - └─ scout: report at data//report.md ► relay findings ► teardown + └─ scout: report at data//report.md ► decision inventory ► relay findings ► teardown ``` You chat with the first mate. It routes each request to a crewmate in its own session endpoint and git worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports. -Optional secondmates extend this to persistent domain supervisors, dispatch profiles let you steer which harness handles which task, and an opt-in X mode lets the same fleet answer public mentions. +Optional secondmates extend this to persistent second mates, dispatch profiles let you steer which harness handles which task, and an opt-in X mode lets the same fleet answer public mentions. `codex-app` is not a runtime backend yet; [docs/codex-app-backend.md](docs/codex-app-backend.md) owns the Codex App boundary. Full architecture - the supervision engine, worktree isolation, secondmates, dispatch profiles, project modes, optional X mode, fleet sync, and self-update - is in [docs/architecture.md](docs/architecture.md). @@ -159,12 +177,13 @@ Full architecture - the supervision engine, worktree isolation, secondmates, dis ## Built-in skills Firstmate ships these user-invocable built-in skills. -Claude and grok use the slash form shown here; codex uses the same names with `$`, such as `$afk`. +Claude, Grok, and Devin use the slash form shown here; Codex uses the same names with `$`, such as `$afk`. | Skill | What it does | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery wedges while you step away | -| `/bearings` | Generate a "pick up where I left off" status report from the read-only fleet snapshot - backlog, per-task crew state, open PRs, scout reports, pending decisions, and date-gated queued work - written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | +| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | +| `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, falling back to Bearings when invoked as the session's first real captain message | +| `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | @@ -182,19 +201,24 @@ Firstmate's skills live in two separate places with different audiences: ## Documentation -- [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, and project modes work. +- [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. -- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for a wedged away-mode escalation delivery. -- [docs/tmux-backend.md](docs/tmux-backend.md) - setup guide for the tmux reference backend: prerequisites, attaching, and watching crew windows. -- [docs/herdr-backend.md](docs/herdr-backend.md) - setup guide for the experimental herdr backend, plus its verification notes and known gaps. -- [docs/zellij-backend.md](docs/zellij-backend.md) - setup guide for the experimental zellij backend, plus its verification notes and known gaps. -- [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps. -- [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. -- [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract. -- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified per-harness hook mechanisms, scoping, loop safety, and fail-open tradeoffs. -- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. +- [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. +- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. +- [docs/tmux-backend.md](docs/tmux-backend.md) - current setup and limits for the tmux reference backend. +- [docs/herdr-backend.md](docs/herdr-backend.md) - current setup, safety boundaries, and limits for the experimental Herdr backend. +- [docs/zellij-backend.md](docs/zellij-backend.md) - current setup and limits for the experimental Zellij backend. +- [docs/orca-backend.md](docs/orca-backend.md) - current setup and limits for the experimental Orca backend. +- [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend. +- [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract. +- [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/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, Devin, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. -- [`AGENTS.md`](AGENTS.md) - the distro's core instruction file and the first mate's full operating manual. +- [docs/documentation-audiences.md](docs/documentation-audiences.md) - documentation audiences and the machine-checked placement boundary. +- [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. ## Contributing diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 69dc0b53bd..12dc7629eb 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -581,8 +581,8 @@ fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending # has since moved its own confirmation to a native agent-state read instead # (docs/herdr-backend.md "Native agent-state submit confirmation"); cmux has # no analogous native primitive, so this composer-row approach remains -# cmux's own confirmation strategy. Echoes empty|pending|unknown|send-failed, the -# SAME vocabulary every existing backend already speaks. +# cmux's own confirmation strategy. Echoes empty|pending|unknown|send-failed, a +# subset of the proof-carrying submit vocabulary. fm_backend_cmux_send_text_submit() { # [expected-label] local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} i=0 state fm_backend_cmux_parse_target "$target" || { printf 'unknown'; return 0; } diff --git a/bin/backends/herdr-workspace-move.py b/bin/backends/herdr-workspace-move.py new file mode 100755 index 0000000000..020e77b2a3 --- /dev/null +++ b/bin/backends/herdr-workspace-move.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Send one narrowly scoped workspace.move request to a Herdr control socket. + +This helper is the wire transport for Firstmate's optional presentation-only +workspace ordering. It accepts only an exact workspace id and a non-negative +insert index, sends only the non-destructive ``workspace.move`` method, and +prints the verified JSON response. + +Wire protocol verified against Herdr 0.7.4, protocol 16: + + request: {"id":"fm-workspace-move","method":"workspace.move", + "params":{"workspace_id":W,"insert_index":N}}\n + response: {"id":"fm-workspace-move","result": + {"type":"workspace_list","workspaces":[...]}}\n +Usage: herdr-workspace-move.py + +Exit status: + 0 the server returned the matching workspace_list response; + 2 arguments or socket connection were invalid; + 3 the request could not be sent or its response could not be read; + 4 the response was malformed, mismatched, or reported an error. +""" + +import json +import socket +import sys +import time + + +CONNECT_TIMEOUT = 5.0 +RESPONSE_TIMEOUT = 5.0 +RECV_CHUNK = 65536 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +REQUEST_ID = "fm-workspace-move" + + +def _read_line(sock, deadline): + buffer = b"" + while b"\n" not in buffer: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + sock.settimeout(remaining) + try: + chunk = sock.recv(RECV_CHUNK) + except (OSError, socket.timeout): + return None + if not chunk: + return None + buffer += chunk + if len(buffer) > MAX_RESPONSE_BYTES: + return None + return buffer.split(b"\n", 1)[0] + + +def main(argv): + if len(argv) != 4: + return 2 + socket_path, workspace_id, raw_index = argv[1:] + if not socket_path.startswith("/") or not workspace_id: + return 2 + if any(char in workspace_id for char in "\t\r\n"): + return 2 + try: + insert_index = int(raw_index) + except ValueError: + return 2 + if insert_index < 0 or str(insert_index) != raw_index: + return 2 + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(CONNECT_TIMEOUT) + sock.connect(socket_path) + except OSError: + return 2 + + request = { + "id": REQUEST_ID, + "method": "workspace.move", + "params": {"workspace_id": workspace_id, "insert_index": insert_index}, + } + try: + sock.sendall( + (json.dumps(request, separators=(",", ":")) + "\n").encode("utf-8") + ) + except OSError: + return 3 + + line = _read_line(sock, time.monotonic() + RESPONSE_TIMEOUT) + if line is None: + return 3 + try: + response = json.loads(line.decode("utf-8", "replace")) + except ValueError: + return 4 + result = response.get("result") if isinstance(response, dict) else None + if ( + response.get("id") != REQUEST_ID + or response.get("error") is not None + or not isinstance(result, dict) + or result.get("type") != "workspace_list" + or not isinstance(result.get("workspaces"), list) + ): + return 4 + sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except (BrokenPipeError, KeyboardInterrupt): + sys.exit(3) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7874988b42..237d2348c5 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -11,14 +11,25 @@ # normal operation; the unit tests source it directly, so the FM_HOME fallback # below keeps that path sane without fm-backend.sh's preamble. # -# Container shape (D4, decided empirically - see herdr-verification-p2.md -# "Task container shape", refined by docs/herdr-backend.md "Task container -# shape"): ONE herdr workspace PER FIRSTMATE HOME (the primary, and each -# secondmate, gets its own), ONE herdr TAB per task inside its home's -# workspace. Workspace-per-task was tried and rejected (bad human-watching -# ergonomics); workspace-per-HOME keeps that same rejection while giving every -# home its own space, labeled distinctly, in the shared spaces sidebar. Target -# resolution and the human-watch story stay parallel to the tmux adapter. +# Default container shape (D4, decided empirically - see +# herdr-verification-p2.md "Task container shape", refined by +# docs/herdr-backend.md "Default task container shape"): ONE herdr workspace PER +# FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB +# per task inside its home's workspace. An optional, default-off presentation +# flag creates a disposable workspace for a clean fresh task instead. That +# workspace is a non-authoritative visual projection containing only the normal +# task pane. Its random token and mutable label never authorize lookup, +# adoption, reuse, closure, deletion, task ownership, or endpoint selection. +# A version 2 journal can participate in replacing only its exact same-identity +# endpoint after metadata, home, session, workspace, tab, pane, parent, shape, +# focus, and agent-absence checks all agree under the session lock. +# Every ambiguous recovered launch uses the default flat home workspace when +# duplicate-agent risk is independently absent. +# Target resolution stays parallel to the tmux adapter in both layouts. +# Projected create, move, and cleanup operations capture the named session's +# exact active workspace and tab. Herdr 0.7.4's last-pane close can focus an +# unrelated neighbor, so projected cleanup serializes and restores only the +# exact pre-close tab id, while refusing to close the active tab itself. # # Target string shape: ":", e.g. "default:w1:p2" (the # pane id itself contains a colon; the session is always the FIRST field, the @@ -29,10 +40,11 @@ # function has no herdr-specific logic; it just returns meta's window= # verbatim). # -# Recovery/orphan discovery (ids may not deterministically match live state +# Authoritative task recovery/orphan discovery (ids may not deterministically match live state # after a server restart in a differently-configured session; see the # verification doc) uses LABEL matching (fm- tab labels), never trusts a -# stored pane id blindly: fm_backend_herdr_list_live. +# stored pane id blindly: fm_backend_herdr_list_live. The presentation journal +# is deliberately excluded from that path. # # Requires: herdr (CLI + socket), jq (JSON parsing). Bootstrap detects these # through fm_backend_required_tools only when herdr is the resolved backend; @@ -73,6 +85,11 @@ FM_BACKEND_HERDR_MIN_PROTOCOL=14 # (14): the adapter's spawn/capture/send primitives work on 14, only the push # subscriber needs 16. FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL=16 +# workspace.move first appears in the protocol-16 schema. +# The installed CLI does not expose it as a workspace subcommand, so the +# presentation path uses one narrowly whitelisted raw-socket request after +# verifying the exact method and parameter schema. +FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL=16 # Per-pane escalation dedupe marker prefix, under the state dir. One marker per # window (keyed like the watcher's own .stale-): set when a ->blocked edge # is enqueued, cleared on any working edge, so exactly one wake fires per @@ -83,9 +100,18 @@ FM_BACKEND_HERDR_ESCALATED_PREFIX=".herdr-escalated-" # at a seeded secondmate home's root, containing exactly that secondmate's id. # The primary firstmate home never carries this marker. FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" +# The default-off presentation projection is intentionally separate from the +# authoritative task endpoint record. +# A per-task journal lives under state/ as .herdr-presentation. +# Version 1 records only the attempted projection's random correlator. +# Version 2 additionally binds the successful projection's exact home, +# session, workspace, tab, pane, parent, and presentation labels so a resumed +# spawn can replace one verified agent-free husk under the session lock. +# No send, capture, Treehouse, or general task-ownership path reads it. +FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace -# label (docs/herdr-backend.md "Task container shape"). The PRIMARY home (no +# label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no # secondmate marker) resolves to the constant "firstmate", byte-identical to # every pre-existing task's recorded label - no forced migration. A SECONDMATE # home resolves to "2ndmate-", so its tasks land in their own @@ -169,6 +195,613 @@ fm_backend_herdr_session() { printf '%s' "${HERDR_SESSION:-default}" } +# fm_backend_herdr_projection_id: generate a compact 128-bit base64url token. +# The token is a non-adversarial visual correlator, never destructive +# authority. +fm_backend_herdr_projection_id() { + local token + token=$(dd if=/dev/urandom bs=16 count=1 2>/dev/null \ + | base64 \ + | tr '+/' '-_' \ + | tr -d '=\r\n') || return 1 + [ "${#token}" -eq 22 ] || return 1 + case "$token" in + *[!A-Za-z0-9_-]*) return 1 ;; + esac + printf '%s' "$token" +} + +fm_backend_herdr_projection_journal_path() { # + printf '%s/%s%s' "$1" "$2" "$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX" +} + +# fm_backend_herdr_projection_journal_create: atomically publish the +# non-authoritative attempt journal before any projection workspace create. +# A hard-link publication in the same state directory gives create-if-absent +# semantics, so concurrent attempts cannot overwrite each other's token. +fm_backend_herdr_projection_journal_create() { # + local state=$1 id=$2 journal token tmp + case "$id" in + ''|.*|*[!A-Za-z0-9._-]*) + echo "error: invalid task id for herdr presentation journal" >&2 + return 1 + ;; + esac + mkdir -p "$state" || return 1 + journal=$(fm_backend_herdr_projection_journal_path "$state" "$id") + if [ -e "$journal" ] || [ -L "$journal" ]; then + echo "error: herdr presentation journal already exists for $id; refusing a concurrent or repeated projected create" >&2 + return 1 + fi + token=$(fm_backend_herdr_projection_id) || { + echo "error: could not generate a 128-bit herdr presentation projection id" >&2 + return 1 + } + tmp=$(mktemp "$state/.${id}.herdr-presentation.XXXXXX") || return 1 + chmod 0600 "$tmp" || { rm -f "$tmp"; return 1; } + if ! { + printf 'version=1\n' + printf 'task_id=%s\n' "$id" + printf 'projection_id=%s\n' "$token" + } > "$tmp"; then + rm -f "$tmp" + return 1 + fi + if ! ln "$tmp" "$journal" 2>/dev/null; then + rm -f "$tmp" + echo "error: herdr presentation journal appeared concurrently for $id; refusing projected create" >&2 + return 1 + fi + rm -f "$tmp" + printf '%s' "$token" +} + +fm_backend_herdr_projection_journal_field() { # + local journal=$1 key=$2 count + count=$(grep -c "^${key}=" "$journal" 2>/dev/null || true) + [ "$count" = 1 ] || return 1 + grep "^${key}=" "$journal" 2>/dev/null | cut -d= -f2- +} + +# fm_backend_herdr_projection_journal_snapshot: validate a version 1 attempt +# journal or a version 2 exact projection binding without sourcing shell code. +# Version 2 sets FM_BACKEND_HERDR_JOURNAL_* globals for same-process callers. +fm_backend_herdr_projection_journal_snapshot() { # + local journal=$1 id=$2 lines expected_label expected_task_label exact + FM_BACKEND_HERDR_JOURNAL_VERSION="" + FM_BACKEND_HERDR_JOURNAL_TASK_ID="" + FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID="" + FM_BACKEND_HERDR_JOURNAL_HOME="" + FM_BACKEND_HERDR_JOURNAL_SESSION="" + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID="" + FM_BACKEND_HERDR_JOURNAL_TAB_ID="" + FM_BACKEND_HERDR_JOURNAL_PANE_ID="" + FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID="" + FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL="" + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL="" + FM_BACKEND_HERDR_JOURNAL_TASK_LABEL="" + [ -f "$journal" ] && [ ! -L "$journal" ] || return 1 + lines=$(wc -l < "$journal" 2>/dev/null | tr -d '[:space:]') + FM_BACKEND_HERDR_JOURNAL_VERSION=$(fm_backend_herdr_projection_journal_field "$journal" version) || return 1 + FM_BACKEND_HERDR_JOURNAL_TASK_ID=$(fm_backend_herdr_projection_journal_field "$journal" task_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID=$(fm_backend_herdr_projection_journal_field "$journal" projection_id) || return 1 + [ "$FM_BACKEND_HERDR_JOURNAL_TASK_ID" = "$id" ] || return 1 + [ "${#FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID}" -eq 22 ] || return 1 + case "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" in + *[!A-Za-z0-9_-]*) return 1 ;; + esac + case "$FM_BACKEND_HERDR_JOURNAL_VERSION:$lines" in + 1:3) return 0 ;; + 2:12) ;; + *) return 1 ;; + esac + FM_BACKEND_HERDR_JOURNAL_HOME=$(fm_backend_herdr_projection_journal_field "$journal" home) || return 1 + FM_BACKEND_HERDR_JOURNAL_SESSION=$(fm_backend_herdr_projection_journal_field "$journal" session) || return 1 + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID=$(fm_backend_herdr_projection_journal_field "$journal" workspace_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_TAB_ID=$(fm_backend_herdr_projection_journal_field "$journal" tab_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PANE_ID=$(fm_backend_herdr_projection_journal_field "$journal" pane_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_journal_field "$journal" parent_workspace_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" parent_label) || return 1 + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" workspace_label) || return 1 + FM_BACKEND_HERDR_JOURNAL_TASK_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" task_label) || return 1 + case "$FM_BACKEND_HERDR_JOURNAL_HOME" in + /*) ;; + *) return 1 ;; + esac + for exact in \ + "$FM_BACKEND_HERDR_JOURNAL_SESSION" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID"; do + case "$exact" in + ''|*[[:space:]]*) return 1 ;; + esac + done + [ -n "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" ] \ + && [ -n "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" ] \ + && [ -n "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" ] || return 1 + expected_label=$(fm_backend_herdr_projection_workspace_label "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID") + expected_task_label="fm-$id" + [ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" = "$expected_label" ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" = "$expected_task_label" ] +} + +# fm_backend_herdr_projection_journal_token: validate and read either journal +# version's non-authoritative visual correlator. +fm_backend_herdr_projection_journal_token() { # + fm_backend_herdr_projection_journal_snapshot "$1" "$2" || return 1 + printf '%s' "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" +} + +fm_backend_herdr_projection_home_identity() { # + local home=$1 + [ -d "$home" ] || return 1 + (cd "$home" 2>/dev/null && pwd -P) +} + +fm_backend_herdr_projection_journal_write_v2() { # + local journal=$1 id=$2 token=$3 home=$4 session=$5 workspace=$6 tab=$7 pane=$8 + local parent_workspace=$9 parent_label=${10} workspace_label=${11} task_label=${12} state tmp + state=$(dirname "$journal") + tmp=$(mktemp "$state/.${id}.herdr-presentation.bind.XXXXXX") || return 1 + chmod 0600 "$tmp" || { rm -f "$tmp"; return 1; } + if ! { + printf 'version=2\n' + printf 'task_id=%s\n' "$id" + printf 'projection_id=%s\n' "$token" + printf 'home=%s\n' "$home" + printf 'session=%s\n' "$session" + printf 'workspace_id=%s\n' "$workspace" + printf 'tab_id=%s\n' "$tab" + printf 'pane_id=%s\n' "$pane" + printf 'parent_workspace_id=%s\n' "$parent_workspace" + printf 'parent_label=%s\n' "$parent_label" + printf 'workspace_label=%s\n' "$workspace_label" + printf 'task_label=%s\n' "$task_label" + } > "$tmp"; then + rm -f "$tmp" + return 1 + fi + [ -f "$journal" ] && [ ! -L "$journal" ] || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$journal" +} + +# fm_backend_herdr_projection_journal_bind: upgrade one exact version 1 +# attempt to a version 2 binding after the live projection and parent relation +# have both been verified under the session lock. +fm_backend_herdr_projection_journal_bind() { # + local journal=$1 id=$2 home=$3 session=$4 workspace=$5 tab=$6 pane=$7 + local parent_workspace=$8 parent_label=$9 workspace_label=${10} task_label=${11} token + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 1 ] || return 1 + token=$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID + fm_backend_herdr_projection_journal_write_v2 \ + "$journal" "$id" "$token" "$home" "$session" "$workspace" "$tab" "$pane" \ + "$parent_workspace" "$parent_label" "$workspace_label" "$task_label" +} + +# fm_backend_herdr_projection_journal_replace_endpoint: atomically advance one +# exact version 2 binding after its old husk was replaced successfully. +fm_backend_herdr_projection_journal_replace_endpoint() { # + local journal=$1 id=$2 old_tab=$3 old_pane=$4 new_tab=$5 new_pane=$6 + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 2 ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" = "$old_tab" ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" = "$old_pane" ] || return 1 + fm_backend_herdr_projection_journal_write_v2 \ + "$journal" "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_HOME" "$FM_BACKEND_HERDR_JOURNAL_SESSION" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" "$new_tab" "$new_pane" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" +} + +# fm_backend_herdr_projection_concise_task_label: strip redundant owner +# prefixes from a task id used only in the presentation workspace label. +# Removes firstmate/, 2ndmate-/, and a presentation-level fm- owner +# prefix when present. The ordinary task tab remains fm- and is not +# built by this helper. +fm_backend_herdr_projection_concise_task_label() { # + local task=$1 + case "$task" in + firstmate/*) task=${task#firstmate/} ;; + 2ndmate-*/*) task=${task#*/} ;; + esac + case "$task" in + fm-*) task=${task#fm-} ;; + esac + printf '%s' "$task" +} + +# fm_backend_herdr_projection_workspace_label: presentation-only child label. +# Format is literal U+2514 BOX DRAWINGS LIGHT UP AND RIGHT, one space, the +# concise task label, then the unchanged · p: suffix. +# Labels and tokens remain non-authoritative correlators only. +fm_backend_herdr_projection_workspace_label() { # + printf '└ %s · p:%s' "$(fm_backend_herdr_projection_concise_task_label "$1")" "$2" +} + +# fm_backend_herdr_presentation_session_lock_path: one machine-private lock +# path per live named Herdr session/socket, shared across every Firstmate home +# that uses that session. +# The path is never under any one home's state/ and secondmates never write the +# primary home. Returns non-zero when the named session's socket cannot be +# resolved unambiguously. +fm_backend_herdr_presentation_lock_namespace() { + printf '%s' '/tmp/firstmate-herdr-presentation' +} + +fm_backend_herdr_presentation_lock_namespace_mode() { + if [ "$(uname -s 2>/dev/null)" = Darwin ]; then + stat -f '%Lp' "$1" 2>/dev/null + else + stat -c '%a' "$1" 2>/dev/null + fi +} + +fm_backend_herdr_presentation_lock_namespace_uid() { + if [ "$(uname -s 2>/dev/null)" = Darwin ]; then + stat -f '%u' "$1" 2>/dev/null + else + stat -c '%u' "$1" 2>/dev/null + fi +} + +fm_backend_herdr_presentation_lock_namespace_valid() { + local dir=$1 expected_uid owner mode + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + expected_uid=$(id -u 2>/dev/null) || return 1 + owner=$(fm_backend_herdr_presentation_lock_namespace_uid "$dir") || return 1 + mode=$(fm_backend_herdr_presentation_lock_namespace_mode "$dir") || return 1 + [ "$owner" = "$expected_uid" ] && [ "$mode" = 700 ] +} + +# Resolve the one verified running named-session socket path as an absolute +# string. Requires JSON string type and non-empty length (jq -r is never used: +# it would turn JSON null into the literal string "null"). Canonicalizes the +# parent directory when that directory exists so symlink parents such as /tmp +# -> /private/tmp cannot yield two lock identities for the same socket. +fm_backend_herdr_presentation_session_socket_path() { # + local session=$1 sessions socket sock_dir sock_base + [ -n "$session" ] || return 1 + sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 + socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' + [.sessions[]? + | select(.name == $want and .running == true) + | select((.socket_path | type) == "string") + | select((.socket_path | length) > 0) + | .socket_path] + | if length == 1 then .[0] else empty end + ' 2>/dev/null) || return 1 + [ -n "$socket" ] || return 1 + case "$socket" in + /*) ;; + *) return 1 ;; + esac + sock_dir=$(dirname "$socket") + sock_base=$(basename "$socket") + [ -n "$sock_dir" ] && [ -n "$sock_base" ] || return 1 + if [ -d "$sock_dir" ]; then + sock_dir=$(cd "$sock_dir" 2>/dev/null && pwd -P) || return 1 + socket="$sock_dir/$sock_base" + fi + printf '%s' "$socket" +} + +fm_backend_herdr_presentation_session_lock_path() { # + local session=$1 socket key dir hash + [ -n "$session" ] || return 1 + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || return 1 + if command -v shasum >/dev/null 2>&1; then + hash=$(printf '%s\0%s' "$session" "$socket" | shasum -a 256 2>/dev/null | awk '{print $1}') + elif command -v sha256sum >/dev/null 2>&1; then + hash=$(printf '%s\0%s' "$session" "$socket" | sha256sum 2>/dev/null | awk '{print $1}') + else + return 1 + fi + [ -n "$hash" ] || return 1 + key=${hash:0:32} + dir=$(fm_backend_herdr_presentation_lock_namespace) || return 1 + [ -n "$dir" ] || return 1 + if [ ! -e "$dir" ] && [ ! -L "$dir" ]; then + if ! mkdir -m 700 "$dir" 2>/dev/null; then + fm_backend_herdr_presentation_lock_namespace_valid "$dir" || return 1 + fi + fi + fm_backend_herdr_presentation_lock_namespace_valid "$dir" || return 1 + printf '%s/order-%s.lock' "$dir" "$key" +} + +# fm_backend_herdr_projection_focus_snapshot: print the exact active +# workspace and tab ids as one tab-separated record. +# Presentation mutations use this read-only snapshot as their sole focus +# restoration authority. +# Labels, workspace order, and ambient client state are never focus authority. +fm_backend_herdr_projection_focus_snapshot() { # + local session=$1 list snapshot workspace tab tabs + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + snapshot=$(printf '%s' "$list" | jq -r ' + [.result.workspaces[]? | select(.focused == true)] + | select(length == 1) + | .[0] + | select((.workspace_id | type) == "string" and (.workspace_id | length) > 0) + | select((.active_tab_id | type) == "string" and (.active_tab_id | length) > 0) + | [.workspace_id, .active_tab_id] + | @tsv + ' 2>/dev/null) || return 1 + [ -n "$snapshot" ] || return 1 + workspace=${snapshot%%$'\t'*} + tab=${snapshot#*$'\t'} + [ -n "$workspace" ] && [ -n "$tab" ] && [ "$workspace" != "$tab" ] || return 1 + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$workspace" 2>/dev/null) || return 1 + printf '%s' "$tabs" | jq -e --arg tab "$tab" ' + (.result.tabs | type) == "array" + and ([.result.tabs[] | select(.focused == true)] | length) == 1 + and ([.result.tabs[] | select(.focused == true)][0].tab_id == $tab) + ' >/dev/null 2>&1 || return 1 + printf '%s\t%s' "$workspace" "$tab" +} + +# fm_backend_herdr_projection_focus_restore: verify that one presentation +# mutation preserved the exact active workspace and tab captured immediately +# before it. +# Herdr 0.7.4's pane.close can focus an unrelated neighboring workspace when +# it removes a non-focused workspace's last pane. +# A single tab.focus on the exact response-independent pre-operation tab id +# restores both the workspace and tab atomically. +fm_backend_herdr_projection_focus_restore() { # + local session=$1 before=$2 operation=$3 workspace tab after info restored + [ -n "$before" ] || { + echo "warning: herdr presentation $operation had no unambiguous pre-operation focus snapshot" >&2 + return 1 + } + after=$(fm_backend_herdr_projection_focus_snapshot "$session") || after= + [ "$after" != "$before" ] || return 0 + workspace=${before%%$'\t'*} + tab=${before#*$'\t'} + info=$(fm_backend_herdr_cli "$session" tab get "$tab" 2>/dev/null) || { + echo "warning: herdr presentation $operation changed focus and the exact prior tab could not be verified for restoration" >&2 + return 1 + } + if ! printf '%s' "$info" | jq -e --arg workspace "$workspace" --arg tab "$tab" ' + .result.tab.workspace_id == $workspace and .result.tab.tab_id == $tab + ' >/dev/null 2>&1; then + echo "warning: herdr presentation $operation changed focus and the exact prior tab response was ambiguous" >&2 + return 1 + fi + fm_backend_herdr_cli "$session" tab focus "$tab" >/dev/null 2>&1 || { + echo "warning: herdr presentation $operation changed focus and exact-tab restoration failed" >&2 + return 1 + } + restored=$(fm_backend_herdr_projection_focus_snapshot "$session") || restored= + if [ "$restored" != "$before" ]; then + echo "warning: herdr presentation $operation did not restore the exact prior workspace and tab" >&2 + return 1 + fi + return 0 +} + +# fm_backend_herdr_projection_close_pane_focus_preserving: close one exact +# response-derived projection pane without leaving the captain focused +# anywhere else. +# If the target belongs to the active tab, exact tab preservation is +# impossible, so cleanup refuses instead of changing focus. +fm_backend_herdr_projection_close_pane_focus_preserving() { # [required-agent-state] + local session=$1 pane_id=$2 required_agent_state=${3:-} + local before active_tab info target_pane target_tab close_status state + FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE="" + [ -n "$pane_id" ] || return 0 + before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "warning: herdr presentation cleanup could not capture exact active workspace and tab; refusing focus-unsafe pane close" >&2 + return 1 + } + active_tab=${before#*$'\t'} + info=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>/dev/null) || { + echo "warning: herdr presentation cleanup could not verify the exact pane; refusing focus-unsafe pane close" >&2 + return 1 + } + target_pane=$(printf '%s' "$info" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + if [ "$target_pane" != "$pane_id" ] || [ -z "$target_tab" ]; then + echo "warning: herdr presentation cleanup received an ambiguous exact-pane response; refusing focus-unsafe pane close" >&2 + return 1 + fi + if [ "$target_tab" = "$active_tab" ]; then + echo "warning: herdr presentation cleanup target is the captain's active tab; refusing a close that cannot preserve focus" >&2 + return 1 + fi + if [ -n "$required_agent_state" ]; then + state=$(fm_backend_herdr_pane_agent_state "$session" "$pane_id") + FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE=$state + [ "$state" = "$required_agent_state" ] || return 1 + fi + if fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1; then + close_status=0 + else + close_status=$? + fi + fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || return 2 + [ "$close_status" -eq 0 ] +} + +# fm_backend_herdr_projection_order_best_effort: place the exact workspace id +# returned by THIS projected create immediately after its owning parent's +# contiguous child block and before the next parent. +# +# is the owning FM_HOME label (firstmate or 2ndmate-). +# New-format └ ... · p: children and, for compatibility only, already +# adjacent old-format firstmate/... or 2ndmate-/... projections may extend +# the block read-only; they are never renamed or moved. +# +# This is presentation-only and always returns success. +# Every unavailable, ambiguous, failed, or unverifiable ordering step prints a +# warning and leaves the safely-created worker running in Herdr's current +# order. +# It never looks up a task endpoint, adopts or reuses a workspace, retries an +# ambiguous move, or calls any close/delete/rename primitive. +# The sole move target is , captured directly from the +# current workspace-create response. +# After a successful move, every pre-existing workspace id sequence excluding +# the new id must be byte-identical to the pre-move sequence. +fm_backend_herdr_projection_order_best_effort() { # + local session=$1 created=$2 parent=$3 list analysis current desired protocol schema socket mover response move_status focus_before + local before_existing after_existing + [ -n "$parent" ] || { + echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2 + return 0 + } + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { + echo "warning: herdr presentation ordering could not list workspaces; leaving worker in Herdr's current order" >&2 + return 0 + } + analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" ' + def is_parent: + (.label | type) == "string" and .label == $parent; + def is_top_level_parent: + (.label | type) == "string" + and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$"))); + def is_new_child: + (.label | type) == "string" + and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$")); + def is_legacy_child: + (.label | type) == "string" + and (.label | test("^(firstmate|2ndmate-[^/]+)/.+ · p:[A-Za-z0-9_-]{22}$")); + def is_legacy_child_for($owner): + is_legacy_child and (.label | startswith($owner + "/")); + def is_child_for($owner): + is_new_child or is_legacy_child_for($owner); + (.result.workspaces // null) as $spaces + | select(($spaces | type) == "array" and ($spaces | length) > 0) + | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $created)]) as $matches + | select(($matches | length) == 1) + | ($matches[0]) as $current + | select($current == (($spaces | length) - 1)) + | ([range(0; $spaces | length) | select($spaces[.] | is_parent)]) as $parents + | select(($parents | length) == 1) + | ($parents[0]) as $pidx + | select($pidx < $current) + | ( + reduce range($pidx + 1; $current) as $i ( + 0; + if ($spaces[$i] | is_child_for($parent)) and (. == ($i - $pidx - 1)) + then . + 1 + else . + end + ) + ) as $block + | (reduce range($pidx + 1 + $block; $current) as $i ( + {valid: true, active_parent: null}; + if .valid == false then . + elif ($spaces[$i] | is_top_level_parent) then + .active_parent = $spaces[$i].label + elif ($spaces[$i] | is_new_child) then + if .active_parent == null then .valid = false else . end + elif ($spaces[$i] | is_legacy_child) then + .active_parent as $owner + | if $owner == null then + .valid = false + elif (($spaces[$i] | is_legacy_child_for($owner)) | not) then + .valid = false + else + . + end + else + .active_parent = null + end + )) as $remainder + | select($remainder.valid == true) + | { + current: $current, + desired: ($pidx + 1 + $block), + parent_index: $pidx, + existing: [$spaces[] | select(.workspace_id != $created) | .workspace_id] + } + ' 2>/dev/null) || analysis= + [ -n "$analysis" ] || { + echo "warning: herdr presentation ordering found an ambiguous workspace layout; leaving worker in Herdr's current order" >&2 + return 0 + } + current=$(printf '%s' "$analysis" | jq -r '.current // empty' 2>/dev/null) + desired=$(printf '%s' "$analysis" | jq -r '.desired // empty' 2>/dev/null) + case "$current:$desired" in + *[!0-9:]*) + echo "warning: herdr presentation ordering could not parse the target position; leaving worker in Herdr's current order" >&2 + return 0 + ;; + esac + [ "$current" != "$desired" ] || return 0 + + command -v python3 >/dev/null 2>&1 || { + echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 + return 0 + } + protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in + ''|*[!0-9]*) + echo "warning: herdr presentation ordering could not verify the client protocol; leaving worker in Herdr's current order" >&2 + return 0 + ;; + esac + if [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ]; then + echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 + return 0 + fi + schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || { + echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 + return 0 + } + if ! printf '%s' "$schema" | jq -e ' + any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") + and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] + and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" + ' >/dev/null 2>&1; then + echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 + return 0 + fi + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "warning: herdr presentation ordering found an ambiguous named session socket; leaving worker in Herdr's current order" >&2 + return 0 + } + + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "warning: herdr presentation ordering could not capture exact active workspace and tab; leaving worker in Herdr's current order" >&2 + return 0 + } + if response=$("$mover" "$socket" "$created" "$desired" 2>/dev/null); then + move_status=0 + else + move_status=$? + fi + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "workspace move" || true + if [ "$move_status" -ne 0 ]; then + echo "warning: herdr presentation workspace move failed or had an ambiguous response; leaving worker running without cleanup" >&2 + return 0 + fi + if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --argjson desired "$desired" ' + .result.type == "workspace_list" + and (.result.workspaces | type) == "array" + and .result.workspaces[$desired].workspace_id == $created + and ([.result.workspaces[] | select(.label == $parent)] | length) == 1 + and ( + [range(0; .result.workspaces | length) as $i + | select(.result.workspaces[$i].label == $parent) + | $i][0] < $desired + ) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation workspace move returned an unverifiable order; leaving worker running without cleanup" >&2 + return 0 + fi + + before_existing=$(printf '%s' "$analysis" | jq -c '.existing' 2>/dev/null) + after_existing=$(printf '%s' "$response" | jq -c --arg created "$created" '[.result.workspaces[] | select(.workspace_id != $created) | .workspace_id]' 2>/dev/null) + if [ "$after_existing" != "$before_existing" ]; then + echo "warning: herdr presentation workspace move did not preserve relative order; leaving worker running without cleanup" >&2 + fi + return 0 +} + # fm_backend_herdr_server_ensure: start the herdr server for # headless (no TUI client) if not already running, mirroring tmux's `tmux # has-session || tmux new-session -d`. Verified: a bare socket CLI call does @@ -250,8 +883,8 @@ fm_backend_herdr_workspace_find() { # # workspace - callers only invoke it once at least one other (real task) tab # exists alongside it, never right after workspace creation - and this # function independently re-checks the tab count as a second layer. -fm_backend_herdr_workspace_prune_seeded_default_tab() { # - local session=$1 wsid=$2 tab_id=$3 tabs tab_count current_label pane_id agent_out agent_status +fm_backend_herdr_workspace_prune_seeded_default_tab() { # [focus-preserving] + local session=$1 wsid=$2 tab_id=$3 close_mode=${4:-direct} tabs tab_count current_label pane_id agent_out agent_status [ -n "$tab_id" ] || return 0 tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null) || return 0 tab_count=$(printf '%s' "$tabs" | jq -r '.result.tabs? // [] | length' 2>/dev/null) @@ -263,7 +896,11 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # /dev/null) agent_status=$(printf '%s' "$agent_out" | jq -r '.result.agent.agent_status // empty' 2>/dev/null) [ "$agent_status" = working ] && return 0 - fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || true + if [ "$close_mode" = focus-preserving ]; then + fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$pane_id" + else + fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || true + fi } # fm_backend_herdr_workspace_ensure: this HOME's persistent workspace inside @@ -428,24 +1065,28 @@ fm_backend_herdr_tab_is_husk() { # esac } -# fm_backend_herdr_agent_alive: CONFIDENT liveness of a live harness-agent -# PROCESS under (":"), for the same -# session-start secondmate-liveness sweep fm_backend_tmux_agent_alive serves -# (bin/fm-bootstrap.sh; docs/herdr-backend.md "Agent liveness probe reuses the -# husk classifier"). Reuses fm_backend_herdr_pane_agent_state, the -# already-verified husk classifier ("Respawn idempotency" above): `dead` -# (structurally gone pane) and `no-agent` (a restored, agent-less bare shell -# - EXACTLY the shape a dead secondmate leaves behind) both collapse to -# `dead`; `live` (a real registered agent_status, including idle/blocked) -# maps to `alive`; `unknown` stays `unknown` - fail-safe toward refusal, -# exactly like the husk check itself. Callers must never treat `unknown` as a -# confirmed-dead signal. -fm_backend_herdr_agent_alive() { # +# fm_backend_herdr_agent_state: recovery-grade state for the same session-start +# sweep as the tmux classifier. It reuses the husk classifier rather than +# creating a second Herdr state machine: a structurally gone pane is `missing`, +# a confirmed agent-less pane is `dead`, a registered agent is `alive`, and an +# unexpected or failed API read is `unreadable`. +fm_backend_herdr_agent_state() { # local target=$1 - fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + fm_backend_herdr_parse_target "$target" || { printf 'unreadable'; return 0; } case "$(fm_backend_herdr_pane_agent_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")" in - dead|no-agent) printf 'dead' ;; + dead) printf 'missing' ;; + no-agent) printf 'dead' ;; live) printf 'alive' ;; + *) printf 'unreadable' ;; + esac +} + +# Backward-compatible three-state view for callers that only need a yes/no +# agent verdict. The detailed state contract is owned by fm_backend_agent_state. +fm_backend_herdr_agent_alive() { # + case "$(fm_backend_herdr_agent_state "$1")" in + alive) printf 'alive' ;; + dead|missing) printf 'dead' ;; *) printf 'unknown' ;; esac } @@ -471,7 +1112,7 @@ fm_backend_herdr_agent_alive() { # # Ordering is deliberate: the REPLACEMENT tab is created FIRST, and the husk # is closed only AFTER that succeeds - never the reverse. Closing a # workspace's LAST remaining tab deletes the whole workspace on real herdr -# (docs/herdr-backend.md "Workspace lifecycle"), and a session-restore husk +# (docs/herdr-backend.md "Default workspace lifecycle"), and a session-restore husk # can legitimately be that workspace's only tab (e.g. its own seeded default # tab was already pruned, long before the restart, by a prior real task tab # existing alongside it). Herdr's lack of label-uniqueness enforcement is @@ -550,6 +1191,457 @@ EOF printf '%s %s' "$tab_id" "$pane_id" } +# fm_backend_herdr_projection_create_task: create one disposable presentation +# workspace and its normal fm- task tab without looking up, adopting, or +# reusing any existing workspace. +# The caller must atomically publish the projection journal first. +# This function sets exact response-derived globals and prints nothing: +# FM_BACKEND_HERDR_PROJECTION_SESSION +# FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID +# FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID +# FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID +# FM_BACKEND_HERDR_PROJECTION_TAB_ID +# FM_BACKEND_HERDR_PROJECTION_PANE_ID +# FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE +# CLEANUP_SAFE becomes 1 only after both creates returned complete exact IDs. +# A missing, failed, or malformed create response stays ambiguous and grants no +# cleanup authority. +fm_backend_herdr_projection_create_task() { # + local cwd=$1 workspace_label=$2 task_label=$3 session out tabs panes tab_count pane_count focus_before + FM_BACKEND_HERDR_PROJECTION_SESSION="" + FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID="" + FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID="" + FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID="" + FM_BACKEND_HERDR_PROJECTION_TAB_ID="" + FM_BACKEND_HERDR_PROJECTION_PANE_ID="" + FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE=0 + + fm_backend_herdr_version_check || return 1 + session=$(fm_backend_herdr_session) + fm_backend_herdr_server_ensure "$session" || return 1 + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "error: herdr presentation workspace create could not capture exact active workspace and tab; refusing a focus-unsafe projection" >&2 + return 1 + } + if out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" --label "$workspace_label" --no-focus 2>/dev/null); then + : + else + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "workspace create" || true + echo "error: herdr presentation workspace create failed ambiguously; leaving its journal quarantined" >&2 + return 1 + fi + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "workspace create" || { + echo "error: herdr presentation workspace create did not preserve exact active focus; leaving its journal quarantined" >&2 + return 1 + } + # shellcheck disable=SC2034 # caller consumes the response-derived global + FM_BACKEND_HERDR_PROJECTION_SESSION=$session + FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null) + FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null) + if [ -z "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" ] \ + || [ -z "$FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID" ] \ + || [ -z "$FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID" ]; then + echo "error: herdr presentation workspace create returned incomplete IDs; leaving its journal quarantined" >&2 + return 1 + fi + + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "error: herdr presentation task-tab create could not capture exact active workspace and tab; refusing a focus-unsafe projection" >&2 + return 1 + } + if out=$(fm_backend_herdr_cli "$session" tab create \ + --workspace "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" \ + --cwd "$cwd" --label "$task_label" --no-focus 2>/dev/null); then + : + else + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "task-tab create" || true + echo "error: herdr presentation task-tab create failed ambiguously; leaving its journal quarantined" >&2 + return 1 + fi + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "task-tab create" || { + echo "error: herdr presentation task-tab create did not preserve exact active focus; leaving its journal quarantined" >&2 + return 1 + } + FM_BACKEND_HERDR_PROJECTION_TAB_ID=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + FM_BACKEND_HERDR_PROJECTION_PANE_ID=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null) + if [ -z "$FM_BACKEND_HERDR_PROJECTION_TAB_ID" ] || [ -z "$FM_BACKEND_HERDR_PROJECTION_PANE_ID" ]; then + echo "error: herdr presentation task-tab create returned incomplete IDs; leaving its journal quarantined" >&2 + return 1 + fi + # shellcheck disable=SC2034 # caller consumes the same-process cleanup gate + FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE=1 + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "error: herdr presentation seeded-tab prune could not capture exact active workspace and tab; refusing a focus-unsafe prune" >&2 + return 1 + } + if ! fm_backend_herdr_workspace_prune_seeded_default_tab \ + "$session" \ + "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" \ + "$FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID" \ + focus-preserving; then + echo "error: herdr presentation seeded-tab prune refused a focus-unsafe close; leaving its journal quarantined" >&2 + return 1 + fi + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "seeded-tab prune" || { + echo "error: herdr presentation seeded-tab prune did not preserve exact active focus; leaving its journal quarantined" >&2 + return 1 + } + + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" 2>/dev/null) || { + echo "error: could not verify the disposable herdr presentation workspace shape" >&2 + return 1 + } + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" 2>/dev/null) || { + echo "error: could not verify the disposable herdr presentation pane shape" >&2 + return 1 + } + if ! printf '%s' "$tabs" | jq -e '(.result.tabs | type) == "array"' >/dev/null 2>&1 \ + || ! printf '%s' "$panes" | jq -e '(.result.panes | type) == "array"' >/dev/null 2>&1; then + echo "error: could not parse the disposable herdr presentation workspace shape" >&2 + return 1 + fi + tab_count=$(printf '%s' "$tabs" | jq -r '.result.tabs | length' 2>/dev/null) + pane_count=$(printf '%s' "$panes" | jq -r '.result.panes | length' 2>/dev/null) + if [ "$tab_count" != 1 ] || [ "$pane_count" != 1 ] \ + || ! printf '%s' "$tabs" | jq -e --arg task "$FM_BACKEND_HERDR_PROJECTION_TAB_ID" \ + --arg seeded "$FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID" \ + '.result.tabs[0].tab_id == $task and ([.result.tabs[] | select(.tab_id == $seeded)] | length) == 0' >/dev/null 2>&1 \ + || ! printf '%s' "$panes" | jq -e --arg pane "$FM_BACKEND_HERDR_PROJECTION_PANE_ID" \ + --arg tab "$FM_BACKEND_HERDR_PROJECTION_TAB_ID" \ + '.result.panes[0].pane_id == $pane and .result.panes[0].tab_id == $tab' >/dev/null 2>&1; then + echo "error: disposable herdr presentation workspace did not converge to exactly one task pane" >&2 + return 1 + fi + return 0 +} + +# fm_backend_herdr_projection_cleanup_exact: same-process abort cleanup for a +# projection whose create calls returned complete exact IDs. +# It performs no lookup and never calls workspace close. +fm_backend_herdr_projection_cleanup_exact() { # + local session=$1 task_pane=$2 seeded_pane=$3 + [ -z "$task_pane" ] || fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$task_pane" || true + if [ -n "$seeded_pane" ] && [ "$seeded_pane" != "$task_pane" ]; then + fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$seeded_pane" || true + fi +} + +# fm_backend_herdr_projection_parent_workspace_exact: resolve one exact parent +# workspace only when its presentation label is unique in the named session. +fm_backend_herdr_projection_parent_workspace_exact() { # + local session=$1 parent_label=$2 list + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + printf '%s' "$list" | jq -er --arg parent_label "$parent_label" ' + (.result.workspaces // null) as $spaces + | select(($spaces | type) == "array") + | [$spaces[]? | select(.label == $parent_label)] + | if length == 1 + and (.[0].workspace_id | type) == "string" + and (.[0].workspace_id | length) > 0 + then .[0].workspace_id + else empty + end + ' 2>/dev/null +} + +# fm_backend_herdr_projection_live_binding_matches: verify one exact projected +# workspace, its single task tab/pane, its unique token label, and its current +# position inside the exact parent's contiguous child block. +# This read-only predicate grants no mutation authority by itself. +fm_backend_herdr_projection_live_binding_matches() { # + local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6 + local parent_label=$7 workspace_label=$8 task_label=$9 list tabs panes + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + printf '%s' "$list" | jq -e \ + --arg token "$token" \ + --arg workspace "$workspace" \ + --arg parent_workspace "$parent_workspace" \ + --arg parent_label "$parent_label" \ + --arg workspace_label "$workspace_label" ' + def is_new_child: + (.label | type) == "string" + and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$")); + def is_legacy_child_for($owner): + (.label | type) == "string" + and (.label | test("^(firstmate|2ndmate-[^/]+)/.+ · p:[A-Za-z0-9_-]{22}$")) + and (.label | startswith($owner + "/")); + (.result.workspaces // null) as $spaces + | select(($spaces | type) == "array") + | select(([$spaces[]? | select(.workspace_id == $workspace)] | length) == 1) + | select(([$spaces[]? | select(.workspace_id == $workspace and .label == $workspace_label)] | length) == 1) + | select(([$spaces[]? | select((.label | type) == "string" and (.label | endswith(" · p:" + $token)))] | length) == 1) + | select(([$spaces[]? | select((.label | type) == "string" and (.label | endswith(" · p:" + $token)) and .workspace_id == $workspace)] | length) == 1) + | select(([$spaces[]? | select(.workspace_id == $parent_workspace and .label == $parent_label)] | length) == 1) + | select(([$spaces[]? | select(.label == $parent_label)] | length) == 1) + | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $parent_workspace)]) as $parents + | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $workspace)]) as $children + | select(($parents | length) == 1 and ($children | length) == 1) + | ($parents[0]) as $parent_index + | ($children[0]) as $child_index + | select($child_index > $parent_index) + | reduce range($parent_index + 1; $child_index) as $i + (true; . and (($spaces[$i] | is_new_child) or ($spaces[$i] | is_legacy_child_for($parent_label)))) + | select(. == true) + ' >/dev/null 2>&1 || return 1 + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$workspace" 2>/dev/null) || return 1 + printf '%s' "$tabs" | jq -e --arg tab "$tab" --arg task_label "$task_label" ' + (.result.tabs | type) == "array" + and (.result.tabs | length) == 1 + and .result.tabs[0].tab_id == $tab + and .result.tabs[0].label == $task_label + ' >/dev/null 2>&1 || return 1 + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$workspace" 2>/dev/null) || return 1 + printf '%s' "$panes" | jq -e --arg tab "$tab" --arg pane "$pane" ' + (.result.panes | type) == "array" + and (.result.panes | length) == 1 + and .result.panes[0].pane_id == $pane + and .result.panes[0].tab_id == $tab + ' >/dev/null 2>&1 +} + +fm_backend_herdr_projection_reclaim_rollback() { # + local session=$1 new_pane=$2 state + state=$(fm_backend_herdr_pane_agent_state "$session" "$new_pane") + case "$state" in + dead) return 0 ;; + no-agent) ;; + live|unknown) return 1 ;; + esac + fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$new_pane" no-agent || return 1 + [ "$(fm_backend_herdr_pane_agent_state "$session" "$new_pane")" = dead ] +} + +# fm_backend_herdr_projection_reclaim_task: replace one exact agent-free +# restored projection husk inside its original workspace. +# The caller holds the session presentation lock and has already established +# that flat fallback is safe across every token match. +# Return 0 means exact reclaim, 2 means non-mutating or exactly rolled-back +# refusal with flat fallback permitted, and 1 means a live/unknown or +# post-mutation uncertainty that must refuse the launch. +fm_backend_herdr_projection_reclaim_task() { # + local session=$1 journal=$2 id=$3 home=$4 meta_workspace=$5 meta_tab=$6 meta_pane=$7 + local parent_label=$8 task_label=$9 cwd=${10} canonical_home state focus_before active_tab out new_tab new_pane info close_status + FM_BACKEND_HERDR_PROJECTION_TAB_ID="" + FM_BACKEND_HERDR_PROJECTION_PANE_ID="" + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + if [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" != 2 ]; then + echo "warning: herdr presentation journal for $id has no exact restart binding; spawning flat" >&2 + return 2 + fi + canonical_home=$(fm_backend_herdr_projection_home_identity "$home") || { + echo "warning: herdr presentation home for $id could not be resolved exactly; spawning flat" >&2 + return 2 + } + if [ "$FM_BACKEND_HERDR_JOURNAL_HOME" != "$canonical_home" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_SESSION" != "$session" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" != "$meta_workspace" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" != "$meta_tab" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" != "$meta_pane" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" != "$parent_label" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" != "$task_label" ]; then + echo "warning: herdr presentation binding for $id does not match its exact home, endpoint, or parent; spawning flat" >&2 + return 2 + fi + if ! fm_backend_herdr_projection_live_binding_matches \ + "$session" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \ + "$meta_workspace" "$meta_tab" "$meta_pane" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$parent_label" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$task_label"; then + echo "warning: herdr presentation binding for $id has an ambiguous, renamed, foreign, or non-nested live shape; spawning flat" >&2 + return 2 + fi + state=$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane") + case "$state" in + no-agent) ;; + dead) + echo "warning: exact herdr presentation pane for $id is gone; spawning flat" >&2 + return 2 + ;; + live|unknown) + echo "error: exact herdr presentation pane for $id is $state; refusing duplicate launch" >&2 + return 1 + ;; + esac + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "warning: herdr presentation reclaim for $id could not capture exact focus; spawning flat" >&2 + return 2 + } + active_tab=${focus_before#*$'\t'} + if [ "$active_tab" = "$meta_tab" ]; then + echo "warning: herdr presentation reclaim for $id would replace the active tab; spawning flat" >&2 + return 2 + fi + if ! out=$(fm_backend_herdr_cli "$session" tab create \ + --workspace "$meta_workspace" --cwd "$cwd" --label "$task_label" --no-focus 2>/dev/null); then + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1 + echo "warning: herdr presentation reclaim for $id could not create an exact replacement; spawning flat" >&2 + return 2 + fi + new_tab=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + new_pane=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null) + if [ -z "$new_tab" ] || [ -z "$new_pane" ]; then + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1 + echo "warning: herdr presentation reclaim for $id returned ambiguous replacement ids; spawning flat" >&2 + return 2 + fi + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1 + info=$(fm_backend_herdr_cli "$session" tab get "$new_tab" 2>/dev/null) || info= + if ! printf '%s' "$info" | jq -e --arg tab "$new_tab" --arg workspace "$meta_workspace" ' + .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace + ' >/dev/null 2>&1; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id could not verify its replacement tab; spawning flat" >&2 + return 2 + fi + info=$(fm_backend_herdr_cli "$session" pane get "$new_pane" 2>/dev/null) || info= + if ! printf '%s' "$info" | jq -e --arg pane "$new_pane" --arg tab "$new_tab" --arg workspace "$meta_workspace" ' + .result.pane.pane_id == $pane + and .result.pane.tab_id == $tab + and .result.pane.workspace_id == $workspace + ' >/dev/null 2>&1; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id could not verify its replacement pane; spawning flat" >&2 + return 2 + fi + state=$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane") + case "$state" in + no-agent) ;; + live|unknown) + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "error: herdr presentation pane for $id became $state during reclaim; refusing duplicate launch" >&2 + return 1 + ;; + dead) + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation pane for $id disappeared during reclaim; spawning flat" >&2 + return 2 + ;; + esac + if fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$meta_pane" no-agent; then + close_status=0 + else + close_status=$? + fi + if [ "$close_status" -ne 0 ]; then + if [ "$close_status" -eq 2 ]; then + return 1 + fi + state=$FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + case "$state" in + live|unknown) + echo "error: herdr presentation pane for $id became $state at the close boundary; refusing duplicate launch" >&2 + return 1 + ;; + esac + echo "warning: herdr presentation reclaim for $id could not close the exact old husk; spawning flat" >&2 + return 2 + fi + if [ "$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane")" != dead ]; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + return 1 + fi + if ! fm_backend_herdr_projection_live_binding_matches \ + "$session" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \ + "$meta_workspace" "$new_tab" "$new_pane" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$parent_label" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$task_label"; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id did not converge exactly; spawning flat" >&2 + return 2 + fi + if ! fm_backend_herdr_projection_journal_replace_endpoint \ + "$journal" "$id" "$meta_tab" "$meta_pane" "$new_tab" "$new_pane"; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id could not publish its replacement binding; spawning flat" >&2 + return 2 + fi + FM_BACKEND_HERDR_PROJECTION_TAB_ID=$new_tab + FM_BACKEND_HERDR_PROJECTION_PANE_ID=$new_pane + return 0 +} + +# fm_backend_herdr_projection_recovery_allows_flat: inspect an existing +# journal's exact token matches without adopting, reusing, renaming, closing, +# or deleting anything. +# Missing matches safely degrade to the normal flat workspace. +# One or more matches allow flat fallback only when every pane is positively +# dead or agent-free; a live or unknown pane refuses a duplicate launch. +fm_backend_herdr_projection_recovery_allows_flat() { # + local session=$1 journal=$2 id=$3 token list wsids count wsid panes pane_ids pane state + token=$(fm_backend_herdr_projection_journal_token "$journal" "$id") || { + echo "error: malformed herdr presentation journal for $id; refusing duplicate launch" >&2 + return 1 + } + fm_backend_herdr_server_ensure "$session" || { + echo "error: could not inspect the quarantined herdr presentation for $id; refusing duplicate launch" >&2 + return 1 + } + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { + echo "error: could not list herdr workspaces while inspecting the quarantined presentation for $id" >&2 + return 1 + } + if ! printf '%s' "$list" | jq -e '(.result.workspaces | type) == "array"' >/dev/null 2>&1; then + echo "error: could not parse herdr workspaces while inspecting the quarantined presentation for $id" >&2 + return 1 + fi + wsids=$(printf '%s' "$list" | jq -r --arg suffix " · p:$token" \ + '.result.workspaces[]? | select((.label | type) == "string" and (.label | endswith($suffix))) | .workspace_id' 2>/dev/null) + count=$(printf '%s\n' "$wsids" | awk 'NF { n += 1 } END { print n + 0 }') + if [ "$count" -eq 0 ]; then + echo "warning: no exact herdr presentation token match for $id; leaving any stale space untouched and spawning flat" >&2 + return 0 + fi + if [ "$count" -gt 1 ]; then + echo "warning: $count exact herdr presentation token matches for $id are quarantined; inspecting only for duplicate-agent risk" >&2 + fi + while IFS= read -r wsid; do + [ -n "$wsid" ] || continue + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$wsid" 2>/dev/null) || { + echo "error: could not inspect herdr presentation workspace $wsid for $id; refusing duplicate launch" >&2 + return 1 + } + if ! printf '%s' "$panes" | jq -e '(.result.panes | type) == "array"' >/dev/null 2>&1; then + echo "error: could not parse herdr presentation workspace $wsid for $id; refusing duplicate launch" >&2 + return 1 + fi + pane_ids=$(printf '%s' "$panes" | jq -r '.result.panes[]? | .pane_id' 2>/dev/null) + while IFS= read -r pane; do + [ -n "$pane" ] || continue + state=$(fm_backend_herdr_pane_agent_state "$session" "$pane") + case "$state" in + dead|no-agent) : ;; + live|unknown) + echo "error: quarantined herdr presentation for $id has a $state pane; refusing duplicate launch" >&2 + return 1 + ;; + esac + done <&2 + return 0 +} + +# fm_backend_herdr_projection_endpoint_matches_journal: read-only correlation +# for retiring a successful projection journal after normal exact-pane +# teardown. +# Exactly one token-bearing workspace must match the endpoint workspace. +# This verdict never authorizes a Herdr mutation. +fm_backend_herdr_projection_endpoint_matches_journal() { # + local session=$1 workspace_id=$2 journal=$3 id=$4 token list matches + token=$(fm_backend_herdr_projection_journal_token "$journal" "$id") || return 1 + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + printf '%s' "$list" | jq -e '(.result.workspaces | type) == "array"' >/dev/null 2>&1 || return 1 + matches=$(printf '%s' "$list" | jq -r --arg suffix " · p:$token" \ + '.result.workspaces[]? | select((.label | type) == "string" and (.label | endswith($suffix))) | .workspace_id' 2>/dev/null) + [ "$matches" = "$workspace_id" ] +} + # fm_backend_herdr_parse_target: split ":" (pane_id itself # contains a colon, e.g. "w1:p2") on the FIRST colon only. Sets # FM_BACKEND_HERDR_SESSION and FM_BACKEND_HERDR_PANE for the caller. @@ -668,9 +1760,9 @@ fm_backend_herdr_strip_ansi() { # # fm_backend_herdr_composer_state: classify the composer's own row as # empty|pending|unknown, scanning a generous tail-window capture of . # herdr's CLI exposes no cursor-row primitive (unlike tmux's #{cursor_y}), so -# this locates the composer row structurally, recognizing TWO row shapes and -# keeping whichever match comes LAST (scanning forward), so a shape earlier in -# scrollback/a popup can never outrank the real (bottom-anchored) composer row: +# this locates the composer structurally, recognizing THREE shapes and keeping +# whichever match comes LAST (scanning forward), so a shape earlier in +# scrollback/a popup can never outrank the real (bottom-anchored) composer: # # bordered - a boxed composer (verified grok 0.2.82): the row's TRIMMED # content both STARTS and ENDS with the same border glyph (│, ┃, @@ -697,6 +1789,15 @@ fm_backend_herdr_strip_ansi() { # # deliberately narrower than the bordered content classifier so a # no-agent shell fallback prompt (`>`, `$`, `%`, or `#`) falls # through to `unknown` instead of being misread as delivered. +# separated - Pi's composer is one or more content rows between two solid +# horizontal `─` separator rows, with no prompt glyph or side +# borders. This shape is accepted ONLY when Herdr's native +# `agent get` identifies the target as Pi and reports it idle, +# done, or blocked. A missing/stale/non-Pi agent identity, a +# working Pi, an over-tall candidate, or an incomplete separator +# pair remains unknown. This identity + structure conjunction is +# what makes a blank Pi row safe without weakening dead-shell or +# ambiguous-pane refusal. # # empty - blank, a bare prompt glyph, known ghost/placeholder text # ("Type a message...", verified grok 0.2.82's empty-composer @@ -730,10 +1831,87 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX +# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match +# individual BYTES rather than whole multibyte characters, so `[❯›]` silently +# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches +# ANY multibyte glyph in that range - including box-drawing corners like ╰, +# misclassifying a bordered composer's bottom border row as the bare shape. +# An alternation's branches are matched as whole literal byte sequences and +# stay correct regardless of locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} +# Pi allows a multi-line composer between its horizontal separators. Bound the +# structural candidate so two unrelated transcript rules with an arbitrarily +# large region between them can never be promoted into a composer. +FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=${FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES:-8} + +fm_backend_herdr_pi_separator_row() { # + local row=$1 + row="${row#"${row%%[![:space:]]*}"}" + row="${row%"${row##*[![:space:]]}"}" + [ "${#row}" -ge 8 ] || return 1 + [ -z "${row//─/}" ] +} + +# Locate the content and closing-row position of the bottom-most complete pair +# of Pi separator rows. A separator closes the preceding candidate and +# immediately opens the next, so an earlier transcript rule can never outrank +# the live bottom composer pair. Globals let the caller compare this shape's +# screen position with generic bordered/bare candidates without losing empty +# composer content through command substitution. +fm_backend_herdr_pi_composer_find() { # + local cap=$1 line plain open=0 lines=0 candidate="" max row=0 open_row=0 + max=$FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES + case "$max" in ''|*[!0-9]*|0) max=8 ;; esac + FM_BACKEND_HERDR_PI_PAIR_FOUND=0 + FM_BACKEND_HERDR_PI_PAIR_VALID=0 + FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=0 + FM_BACKEND_HERDR_PI_PAIR_LINE=0 + FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=0 + FM_BACKEND_HERDR_PI_CONTENT="" + while IFS= read -r line; do + row=$((row + 1)) + plain=$(fm_backend_herdr_strip_ansi "$line") + if fm_backend_herdr_pi_separator_row "$plain"; then + FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=$row + if [ "$open" -eq 1 ]; then + FM_BACKEND_HERDR_PI_PAIR_FOUND=1 + FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=$open_row + FM_BACKEND_HERDR_PI_PAIR_LINE=$row + if [ "$lines" -le "$max" ]; then + FM_BACKEND_HERDR_PI_PAIR_VALID=1 + FM_BACKEND_HERDR_PI_CONTENT=$candidate + else + FM_BACKEND_HERDR_PI_PAIR_VALID=0 + FM_BACKEND_HERDR_PI_CONTENT="" + fi + fi + open=1 + open_row=$row + lines=0 + candidate="" + elif [ "$open" -eq 1 ]; then + [ -z "$candidate" ] || candidate="${candidate}"$'\n' + candidate="${candidate}${line}" + lines=$((lines + 1)) + fi + done < -> \t + local out + out=$(fm_backend_herdr_cli "$1" agent get "$2" 2>/dev/null) || return 1 + printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null +} fm_backend_herdr_composer_state() { # -> empty|pending|unknown - local target=$1 cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + local target=$1 session pane cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + local identity agent agent_status row=0 generic_line=0 + fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + session=$FM_BACKEND_HERDR_SESSION + pane=$FM_BACKEND_HERDR_PANE cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES" 2>/dev/null \ || fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; } # Structural scan: locate the bottom-most composer row and remember its RAW @@ -741,6 +1919,7 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown # keeps ghost text so the border/prompt glyph is still visible); the raw row is # kept for ANSI-aware content extraction after the scan. while IFS= read -r line; do + row=$((row + 1)) trimmed=$(fm_backend_herdr_strip_ansi "$line") trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" @@ -749,17 +1928,54 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown '│'*'│'|'┃'*'┃'|'|'*'|') shape=bordered raw_match=$line + generic_line=$row found=1 ;; *) if printf '%s' "$trimmed" | grep -qE "$FM_BACKEND_HERDR_BARE_PROMPT_RE"; then shape=bare raw_match=$line + generic_line=$row found=1 fi ;; esac done < <(printf '%s\n' "$cap") + # Pi has no prompt glyph or side border. Compare its bottom-most complete + # separator pair with the last generic match so an earlier bordered transcript + # row can never suppress the live Pi composer. Identity is consulted only when + # a lower separator pair could change the verdict. + fm_backend_herdr_pi_composer_find "$cap" + if [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -eq 1 ] \ + && [ "$FM_BACKEND_HERDR_PI_PAIR_LINE" -gt "$generic_line" ] \ + && [ "$generic_line" -lt "$FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE" ]; then + identity=$(fm_backend_herdr_agent_identity_raw "$session" "$pane" 2>/dev/null || true) + IFS=$'\t' read -r agent agent_status < -> empty|pending|unknown stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" + elif [ "$shape" = separated ]; then + # The native Pi identity plus the complete separator pair is the genuine + # composer container, equivalent to a bordered box for shared content + # classification. ANSI stripping keeps real text and drops only styling. + bordered=1 fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } @@ -844,10 +2065,10 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown # re-invokes this function from scratch with the same text after seeing # an error, which is a human/escalation decision, not an automatic # retry). -# Echoes empty|pending|unknown|send-failed, the SAME vocabulary fm-send.sh -# already branches on for tmux ("empty" means "confirmed submitted" for every -# backend; how each backend confirms it is an internal decision - herdr's is -# no longer literally "the composer read empty"). +# Echoes empty|pending|unknown|send-failed, a subset of the proof-carrying +# submit vocabulary. Empty means confirmed submitted for every backend; how +# each backend confirms it is an internal decision, and herdr's is no longer +# literally "the composer read empty". fm_backend_herdr_send_text_submit() { # local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dee5813d64..24f07dabdc 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -117,10 +117,22 @@ fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" } -# fm_backend_tmux_kill: remove the task's window, best-effort. Mirrors -# fm-teardown.sh's `tmux kill-window -t "$T" 2>/dev/null || true`. +# fm_backend_tmux_kill: remove one explicitly named task window, best-effort. +# Empty, omitted, and malformed targets return nonzero before invoking tmux so +# tmux can never interpret an empty target as the caller's current window. fm_backend_tmux_kill() { # - tmux kill-window -t "$1" 2>/dev/null || true + local target=${1:-} session window + case "$target" in + *:*) + session=${target%%:*} + window=${target#*:} + ;; + *) return 1 ;; + esac + case "$session:$window" in + :*|*:|*:*:*) return 1 ;; + esac + tmux kill-window -t "=$session:=$window" 2>/dev/null || true } # fm_backend_tmux_current_command: 's live foreground process name - @@ -136,32 +148,68 @@ fm_backend_tmux_current_command() { # tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null } -# fm_backend_tmux_agent_alive: CONFIDENT liveness of a live harness-agent -# PROCESS in 's pane, distinct from fm_backend_target_exists's -# pane-PRESENCE-only check (a pane that still exists but is sitting at a bare -# idle shell passes THAT check as "alive" - the secondmate-liveness gap -# AGENTS.md's session-start guarantee closes). See docs/tmux-backend.md -# "Agent liveness probe" for the empirical basis. Prints one of: -# alive - the foreground command is one of the verified harness binaries -# (claude, codex, opencode, grok - each confirmed to run as its -# own process name, never wrapped by a generic interpreter). -# dead - the foreground command is a bare shell: nothing is running in -# the pane, so a prior agent process has exited. -# unknown - anything else, INCLUDING a bare "node"/"python" interpreter -# name (pi's own launcher execs into a generic "node" process -# with no reliable way to attribute it back to pi from outside -# the pane - docs/tmux-backend.md "Known gaps"), or an unreadable -# pane. Callers must never treat unknown as a confirmed-dead -# signal (bin/fm-bootstrap.sh's secondmate-liveness sweep gates a -# respawn on `dead` only). -fm_backend_tmux_agent_alive() { # - local target=$1 comm - comm=$(fm_backend_tmux_current_command "$target") || { printf 'unknown'; return 0; } +# fm_backend_tmux_agent_state: recovery-grade harness-agent state for one +# recorded target. See bin/fm-backend.sh's fm_backend_agent_state for the +# shared state vocabulary and docs/tmux-backend.md "Agent liveness probe" for +# the empirical basis. Tmux silently falls back to the active window when a +# named target is absent, so the exact recorded window must appear in a +# successful session inventory before its foreground command can be trusted. +# An omitted window or a definitive missing-session/server response is +# `missing`; any other inventory or pane read failure is `unreadable`, so a +# transient tmux problem never licenses a duplicate. +fm_backend_tmux_agent_state() { # + local target=$1 comm session window windows inventory_status + case "$target" in + *:*:*|'':*|*:'') printf 'unreadable'; return 0 ;; + *:*) ;; + *) printf 'unreadable'; return 0 ;; + esac + session=${target%%:*} + window=${target#*:} + if windows=$(LC_ALL=C tmux list-windows -t "$session" -F '#{window_name}' 2>&1); then + inventory_status=0 + else + inventory_status=$? + fi + if [ "$inventory_status" -ne 0 ]; then + case "$windows" in + *"can't find session:"*|*"no server running on "*|*"error connecting to "*" (No such file or directory)"|*"error connecting to "*" (Connection refused)") + printf 'missing' + ;; + *) + printf 'unreadable' + ;; + esac + return 0 + fi + if ! printf '%s\n' "$windows" | grep -Fqx "$window"; then + printf 'missing' + return 0 + fi + + comm=$(fm_backend_tmux_current_command "$target") || { + printf 'unreadable' + return 0 + } comm=${comm#-} + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + # shellcheck source=bin/fm-harness-process.sh + . "$script_dir/fm-harness-process.sh" case "$comm" in - '') printf 'unknown' ;; - *claude*|*codex*|*opencode*|*grok*) printf 'alive' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; + '') printf 'unreadable' ;; + *) + if fm_harness_process_name "$comm" '' >/dev/null; then printf 'alive'; else printf 'ambiguous'; fi + ;; + esac +} + +# Backward-compatible three-state view for callers that only need a yes/no +# agent verdict. The detailed state contract is owned by fm_backend_agent_state. +fm_backend_tmux_agent_alive() { # + case "$(fm_backend_tmux_agent_state "$1")" in + alive) printf 'alive' ;; + dead|missing) printf 'dead' ;; *) printf 'unknown' ;; esac } diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 162ea5477b..20d53a3c2d 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -501,8 +501,8 @@ fm_backend_zellij_capture() { # [expected-label] # also the load-bearing defense against the # unconditional-exit-0 CLI quirk documented in the file header: a truly dead # target never shows a change, so it correctly reports pending/unknown rather -# than a false "sent". Echoes empty|pending|unknown|send-failed, the SAME -# vocabulary fm-send.sh already branches on for tmux and herdr. +# than a false "sent". Echoes empty|pending|unknown|send-failed, a subset of the +# proof-carrying submit vocabulary. fm_backend_zellij_send_text_submit() { # [expected-label] local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} typed after i=0 fm_backend_zellij_send_literal "$target" "$text" "$expected_label" || { printf 'send-failed'; return 0; } diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 61c6a28cb9..57b7f6590d 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -77,9 +77,10 @@ fm_afk_launch_lock_owned() { } fm_afk_launch_lock_acquire() { - local i incomplete=0 identity + local attempt=0 incomplete=0 identity mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 - for i in $(seq 1 200); do + while [ "$attempt" -lt 200 ]; do + attempt=$((attempt + 1)) if mkdir "$FM_AFK_LAUNCH_LOCK" 2>/dev/null; then if ! printf '%s' "$$" > "$FM_AFK_LAUNCH_LOCK/pid"; then rm -rf "$FM_AFK_LAUNCH_LOCK" @@ -257,12 +258,13 @@ fm_afk_launch_terminal_alive() { # } fm_afk_launch_wait_ready() { # - local backend=$1 target=$2 i + local backend=$1 target=$2 attempt=0 if [ -n "${FM_AFK_LAUNCH_ENTRY:-}" ]; then fm_afk_launch_terminal_alive "$backend" "$target" return fi - for i in $(seq 1 100); do + while [ "$attempt" -lt 100 ]; do + attempt=$((attempt + 1)) daemon_lock_held_by_live_daemon && return 0 fm_afk_launch_terminal_alive "$backend" "$target" || return 1 sleep 0.05 @@ -287,8 +289,9 @@ fm_afk_launch_commit_terminal() { # [already-recorde } fm_afk_launch_herdr_recover_created() { #