diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 8820157fb..517e8ba02 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -1,6 +1,6 @@ --- 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 only captain-relevant events as one batched digest, 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. 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. user-invocable: true metadata: internal: true @@ -16,46 +16,50 @@ batched digest rather than per-wake injections. ## What it does -1. **Set the durable away-mode flag:** - - ```sh - date '+%s' > state/.afk - ``` - - This file survives a firstmate restart: recovery re-enters afk if the - flag is present. - -2. **Ensure the sub-supervisor daemon is running.** Check the pid file; start - the daemon only if it is dead or absent: - - ```sh - if [ -f state/.supervise-daemon.pid ] && kill -0 "$(cat state/.supervise-daemon.pid)" 2>/dev/null; then - : # daemon already alive - it picks up the flag on its next cycle - else - nohup bin/fm-supervise-daemon.sh >/dev/null 2>&1 & - fi - ``` - +1. **Enter the lifecycle through `bin/fm-afk-launch.sh`.** + This owns the durable state write, session-scoped stale-artifact clearing, + terminal record, and rollback. + The flag survives a firstmate restart, so recovery re-enters afk when it is present. + +2. **Ensure the sub-supervisor daemon is running as a tracked background process.** + Its hosting differs by harness. + Pick the right path: + - **Harness WITH a native in-pane tracked-background tool** (e.g. claude's + background bash, grok's background tool): first run + `bin/fm-afk-launch.sh start-native`, then run + `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. + This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. + The launcher still owns lifecycle state and records the no-terminal mode, while the daemon inherits and auto-discovers the captain pane. + If the native launch fails, run `bin/fm-afk-launch.sh stop` to roll back the prepared lifecycle. + Do not wrap it in `nohup ... &` (Codex/herdr can reap fire-and-forget shell children after a tool call returns). + - **Harness WITHOUT one** (e.g. pi): run `bin/fm-afk-launch.sh start`. It is + the single owner of the daemon terminal: it creates a NON-VISIBLE tracked + terminal for the current backend (a herdr dedicated `--no-focus` workspace, + a detached tmux session), records its exact id, and passes the captain pane + 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"). + 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. The daemon is **presence-gated**: it injects escalations only while `state/.afk` exists, and stays quiet otherwise. 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 only captain-relevant events, and the - captain can exit by sending any real message. +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. ## 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. Clear `state/.afk`, stop the daemon, 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 (arm `bin/fm-watch-arm.sh`). +- 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. - Re-invoking `/afk` while already away -> stay afk (refresh the flag); this @@ -80,16 +84,6 @@ 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). -## Fleet-freeze guard - -Before either guard below, `inject_msg` calls the shared `fm_fleet_freeze_refuse` -(`bin/fm-freeze-lib.sh`) and returns without injecting while local -`state/.fleet-freeze` exists (set by `bin/fm-freeze.sh on`). The daemon's main -loop refuses to start at all under an active freeze. This is a manual park -switch, not a routine gate: a frozen fleet means the captain or firstmate -deliberately paused everything, so the daemon must not inject escalations (or -start injecting) until `bin/fm-freeze.sh off` lifts it. - ## Busy-guard and composer guard The daemon never injects into an in-use pane. Two checks run before every @@ -97,42 +91,36 @@ injection, dispatched through `bin/fm-backend.sh` for the supervisor's own backend (tmux or herdr; see "Auto-discovered supervisor pane" below): - **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader. -- **`pane_input_pending`** - the composer holds real unsubmitted text (a - human's half-typed line, or a previous injection whose Enter was swallowed). - On tmux, the cursor-line detector **strips the harness's composer box - borders first**, so an idle _bordered_ composer (claude draws `│ > … │`) is - correctly read as empty, not pending. Without this, every idle claude pane - looked like pending input and the daemon deferred 100% of escalations - (incident afk-invx-i5). `FM_COMPOSER_IDLE_RE` still overrides empty-composer - matching after border stripping. On herdr, the equivalent structural - border-row classifier (`fm_backend_herdr_composer_state`, - docs/herdr-backend.md) plays the same role. - -Either condition defers the injection; the buffered escalation survives in -`state/.subsuper-escalations` and is retried on the next housekeeping tick. In -afk mode the composer guard is belt-and-suspenders (no human is typing), but it -protects against the race window between the captain returning and their -message landing, and against the daemon's own previous injection sitting unsent. +- **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. + `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. +In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. **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 empty composer. +attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. 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), and a flash on the supervisor client's status line. +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. So a guard false-positive becomes a visible stall, never an unbounded silent no-op. ## Submit model The digest is typed **once** (`send-keys -l` on tmux, `pane send-text` on herdr - both literal, non-submitting sends), then submitted with Enter and -**verified**: Enter is retried (Enter only, never a retype) until the composer -clears. -A submit "landed" only when the composer is confirmed empty afterward, using -the same corrected, border-aware detector as the composer guard. -A bordered-empty claude composer is recognized as submitted rather than -mistaken for a swallowed Enter. +**verified** through the selected backend's submit primitive. +Enter is retried (Enter only, never a retype) until the backend confirms the +submit landed. +For tmux that confirmation is a cleared composer, using the same corrected, +border-aware detector as the composer guard. +For herdr, normal idle-baseline submits are confirmed by native agent-state showing a real turn started; the ANSI-aware composer classifier remains the affirmative-empty pre-injection guard and conservative fallback for non-idle or unreadable baselines. +A bordered-empty or ghost-only composer is recognized as empty where that backend uses composer confirmation, rather than mistaken for a swallowed Enter. `fm-send.sh` uses the same primitive and exits non-zero when a steer's Enter is positively swallowed, so firstmate learns an instruction did not land instead of leaving it unsubmitted. @@ -142,20 +130,17 @@ did not land instead of leaving it unsubmitted. The daemon wraps `fm-watch.sh`, runs the watcher as a child, classifies each wake reason in bash, and self-handles the routine majority without consuming a firstmate turn. -Only captain-relevant events escalate to firstmate's context, and even then as -one pre-read, single-line, batched digest. -The classification predicates (the captain-relevant verb set, the signal/stale -tests, and the fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same -library the always-on watcher uses for its own triage when afk is off, so the two -modes apply one identical policy. While `state/.afk` exists the daemon owns the -watcher, so the watcher reverts to one-shot and lets the daemon do the triage - -the two never run their triage at the same time. +Captain-relevant events, plus a bounded recheck of a declared external wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest. +The classification predicates (the captain-relevant verb set, declared-pause vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy. +While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time. 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` 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 @@ -179,33 +164,30 @@ the marker lets firstmate distinguish it from a real captain message. - **Single-line digest** - embedded newlines are collapsed to a literal separator before injection, so submission is unambiguous regardless of harness. -- **Composer guard on the supervisor pane** - before injecting, the daemon - checks both `pane_is_busy` (harness busy footer means agent mid-turn) and - `pane_input_pending` (real unsubmitted text on the cursor line means human - mid-typing or previous injection with swallowed Enter). Either condition - defers injection and preserves the buffer for retry. The daemon never merges - its digest into the captain's half-typed line. -- The composer detector, shared with `fm-send.sh` in `bin/fm-tmux-lib.sh`, drops - dim/faint ghost text, then strips harness composer box borders, so a ghost-only - or idle bordered composer such as claude's `│ > ... │` reads as empty, not - pending. Without these filters, idle bordered composers and dim ghost - suggestions can look like pending input and stall supervision. `FM_COMPOSER_IDLE_RE` - still overrides empty-composer matching after dim-ghost and border stripping, - and `FM_BUSY_REGEX` overrides busy footers. +- **Composer guard on the supervisor pane** - before injecting, the daemon checks `pane_is_busy` (harness busy footer means agent mid-turn) and reads `fm_backend_composer_state` directly. + Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts. + Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell. +- The shared composer classifier receives a candidate row only after the active backend performs its own capture and structural row recognition. + tmux and herdr route their raw styled candidate rows through the shared `fm_composer_strip_ghost` extractor, which removes dim/faint and dark-TRUECOLOR ghost/placeholder text before classification. + They read the composer shape from a separately ANSI-stripped plain row because a dark TRUECOLOR border can be stripped with ghost content. + A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same. + `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides busy footers. - **Max-defer escape** - the daemon must never silently wedge. If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one - normal flush, which still requires an idle pane and empty composer. If that + normal flush, which still requires an idle pane and an affirmatively empty composer. If that cannot confirm a submit, it raises a loud, rate-limited wedge alarm: ERROR log, - durable `state/.subsuper-inject-wedged` marker, and a status-line flash. A + durable `state/.subsuper-inject-wedged` marker, a tmux status-line flash when + applicable, and a backend-independent active alert. A composer false-positive surfaces as a visible stall, never an unbounded silent no-op. - **Verified type-once submit model** - the digest is typed once (`send-keys -l` on tmux, `pane send-text` on herdr), then submitted with Enter and verified. - Enter is retried, Enter only and never a retype, until the composer is - confirmed empty. That empty composer is the acknowledgement that the submit - landed, using the same dim-ghost-aware and border-aware detector (tmux) or - structural border-row classifier (herdr) so a ghost-only or bordered-empty - claude composer counts as submitted rather than a false swallowed Enter. + Enter is retried, Enter only and never a retype, until the backend submit + primitive reports `empty` as its caller-facing success verdict. + For tmux that verdict means the shared-ghost-aware and border-aware composer + 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. - **Portable singleton lock** - the daemon uses the repo's portable lock helper @@ -228,6 +210,13 @@ the marker lets firstmate distinguish it from a real captain message. misapplying tmux primitives to a pane that isn't one (docs/herdr-backend.md "Away-mode daemon: herdr supervisor-pane 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. + ## Reliability properties These properties must hold: @@ -235,6 +224,7 @@ These properties must hold: - Nothing is lost. The durable queue plus `fm-wake-drain.sh` recover any missed or crashed injection. - Wedge detection is bounded-latency, not lossy. +- Declared external waits are rechecked on a separate, bounded cadence rather than being mislabeled as wedges. - The catch-all scan backs up the keyword classifier. - The daemon preserves a single-instance portable lock, crash-loop backoff, a pane-gone guard, and a signal-trapped shutdown that flushes buffered diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md new file mode 100644 index 000000000..6aa17631c --- /dev/null +++ b/.agents/skills/bearings/SKILL.md @@ -0,0 +1,76 @@ +--- +name: bearings +description: Generate a "pick up where I left off" status report from firstmate's live fleet state. Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". Reads bounded local fleet state cheaply, optionally checks open PRs when requested, composes a scannable dated report to data/status-report-.md, and surfaces a concise version in chat; it is read-mostly and must not tear down, merge, or mutate task state as a side effect of producing the brief. +user-invocable: true +metadata: + internal: true +--- + +# 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. +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. + +## What it does + +1. **Gather live fleet state with one deterministic command.** + Run `bin/fm-bearings-snapshot.sh` and read its compact output. + It is the single bounded, deterministic source for this report and renders TOON by default. + Do not hand-probe the snapshot schema and do not make ad-hoc `gh-axi`/`gh` calls to assemble fleet facts; this command already assembles them. + 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. + 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. + +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: + - **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. + - **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. + +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. + 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. + - 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 + +This skill is the one owner of the `/bearings` chat-response format; the snapshot and classifier own the data that feeds it, and no other file restates this contract. +Every `/bearings` chat response renders EXACTLY these four sections, in THIS order, and nothing else structural (there is no At Anchor section): + +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." +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. + 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. +- 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. + +## Tone and content rules + +- This report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names - the captain works with these directly and needs them to resume; keep it organized and scannable, not a raw dump. +- Every PR reference is a full `https://...` URL, never a bare `#number`; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same report. +- Never include PHI or secret values; the report is an operational artifact, but it is still subject to the same security and compliance rules that govern everything else in this fleet. + +## Supervision discipline + +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. diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md new file mode 100644 index 000000000..d3f62e637 --- /dev/null +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -0,0 +1,49 @@ +--- +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, 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. +user-invocable: false +metadata: + internal: true +--- + +# 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 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. + +- `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. +- `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). +- `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: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from ` - the repo root and the active `FM_HOME` already have diverged backlog files; a bare `tasks-axi` invoked from the repo root would discover the wrong `.tasks.toml` and mutate the wrong home (`docs/configuration.md` "Backlog backend" owns the mechanism). + Always invoke `bin/fm-tasks-axi.sh` instead of bare `tasks-axi`; no captain-facing action needed unless a mutation was already misdirected before this warning was seen. +- `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. +- `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/firstmate-codexapp/SKILL.md b/.agents/skills/firstmate-codexapp/SKILL.md new file mode 100644 index 000000000..32eeb3f5e --- /dev/null +++ b/.agents/skills/firstmate-codexapp/SKILL.md @@ -0,0 +1,109 @@ +--- +name: firstmate-codexapp +description: >- + Agent-only playbook for coordinating visible Codex Desktop threads alongside Firstmate without pretending they are a selectable shell backend. + Use before creating, reading, steering, archiving, debugging, or reviewing a Codex App visible thread for Firstmate work, and before responding to requests to make Codex App native to Firstmate. +user-invocable: false +metadata: + internal: true +--- + +# firstmate-codexapp + +## Overview + +Use this playbook when Firstmate work needs a visible Codex Desktop thread. +The current supported shape is Desktop host-tool choreography plus an explicit status-file return-channel check, not a `codex-app` value in `FM_BACKEND`. + +## Boundary + +Codex Desktop visible threads are companion host-tool workflows, not a selectable Firstmate backend. +Read `docs/codex-app-backend.md` when it exists in this checkout; that document owns the acceptance contract, bridge requirement, status-return requirement, and staged rollout. + +If local helper scripts exist for Codex App work, use only helpers explicitly provided by the operator or maintained by Firstmate. +For helpers outside `bin/`, inspect the source or header before running `--help`. + +## Preflight + +1. Confirm this session is running inside Codex Desktop and that the host tools are exposed. + Search exact names when needed: `create_thread`, `list_threads`, `read_thread`, `send_message_to_thread`, `archive`, and `set_thread_archived`. +2. Confirm the target repository is already saved as a Codex Desktop project. + No host tool currently creates Codex App projects for an agent, so the human must add the project in Desktop before a created thread can reliably land there. +3. Do not create projectless threads for repo work. + If the project is absent, stop and ask for the project to be added or use a normal Firstmate backend instead. +4. Decide whether this is a real Firstmate-managed task or a visible companion thread. + A real task needs a task id, an isolated worktree or Desktop-owned cwd, a branch plan, and a writable `state/.status` path. + +## Create And Send + +When creating a visible thread, use the Desktop host tool, not shell imitation. +Target the saved project and ask the worker to start by reporting: + +```text +pwd +git rev-parse --show-toplevel +git branch --show-current +git log --oneline --max-count=3 +``` + +For writable repo work, instruct the worker to use the Codex-created current directory. +Do not tell it to `cd` into the saved project checkout for edits, commits, no-mistakes, pushes, or PR work. + +When sending follow-up instructions, use `send_message_to_thread`. +If the user types directly into the visible thread, treat that as authoritative and reconcile from `read_thread` instead of undoing it. + +## Status Return Channel + +A Desktop-owned Codex thread can append to Firstmate status files only when the prompt gives an absolute path and the Desktop permission context can write that checkout. +That makes status writes a verified return-channel requirement, not a fact to assume. + +For a Firstmate-managed task, include an explicit status instruction: + +```text +Append supervisor-visible status lines to /state/.status. +Use only these prefixes for status changes: working:, needs-decision:, blocked:, paused:, done:, failed:. +Use paused: only for a deliberate known external wait that should be rechecked later, never for a blocker that needs firstmate to act. +Before doing substantive work, append "working: Codex Desktop thread started". +``` + +Verify the return channel before treating the thread as supervised: + +- `read_thread` shows the worker attempted the status write. +- The local `state/.status` file contains the expected line. +- If available, the transcript includes a file-change entry for that status file. + +If the thread cannot write the status file, keep it as a visible companion thread only. +Do not claim it is a complete Firstmate backend. + +## Observe And Reconcile + +Use `read_thread` for thread truth. +Use `list_threads` only to find or recover a visible thread id, not as a replacement for reading the transcript. + +For Firstmate reconciliation, prefer concrete evidence: + +- thread id and project +- current Desktop-owned cwd +- branch name +- last meaningful thread state +- latest status file line +- PR URL when one exists + +Avoid repeating long transcripts into Firstmate docs or PR bodies. +Summarize only the host-tool calls, the status-file result, and the archive result. + +## Archive + +Archive through the Desktop host tool: `archive` when that is the exposed primitive, or `set_thread_archived(threadId=, archived=true)` when that is the exposed tool name. +Archiving can remove the thread from normal sidebar/project views, but it should not erase the transcript or landed work. + +For companion threads, archive the thread and report where the durable work landed. +If there is a real Firstmate task record, leave teardown decisions to the normal Firstmate task flow instead of this skill. + +## Failure Signals + +- Missing Desktop project: ask the human to add the target project in Codex Desktop, or use a normal backend. +- Missing host tools: do not simulate them with shell files; use a terminal backend instead. +- Status file not updated: treat the thread as unsupervised until the return channel is proven. +- Worker editing the saved project checkout instead of its Desktop cwd: stop and decide whether to salvage the branch before continuing. +- Production `codex-app` backend request: read `docs/codex-app-backend.md` and do not invent a local adapter. diff --git a/.agents/skills/firstmate-codexapp/agents/openai.yaml b/.agents/skills/firstmate-codexapp/agents/openai.yaml new file mode 100644 index 000000000..81263294d --- /dev/null +++ b/.agents/skills/firstmate-codexapp/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Firstmate Codex App" + short_description: "Operate visible Codex Desktop threads" + default_prompt: "Use $firstmate-codexapp to coordinate visible Codex Desktop threads without pretending they are a shell backend." diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 83ab1d2a7..1b57f2a81 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -73,7 +73,7 @@ Firstmate adds this skill's load instruction to firstmate-repo briefs by hand in - Plain dash `-`, never an em dash. - Never add an agent name as a commit co-author. - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. -- Run `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` before treating a script change as done. +- 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. diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index 32e67deb4..f8261cacd 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -51,7 +51,7 @@ Do not manually patch metadata to make an externally-created Orca terminal look ## Supervision Use `bin/fm-peek.sh`, `bin/fm-send.sh`, `bin/fm-crew-state.sh`, and `bin/fm-teardown.sh` for routine operation. -For steer messages, send short lines through `FM_HOME= bin/fm-send.sh fm- '...'` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home. +For steer messages, send short lines through `bin/fm-send.sh '...'`; the stable `fm-` alias also works. Put long instructions in the task brief or a temporary file and point the crewmate at that file. When supervising, treat `state/.meta` as the routing record and Orca's own ids as backend implementation details. @@ -84,7 +84,7 @@ Keep Orca smoke tests focused on lifecycle plumbing: 1. Select Orca intentionally for a disposable task or scout. 2. Spawn through `bin/fm-spawn.sh`. 3. Confirm metadata records the Orca backend, terminal, Orca worktree id, and isolated worktree path. -4. Verify `bin/fm-peek.sh`, a short `FM_HOME= bin/fm-send.sh` steer (unless `FM_HOME` is already set to the active firstmate home), watcher wake behavior, and `bin/fm-crew-state.sh`. +4. Verify `bin/fm-peek.sh`, a short `bin/fm-send.sh` steer, watcher wake behavior, and `bin/fm-crew-state.sh`. 5. Tear down through `bin/fm-teardown.sh` after the task is safely disposable or landed. 6. Restore the previous backend selection if Orca was selected only for the smoke test. diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 06ea5959d..427a7432d 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -4,7 +4,7 @@ description: >- Agent-only playbook for handling X mode mentions and follow-ups. Use on an "x-mention " check wake to read the stashed mention, classify it, act autonomously on eligible requests, reply or dismiss, and link spawned work. Also use on an "x-mode-error ..." check wake to report the X-mode configuration blocker instead of answering a mention. - Also use on milestone and terminal wakes for an X-linked task before posting completion follow-ups, ending terminal outcomes with --final. + Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, ending terminal outcomes with --final. Loaded only when X mode is enabled. user-invocable: false metadata: @@ -13,7 +13,7 @@ metadata: # fmx-respond -X mode lets a firstmate instance answer and act on public mentions of the shared `@myfirstmate` bot on X. +X mode lets a firstmate instance answer and act on public mentions routed through the shared `@myfirstmate` relay. A mention arrives through the watcher as a `check:` wake whose payload is `x-mention `. The full mention is stashed locally; this skill acts on any request it carries and turns it into one public reply, or deliberately skips it when there is nothing to answer. @@ -50,9 +50,10 @@ How the reply lands depends on whether the work finishes during this turn: - **Work that spawns a real, longer-running job** (dispatching a crewmate, a scout investigation, a ship task) cannot report an outcome yet, so it follows **acknowledge first -> act -> follow up on completion**: 1. **Acknowledge first.** Post an immediate, public-safe reply that you have the captain's order and are on it (the normal answer endpoint, via `bin/fm-x-reply.sh`). This is the legitimate, work-backed version of "aye, will do": it is paired with actually starting the work in the same turn, never a promise left empty. 2. **Act.** Dispatch the work through the normal lifecycle right away. - 3. **Link it for the follow-up.** Associate the spawned task with this mention so completion follow-ups can be posted later: `bin/fm-x-link.sh ` (records the request id, a timestamp, and a follow-up counter in the task's state). - Do this right after the task is spawned. - If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags from the prior task so the successor keeps the consumed follow-up count and original 7-day window. + 3. **Link it for the follow-up, before clearing the inbox.** Associate the spawned task with this mention so completion follow-ups can be posted later: `bin/fm-x-link.sh ` (records the request id, a timestamp, a follow-up counter, and reply-platform context). + Do this right after the task is spawned, and always **before** removing the inbox file (step 2f). + `bin/fm-x-link.sh` reads the mention's platform from the still-present inbox payload, so linking before cleanup is what keeps a longer Discord follow-up on the Discord budget instead of the X 280-char one; if the inbox is already gone it falls back to an authoritative relay lookup by request_id and, failing even that, warns loudly - but the local link-before-cleanup order is the fast, correct path, so keep it. + If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. 4. **Follow up on genuine milestones, sparingly.** Firstmate gets up to **three** follow-ups per mention, within a 7-day window, chained in the same thread - spend them only on changes the captain would actually want to hear about (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. The task's final outcome - shipped / reported / merged / failed - is always posted with `--final`, which clears the link regardless of how many follow-ups remain. That posting happens on the task's milestone and completion wakes (see "Completion follow-up" below), not this turn. @@ -71,9 +72,9 @@ Normal reversible work - filing backlog, a scout investigation, gated code chang ## The reply is public. Treat it as such. -The answer is posted publicly on X under a **shared** bot account. +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. -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 tweet. +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: @@ -104,10 +105,10 @@ Reply in firstmate's own voice - the crisp, lightly nautical first-mate persona - The asker **is** your captain (owner-only routing - see the top of this skill), so address them as "captain" when it fits and treat their request as a genuine captain instruction, within the public-safety limits above. You are answering the captain in public, not a stranger. - Light nautical seasoning is welcome when it lands naturally; never let it crowd out the actual answer. -- **Be concise by default: aim for a single tweet, two at the very most.** A short, sharp answer beats a wall of text. Write tight on purpose - one or two sentences. +- **Be concise by default: aim for a single message, two at the very most.** A short, sharp answer beats a wall of text. Write tight on purpose - one or two sentences. You do not hand-format threads or add "(1/n)" numbering yourself. -Compose the reply as one piece of prose; if it is genuinely too long for one tweet, `bin/fm-x-reply.sh` automatically splits it into a numbered thread on word boundaries. +Compose the reply as one piece of prose; if it is genuinely too long for one message, `bin/fm-x-reply.sh` automatically splits it into a platform-aware numbered thread on fenced-code, paragraph, line, and word boundaries. Conciseness is still your job - lean on the auto-split only when the answer truly needs the length, not as license to ramble. Do not attach an image for prose. @@ -127,15 +128,16 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin 2. **Drain every pending mention.** For each `state/x-inbox/*.json` file: a. Read the object: you need `request_id`, `text`, and `in_reply_to`. `in_reply_to` is `{author_handle, text}` when this mention is a reply within an ongoing conversation, or `null` for a fresh, standalone mention. - Ignore `tweet_id` entirely - you never name a tweet; the relay binds the reply for you. + Ignore `tweet_id` entirely - you never name a platform message id; the relay binds the reply for you. b. **Classify the mention into one of three cases** (see "A request to act on: acknowledge first, act, then follow up on completion"): - **Actionable instruction / request** ("add this to the backlog", "look into X", "fix Y", "ship Z") - go to step 2c and do the work first. - **Question** - nothing to do; skip step 2c and answer from live fleet state in step 2d. - **Pure acknowledgment** ("thanks", "👍", "nice", "got it", a reaction, or a follow-up that just closes the loop with nothing to add) - **skip**: post nothing, but **dismiss it at the relay** (step 2e-skip), then remove the inbox file (the cleanup of step 2f), and move on **without** calling `bin/fm-x-reply.sh`. A deliberate non-answer is the correct outcome here, not a failure. When in doubt between an instruction and a question, do the smallest safe lifecycle step the request implies; when in doubt between a question and bare politeness, lean toward skipping - a needless reply is noise on a public bot. c. **Act on an actionable request through the normal lifecycle.** Treat it exactly as a captain prompt typed in session: run ordinary intake (resolve the project), then file the backlog item, dispatch a crewmate, start a scout, or ship through the gate - whatever the request calls for. - **Destructive, irreversible, or security-sensitive work is the exception** (X is a public, relayed channel and does not carry full in-session trust): do not execute it from the mention. Flag it to the captain through the normal trusted channel first - the same carve-out as `yolo` (AGENTS.md §1, §7) - act only on the captain's word, and in step 2d say only that it has been flagged for the captain. + **Destructive, irreversible, or security-sensitive work is the exception** (X mode is a public, relayed channel and does not carry full in-session trust): do not execute it from the mention. Flag it to the captain through the normal trusted channel first - the same carve-out as `yolo` (AGENTS.md §1, §7) - act only on the captain's word, and in step 2d say only that it has been flagged for the captain. **If the request spawned a real, longer-running task** (you ran `bin/fm-spawn.sh`), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. + **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` reads the mention's reply platform from the still-present inbox payload, so linking after the file is removed strands a longer Discord follow-up on the X 280-char budget (it then falls back to a relay lookup and, failing that, a loud warning, but the correct order avoids both). Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final`. If the work completed in this turn (a backlog item filed, a question answered), there is no task to link and step 2d reports the outcome directly. d. **Compose the reply.** For a **question**, answer `.text` from the fleet state gathered in step 1. For an **actionable request that completed now**, report the outcome of step 2c (what was done, or - for escalated work - that it has been flagged for the captain). For an **actionable request that spawned a linked task**, acknowledge that you have the order and are on it - milestone updates and the final outcome follow later as completion follow-ups, so do not promise a result you do not yet have. Either way keep it short, in firstmate's voice, and public-safe. @@ -151,7 +153,7 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin (`bin/fm-x-reply.sh -`, reading the reply on stdin, is equally fine.) It echoes the `request_id` and exits 0 on success; non-zero on a failed live post or failed dry-run record. When the reply carries one real visual artifact, add `--image `: the helper reads one local PNG, JPEG, GIF, WebP, BMP, or TIFF, detects the media type, base64-encodes it, and sends it in the relay's optional `image` object without ever inlining image bytes into the shell command. - If the reply auto-splits into a thread, the image rides the first/opener tweet only. + If the reply auto-splits into a thread, the image rides the first/opener message only. e-skip. **For a skip, dismiss it at the relay instead of replying.** A pure acknowledgment gets no reply, but clearing only the local inbox file is not enough: the relay keeps re-offering that request on every poll until it times out to a polite "offline" auto-reply. So before clearing the file, tell the relay to drop the request: ```sh @@ -161,6 +163,7 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin It posts nothing, stops the re-offer, and prevents the offline auto-reply; it echoes the `request_id` and exits 0 on success (it honors `FMX_DRY_RUN` like `bin/fm-x-reply.sh`, recording the would-be dismiss to `state/x-outbox/` instead of posting). Do **not** call `bin/fm-x-reply.sh` for a skip. f. **On success (a posted reply, or a relay dismiss for a skip), remove that inbox file:** `rm -f state/x-inbox/.json` (and your temporary reply file). This is the local idempotency guard - a cleared file is never answered twice. + For an acknowledged actionable request that spawned a task, this cleanup comes **after** the step 2c link, never before: `bin/fm-x-link.sh` reads the reply platform from this inbox payload, so removing it first would strand the follow-up on the wrong split budget. g. **On failure** (a non-zero exit from `bin/fm-x-reply.sh` or `bin/fm-x-dismiss.sh`), leave that inbox file in place, move on to the next, and do not retry blindly. If you had already acted on this mention in step 2c before the post failed, do **not** redo that work on a later drain - check whether it is already done (e.g. the backlog item exists, the crewmate is already running) and only retry the reply. If a reply or dismiss fails twice, surface it to the captain as a blocker with the stderr detail; for live post failures include the relay's HTTP status when available. @@ -169,26 +172,26 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin ## Dry-run / preview mode When `FMX_DRY_RUN` is set (truthy, in the environment or `.env`), `bin/fm-x-reply.sh` does **not** post and `bin/fm-x-dismiss.sh` does **not** call the relay. -The reply client records the full would-be reply payload to `state/x-outbox/.json` (`{request_id, text}` for one tweet, or `{request_id, text, texts}` for a thread), prints a `DRY RUN` summary to stderr, and still echoes the `request_id` and exits 0. +The reply client records the full would-be reply payload to `state/x-outbox/.json` (`{request_id, text}` for one message, or `{request_id, text, texts}` for a thread), prints a `DRY RUN` summary to stderr, and still echoes the `request_id` and exits 0. The dismiss client records `{request_id, endpoint:"dismiss"}` to the same outbox path, prints a `DRY RUN` summary to stderr, and still echoes the `request_id` and exits 0. Truthy means anything except unset, empty, `0`, `false`, `no`, or `off`; an explicit environment value wins over `.env`. When an image was attached, the dry-run record keeps only compact `{media_type, bytes, source_path}` metadata instead of the base64 bytes, so a preview never writes a multi-MB blob. Dry-run needs `jq` to build the JSON payload, but it needs neither `FMX_PAIRING_TOKEN` nor the relay because it runs before token and network checks. Your procedure does not change: compose as usual and call `bin/fm-x-reply.sh ... --text-file `, or call `bin/fm-x-dismiss.sh ` for a skip. -Because the call still succeeds, the loop completes normally (clear the inbox file as in step 2f); the only difference is nothing reaches X. -This is the mode for end-to-end testing the poll -> compose -> would-post loop without a public tweet. +Because the call still succeeds, the loop completes normally (clear the inbox file as in step 2f); the only difference is nothing reaches the relay. +This is the mode for end-to-end testing the poll -> compose -> would-post loop without a public post. Inspect `state/x-outbox/` to see exactly what would have been posted. The completion follow-up honors `FMX_DRY_RUN` the same way (it flows through `bin/fm-x-reply.sh --followup`): the would-be follow-up is recorded to `state/x-outbox/`, and the local counter and link mutate exactly as a live post would. -A non-final dry-run follow-up increments `x_followups` and keeps the link while under the cap; `--final`, the cap, or an expired window clears it, so the whole acknowledge -> act -> follow-up loop is testable without a public tweet. +A non-final dry-run follow-up increments `x_followups` and keeps the link while under the cap; `--final`, the cap, or an expired window clears it, so the whole acknowledge -> act -> follow-up loop is testable without a public post. ## Completion follow-up (posted on milestone and done wakes, not this turn) When an actionable request spawned a task and you linked it (step 2c), progress and the **outcome** are delivered later as follow-up replies, not in this turn. -This skill is the sole owner of the completion-follow-up procedure below; AGENTS.md §13 declares the load trigger for X-linked milestone or terminal wakes, and AGENTS.md §8 reinforces the terminal final-follow-up step before teardown. +This skill is the sole owner of the completion-follow-up procedure below; AGENTS.md §13 declares the load trigger for X-mode-linked milestone or terminal wakes, and AGENTS.md §8 reinforces the terminal final-follow-up step before teardown. This skill's own responsibility during the mention-handling turn is linking the task in step 2c; the full completion path is: - Firstmate has **up to three** follow-ups per mention, within a 7-day window, chained in the same thread - it spends them only on genuine milestones the captain would want surfaced (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. -- If a linked task is replaced by a successor for the same relay request, carry the prior `x_followups=` value and `x_request_ts=` with `bin/fm-x-link.sh --carry-count --carry-ts ` so recovery preserves the consumed budget and original window. +- If a linked task is replaced by a successor for the same relay request, carry the prior `x_followups=`, `x_request_ts=`, `x_platform=`, and `x_reply_max_chars=` values with `bin/fm-x-link.sh --carry-count --carry-ts --carry-platform --carry-max ` so recovery preserves the consumed budget, original window, and reply split budget after the inbox file is gone. - On each such milestone, firstmate checks whether a follow-up is still due with `bin/fm-x-followup.sh --check ` (prints the `request_id` when the link exists, the count is under the cap, and the window has not lapsed; silent otherwise, pruning an exhausted or expired link). - If due, it composes a short, public-safe update and posts it with `bin/fm-x-followup.sh --text-file ` (or stdin), which posts via the relay's follow-up endpoint; a successful non-final post increments the counter and keeps the link so a later milestone can still post against it. When the update carries one real visual artifact, add `--image `; the helper forwards it to `bin/fm-x-reply.sh --followup` so the same image contract used for ordinary replies applies here too. @@ -201,7 +204,7 @@ This skill's own responsibility during the mention-handling turn is linking the - An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. - Destructive, irreversible, or security-sensitive asks are flagged to the captain through the trusted channel first and never run straight from a mention; the public reply says only that it has been flagged. - One answered mention = one reply (plus up to three completion follow-ups for a spawned task, spent only on genuine milestones); a skipped mention posts no reply but is **dismissed at the relay** (`bin/fm-x-dismiss.sh`) so the relay drops it rather than re-offering it (which would otherwise churn every poll and end in an "offline" auto-reply). A single wake may cover several pending mentions - drain them all. -- Conversations: `in_reply_to` carries the parent tweet for continuity; a pure acknowledgment with nothing to answer is dismissed at the relay and skipped, not replied to. The relay already guards against self-replies and caps replies per conversation, so you only judge "is there something to answer here?". +- Conversations: `in_reply_to` carries the parent post for continuity; a pure acknowledgment with nothing to answer is dismissed at the relay and skipped, not replied to. The relay already guards against self-replies and caps replies per conversation, so you only judge "is there something to answer here?". - Never inline mention-influenced reply text into a shell command; always go through `--text-file` or stdin. - The reply length authority is the relay (it trims), but a tight reply is on you. - Never edit `bin/fm-x-poll.sh`, `bin/fm-x-reply.sh`, or the watcher to "answer faster"; the cadence is handled by the locked session-start bootstrap step. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index e66308fbe..67bffd886 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -25,12 +25,14 @@ If `config/crew-harness` is unset or `default`, there is no concrete value to in 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 mechanics, including launch command, autonomy flag, and turn-end hook, live in `bin/fm-spawn.sh`. +The per-task mechanics, including launch command, autonomy flag, and 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 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, and the verified knowledge here. +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 @@ -45,6 +47,32 @@ When verifying a new adapter, record its env marker and command name in `bin/fm- For stuck recovery, the target window's harness is recorded as `harness=` in `state/.meta`. 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`. +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 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. + +## 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`. +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. +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. @@ -90,16 +118,18 @@ Claude renders a predicted-next-prompt suggestion as dim/faint text inside an ot 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 pane reader in `bin/fm-tmux-lib.sh` captures only the composer line with ANSI styling, drops dim/faint SGR 2 runs, and ignores them, so only normal-intensity typed text counts as pending input. +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. 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 Stop hook (verified 2026-07-04, Claude Code 2.1.201).** +**Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204).** 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` (tracked at the repo root) registers a second, structural Stop hook, `bin/fm-turnend-guard.sh` (docs/turnend-guard.md), that can genuinely block a turn from ending: exiting the hook command with status 2 and a reason on stderr reliably forces the model to continue and act on that reason - verified live with `claude -p`, both interactively and headless. +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. 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. ## codex (VERIFIED 2026-06-11, codex-cli 0.139.0) @@ -111,7 +141,7 @@ After those settings are loaded, hook command resolution is still cwd-sensitive | Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | A `$` invocation opens a `$`-autocomplete (skill) popup, the same hazard as the `/` slash popup: submitting too fast lets the popup swallow the Enter, so the invocation never lands. -`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target's `state/.meta`. +`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target metadata for exact task ids or legacy `fm-` labels. That scope matters because, unlike `/`, a leading `$` commonly starts ordinary text (`$5/month`, `$HOME`), so a universal `$` rule would needlessly slow plain steers to claude/opencode/pi; only a codex target receiving a `$...` message gets the popup-settle. An explicit `session:window` target has no meta, so its harness is unknown and treated as non-codex (the safe fast-path default). This is why the validation trigger (`$no-mistakes`) to a codex crew now lands on the first Enter instead of biting the popup. @@ -123,7 +153,16 @@ The decision persists for the repo, so later worktrees of the same project skip Resume after exit with `codex resume `. The session id is printed on quit. -## opencode (VERIFIED 2026-06-11, v1.15.7-1.17.3) +**Primary-session guard fact (verified 2026-07-08, codex-cli 0.142.1).** +The firstmate PRIMARY's own `.codex/hooks.json` registers a Stop hook that pipes Codex's Stop payload to `bin/fm-turnend-guard.sh`. +Codex Stop hooks block on exit 2 and expose `stop_hook_active` for the same one-block loop safety Claude uses. +Codex's Stop payload includes `cwd`, but the tracked primary hook does not use it to choose the guard executable. +Verified on 2026-07-08: Codex runs the Stop hook command with process PWD set to the hook-loaded project root, and no `CODEX_PROJECT_DIR`, `CODEX_WORKSPACE_ROOT`, or `CODEX_CWD` root variable is set. +The tracked hook anchors to `pwd -P`, verifies that root is firstmate-shaped and hook-bearing, and then invokes `bin/fm-turnend-guard.sh` with the original payload. +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) | Fact | Value | |---|---| @@ -136,6 +175,12 @@ 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. +**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) | Fact | Value | @@ -156,6 +201,14 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. +**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).** +The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. +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. + ## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit behavior re-verified 2026-07-03, grok 0.2.82) Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. @@ -180,7 +233,18 @@ Startup dialog: the "Run Grok Build in a project directory?" project picker appe `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. Pin `[hints] project_picker_disabled = true` in `~/.grok/config.toml` if a non-project launch ever needs to skip it. -**Known gap, unfixed (found 2026-07-03, not yet in scope of any fix):** a freshly-dismissed, never-typed-into grok composer shows a placeholder ("Type a message...") styled with a dark 24-bit TRUECOLOR foreground, not the SGR-2 dim/faint attribute `fm_tmux_strip_ghost` detects, so it is NOT stripped and reads as real pending text - `FM_COMPOSER_IDLE_RE` is NOT already set to cover it. Worse, live-verified: in that exact 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. A correct fix needs a row-window read near `cursor_y` (or a structural scan like the herdr adapter's composer-row finder, `bin/backends/herdr.sh`), not just a wider idle regex. 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. +**TRUECOLOR placeholder styling: covered (task afk-herdr-false-pending, 2026-07-10).** +A freshly-dismissed, never-typed-into grok composer shows a placeholder ("Type a message...") styled with a dark 24-bit TRUECOLOR foreground, not the SGR-2 dim/faint attribute the ghost stripper originally detected. +The shared ANSI-aware owner `fm_composer_strip_ghost` (`bin/fm-composer-lib.sh`) now drops a dark/muted truecolor foreground (perceived luminance below `FM_COMPOSER_GHOST_LUMA_MAX`, default 128) as well as dim/faint, so the placeholder is stripped and the row reads empty on both ANSI-capable backends (tmux and herdr route through the same owner). +Verified live against grok 0.2.93: real input is the bright `38;2;224;222;244` (luminance ~225, kept), while grok's borders and placeholder/hint text are dark truecolor (`38;2;50;47;70` .. `38;2;110;106;134`, luminance ~51..110, dropped). +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. 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. @@ -192,3 +256,11 @@ The hook reads `$GROK_WORKSPACE_ROOT`, which is always set for hooks and equals This keeps the hook outside the worktree, needs no trust grant, and writes only firstmate-owned files. `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).** +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. +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. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 120f8a0f8..87b65269e 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -31,12 +31,16 @@ The `projects:` field is a non-exclusive clone list, not ownership. Scaffold a secondmate charter with: ```sh -bin/fm-brief.sh --secondmate ... +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. +Pass `--no-projects` instead of a project list to scaffold a project-less charter for a domain whose subject is the firstmate repo itself, whose home is a firstmate worktree and whose crews take pooled worktrees of the same repo. +`--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. @@ -44,15 +48,18 @@ Preserve that wording when filling the charter, including the marker rule that m Provision the persistent home and registry entry after the charter is filled: ```sh -bin/fm-home-seed.sh ... +bin/fm-home-seed.sh {...|--no-projects} ``` +Pass `--no-projects` in the project position to seed the project-less home described above; the same mutual-exclusion and fail-loud-on-omission rules apply. +It may only seed a home with no project clones or project-registry entries, and refuses conversion of populated homes without changing them. `-` durably leases a fresh firstmate worktree via `treehouse get --lease` under the secondmate id. The lease survives with no live process and is never recycled by later `treehouse get` or `prune`. The slot stays reserved across restarts until the lease is released. Release happens only on explicit retirement or seed rollback, never on routine restart or recovery. `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. +It also writes the required `.fm-secondmate-home` identity marker, which is gitignored and must remain in place for home validation. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. @@ -64,9 +71,15 @@ 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. 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 same launch also propagates 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/`. +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. +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. `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`. `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. @@ -86,6 +99,7 @@ It fires only when the caller is running from inside a seeded secondmate home's ## Backlog handoff +Apply `AGENTS.md` section 10's work-items-only backlog contract before creation or handoff. When a secondmate is created for a domain, existing main-backlog items that fall under its scope should become its work instead of staying stranded in the main backlog. Scope-matching is firstmate's judgment against the secondmate's natural-language scope, not a keyword rule. Read `data/backlog.md`, pick queued items that fit the new scope, and move them with: @@ -95,9 +109,12 @@ bin/fm-backlog-handoff.sh ... ``` After seeding, run this handoff for the new secondmate's in-scope queued items. -The helper resolves the secondmate home from `data/secondmates.md` and mechanically moves each named item from the main `data/backlog.md` into the secondmate home's `data/backlog.md`. -It preserves the line and its section, so the item is neither duplicated nor lost. -It refuses `## In flight` entries because active task ownership also lives in tmux and `state/`. +The helper resolves and validates the secondmate home from `data/secondmates.md`, then delegates the item move to `tasks-axi mv` (the single owner of the backlog format), which moves each named item - and a whole connected set, blocker plus dependents, atomically - from the main `data/backlog.md` into the secondmate home's `data/backlog.md`. +This delegated route remains required when `config/backlog-backend=manual`, which controls only routine firstmate backlog edits. +It moves each queued item's whole block - the `- [ ] ...` header plus every following two-or-more-space-indented body line and blank separator, up to the next item or column-0 section heading - byte-exact under the same section, treating an indented `## ...` line as body rather than a section boundary, so neither the header nor its body is duplicated or orphaned. +It refuses a selected item with a single-space or tab-indented continuation rather than risk leaving content orphaned in the main backlog. +It accepts in-scope `## Queued` entries only and refuses `## In flight` and historical `## Done` entries. +Done records stay with their home for pruning or archiving. It is idempotent; an item already in the secondmate backlog is skipped. It refuses any destination that is not a genuine seeded firstmate home with safe operational directories and a matching `.fm-secondmate-home` marker, so a move can never land in a project. Do not hand off `local-only` items. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index da6bf7034..c257b3703 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -29,19 +29,28 @@ 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. - `data/learnings.md` may not exist yet; create it on first learning, in the same dated, evidence-backed, curated style as `data/captain.md` - rewrite and prune stale or superseded entries rather than appending forever. + - 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. + 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`. - 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. - Knowledge generalizable to every firstmate user: this repo's own `AGENTS.md` (or other shared, tracked material), shipped through the normal branch -> no-mistakes -> PR -> captain-merge pipeline for this repo (section 1), never hand-committed straight to `main`. - - Task-scoped notes: append to the relevant backlog item's notes with `bin/fm-tasks-axi.sh update --append ""`, or hand-edit `data/backlog.md` per the active backend (section 10). + - Task-scoped notes: inspect the relevant backlog item with `bin/fm-tasks-axi.sh show --full`, judge whether the new note is new, duplicate, superseding, or obsolete, then write a considered replacement body with `bin/fm-tasks-axi.sh update --body-file `. + When the replacement intentionally supersedes prior state that should remain recoverable, add `--archive-body` to that update command so the prior body stays recoverable without copying it into the replacement. + Never append. + If hand-editing `data/backlog.md` per the active backend, make the same inspect-then-update edit in place. - Undone next steps: file each as a queued backlog item (section 10), with `blocked-by` recorded if it genuinely depends on something else. -4. **Curate, don't just append.** - When a finding overlaps or supersedes something already on disk, prefer rewriting or pruning the existing entry over 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. - Do not invent other graduation paths. +4. **Curate with inspect-then-update.** + Every write starts by reading the current destination and deciding how the finding changes what is already there. + Use this checklist before writing: + - Which existing bullet, section, or task body does this supersede? + - 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. + Do not invent other graduation paths. 5. **Report to the captain.** Summarize, in plain outcome language (section 9): what was stowed and where, what was filed to the backlog, and whether the session is now safe to reset or destroy - i.e. whether every durable finding from this sweep now lives on disk rather than only in this conversation. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 073736afe..015bc1350 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -27,7 +27,7 @@ This touches only the firstmate repo and its own worktrees, never anything under It fast-forwards this firstmate repo's default branch from origin, then fast-forwards every registered secondmate home (each a treehouse worktree of this same repo, leased at a detached HEAD on the default branch) the same way. It prints one status line per target (`updated ..` / `already current` / `skipped: `), followed by two action lines that tell you exactly what to do next: - `reread-firstmate: yes|no` - - `nudge-secondmates: |none` + - `nudge-secondmates: fm-...|none` 2. **Re-read AGENTS.md if your own instructions changed.** When the updater printed `reread-firstmate: yes`, the tracked instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) just advanced under you. @@ -37,7 +37,7 @@ This touches only the firstmate repo and its own worktrees, never anything under 3. **Nudge each updated live secondmate.** For every target listed on the `nudge-secondmates:` line (do nothing when it says `none`), send a one-line re-read nudge so that secondmate picks up its new instructions too: ```sh - FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.' + FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.' ``` Include `FM_HOME=` unless `FM_HOME` is already set to the active firstmate home. This is a gentle steer, not an interruption: the secondmate already got a safe tracked-files fast-forward, and the nudge never forces, tears down, or discards its work. diff --git a/.claude/settings.json b/.claude/settings.json index a70ee43e4..6bbd714d7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,20 @@ { "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..0ed18a74a --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,32 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "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-arm-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-arm-pretool-check.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-arm-pretool-check.sh\"'", + "timeout": 10 + }, + { + "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-cd-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-cd-pretool-check.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-cd-pretool-check.sh\"'", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "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-turnend-guard.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.Stop[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-turnend-guard.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-turnend-guard.sh\"'", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ca0a2b4..a75d82d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - run: shellcheck bin/*.sh bin/backends/*.sh tests/*.sh + - name: Install pinned ShellCheck + run: | + set -eu + bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + # Single owner of the lint definition (file set + config + version). Do not + # re-spell the shellcheck command here; keep CI and the pre-push gate on it. + - run: bin/fm-lint.sh tests: name: Behavior tests @@ -27,6 +34,11 @@ jobs: - 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: Require tmux for e2e tests run: | set -eu @@ -35,6 +47,11 @@ jobs: exit 1 } tmux -V + - name: Install tasks-axi for backlog-handoff delegation + run: | + set -eu + npm install -g tasks-axi + tasks-axi --version - run: | set -eu for test_script in tests/*.test.sh; do diff --git a/.gitignore b/.gitignore index dc785fb89..5ed2da0c3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,10 @@ state/ data/ .no-mistakes/ .lavish/ +.fm-secondmate-home .DS_Store +__pycache__/ +*.pyc .env config/crew-harness config/crew-dispatch.json @@ -12,3 +15,4 @@ config/backlog-backend config/backend config/x-mode.env config/cmux-socket-password +config/wedge-alarm diff --git a/.grok/hooks/fm-primary-cd-check.json b/.grok/hooks/fm-primary-cd-check.json new file mode 100644 index 000000000..af781aa93 --- /dev/null +++ b/.grok/hooks/fm-primary-cd-check.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-cd-pretool-check.sh\"'", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.grok/hooks/fm-primary-pretool-check.json b/.grok/hooks/fm-primary-pretool-check.json new file mode 100644 index 000000000..9f958da66 --- /dev/null +++ b/.grok/hooks/fm-primary-pretool-check.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-arm-pretool-check.sh\"'", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.grok/hooks/fm-primary-turnend-guard.json b/.grok/hooks/fm-primary-turnend-guard.json new file mode 100644 index 000000000..12bfe512c --- /dev/null +++ b/.grok/hooks/fm-primary-turnend-guard.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-turnend-guard-grok.sh\"'", + "timeout": 180 + } + ] + } + ] + } +} diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 6d36dfa31..f2bb03231 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -1,11 +1,19 @@ # Per-repo no-mistakes overrides. -# Run the firstmate bash behavior suite deterministically as the test-step -# baseline, instead of delegating to an agent (an agent-driven test step has -# crashed the daemon). Mirrors .github/workflows/ci.yml: iterate every -# tests/*.test.sh, run each, and fail the step if any one exits non-zero. The -# e2e tests need tmux on PATH, which the firstmate environment provides. +# 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. 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. diff --git a/.opencode/plugins/fm-primary-cd-check.js b/.opencode/plugins/fm-primary-cd-check.js new file mode 100644 index 000000000..b542b7585 --- /dev/null +++ b/.opencode/plugins/fm-primary-cd-check.js @@ -0,0 +1,64 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; + +// PreToolUse seatbelt for OpenCode: block a stray persistent top-level `cd` in +// the primary firstmate checkout before the agent's bash tool relocates the +// shell out of the home (see bin/fm-cd-pretool-check.sh and docs/cd-guard.md). +// This mirrors fm-primary-pretool-check.js, calling the cd-guard owner instead +// of the watcher-arm one. tool.execute.before can block by throwing (verified +// 2026-07-09 against OpenCode 1.17.15 for the watcher-arm plugin; the same +// mechanism carries this guard). The owner script is itself inert outside the +// real primary checkout, so a crewmate/scout worktree is never affected. + +function runProcess(command, args) { + return new Promise((resolvePromise) => { + const child = spawn(command, args, { stdio: ["ignore", "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", () => resolvePromise({ code: 0, stdout: "", stderr: "" })); + child.on("close", (code) => resolvePromise({ code: code ?? 0, stdout, stderr })); + }); +} + +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; + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +export const FmPrimaryCdCheck = async ({ directory, worktree }) => { + const root = worktree ? (() => { + try { + return realpathSync(worktree); + } catch { + return resolve(worktree); + } + })() : await resolveRoot(directory); + + return { + "tool.execute.before": async (input, output) => { + if (!root || input?.tool !== "bash") return; + const command = output?.args?.command; + if (!command || typeof command !== "string") return; + + const result = await runProcess(`${root}/bin/fm-cd-pretool-check.sh`, ["--command", command]); + if (result.code !== 2) return; + + const reason = result.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt"; + throw new Error(reason); + }, + }; +}; diff --git a/.opencode/plugins/fm-primary-pretool-check.js b/.opencode/plugins/fm-primary-pretool-check.js new file mode 100644 index 000000000..eb0d6250d --- /dev/null +++ b/.opencode/plugins/fm-primary-pretool-check.js @@ -0,0 +1,64 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; + +// PreToolUse seatbelt for OpenCode: the arm mechanism itself lives entirely in +// fm-primary-watch-arm.js (a plugin-owned child process, never a model tool +// call), so the residual risk here is the AGENT shelling `bin/fm-watch-arm.sh` +// wrong through its own bash tool - the anti-pattern bin/fm-arm-pretool-check.sh +// guards against (see that script's header and docs/arm-pretool-check.md). +// tool.execute.before can block by throwing (verified 2026-07-09 against +// OpenCode 1.17.15: throwing here prevents the bash command from running and +// surfaces the thrown message as the failed tool result). + +function runProcess(command, args) { + return new Promise((resolvePromise) => { + const child = spawn(command, args, { stdio: ["ignore", "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", () => resolvePromise({ code: 0, stdout: "", stderr: "" })); + child.on("close", (code) => resolvePromise({ code: code ?? 0, stdout, stderr })); + }); +} + +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; + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +export const FmPrimaryPretoolCheck = async ({ directory, worktree }) => { + const root = worktree ? (() => { + try { + return realpathSync(worktree); + } catch { + return resolve(worktree); + } + })() : await resolveRoot(directory); + + return { + "tool.execute.before": async (input, output) => { + if (!root || input?.tool !== "bash") return; + const command = output?.args?.command; + if (!command || typeof command !== "string") return; + + const result = await runProcess(`${root}/bin/fm-arm-pretool-check.sh`, ["--command", command]); + if (result.code !== 2) return; + + const reason = result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt"; + throw new Error(reason); + }, + }; +}; diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js new file mode 100644 index 000000000..b38f9c2c4 --- /dev/null +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -0,0 +1,97 @@ +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; + +let skipNextIdle = false; + +function runProcess(command, args, input = "") { + return new Promise((resolve) => { + const child = spawn(command, args, { + 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", () => resolve({ code: 0, stdout: "", stderr: "" })); + child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr })); + child.stdin.end(input); + }); +} + +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); +} + +function resolvePath(anchor) { + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +function runGuard(root) { + if (!root) return Promise.resolve({ code: 0, stderr: "" }); + return runProcess(`${root}/bin/fm-turnend-guard.sh`, [], '{"stop_hook_active":false}'); +} + +async function letWatchArmRun(sessionID, client) { + const coordinator = globalThis[COORDINATOR_KEY]; + if (!coordinator?.ensureArmed) return false; + const status = await coordinator.ensureArmed(sessionID, client); + return status === "armed" || status === "wake" || status === "failed"; +} + +export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => { + const root = worktree ? resolvePath(worktree) : await resolveRoot(directory); + + return { + event: async ({ event }) => { + if (event.type !== "session.idle") return; + + if (skipNextIdle) { + skipNextIdle = false; + return; + } + + const sessionID = event.properties?.sessionID; + if (!sessionID) return; + + if (await letWatchArmRun(sessionID, client)) return; + + const result = await runGuard(root); + if (result.code !== 2) return; + + try { + 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, + }, + ], + }, + }); + skipNextIdle = true; + } catch { + skipNextIdle = false; + } + }, + }; +}; diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js new file mode 100644 index 000000000..5147f839a --- /dev/null +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -0,0 +1,240 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; +const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); + +let child = null; +let armStatus = "idle"; +let waiters = new Set(); + +function setArmStatus(status) { + armStatus = status; + for (const resolve of waiters) resolve(status); + waiters.clear(); +} + +function readyStatus() { + if (armStatus === "armed" || armStatus === "wake" || armStatus === "failed" || armStatus === "external") return armStatus; + return ""; +} + +function waitForArmReady() { + const ready = readyStatus(); + if (ready) return Promise.resolve(ready); + return new Promise((resolve) => { + let timer = null; + const waiter = (status) => { + if (timer) clearTimeout(timer); + waiters.delete(waiter); + resolve(status); + }; + timer = setTimeout(() => { + waiters.delete(waiter); + resolve("timeout"); + }, ARM_READY_TIMEOUT_MS); + waiters.add(waiter); + }); +} + +function runProcess(command, args, options = {}) { + return new Promise((resolve) => { + const proc = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + proc.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + proc.on("error", (error) => resolve({ code: 127, stdout, stderr: String(error?.message ?? error) })); + proc.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr })); + }); +} + +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); +} + +function resolvePath(anchor) { + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +function effectivePaths(root) { + const fmRoot = process.env.FM_ROOT_OVERRIDE || root; + const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || fmRoot; + const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; + const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; + return { root: fmRoot, home: fmHome, state, config }; +} + +async function isPrimaryRoot(root, home) { + if (!root) return false; + if (!existsSync(`${root}/AGENTS.md`) || !existsSync(`${root}/bin`)) return false; + if (existsSync(`${root}/.fm-secondmate-home`)) return false; + if (home && home !== root && existsSync(`${home}/.fm-secondmate-home`)) return false; + const gitDir = await runProcess("git", ["-C", root, "rev-parse", "--git-dir"]); + const commonDir = await runProcess("git", ["-C", root, "rev-parse", "--git-common-dir"]); + if (gitDir.code !== 0 || commonDir.code !== 0) return false; + return gitDir.stdout.trim() === commonDir.stdout.trim(); +} + +function shouldArm(paths) { + if (existsSync(`${paths.state}/.afk`)) return false; + if (existsSync(`${paths.config}/x-mode.env`)) return true; + try { + return readdirSync(paths.state).some((name) => name.endsWith(".meta")); + } catch { + return false; + } +} + +async function sessionOwnsLock(paths) { + let lockPid = ""; + try { + lockPid = readFileSync(`${paths.state}/.lock`, "utf8").trim(); + } catch { + return false; + } + if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return false; + let pid = String(process.pid); + for (let i = 0; i < 8; i += 1) { + if (pid === lockPid) return true; + const result = await runProcess("ps", ["-o", "ppid=", "-p", pid]); + if (result.code !== 0) return false; + pid = result.stdout.trim(); + if (!pid || pid === "1") return false; + } + return false; +} + +function firstWakeOrFailure(stdout, stderr, code) { + 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 ""; + 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 ""; +} + +function observeArmOutput(stdout, stderr) { + const combined = `${stdout}\n${stderr}`; + if (combined.split(/\r?\n/).some((line) => /^watcher: started\b/.test(line))) { + setArmStatus("armed"); + return; + } + if (combined.split(/\r?\n/).some((line) => /^watcher: healthy\b/.test(line))) { + setArmStatus("external"); + return; + } + if (combined.split(/\r?\n/).some((line) => /^watcher: FAILED/.test(line))) { + setArmStatus("failed"); + } +} + +async function sendPrompt(client, sessionID, text) { + await client.session.promptAsync({ + path: { id: sessionID }, + body: { + parts: [ + { + type: "text", + text, + }, + ], + }, + }); +} + +function spawnArm(paths, sessionID, client) { + setArmStatus("starting"); + const env = { + ...process.env, + FM_HOME: paths.home, + FM_ROOT_OVERRIDE: paths.root, + }; + 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'], { + cwd: paths.root, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + observeArmOutput(stdout, stderr); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + observeArmOutput(stdout, stderr); + }); + 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 { + } + }); + 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 { + } + }); +} + +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(); +} + +export const FmPrimaryWatchArm = async ({ client, directory, worktree }) => { + const root = worktree ? resolvePath(worktree) : await resolveRoot(directory); + const paths = effectivePaths(root); + globalThis[COORDINATOR_KEY] = { + ensureArmed: (sessionID, activeClient) => ensureArm(paths, sessionID, activeClient ?? client), + }; + + return { + event: async ({ event }) => { + if (event.type !== "session.idle") return; + const sessionID = event.properties?.sessionID; + if (!sessionID) return; + void ensureArm(paths, sessionID, client); + }, + }; +}; diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts new file mode 100644 index 000000000..644f939ce --- /dev/null +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -0,0 +1,188 @@ +// Firstmate primary watcher bridge for Pi. +import { spawn, spawnSync } 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 } from "typebox"; + +type ArmResult = { + ok: boolean; + message: string; +}; + +type LockOwnership = "owned" | "missing" | "other"; + +const extensionFile = fileURLToPath(import.meta.url); +const extensionDir = dirname(extensionFile); +const root = resolve(extensionDir, "../.."); +const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; +const fmRoot = process.env.FM_ROOT_OVERRIDE || root; +const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; +const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; +const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; +const marker = `${state}/.pi-watch-extension-loaded`; +const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; + +let child: any = null; +let seq = 0; + +function parentPid(pid: string): string { + const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); + if (result.status !== 0) return ""; + return result.stdout.trim(); +} + +function pidAlive(pid: string): boolean { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +function lockOwnership(): LockOwnership { + let lockPid = ""; + try { + lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); + } catch { + return "missing"; + } + if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; + let pid = String(process.pid); + for (let i = 0; i < 8; i += 1) { + if (pid === lockPid) return "owned"; + pid = parentPid(pid); + if (!pid || pid === "1") break; + } + return pidAlive(lockPid) ? "other" : "missing"; +} + +function sessionOwnsLock(): boolean { + return lockOwnership() === "owned"; +} + +function markLoaded(): void { + if (lockOwnership() === "other") return; + mkdirSync(state, { recursive: true }); + writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); +} + +function actionableLine(output: string): string { + const lines = output.split(/\r?\n/); + return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || ""; +} + +function failureLine(stdout: string, stderr: string, code: number | null): string { + const combined = `${stdout}\n${stderr}`.trim(); + 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}`; + 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 ""; +} + +export default function (pi: ExtensionAPI) { + function stopArm(): void { + if (child) child.kill("SIGTERM"); + child = null; + } + + const cleanupOnProcessExit = () => { + stopArm(); + }; + process.once("exit", cleanupOnProcessExit); + + 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" }, + ); + } + + function startArm(): ArmResult { + if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + markLoaded(); + if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; + const id = ++seq; + const env = { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_CONFIG_OVERRIDE: config, + FM_WATCH_ARM_SCRIPT: armScript, + }; + 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"], { + cwd: fmRoot, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + 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. + } + }); + child.on("error", async (error: Error) => { + child = null; + try { + await sendWake(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`); + } catch { + // Fail open. + } + }); + return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + } + + pi.on?.("session_start", () => { + markLoaded(); + }); + pi.on?.("session_shutdown", () => { + stopArm(); + process.off("exit", cleanupOnProcessExit); + }); + + 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(); + ctx.ui.notify(result.message, result.ok ? "info" : "warning"); + }, + }); + + 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.", + promptGuidelines: [ + "For Pi watcher supervision, call fm_watch_arm_pi instead of running bin/fm-watch-arm.sh through bash.", + ], + parameters: Type.Object({}), + execute: async () => { + const result = startArm(); + return { + content: [{ type: "text", text: result.message }], + details: result, + }; + }, + }); + + markLoaded(); +} diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts new file mode 100644 index 000000000..fe90145a1 --- /dev/null +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -0,0 +1,143 @@ +import { spawn, spawnSync } 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"; + +let guardFollowupActive = false; + +type LockOwnership = "owned" | "missing" | "other"; + +const extensionFile = fileURLToPath(import.meta.url); +const extensionDir = dirname(extensionFile); +const root = resolve(extensionDir, "../.."); +const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; +const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; +const marker = `${state}/.pi-turnend-extension-loaded`; +const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; + +function parentPid(pid: string): string { + const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); + if (result.status !== 0) return ""; + return result.stdout.trim(); +} + +function pidAlive(pid: string): boolean { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +function lockOwnership(): LockOwnership { + let lockPid = ""; + try { + lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); + } catch { + return "missing"; + } + if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; + let pid = String(process.pid); + for (let i = 0; i < 8; i += 1) { + if (pid === lockPid) return "owned"; + pid = parentPid(pid); + if (!pid || pid === "1") break; + } + return pidAlive(lockPid) ? "other" : "missing"; +} + +function markLoaded(): void { + if (lockOwnership() === "other") return; + mkdirSync(state, { recursive: true }); + writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); +} + +function runGuard(): Promise<{ code: number; stderr: string }> { + return new Promise((resolveResult) => { + const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { + stdio: ["pipe", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolveResult({ code: 0, stderr: "" })); + child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); + child.stdin.end('{"stop_hook_active":false}'); + }); +} + +// PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md; +// bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same +// extension file rather than separate ones so no extra Pi -e flag is needed at +// launch - the primary already loads this file for the turn-end guard, and +// pi.on("tool_call", ...) can block (verified 2026-07-09 against pi 0.80.5: +// returning {block: true} prevents the bash command from running). Each owner +// script owns its own decision and is inert outside the real primary checkout. +function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> { + return new Promise((resolveResult) => { + const child = spawn(`${root}/bin/${script}`, ["--command", command], { + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolveResult({ code: 0, stderr: "" })); + child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); + }); +} + +function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> { + return runChecker("fm-arm-pretool-check.sh", command); +} + +function runCdCheck(command: string): Promise<{ code: number; stderr: string }> { + return runChecker("fm-cd-pretool-check.sh", command); +} + +export default function (pi: ExtensionAPI) { + pi.on?.("session_start", () => { + markLoaded(); + }); + + pi.on("tool_call", async (event) => { + if (event.type !== "tool_call" || event.toolName !== "bash") return {}; + const command = String((event.input as { command?: unknown })?.command ?? ""); + if (!command) return {}; + const cdResult = await runCdCheck(command); + if (cdResult.code === 2) { + return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" }; + } + const result = await runPretoolCheck(command); + if (result.code !== 2) return {}; + return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; + }); + + pi.on("agent_settled", async () => { + if (guardFollowupActive) { + guardFollowupActive = false; + return; + } + + const result = await runGuard(); + if (result.code !== 2) return; + + guardFollowupActive = true; + try { + await pi.sendUserMessage( + "TURN WOULD END BLIND - supervision is off. " + + "Resume supervision according to the session-start operating block before ending the turn.\n\n" + + result.stderr, + { deliverAs: "followUp" }, + ); + } catch { + guardFollowupActive = false; + } + }); + + markLoaded(); +} diff --git a/AGENTS.md b/AGENTS.md index 3a30539b1..1f5d4699b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Hard rules, in priority order: 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 section 7's ship-teardown detail, not restated here. + The full PR-containment mechanics and the `pr=` discovery fallback are owned by `bin/fm-teardown.sh`'s header, not restated here. 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. @@ -77,14 +77,15 @@ bin/ helper scripts, committed; read each script's header before config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) 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 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); not inherited into secondmate homes +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/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 curated personal preferences and working style; LOCAL, gitignored, and canonical even if harness memory mirrors it - learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store + captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update + 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) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate @@ -94,21 +95,22 @@ 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=, and x_followups= for an X-mention-originated task (section 14) + .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) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) 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 .wake-queue durable queued wakes: epochseqkindkeypayload - .pending-acks durable fm-send --expect-ack rows: idsent_atdeadlinepre_status_sigpre_status_linesescalatedsummary; scanned by the watcher, pruned per-task by teardown .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .fleet-freeze durable fleet-freeze flag; present = fm-spawn.sh, fm-send.sh, fm-watch.sh, fm-watch-arm.sh, and the supervise daemon refuse fleet movement (set by bin/fm-freeze.sh on, cleared by bin/fm-freeze.sh off; section 8) + .pending-acks durable fm-send --expect-ack rows: idsent_atdeadlinepre_status_sigpre_status_linesescalatedsummary; scanned by the watcher, pruned per-task by teardown (section 8) .watch.lock .wake-queue.lock .pending-acks.lock watcher singleton and queue serialization locks - .hash-* .count-* .stale-* .stale-since-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; 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 .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch + .pi-watch-extension-loaded .pi-turnend-extension-loaded pi-harness session-start markers hashing the loaded `.pi/extensions/*.ts` content; never touch .no-mistakes/ local validation state and evidence; gitignored ``` @@ -126,20 +128,23 @@ It composes today's `fm-lock.sh`, `fm-bootstrap.sh`, and `fm-wake-drain.sh` - ca 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. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - The three MUTATING sweeps - fleet sync, the local secondmate fast-forward sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + 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, re-arm, or checkout repair commands. + 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; the `state/.fleet-freeze` flag (present prints its contents and a reminder that spawn, send, the watcher, and the daemon refuse fleet movement while it exists); and one cheap alive/dead read of each task's recorded backend endpoint. +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; the `state/.fleet-freeze` flag (present prints its contents and a reminder that spawn, send, the watcher, and the daemon refuse fleet movement while it exists; section 8); 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. **Next step** - a conditional closing reminder for the actual watcher owner: stay read-only when the lock was refused, use `/afk` when away mode is active, stay in orchestration/diagnosis mode when the fleet is frozen, source `config/x-mode.env` before arming when X mode is active, or arm normally otherwise. - The script itself never arms the watcher, because a fire-and-forget arm from inside a script that then exits would be reaped immediately, silently dropping supervision. +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. @@ -147,47 +152,11 @@ Tell the captain another active session is already managing the work and operate Bootstrap is detect, then consent, then install. Never install anything the captain has not approved in this session. -The mutating sweeps that run only when locked: fleet sync via `bin/fm-fleet-sync.sh`, best-effort and non-fatal, under the hard-rule exception in section 1 (set `FM_FLEET_PRUNE=0` to temporarily disable that branch pruning); and a sweep of every live secondmate home, fast-forwarding each one's worktree to firstmate's own current default-branch commit so the fleet stays converged on whatever version firstmate is on. -The live set comes from `state/.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. -This is a purely local fast-forward (every secondmate home is a worktree of this same repo, sharing one object store), never a fetch from origin and never a surprise pull: the version followed is simply whatever the primary is currently on, which only the captain changes deliberately via `git pull` or `/updatefirstmate`. -A tracked-files fast-forward never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a dirty, diverged, or in-flight home is skipped untouched. -The same sweep also propagates the primary's declared inheritable config (`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`; sections 4 and 10) into each live secondmate home's `config/`, so every secondmate's own crewmates, dispatch profiles, and backlog backend stay on the primary's settings. -Because `config/` is gitignored this 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 inheritable items (never `config/secondmate-harness`). +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`. -It is config-only: it uses the same live secondmate discovery and the same `propagate_inheritable_config` helper as bootstrap, prints a per-home/per-item summary, does not fast-forward tracked files, and does not nudge secondmates. -The propagation helper itself keeps stdout silent for existing callers, but warns on stderr when an item is skipped because the destination does not allow it or when a copy/remove error occurs. -The sweep reports the `NUDGE_SECONDMATES:` line below only when a running secondmate actually advanced with an instruction-surface change (`AGENTS.md`, `bin/`, or `.agents/skills/`), so firstmate knows which ones to live-converge. 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; handle each: - -- `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, and a version probe that hangs past `FM_NO_MISTAKES_PROBE_TIMEOUT` seconds (default 3) degrades to this same line instead of wedging bootstrap. - For `tasks-axi`, this appears only when `config/backlog-backend` is absent or set to `tasks-axi`; hand-edit fallback continues until the captain approves installation. -- `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; 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 JSON, unverified harness name, 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 as `rule: -> ` lines, plus `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. -- `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. -- `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; bootstrap continued, but inspect the reason because the secondmate may be stale after a primary update. -- `TASKS_AXI: available` - a default-backend capability fact, not a problem; record it silently and use section 10 for backlog mutations. - It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the compatibility probe accepts `tasks-axi --version` as 0.1.1 or newer. - If the backend is not opted out and `tasks-axi` is missing or incompatible, bootstrap reports `MISSING: tasks-axi (install: npm install -g tasks-axi)` but still falls back to hand-editing and never blocks work. - If `config/backlog-backend=manual`, bootstrap hand-edits and does not suggest installing `tasks-axi`. -- `TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from ` - `FM_HOME` points somewhere other than this repo root and the two `data/backlog.md` files have diverged; bare `tasks-axi` discovers `.tasks.toml` from the working directory, so running it from the repo root can silently mutate the wrong home's backlog. - Always invoke `bin/fm-tasks-axi.sh` (section 10) instead of bare `tasks-axi`; it resolves and `cd`s into the effective `FM_HOME` before running. -- `NUDGE_SECONDMATES: ` - 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. -- `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts; follow section 14 for watcher cadence restart only when a running watcher needs the transition applied immediately. - -Bootstrap's fleet refresh is bounded by `FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT` seconds, default 20; a timeout is reported as a `FLEET_SYNC` skip and does not block startup. +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. @@ -216,38 +185,14 @@ The file is JSON so firstmate can read the natural-language rules and bootstrap 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`. -Schema: - -```json -{ - "rules": [ - { - "when": "", - "use": { - "harness": "", - "model": "", - "effort": "" - }, - "why": "" - } - ], - "default": { - "harness": "", - "model": "", - "effort": "" - } -} -``` - -Per rule, `when` and `use` are required, and `use.harness` is required. -`use.model`, `use.effort`, and `why` are optional. -`default` is optional. -An omitted model or effort means the selected harness uses its own default for that axis. +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. -Resolve the chosen rule's `use` object into a 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. +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). @@ -255,6 +200,9 @@ That refusal is the consultation backstop, so the rules are never silently skipp 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". @@ -266,27 +214,20 @@ 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`. -`fm-spawn` only checks whether the file exists so it can enforce the explicit-harness backstop for crewmate and scout dispatches. Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). -If the selected profile asks for an effort value the selected harness does not accept, `fm-spawn` records the requested `effort=` in meta for traceability but omits the launch flag so the harness starts successfully. -Bootstrap reports this as a `CREW_DISPATCH` diagnostic when it can see the invalid harness/effort pair in `config/crew-dispatch.json`. - 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. -So an absent or `default` `config/secondmate-harness` behaves exactly as before this knob existed - secondmates launch on the crew harness - and setting it splits the two: e.g. primary `config/crew-harness=codex` with `config/secondmate-harness=claude` runs the secondmate AGENTS on claude while all crewmates (the primary's and the secondmates' own) run on codex. -`bin/fm-spawn.sh` resolves a `--secondmate` launch through `secondmate` mode and a crewmate/scout launch through `crew` mode; an explicit per-spawn `--harness` flag or positional harness arg still overrides either kind. -The split is durable: every secondmate respawn (recovery, `/updatefirstmate`, restart) re-resolves from `config/secondmate-harness`, so it survives restarts without being recorded per-task. +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. -Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when `config/crew-harness` names a concrete adapter, such as `codex`; an unset or `default` value has nothing concrete to inherit, so the secondmate's own crewmates fall back to the secondmate's own/detected harness instead. -Propagation timing, mechanism, and `bin/fm-config-push.sh` are section 3's canonical statement. +`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 mechanics (launch command, autonomy flag, turn-end hook) live in `bin/fm-spawn.sh`; the knowledge you need while supervising (busy signature, exit, interrupt, dialogs, quirks, skill invocation, resume) lives in the agent-only `harness-adapters` skill. +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. @@ -304,7 +245,7 @@ Reconcile reality with your records before doing anything else, working from the 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. - When tracked state and the live Herdr surface disagree, `bin/fm-fleet-map.sh` prints a read-only inventory of both to diagnose the drift. + When tracked state and the live Herdr surface disagree, `bin/fm-fleet-map.sh` prints a read-only inventory of both - matching by exact target then by cwd, and warning on stale-tracked records and operator-untracked agents - to diagnose the drift; it never spawns, steers, tears down, or mutates anything. 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. @@ -316,7 +257,7 @@ Reconcile reality with your records before doing anything else, working from the 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 section 8 watcher checklist through the digest's own closing reminder; if the lock was refused or `state/.afk` exists, follow the digest's no-direct-arm guidance. +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. @@ -337,16 +278,10 @@ Add the line when you clone or create a project, keep the description useful for 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. -Every persistent secondmate has one line: - -```markdown -- - (home: ; scope: ; projects: , ; added ) -``` - +`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 home leases, secondmate harness pins, transactional rollback, validation, project clone restrictions, handoff edge cases, charter copy rules, and teardown internals. +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. @@ -354,11 +289,9 @@ It must never spawn a survey, audit, or self-directed "find improvements" task o 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, the existing main-backlog items that fall under its scope should become its work instead of staying stranded in the main backlog. -Scope-matching is firstmate's judgment against the secondmate's natural-language scope, not a keyword rule. -Read `data/backlog.md`, pick queued items that fit the scope, and move them with `bin/fm-backlog-handoff.sh ...`. +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). -For idempotence, destination validation, and refusal of `## In flight` entries, load `secondmate-provisioning`. +`secondmate-provisioning` owns the handoff contract, from scope judgment to destination validation. ### Project memory ownership @@ -368,6 +301,9 @@ Firstmate keeps project knowledge split by ownership. 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. **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. @@ -388,14 +324,14 @@ Do not eagerly backfill every project. Route each piece of durable knowledge to its most specific home: -| Kind of knowledge | Home | -| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| Captain preferences and working style | `data/captain.md` | -| 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` | -| Knowledge generalizable to every firstmate user | the shared `AGENTS.md`, shipped via PR through the pipeline | -| Task-scoped notes | backlog item notes (`bin/fm-tasks-axi.sh update --append ""`, or hand-edit per the active backend) | -| Investigation findings | scout reports at `data//report.md` | +| 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 `bin/fm-tasks-axi.sh show --full`, then replace the body with `bin/fm-tasks-axi.sh 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` | 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. @@ -449,11 +385,11 @@ Read `data/secondmates.md` before dispatching and compare the work request to ea 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 fm- ''` unless `FM_HOME` is already set to the active firstmate home, and let it run the normal lifecycle inside its own home. -The bare `fm-` target resolves through this home's `state/.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home. -`fm-send` is fail-closed: `FM_HOME` must be set, a missing `fm-` prefix is rejected with a "did you mean fm-?" diagnostic, 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. +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 bare `fm-` 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. +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. @@ -484,54 +420,31 @@ Load `harness-adapters` before spawning or recovering any direct report so trust ```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 # explicit per-task harness override -bin/fm-spawn.sh projects/ codex # per-task harness override -bin/fm-spawn.sh projects/ grok # per-task harness override bin/fm-spawn.sh projects/ --harness codex --model gpt-5.5 --effort high # explicit profile axes -bin/fm-spawn.sh projects/ --backend tmux # explicit runtime backend; tmux is the verified reference backend (docs/tmux-backend.md) -bin/fm-spawn.sh projects/ --backend herdr # experimental herdr backend (docs/herdr-backend.md); version-gates at spawn -bin/fm-spawn.sh projects/ --backend zellij # experimental zellij backend (docs/zellij-backend.md); version-gates at spawn -bin/fm-spawn.sh projects/ --backend orca # experimental Orca backend (docs/orca-backend.md); Orca owns worktree + terminal; Escape unsupported -bin/fm-spawn.sh projects/ --backend cmux # experimental cmux backend (docs/cmux-backend.md); GUI-first macOS-only, treehouse still owns worktree; requires a one-time socket-access setup (docs/cmux-backend.md "Setup") +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 a registered persistent secondmate in its home -bin/fm-spawn.sh --secondmate # launch or recover an explicit secondmate home +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 ``` -Dispatch several tasks in one call by passing `id=repo` pairs instead of a single ` `; each pair is spawned through the same single-task path, shared `--scout`, `--harness`, `--model`, `--effort`, and `--backend` flags apply to all, and the looping happens inside the script so you never hand-write a multi-task shell loop. -If one pair fails, the rest still run and the batch exits non-zero. -When `config/crew-dispatch.json` exists, include a shared `--harness` for every crewmate or scout batch after consulting the dispatch rules. - -The script resolves the harness (`fm-harness.sh crew` for crewmate/scout tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1`/cmux runtime signals, nesting resolved innermost-first (`$TMUX`, then `HERDR_ENV=1`, then cmux's primary `CMUX_WORKSPACE_ID` marker and documented macOS-only fallbacks last, since cmux is a terminal application rather than a nestable multiplexer; docs/cmux-backend.md "Runtime auto-detection") - then `tmux`; an auto-detected herdr or cmux spawn prints a loud stderr notice, auto-detected tmux stays silent; zellij and orca are never auto-detected, only explicit `--backend `/`FM_BACKEND=`/`config/backend`), validates the requested backend against spawn-capable adapters, owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. +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. -A non-flag third argument containing whitespace is treated as a raw launch command (only for verifying new adapters). -When `config/crew-dispatch.json` exists, the script refuses crewmate or scout launches without an explicit harness because firstmate must have already resolved the profile choice at intake. -When `--model` or `--effort` is omitted, the corresponding meta value is `default` and no launch flag is passed for that axis, except that a `kind=secondmate` spawn can fill the omitted axis from the optional tokens in `config/secondmate-harness`. -For `kind=secondmate`, the same script launches in the registered or explicit firstmate home instead of running `treehouse get` for a project, records `home=` and `projects=`, and uses the charter brief as the launch prompt. - -For ship and scout tasks, tmux/herdr/zellij/cmux create a runtime endpoint and run `treehouse get`; Orca creates an Orca-owned worktree, validates it, then creates the terminal. In all cases, 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), installs the turn-end hook, records `state/.meta`, and launches the agent with the brief. -For grok, the turn-end hook is one firstmate-owned global hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, activated only when the worktree holds the per-task `.fm-grok-turnend` token pointer that matches `state/.grok-turnend-token`; teardown removes the pointer and token. -For `kind=secondmate`, the script creates the same kind of runtime endpoint but starts directly in the persistent home. -With herdr, ordinary crewmate and scout spawns use the current `FM_HOME` workspace; a primary `--secondmate` spawn uses the secondmate target home's workspace, so secondmate-owned tabs do not mix into the primary `firstmate` space. -With zellij there is no per-home workspace split: every task, primary or secondmate, lands as a tab in the one shared `firstmate` zellij session (docs/zellij-backend.md). -Before launching a secondmate, the script fast-forwards its home worktree to firstmate's own current default-branch commit, so a freshly spawned or recovery-respawned secondmate always starts on firstmate's current version. -This is a purely local fast-forward of tracked files - never a fetch from origin, and never touching the gitignored operational dirs - so the secondmate's backlog, projects, and any prior in-flight work are untouched; a dirty, diverged, or in-flight home is left as-is and launches unchanged. -If that pre-launch fast-forward is skipped, `fm-spawn.sh` prints a concise warning to stderr and still launches the secondmate from its unchanged checkout. -The spawn also propagates the primary's inheritable config into the secondmate home's `config/`, mirroring the bootstrap sweep (section 3). -No nudge is needed at spawn because the agent reads `AGENTS.md` fresh on launch. -For already-live secondmates, use `bin/fm-config-push.sh` when only this inherited config needs to be pushed. +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`. -Add the task to `data/backlog.md` under In flight. +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. -Use `bin/fm-send.sh --expect-ack fm- ...` for captain dispatches or other must-not-drop steers; the target acknowledges by writing any status line, and the watcher escalates once through `ack-missed:` if the deadline passes. +For a captain dispatch or other must-not-drop steer, add `--expect-ack `: the target acknowledges by writing any status line, and the watcher escalates once through an `ack-missed:` wake if the deadline passes without one (`state/.pending-acks`; section 8). 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`, or captain-relevant phase changes wake the main firstmate. +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. @@ -558,7 +471,6 @@ With `yolo=on`, firstmate makes those calls itself without asking - resolve ask- "Routine" means reversible by a follow-up PR, inside the task's stated scope, no schema or data migration, no auth or permission surface, no secret handling, no deletion of user data, and CI green; a finding failing any one clause escalates even under yolo. 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. -The script itself enforces the never-merge-a-red-PR rule: before merging it asserts the PR's check rollup is green, allowing a PR with no checks configured but refusing pending, failing, canceled, or unknown states with no override flag, so this holds even under yolo. 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 @@ -577,7 +489,6 @@ Read its current state with `bin/fm-crew-state.sh `: a deterministic, token- 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 same guard covers a leftover `done: ... checks green` log line during a fresh active run - for example a rebase that restarted validation - which is trusted only when it can plausibly belong to the current run (it names the run's own id, or a ci step already exists for it); otherwise it is flagged superseded instead of short-circuiting the fresh run straight to done. 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`. @@ -607,14 +518,9 @@ bin/fm-teardown.sh ``` 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. -"Landed" is broader than remote-reachable: for a normal ship task whose commits are not reachable from any remote-tracking branch, the script also accepts the work when its PR is merged and GitHub reports a PR head that contains the current local work, or when its content is already present in the up-to-date default branch. -Containment means local `HEAD` is the PR head, local `HEAD` is an ancestor of the PR head, or the unpushed local patches have matching patch IDs in that PR head after no-mistakes replayed the branch. -This recognizes the common squash-merge-then-delete-branch flow, where the branch's own commits live nowhere on a remote yet the change is fully in `main`; a merged-and-deleted branch now tears down cleanly instead of false-refusing. -The PR is looked up from the task's recorded `pr=` when present, or, when no `pr=` was ever recorded, by finding a merged PR whose head branch matches the worktree's branch and fetching its head via `refs/pull//head` if the branch itself was deleted - so a task whose merge skipped `bin/fm-pr-check.sh` (typically a yolo-authorized merge on a repo with no PR CI, where the "checks green" trigger never fires) still tears down cleanly instead of false-refusing. -Genuinely unlanded work (no merged PR head containing the local work and content not in the default branch) and dirty worktrees still refuse, and a gh lookup error falls back to the content check rather than silently allowing. +`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`. -After a successful PR-based teardown, it also runs `bin/fm-fleet-sync.sh` for that project, best-effort, so safe clone states catch up to the merge, clean detached ancestor drift self-heals, and the just-merged branch, now gone on the remote and free of its worktree, is pruned immediately. -Unsafe drift is reported as `STUCK:` and left untouched. +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 `bin/fm-tasks-axi.sh 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. @@ -644,81 +550,78 @@ From there the task is an ordinary ship task through its mode-specific validatio ## 8. Supervision protocol The watcher is the backbone. -Whenever at least one task is in flight, keep `bin/fm-watch.sh` running through a harness-tracked `bin/fm-watch-arm.sh` background task. -It costs zero tokens while running. +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).** -The watcher classifies every wake it detects in bash and absorbs the benign majority without ever waking you, but it never absorbs a crewmate that has stopped. -The no-verb signal path - a `signal` whose status carries no captain-relevant verb (a `working:` note, a bare turn-ended) - is absorbed ONLY while that crewmate shows positive evidence it is still working: its no-mistakes run for its branch is in an actively-running step, or its pane shows the harness busy signature. -For a fresh `stale` pane, the watcher checks the same positive evidence before trusting the status log: a provably-working crew is absorbed, and a crew that is NOT provably working surfaces whether the log looks terminal or non-terminal. -The watcher reads that evidence with `bin/fm-crew-state.sh` (run-step first, then pane), so a finish that wrote no `done:` status - for example one reported only through interactive pane menus - is no longer swallowed. -A `heartbeat` with no captain-relevant change is likewise absorbed. -Absorbed wakes are advanced past their suppression marker and logged to `state/.watch-triage.log` while the watcher keeps blocking - no queue entry, no exit, no LLM turn. -It exits with one reason line on an _actionable_ wake: a `signal` carrying a captain-relevant verb (`needs-decision:`/`blocked:`/`failed:`/`done:`/`PR ready`/`checks green`/`ready in branch`/`merged`); a no-verb `signal` whose crewmate is NOT provably working (it stopped its turn with no running pipeline and no busy pane, so it may be done, waiting on a decision, or wedged); any `check`; a `stale` whose crewmate is not provably working, whether or not its status log's last line is captain-relevant (surfaced at once, never left to wait out the timer); a provably-working `stale` that stays idle past the wedge threshold (`FM_STALE_ESCALATE_SECS`, default 240s); or the heartbeat fleet-scan's fail-safe backstop catching a captain-relevant status the per-wake path missed. -It also exits on `ack-missed:` when a `fm-send --expect-ack` deadline passes without any target status write; these pending rows live in `state/.pending-acks` and are marked escalated after the first wake so they do not spam. -Repeated provably-working stale escalations on the same unchanged pane are counted in `state/.wedge-escalations-*`; at `FM_WEDGE_DEMAND_INSPECT_COUNT` (default 3), the stale reason includes `demand-deep-inspection` so the wake is not mistaken for another routine validation wait. -A captain-relevant status-log line does not by itself make a stale pane terminal: a crewmate gets no new status entry once firstmate hands it to a no-mistakes validation, so its last line can still read `done:` from BEFORE that validation started for the run's entire duration; a provably-working crew therefore always wins over that stale line and is absorbed (with the same wedge-escalation safety net), and only a crewmate that is NOT provably working has its status log trusted to decide terminal-vs-non-terminal. -Only an actionable wake is written to the durable queue at `state/.wake-queue` - before advancing suppression markers such as `.seen-*`, `.stale-*`, `.last-check`, or `.last-heartbeat` - and only an actionable wake ends the background task, so you re-arm exactly once per actionable event instead of once per wake. -That is what eliminates the quiet-stretch churn without swallowing a finish: during a long crew validation the run is actively running, so the crewmate's `turn-ended`/`working:`/stale wakes (and no-change heartbeats) are absorbed in bash, the liveness beacon (`state/.last-watcher-beat`) stays fresh the whole time so `fm-guard.sh` never false-alarms, and your LLM is woken only when something genuinely needs you - including the moment that crewmate stops with no running pipeline, which now surfaces immediately. -The classifier lives in `bin/fm-classify-lib.sh` and is shared: the captain-relevant verb set and status-scan primitives back both this always-on watcher and the away-mode daemon, so the overlapping policy cannot drift; the provably-working predicate (`crew_is_provably_working`, reusing `bin/fm-crew-state.sh`) lives in that same library and runs only on the watcher's no-verb signal and first-sighting stale paths, never on every wake, so the per-wake triage stays cheap. -While `state/.afk` exists the daemon owns supervision, so the watcher reverts to one-shot - it surfaces every wake for the daemon to classify (skipping the provably-working read entirely) - and never double-triages; the daemon keeps its own bounded-latency stale backstop for a crewmate that stops in away mode. +`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 arm chain IS the supervision: while any task is in flight, keep exactly one live `bin/fm-watch-arm.sh` background task at all times, because if no cycle is live firstmate is blind. -Each cycle is one harness-tracked background task that blocks until an actionable wake is due (benign wakes are absorbed in bash without ending the task), fires with one reason line, and ends, so the chain survives only when firstmate starts the next cycle after each fire. -After handling the drained wakes, re-arm before you end the turn by running `bin/fm-watch-arm.sh` as its own background task. -Arm or re-arm the watcher only through the harness's own tracked background mechanism - the one that survives the call and notifies you when the process exits - so the cycle actually persists and the next wake reaches you. -Never fire-and-forget the watcher with a shell `&` inside another call: that backgrounded child is reaped when the call returns, so supervision silently stops, and worse, the dying process reports a false "already running" that hides the gap. -**Standalone, never bundled.** -Run `bin/fm-watch-arm.sh` as its OWN background task with nothing else in that bash, never tacked onto the tail of a multi-command call: bundled, its self-verifying status line is buried in unrelated output and it can silently no-op as a side effect of those other commands, so no fresh cycle gets established and supervision lapses unnoticed. -`bin/fm-watch-arm.sh` is self-verifying: it confirms a genuinely live watcher with a fresh beacon and prints exactly one honest status line - `watcher: started ...`, `watcher: healthy ...`, or `watcher: FAILED - no live watcher with a fresh beacon` (which exits non-zero) - so treat that line, not a process count or an unverified "already running", as the source of truth for watcher state. -**Re-arm after each FIRE; do not churn on a no-op.** -Read that line to know whether a cycle is already live: `started` (this arm just launched the live cycle, now blocking for the next wake) and `healthy` (a live cycle already held the lock) both mean a cycle is live, so do NOT start another - re-running it while one is healthy only churns no-op tasks and never establishes a fresh cycle; `FAILED` means no live cycle, so arm one now after draining any queued wakes. -A cycle is down only when its background task completes carrying a WAKE REASON (`signal`/`stale`/`check`/`heartbeat`): that is the watcher firing, and that is the one moment to handle the wake and then start exactly one fresh cycle. -The watcher is singleton-safe: acquisition is race-proof, so under any number of concurrent arms at most one watcher ever holds this home's lock, and a duplicate that somehow starts self-evicts within one poll once it sees the lock no longer names it. -If one is already alive with a fresh liveness beacon, another invocation exits cleanly instead of creating a duplicate watcher; if the live holder's beacon is stale, the new invocation exits with an actionable failure. +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 a live cycle running: 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 stops only this home's watcher (the pid recorded in this home's `state/.watch.lock`) and starts a fresh one. +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 arming it, do not send idle progress updates to the captain; wait until it returns `signal`, `stale`, `check`, or `heartbeat`, unless the captain asks for status. +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-watch-arm.sh # safe verified re-arm; run as harness-tracked background; no-ops if healthy -bin/fm-watch-arm.sh --restart # home-scoped forced restart; never a broad pkill -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-freeze.sh on|off|status # park/unpark the fleet during an incident; spawn, steer, and the watcher refuse while frozen -bin/fm-usage-tripwire.sh # read-only check for a token/session usage burst; arm as a per-task state/.check.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 +bin/fm-freeze.sh on|off|status # park/unpark the fleet during an incident; spawn, steer, and the watcher refuse while frozen +bin/fm-usage-tripwire.sh # read-only check for a token/session usage burst; arm as a per-task state/.check.sh +bin/fm-reconcile-stale.sh # dry-run-first report (then --clean --yes) for tracked state with no live endpoint ``` +**Fleet freeze is a manual park switch, not an automated guard.** +Run `bin/fm-freeze.sh on [reason...]` to park the fleet locally: while `state/.fleet-freeze` exists, `fm-spawn.sh`, `fm-send.sh`, `fm-watch.sh`, `fm-watch-arm.sh`, and the away-mode daemon's injection path all refuse with the recorded reason, so nothing spawns, gets steered, or wakes anything until the freeze is lifted. +Unfreeze with `bin/fm-freeze.sh off`, or set `FM_FLEET_FREEZE_BYPASS=1` for one deliberate command when a single action genuinely needs to bypass the park. +`bin/fm-freeze.sh status` reports the current state and reason; the session-start digest surfaces an active freeze prominently and its closing reminder tells you to stay in orchestration/diagnosis mode instead of arming the watcher normally. + +`bin/fm-usage-tripwire.sh` is a read-only watcher check that alarms on an abnormal token/session usage burst; arm it per-task by linking or copying it to `state/.check.sh` (or a standing check path) so the watcher's slow-poll cycle picks it up. + +`bin/fm-reconcile-stale.sh` finds tracked task state (`state/*.meta`) with no live backend endpoint and reports it: the recorded backend target and worktree/home path, a landed-work assessment (its carve-outs mirror `fm-teardown.sh` - secondmate-always-blocked and scout-report-gates-clean - but the assessor is deliberately more conservative and omits `fm-teardown.sh`'s PR-head patch-id containment fallback, so use `fm-teardown.sh` directly for a squash-merge case it would recognize as landed but this reports as unlanded or blocked), and any operator-untracked backend agents. +Its default mode is a dry-run report only; cleanup is explicit and per-id with `bin/fm-reconcile-stale.sh --clean --yes`, which re-verifies the endpoint is dead and the work path has no unlanded work before removing only volatile firstmate state for that id - never worktrees, secondmate homes, project clones, branches, or backend endpoints. +Prefer `bin/fm-teardown.sh` for anything still reachable through its normal lifecycle; reach for `fm-reconcile-stale.sh` only once a record is provably dead and orphaned. + 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` 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. + 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 re-arming. + 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: read each crewmate's current state with `bin/fm-crew-state.sh ` (the cheap first read - it reconciles the authoritative run-step over a possibly-stale status-log line, so a crewmate whose gate you already resolved no longer reads as still parked), peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then re-arm the watcher. +5. `ack-missed:` a `fm-send --expect-ack` deadline passed with no status write from the target; the row in `state/.pending-acks` is marked escalated so it does not spam, but treat the target as possibly stuck and steer or peek it. +6. `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. An `unknown` read from `bin/fm-crew-state.sh` (no matching run, no pane) is a possibly dead endpoint, not a state to move past on any of the wakes above: check the recorded backend endpoint per the recovery procedure (section 5 step 6), and if dead, reconcile via recovery-by-kind; never absorb it silently. -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-mention's **final** completion follow-up if that task is X-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 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. -Heartbeats back off exponentially while they are the only wakes firing (600s doubling to a 2h cap - an idle fleet stops burning turns); any signal, stale, or check wake resets the cadence to the base interval. -Due per-task checks run before signal scanning so chatty crewmate status updates cannot starve slow polls like merge detection. - 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; a task's meta may record a different `backend=` - herdr, zellij, orca, and cmux are the other implemented, spawn-capable, experimental backends today, docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, and docs/cmux-backend.md). +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`. @@ -726,47 +629,23 @@ During heartbeat review, a `kind=secondmate` task whose recorded endpoint is DEA 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.** -Arming the watcher is the last action of every wake-handling turn - but the protocol no longer relies on remembering that. -While running, `fm-watch.sh` touches `state/.last-watcher-beat` every poll cycle. -The supervision scripts (`fm-peek`, `fm-send`, `fm-spawn`, `fm-teardown`, `fm-pr-check`, `fm-promote`, `fm-review-diff`, `fm-fleet-sync`, `fm-update`) call `bin/fm-guard.sh` first, which warns to stderr when any task is in flight (`state/*.meta` exists) but queued wakes are pending, or that beacon is missing or older than `FM_GUARD_GRACE` (default 300s). -`bin/fm-wake-drain.sh` runs the same guard after it drains, so the liveness check also fires on a drain-and-handle turn that runs no other supervision script, narrowing the window in which a lapsed chain can hide; the grace beacon keeps it silent right after a normal fire and it warns only on a genuine stale-beyond-grace lapse. -The no-watcher case leads with a prominent, bordered ●-marked banner (in-flight count, beacon age, and the exact one-line re-arm command) so it reads as an alarm rather than a buried stderr line you can skim past. -The banner is only a supervision warning: the guarded operation still runs. -When the guarded operation is `fm-send`, `fm-send` sets the banner's continuation line to say explicitly that the requested message WILL still be sent. -So the next time you touch the fleet with queued wakes or no watcher alive, the tool output itself tells you what to do - a pull-based guard that works on any harness, since it rides the script output you already read rather than a harness-specific hook. -The grace window keeps normal handling (watcher briefly down between a wake and its re-arm) silent. +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, arm `bin/fm-watch-arm.sh` after draining any queued wakes. - -`fm-guard.sh` carries a second, independent alarm in the same bordered ●-marked style: the **worktree-tangle** guard. -Firstmate is a treehouse-pooled git repo of itself - the primary checkout (the repo root, `FM_ROOT`) and every crewmate worktree and secondmate home are linked worktrees of one repo - and the primary must stay on its default branch. -If a crewmate sent to work firstmate-on-itself branches or commits in the primary 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. -The check is scoped precisely to the primary: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes on the default branch) and the default branch itself never alarm; only a named non-default branch checked out in the primary does. -The same assertion runs at session start as the bootstrap `TANGLE:` line inside the `bin/fm-session-start.sh` digest (section 3), with read-only wording when this session does not hold the fleet lock. -Two further guards prevent the tangle upstream: `fm-spawn` refuses to launch unless treehouse or Orca yields a genuine isolated worktree distinct from the primary checkout, and every ship brief's first instruction has the crewmate verify it is in its own worktree before branching (section 11). - -**Fleet freeze is a manual park switch, not an automated guard.** -Run `bin/fm-freeze.sh on [reason...]` to park the fleet locally: while `state/.fleet-freeze` exists, `fm-spawn.sh`, `fm-send.sh`, `fm-watch.sh`, `fm-watch-arm.sh`, and the away-mode daemon's injection path all refuse with the recorded reason, so nothing spawns, gets steered, or wakes anything until the freeze is lifted. -Unfreeze with `bin/fm-freeze.sh off`, or set `FM_FLEET_FREEZE_BYPASS=1` for one deliberate command when a single action genuinely needs to bypass the park. -`bin/fm-freeze.sh status` reports the current state and reason; the session-start digest surfaces an active freeze as its own `FLEET FREEZE` subsection (section 3) and its closing reminder tells you to stay in orchestration/diagnosis mode instead of arming the watcher normally. -Use it for incident response or any other time fleet activity should pause without tearing anything down. +If a guard warning says watcher liveness is stale, drain any queued wakes and then resume the emitted supervision protocol. -**Stale tracked-state cleanup is diagnose-first, never a substitute for normal teardown.** -`bin/fm-fleet-map.sh` is a read-only diagnostic that prints every tracked `state/*.meta` record next to visible Herdr agents (or other backends' endpoint liveness), matching by exact target then by cwd, and warns on `stale-tracked` records (tracked but no live endpoint found) and `operator-untracked-herdr` agents (a live agent no tracked record claims); it never spawns, steers, tears down, or mutates anything. -`bin/fm-reconcile-stale.sh` reuses that same matching to reconcile it: its default mode is a dry run that reports every stale-tracked record together with a landed-work assessment (its carve-outs mirror `fm-teardown.sh` - secondmate-always-blocked and scout-report-gates-clean - but the assessor is deliberately more conservative than `fm-teardown.sh` and omits its PR-head patch-id containment fallback, so use `bin/fm-teardown.sh` directly for a squash-merge case it would recognize as landed but this reports as unlanded or blocked) and any operator-untracked agents, and it writes nothing. -`--clean --yes` re-verifies the endpoint is still dead, the recorded work path holds no unlanded work, and the fleet is not frozen, then removes only that id's volatile state files (`*.status`, `*.meta`, `*.turn-ended`, `*.check.sh`, `*.pi-ext.ts`, `*.grok-turnend-token`) and its `tasktmp`; it never removes worktrees, secondmate homes, project clones, branches, or backend endpoints. -Prefer `bin/fm-teardown.sh` for anything still reachable through its normal lifecycle; reach for `fm-reconcile-stale.sh` only once a record is provably dead and orphaned. +`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 the `claude` harness, "no turn ends blind" has a structural backstop beyond the pull-based `fm-guard.sh` banner: `bin/fm-turnend-guard.sh`, a Claude Code Stop hook registered in the tracked `.claude/settings.json`, fires on every primary turn end and, when tasks are in flight without a live identity-matched watcher lock and fresh beacon, blocks the stop (exit 2 with a reason) so the turn cannot end without acting on it first. -It shares status fields with `fm-guard.sh` via `bin/fm-supervision-lib.sh`, uses `bin/fm-wake-lib.sh` for live watcher lock health, and never blocks more than once per turn (Claude Code's own `stop_hook_active` loop-guard field lets it always allow the retry). -It is scoped to fire only in the actual primary checkout - never in a crewmate/scout worktree or a secondmate home - and stays silent when supervision is healthy. -See `docs/turnend-guard.md` for the verified Stop-hook mechanism and the scoping details. -This backstop exists only for `claude` today; other harnesses still rely on the pull-based guard alone (future work, same empirical-verification-first pattern as every other harness fact in `harness-adapters`). -Watcher liveness is not enough if you are foreground-blocked. -Whenever one or more tasks are in flight, do not run long foreground-blocking operations in your own session. -This is about firstmate's own session: it includes a no-mistakes pipeline firstmate runs for this repo, long builds, and any other multi-minute command. -Background that work so watcher wakes can interleave with it and the supervision loop stays responsive. -A crewmate driving its own `no-mistakes` validation does the opposite: it drives that gate loop synchronously and processes every return, never idle-waiting for its own validation run to advance on its own. +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, fires only in the actual primary checkout, 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. @@ -781,7 +660,7 @@ Inline facts that must survive without a loaded skill: - 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: clear `state/.afk`, stop the daemon, flush catch-up from `state/.wake-queue`, `state/.subsuper-escalations`, and `state/.subsuper-inject-wedged`, then re-arm normal watcher supervision. +- 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. @@ -816,7 +695,10 @@ As a courtesy, mention cost when unusually much work is running (more than ~8 co ## 10. Backlog format `data/backlog.md` is the durable queue. -Update it on every dispatch, completion, and decision. +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 `bin/fm-tasks-axi.sh hold --reason "" --kind captain` for a captain-gated thread. +Update the backlog on every dispatch, completion, and decision for a work item. ```markdown ## In flight @@ -838,14 +720,14 @@ Re-evaluate Queued on every teardown and every heartbeat: anything whose blocker 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 hand-editing even when `tasks-axi` is installed. -Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.1.1 or newer. +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 `bin/fm-tasks-axi.sh` 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 suggests `npm install -g tasks-axi` through the normal consent flow, and every firstmate home falls back to hand-editing `data/backlog.md` exactly as this section describes until it is installed. -When `config/backlog-backend=manual`, every firstmate home hand-edits and bootstrap does not suggest installing `tasks-axi`. +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 too. +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, `bin/fm-tasks-axi.sh 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. @@ -855,11 +737,12 @@ Map firstmate's real backlog operations to the approved commands: - File an item: `bin/fm-tasks-axi.sh 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: `bin/fm-tasks-axi.sh 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: `bin/fm-tasks-axi.sh done --pr ` for a PR-based ship, `--report ` for a scout, or `--note "local main"` for a local-only merge. -- Append a status note: `bin/fm-tasks-axi.sh update --append ""`; replace fields with `--title`, `--body`, or `--body-file `. +- Update task notes: inspect first with `bin/fm-tasks-axi.sh show --full`, then replace the considered body with `bin/fm-tasks-axi.sh update --body-file `. + Add `--archive-body` to that update command when superseding prior state should remain recoverable. - Manage dependencies: `bin/fm-tasks-axi.sh block --by ` and `bin/fm-tasks-axi.sh unblock --by `, then `bin/fm-tasks-axi.sh 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: `bin/fm-tasks-axi.sh show --full`. -- Hand a task off to a secondmate home: 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. +- 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: `bin/fm-tasks-axi.sh 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. @@ -878,14 +761,17 @@ 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 secondmates use `bin/fm-brief.sh --secondmate ...`. +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 or `needs-decision`/`blocked`/`done`/`failed`, because every append wakes firstmate. +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. @@ -900,16 +786,18 @@ It performs only fast-forward self-updates of firstmate and registered secondmat These skills are not captain-invocable; they are conditional operating references you must load at the trigger points below. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `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. - `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`. -- `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-linked task before posting its completion follow-up; relevant only when X mode is on. +- `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 of the shared `@myfirstmate` bot on X, and act on actionable mention requests, in firstmate's own voice, from its live fleet state. +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.** @@ -918,26 +806,19 @@ That token is the whole consent, including standing authorization for normal rev 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.** -Bootstrap wires the relay poll automatically and purely additively from `.env` presence; see `docs/configuration.md` "X mode (.env)" for the generated-artifact mechanism, the wire protocol, and the watcher-backbone non-interference guarantee. - -**Cadence.** -An X instance polls every 30s instead of the default 300s. -To get that, arm the watcher with the X cadence sourced, exactly as section 8 describes but prefixed: - -```sh -[ -f config/x-mode.env ] && . config/x-mode.env -bin/fm-watch-arm.sh # as the harness's tracked background task -``` - -The sourced file exports `FM_CHECK_INTERVAL=30` into the arm, which the watcher it forks inherits, so only an X instance speeds up; a non-X instance has no such file and keeps the 300s default. -Because `bin/fm-watch.sh` reads `FM_CHECK_INTERVAL` only at process start and the arm no-ops on an already-healthy watcher, a cadence **transition** (opt-in while a watcher is already running, or opt-out) is applied by restarting the home-scoped watcher with the new environment: `[ -f config/x-mode.env ] && . config/x-mode.env; bin/fm-watch-arm.sh --restart` (omit the source on opt-out so the 300s default returns), run as the harness's tracked background task. -Bootstrap deliberately does not restart the watcher itself - it must never block, and `fm-watch-arm.sh --restart` is home-scoped (never a broad `pkill`). -X mode is also a reason to keep the watcher armed even with no fleet work, so an X-only user is still served. -Cadence under away-mode (the supervise daemon owns the watcher then) is a separate follow-up and out of scope here; while afk is active the daemon's default cadence applies. +**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. **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-linked task reaches a terminal state, post its final completion follow-up per section 8's wake-handling step before tearing down. +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. + +## 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. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e27af3e1e..a590180db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,16 +37,17 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Only shared material is tracked: `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and `skills/`. `.agents/skills/` holds agent-loaded skills that assume a live firstmate home and carry `metadata.internal: true` so installers such as [skills.sh](https://skills.sh) hide them from discovery; `skills/` holds standalone, installer-facing public skills with no firstmate dependency (see the README's "Two-tier skill layout"). Everything personal to one captain's fleet (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) is gitignored; never commit it. - The root `.tasks.toml` is tracked `tasks-axi` config for `data/backlog.md`; compatible `tasks-axi` is the default backend for routine backlog mutations, run through `bin/fm-tasks-axi.sh` rather than bare `tasks-axi` so it always resolves the effective `FM_HOME`. - A local `config/backlog-backend=manual` opt-out forces hand-editing and stays gitignored. - A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; spawn-supported values are `tmux` plus experimental `herdr`, `zellij`, `orca`, and `cmux`. + The root `.tasks.toml` is tracked `tasks-axi` config for `data/backlog.md`; compatible `tasks-axi` is the default backend for routine backlog mutations, with the compatibility definition owned by [`docs/configuration.md`](docs/configuration.md) ("Backlog backend"). + A local `config/backlog-backend=manual` opt-out forces firstmate's routine backlog updates to hand-editing and stays gitignored; validated secondmate handoffs still delegate through `tasks-axi mv`. + A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; spawn-supported values are `tmux` plus experimental `herdr`, `zellij`, `orca`, and `cmux`, while `codex-app` is documented only in `docs/codex-app-backend.md`. It does not make `data/` tracked. - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. Test scripts and helpers in `tests/` are plain bash too. - `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` must pass, and CI enforces it. + `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`, or `docs/cmux-backend.md`. +- 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. - 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,72 +62,23 @@ A crewmate picking up such a brief should load the skill even if the brief preda 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 test command to the same bash behavior suite as CI. +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. 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 -shellcheck bin/*.sh bin/backends/*.sh tests/*.sh # lint the toolbelt and behavior tests; CI enforces this +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 -tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, duplicate-collapse, and drain liveness guard tests -tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, watch-arm liveness, guard-warning, and fleet-freeze refusal tests -tests/fm-turnend-guard.test.sh # shared supervision predicate plus Claude Stop-hook scoping, loop guard, fail-open, and live watcher health tests -tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale status-log override, wedge threshold, repeated wedge demand marker, heartbeat backstop, and afk one-shot coherence -tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, fleet-freeze injection refusal, and fm-send submit tests -tests/fm-usage-tripwire.test.sh # fm-usage-tripwire.sh watcher-check tests: healthy silence, session-count and output-token breach alarms, mtime-window bounding, missing transcript dirs, and per-entry timestamp scoping on long-lived files -tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests -tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations -tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for kind=secondmate targets: marked vs crewmate/explicit/--key, and the exact marker byte sequence -tests/fm-send-strict.test.sh # fm-send strict target resolution: bare lane id did-you-mean, unset FM_HOME, unresolvable selectors, prefixless herdr pane ids, dead explicit tmux targets, and healthy fm- sends -tests/fm-ack-loop.test.sh # fm-send --expect-ack pending-ack recording, raw-pane refusal, status-change clearing, single-escalation ack-missed watcher surfacing, and deadline late-label math -tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: restart catch-up, batching, dedupe, stale-pane routing, and digest injection -tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests -tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) -tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the afk daemon's herdr transport, on an isolated throwaway HERDR_SESSION: partial-input deferral, swallowed-Enter retry, a normal digest, and the max-defer wedge alarm on a persistently pending composer -tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, and crew-dispatch reporting tests -tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, fleet-freeze digest/next-step surfacing, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts -tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests -tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, single-project name resolution, and bootstrap relay -tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, completion follow-up counters/caps, dry-run preview, and .env-presence activation tests -tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, read-only remediation suppression, and spawn/brief isolation tests -tests/fm-freeze.test.sh # fm-freeze.sh on/off/status toggling, spawn/send refusal while frozen, and explicit one-command FM_FLEET_FREEZE_BYPASS tests -tests/fm-fleet-map.test.sh # fm-fleet-map.sh read-only tracked-state-to-visible-Herdr-agent mapping, exact-target-vs-cwd-only matching, and Herdr-unavailable fallback tests -tests/fm-reconcile-stale.test.sh # fm-reconcile-stale.sh dry-run reporting, --clean --yes refusals (missing --yes, unlanded work, live endpoint, fleet freeze, secondmate/scout carve-outs), and clean-removes-only-volatile-state tests -tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166), clean no-mistakes/direct-PR/local-only brief generation, and ship/scout never-restart-shared-daemon rule tests -tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests -tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption -tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests -tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, bootstrap nudge gating, and spawn hook tests -tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests -tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests -tests/fm-secondmate-safety.test.sh # secondmate home safety, idle charter, handoff validation, teardown boundary, and child-cleanup fail-closed tests -tests/fm-home-guard.test.sh # fm-home-guard-lib.sh FM_HOME ownership guard: secondmate-context refusal to mutate a foreign FM_HOME, own-home status allowed, and unchanged repo-root scratch-home flow -tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, tasks-axi/manual backlog reminder, --force override, stale-vs-live worktree git index.lock recovery -tests/fm-tasks-axi.test.sh # bin/fm-tasks-axi.sh wrapper: runs tasks-axi from the effective FM_HOME regardless of caller cwd, and refuses a .tasks.toml markdown path that resolves outside FM_HOME -tests/fm-review-diff.test.sh # fm-review-diff.sh authoritative review diff coverage: recorded pr_head=, fetched refs/pull//head, no-pr local branch behavior, and warning fallback -tests/fm-pr-merge.test.sh # fm-pr-merge.sh records pr= and available pr_head= before merging, refuses to merge on failing/pending/cancelled/unknown check rollups (via gh-axi text summary and gh --json bucket fallback) while allowing green or no-checks-configured PRs, parses PR URLs into gh-axi number/--repo calls, defaults to squash, preserves explicit merge methods, rejects malformed URLs and repo overrides, and propagates real merge failures -tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes and ci log-tail checks-green detection, stale checks-green and needs-decision/blocked superseded by resumed work, genuine-parked, cross-branch runs-list attribution, pane/status-log fallback, scout skip, torn-down/missing-meta graceful -tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, shell-portable sourced backend matching, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown -tests/fm-backend-tmux-smoke.test.sh # real (private-socket) tmux smoke test for the tmux adapter: create/duplicate-refuse, send text + Enter, send literal + key, bounded capture, live-window resolve, kill -tests/fm-backend-herdr.test.sh # fake herdr CLI unit tests for the experimental herdr adapter, including version/tool gates, target parsing, send/capture, structural composer-state verification, slash-submit retry regression coverage, native busy state, per-home workspace-label resolution, default-tab prune safety, restored-layout husk replacement, and verified CLI bug workarounds -tests/fm-backend-herdr-smoke.test.sh # real herdr adapter smoke test, skipped when herdr or jq is unavailable, using an isolated throwaway HERDR_SESSION and guarded session cleanup, including live-agent duplicate refusal and no-agent husk replacement -tests/fm-backend-autodetect-smoke.test.sh # real herdr auto-detection smoke test, skipped when herdr, jq, or treehouse is unavailable, using the same guarded session cleanup; whole-script retry (tests/real-herdr-smoke-retry.sh) on fm-spawn.sh's treehouse-lease-contention timeout signature -tests/fm-backend-herdr-workspace-per-home-e2e.test.sh # mandatory isolated E2E for workspace-per-home: primary and secondmate-shaped homes, a crewmate spawned from a secondmate home, teardown, list-live recovery; whole-script retry (tests/real-herdr-smoke-retry.sh) on fm-spawn.sh's treehouse-lease-contention timeout signature -tests/fm-backend-herdr-prune-safety-e2e.test.sh # isolated real-herdr E2E for the default-tab prune self-kill regression: adopted label-collision workspaces are never pruned, while freshly created workspaces still prune their seeded default tab -tests/fm-backend-herdr-respawn-idem-e2e.test.sh # isolated real-herdr E2E for restored-layout husk respawn idempotency across a real session restart, covering crewmate/scout and secondmate-shaped tabs plus live-agent duplicate refusal -tests/fm-backend-zellij.test.sh # fake zellij CLI unit tests for the experimental zellij adapter, including version/tool gates, target parsing, home-scoped title creation, legacy-title fallback, send/capture, current-path probing, label-checked target safety, secondmate child cleanup, and tab cleanup -tests/fm-backend-zellij-smoke.test.sh # real zellij adapter smoke test, skipped when zellij or jq is unavailable, using an isolated throwaway FM_ZELLIJ_SESSION and guarded session cleanup -tests/fm-backend-orca.test.sh # fake Orca CLI unit tests for primitive adapter routing: capture, send text, Enter/interrupt keys, close, and dispatcher sourcing -tests/cmux-test-safety.sh # guarded cleanup helper for real-cmux tests, refusing to close anything except a matching fm-test- workspace -tests/fm-backend-cmux.test.sh # fake cmux CLI unit tests for the experimental cmux adapter, including socket auth, title scoping, target recovery, fresh-surface liveness, current-path probing, structural composer verification, and secondmate refusal -tests/fm-backend-cmux-smoke.test.sh # real cmux adapter smoke test, skipped when cmux or jq is unavailable or the socket is not password-mode authenticated, using fm-test- workspaces and guarded cleanup [ "$(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. + ## Questions Open an issue, or talk to me on [Discord](https://discord.gg/Wsy2NpnZDu). diff --git a/README.md b/README.md index df8015956..dfd632757 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ 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. -There is no app to install; the orchestrator is `AGENTS.md`, bundled firstmate skills, and helper scripts that any terminal coding agent can follow. -This is not an agent harness. This is not a single skill. This is not a CLI. -This is.. a directory that turns any agent into your firstmate, and you the captain. +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. +An agent distro is a portable directory of instructions, skills, tooling, policies, and state conventions that turns a general-purpose agent into a specialized one. +There is no app to install: the cloned repo is the distro - `AGENTS.md`, bundled firstmate skills, and helper scripts that any terminal coding agent can follow. +Launching a supported harness inside it instantiates your first mate - and makes you the captain. ## Features @@ -44,27 +46,66 @@ This is.. a directory that turns any agent into your firstmate, and you the capt - **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. - **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, kept on the primary firstmate version by guarded local fast-forwards. -- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; `fm-send.sh --expect-ack` covers must-not-drop steers by escalating once if the target never acknowledges; on Claude, a primary turn-end hook also blocks a blind stop when work is in flight and supervision is not live. +- **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 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. -- **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 and carries on. - **Incident guardrails** - `fm-freeze.sh on` parks the whole fleet (spawn, steer, and the watcher all refuse) until you lift it; `fm-fleet-map.sh` gives a read-only diagnosis of tracked state vs. the visible Herdr surface and `fm-reconcile-stale.sh` cleans up tracked state for tasks that are provably dead and already landed without ever touching anything still live; and `fm-usage-tripwire.sh` is a watcher check that alarms on abnormal token/session usage bursts. +- **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). ## Quick Start -**Requirements:** a verified agent harness (claude, codex, opencode, pi, or grok), git with GitHub auth, and tmux for the reference session backend. +### Requirements + +- A verified agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. +- Git and the GitHub CLI, authenticated through `gh auth login`. +- tmux, for the reference session backend. + The first mate detects and offers to install everything else. +### 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. +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. + +### Install and launch + ```sh gh auth login git clone https://github.com/kunchenguid/firstmate -cd firstmate && claude # launch your harness here; AGENTS.md takes over +cd firstmate +``` + +Then launch one of the co-primary harnesses; AGENTS.md takes over from there: + +**Claude Code** + +```sh +claude ``` -Then just talk: +**Grok** + +```sh +grok --trust +``` + +**Pi** + +```sh +pi +``` + +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. + +### Talk to it ```sh > ahoy! look at my github project xyz, then fix the flaky login test and add dark mode @@ -80,6 +121,8 @@ Then just talk: > alright merge it ``` +### More backends + Setup guides for tmux (the default) and every other supported backend (herdr, zellij, Orca, cmux) are linked in [Documentation](#documentation) below. ## How It Works @@ -110,6 +153,7 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze 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. +`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). @@ -118,11 +162,12 @@ Full architecture - the supervision engine, worktree isolation, secondmates, dis 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`. -| Skill | What it does | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash and escalates only captain-relevant events as one batched digest, cutting supervision cost while you step away | -| `/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 | +| 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 | +| `/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 | Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). @@ -140,14 +185,19 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, and project modes work. - [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/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified Claude Code Stop-hook mechanism, scoping, 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/arm-pretool-check.md](docs/arm-pretool-check.md) - the PreToolUse seatbelt blocking commands that would bypass the watcher-arm contract. +- [docs/cd-guard.md](docs/cd-guard.md) - the PreToolUse seatbelt blocking a persistent `cd` that would relocate the primary shell. +- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. -- [`AGENTS.md`](AGENTS.md) - firstmate's full operating manual for the orchestrator agent. +- [`AGENTS.md`](AGENTS.md) - the distro's core instruction file and the first mate's full operating manual. - [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 d2a27e29f..873b06685 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -63,10 +63,11 @@ # herdr (auto-closes the workspace) nor zellij (leaves a ghost tab): # `close-surface` REFUSES outright with a typed error # (`invalid_state: Cannot close the last surface`), leaving both the -# surface and the workspace untouched. `close-workspace` cleanly removes -# the whole workspace (surface included) in one call with no ghost left -# behind. Kill therefore closes the whole workspace directly, which also -# reclaims any extra surfaces inside the task workspace. +# surface and the workspace untouched. `close-workspace` removes the +# whole workspace (surface included) only when it is not the last +# workspace in its window. `fm_backend_cmux_kill` handles the documented +# last-in-window exception below, while still reclaiming every surface in +# the task workspace. # 5. Workspace ids do NOT survive an app relaunch - verified via source # (`Sources/Workspace.swift`'s only initializer unconditionally sets # `self.id = UUID()`, with no restored-id parameter, unlike surfaces' @@ -112,6 +113,12 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-backend-hometag-lib.sh . "$FM_BACKEND_CMUX_ROOT/bin/fm-backend-hometag-lib.sh" +# Shared composer-content classifier (empty|pending|unknown, and the fleet-wide +# dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by +# every backend so the decision cannot drift. +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_CMUX_ROOT/bin/fm-composer-lib.sh" + # Verified minimum: the version the live pass ran against (docs/cmux-backend.md). FM_BACKEND_CMUX_MIN_MAJOR=0 FM_BACKEND_CMUX_MIN_MINOR=64 @@ -521,16 +528,17 @@ fm_backend_cmux_capture() { # [expected-label] } # fm_backend_cmux_composer_state: classify the composer's own row as -# empty|pending|unknown. Adapted directly from herdr's structural border-row -# classifier (fm_backend_herdr_composer_state, bin/backends/herdr.sh:598-665) -# per the build task's explicit direction - this is the highest-risk piece of -# a new backend's send-and-verify logic, and cmux's `read-screen` gives the -# same kind of plain-text capture with no cursor-row primitive that herdr's -# `pane read` does, so the same structural approach applies unchanged: locate -# the composer row as the only captured line whose TRIMMED content both -# STARTS and ENDS with the same border glyph (│, ┃, or a plain ASCII |), -# scanning forward and keeping the LAST match so an earlier border-shaped line -# (scrollback, a popup) never outranks the real bottom-anchored composer row. +# empty|pending|unknown. Adapted from the bordered-row branch of herdr's +# structural classifier (fm_backend_herdr_composer_state) per the build task's +# explicit direction - this is the highest-risk piece of a new backend's +# send-and-verify logic, and cmux's `read-screen` gives plain-text capture +# with no cursor-row primitive and no ANSI style channel like herdr's newer +# `pane read --format ansi` path. The cmux classifier intentionally remains +# border-row based: locate the +# composer row as the only captured line whose TRIMMED content both STARTS and +# ENDS with the same border glyph (│, ┃, or a plain ASCII |), scanning forward +# and keeping the LAST match so an earlier border-shaped line (scrollback, a +# popup) never outranks the real bottom-anchored composer row. FM_BACKEND_CMUX_COMPOSER_LINES=${FM_BACKEND_CMUX_COMPOSER_LINES:-20} FM_BACKEND_CMUX_IDLE_RE=${FM_BACKEND_CMUX_IDLE_RE:-'^Type a message\.\.\.$'} @@ -554,31 +562,25 @@ fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" - case "$stripped" in - '❯'|'>'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - case "$stripped" in - '❯ '*|'> '*|'$ '*|'% '*|'# '*) stripped=${stripped#??} ;; - '❯'*|'>'*|'$'*|'%'*|'#'*) stripped=${stripped#?} ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if printf '%s' "$stripped" | grep -qE "$FM_BACKEND_CMUX_IDLE_RE"; then - printf 'empty'; return 0 - fi - printf 'pending' + # A row was found only by the bordered shape above, so content came from a + # genuine composer box - delegate to the shared owner with bordered=1. A bare + # dead-shell prompt has no bordered row and already returned 'unknown' above. + fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_CMUX_IDLE_RE" } # fm_backend_cmux_send_text_submit: type into once (raw, # unsubmitted, via send_literal), then submit with a named Enter key, retried # (Enter only, never retyped) until the composer's own row reads empty. -# Mirrors fm_backend_herdr_send_text_submit's verification strategy exactly: -# a slash-command popup's first Enter can close the popup and fill an -# argument-hint placeholder into the composer rather than submitting, which a -# raw-diff check would misread as "submitted" - classifying the composer row -# specifically avoids that false positive, so the retry loop correctly sends -# a second Enter when needed. Echoes empty|pending|unknown|send-failed, the +# Mirrors fm_backend_herdr_send_text_submit's ORIGINAL (composer-row) +# verification strategy: a slash-command popup's first Enter can close the +# popup and fill an argument-hint placeholder into the composer rather than +# submitting, which a raw-diff check would misread as "submitted" - +# classifying the composer row specifically avoids that false positive, so +# the retry loop correctly sends a second Enter when needed. Herdr's adapter +# 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. 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 @@ -595,17 +597,59 @@ fm_backend_cmux_send_text_submit() { # done } +# fm_backend_cmux_window_of_workspace: echo " " for +# the window that contains , or nothing if it is not found live. +# `workspace list --json` with no `--window` is scoped to the CURRENT window +# only (verified live), so the containing window is found by walking every +# window from `list-windows --json` and asking each for its own scoped list. +# The count comes from the same scoped workspace list that confirms membership. +fm_backend_cmux_window_of_workspace() { # -> " " + local wsid=$1 wins wid wss count + wins=$(fm_backend_cmux_cli list-windows --json --id-format uuids 2>/dev/null) || return 0 + while IFS= read -r wid; do + [ -n "$wid" ] || continue + wss=$(fm_backend_cmux_cli workspace list --json --id-format uuids --window "$wid" 2>/dev/null) || continue + count=$(printf '%s' "$wss" | jq -er --arg id "$wsid" ' + (.workspaces // []) as $workspaces + | select(any($workspaces[]?; .id == $id)) + | ($workspaces | length) + ' 2>/dev/null) || continue + printf '%s %s' "$wid" "$count" + return 0 + done < <(printf '%s' "$wins" | jq -r '.[]? | .id' 2>/dev/null) +} + # fm_backend_cmux_kill: remove the task's whole workspace, best-effort (mirrors # every other backend's `kill` `|| true` contract). A cmux task owns one # workspace, so teardown reclaims that workspace and all of its surfaces. +# +# The selected-workspace teardown bug (docs/cmux-backend.md "Closing the last +# workspace in a window"): cmux keeps every window at >=1 workspace, so +# `close-workspace` on the ONLY workspace in its window silently no-ops - it +# still returns `OK`, but the workspace stays, which is exactly what left a +# selected task workspace open at teardown (the last workspace in a window is +# always the selected one). `close-window`/`window.close` cannot rescue it +# either: a window holding a live terminal session cannot be closed over the +# control socket (verified: returns success-shaped output, closes nothing). +# The reliable primitive is close-workspace on a NON-last workspace, so when the +# target is the last one in its window a throwaway sibling is created first, +# leaving that window a fresh default workspace (never an fm-- title, so +# recovery/list_live ignore it) - cmux's own "closed the last tab" outcome. fm_backend_cmux_kill() { # [unused] [expected-label] - local expected_label=${3:-} + local expected_label=${3:-} wsid wininfo win count if [ -n "$expected_label" ]; then fm_backend_cmux_target_ready "$1" "$expected_label" || return 0 else fm_backend_cmux_parse_target "$1" || return 0 fi - fm_backend_cmux_cli close-workspace --workspace "$FM_BACKEND_CMUX_WORKSPACE" >/dev/null 2>&1 || true + wsid=$FM_BACKEND_CMUX_WORKSPACE + wininfo=$(fm_backend_cmux_window_of_workspace "$wsid") + win=${wininfo%% *} + count=${wininfo##* } + if [ -n "$win" ] && [ "$count" = 1 ]; then + fm_backend_cmux_cli new-workspace --window "$win" --focus false --id-format uuids >/dev/null 2>&1 || true + fi + fm_backend_cmux_cli close-workspace --workspace "$wsid" >/dev/null 2>&1 || true } # fm_backend_cmux_list_live: recovery/orphan discovery. Lists every workspace diff --git a/bin/backends/herdr-eventwait.py b/bin/backends/herdr-eventwait.py new file mode 100755 index 000000000..96e7f6650 --- /dev/null +++ b/bin/backends/herdr-eventwait.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Raw AF_UNIX subscriber for herdr's native pane.agent_status_changed stream. + +This is the WIRE TRANSPORT half of the herdr push-escalation path +(bin/backends/herdr.sh fm_backend_herdr_wait_transition). It deliberately does +NOT know firstmate's supervision policy: it opens ONE connection to a herdr +session's control socket, subscribes to pane.agent_status_changed for the given +panes (all statuses, so working/idle/done edges are seen too), and prints one +projected line per event to stdout, flushing each so the bash caller can react +sub-second. The bash side normalizes each line through the shared transition +shape and applies the single-owner policy table (bin/fm-transition-lib.sh); the +bash side also decides when to stop and kills this reader. + +Wire protocol (verified: herdr 0.7.3, protocol 16, newline-delimited JSON): + request : {"id","method":"events.subscribe","params":{"subscriptions":[ + {"type":"pane.agent_status_changed","pane_id":P}, ...]}}\n + ack : {"id",...,"result":{"type":"subscription_started"}}\n + stream : {"event":"pane.agent_status_changed", + "data":{"pane_id","workspace_id","agent_status","agent",...}}\n + +Usage: herdr-eventwait.py [ ...] + +Output (one line per pane.agent_status_changed event, TAB-separated, a raw +projection - NOT the final normalized record; the bash normalizer adds the +from_status and builds the canonical shape): + @subscribed + \t\t\t + +Exit status: + 0 streamed until the timeout elapsed with no error - a clean bounded wait; + the caller treats this as "no fast escalation, poll cadence preserved". + 2 bad arguments, could not connect, or could not send the subscribe request. + 3 the subscribe request did not return a subscription_started ack. + 4 the server closed the stream early or a receive operation failed. +A non-zero exit tells the bash caller to fall back to plain polling for this +cycle (the permanent fail-closed backstop), never to go silent. +""" +import json +import socket +import sys +import time + +CONNECT_TIMEOUT = 5.0 +ACK_TIMEOUT = 5.0 +RECV_CHUNK = 65536 + + +def _read_line(sock, buf, deadline): + """Read one newline-terminated chunk from sock, honoring an absolute + monotonic deadline. Returns (line_bytes_or_None, buf, outcome), where + outcome is line, timeout, closed, or error.""" + while b"\n" not in buf: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None, buf, "timeout" + sock.settimeout(remaining) + try: + chunk = sock.recv(RECV_CHUNK) + except socket.timeout: + return None, buf, "timeout" + except OSError: + return None, buf, "error" + if not chunk: + return None, buf, "closed" + buf += chunk + line, buf = buf.split(b"\n", 1) + return line, buf, "line" + + +def _clean(value): + return str(value).replace("\t", " ").replace("\r", " ").replace("\n", " ") + + +def main(argv): + if len(argv) < 4: + return 2 + sock_path = argv[1] + try: + timeout = float(argv[2]) + except ValueError: + return 2 + panes = argv[3:] + if not panes or timeout <= 0: + return 2 + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(CONNECT_TIMEOUT) + sock.connect(sock_path) + except OSError: + return 2 + + subscriptions = [ + {"type": "pane.agent_status_changed", "pane_id": pane} for pane in panes + ] + request = { + "id": "fm-eventwait", + "method": "events.subscribe", + "params": {"subscriptions": subscriptions}, + } + try: + sock.sendall((json.dumps(request) + "\n").encode("utf-8")) + except OSError: + return 2 + + start = time.monotonic() + deadline = start + timeout + buf = b"" + + # Bounded wait for the subscription_started ack (its own short budget, but + # never past the overall deadline). + ack_deadline = min(deadline, start + ACK_TIMEOUT) + line, buf, outcome = _read_line(sock, buf, ack_deadline) + if line is None: + return 2 + try: + ack = json.loads(line.decode("utf-8", "replace")) + except ValueError: + return 3 + result = ack.get("result") or {} + if result.get("type") != "subscription_started": + return 3 + + sys.stdout.write("@subscribed\n") + sys.stdout.flush() + + # Stream projected events until the deadline or the server closes. + while True: + line, buf, outcome = _read_line(sock, buf, deadline) + if line is None: + return 0 if outcome == "timeout" else 4 + try: + message = json.loads(line.decode("utf-8", "replace")) + except ValueError: + continue + if message.get("event") != "pane.agent_status_changed": + continue + data = message.get("data") or {} + fields = ( + _clean(data.get("pane_id") or ""), + _clean(data.get("workspace_id") or ""), + _clean(data.get("agent_status") or ""), + _clean(data.get("agent") or ""), + ) + sys.stdout.write("\t".join(fields) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except BrokenPipeError: + # The bash caller stopped reading (found its actionable edge and killed + # us). That is a normal, successful end of the wait. + sys.exit(0) + except KeyboardInterrupt: + sys.exit(0) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index a86426416..f75354301 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -25,8 +25,9 @@ # remainder is the whole pane id - fm_backend_herdr_parse_target splits on the # first colon only). This is the value stored in a herdr task's meta window= # field and is what fm_backend_resolve_selector already returns unchanged for -# both the fm- and explicit backend-target forms (that function has no -# herdr-specific logic; it just returns meta's window= verbatim). +# exact task-id, legacy fm-, and explicit backend-target forms (that +# function has no herdr-specific logic; it just returns meta's window= +# verbatim). # # Recovery/orphan discovery (ids may not deterministically match live state # after a server restart in a differently-configured session; see the @@ -48,7 +49,35 @@ FM_BACKEND_HERDR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_ROOT}}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# Shared composer-content classifier (empty|pending|unknown, and the fleet-wide +# dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by +# every backend so the decision cannot drift. +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_HERDR_ROOT/bin/fm-composer-lib.sh" + +# Shared, backend-neutral normalized-transition shape and the single-owner +# status->action policy table (bin/fm-transition-lib.sh). This adapter's event +# subscriber (fm_backend_herdr_wait_transition) normalizes every +# pane.agent_status_changed edge through fm_transition_record and routes it +# through fm_transition_policy - it never re-encodes the mapping. +# shellcheck source=bin/fm-transition-lib.sh +. "$FM_BACKEND_HERDR_ROOT/bin/fm-transition-lib.sh" + FM_BACKEND_HERDR_MIN_PROTOCOL=16 +# events.subscribe (the native pane.agent_status_changed push stream) and its +# subscription_event schema first shipped at protocol 16 (verified: herdr +# 0.7.3). Below this, or with the events surface absent from `herdr api schema`, +# the event fast-path fails closed to the watcher's poll loop +# (fm_backend_herdr_events_capable). Distinct from FM_BACKEND_HERDR_MIN_PROTOCOL +# (14): the adapter's spawn/capture/send primitives work on 14, only the push +# subscriber needs 16. +FM_BACKEND_HERDR_MIN_EVENTS_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 +# ->blocked edge and a reconnect level-reconcile never re-delivers a still- +# blocked pane. Mirrors bin/fm-watch.sh's .stale- naming. +FM_BACKEND_HERDR_ESCALATED_PREFIX=".herdr-escalated-" # .fm-secondmate-home is written by bin/fm-home-seed.sh (AGENTS.md section 6) # at a seeded secondmate home's root, containing exactly that secondmate's id. # The primary firstmate home never carries this marker. @@ -108,7 +137,7 @@ fm_backend_herdr_tool_check() { } # fm_backend_herdr_version_check: refuse loudly on a missing/incompatible -# herdr client. Verified locally: v0.7.2, protocol 16 (herdr status --json's +# herdr client. Verified locally: v0.7.1, protocol 14 (herdr status --json's # .client.protocol; client info is session-independent, unlike .server). fm_backend_herdr_version_check() { fm_backend_herdr_tool_check || return 1 @@ -398,6 +427,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() { # + local target=$1 + fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + case "$(fm_backend_herdr_pane_agent_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")" in + dead|no-agent) printf 'dead' ;; + live) printf 'alive' ;; + *) printf 'unknown' ;; + esac +} + # fm_backend_herdr_create_task: create the task's tab (one pane) in # ("session:workspace_id"). Herdr does NOT enforce label # uniqueness itself (verified: two tabs can share a label), so the duplicate @@ -582,7 +633,7 @@ fm_backend_herdr_send_key() { # # rows for a default-sized pane), instead of clamping to the last N lines - it # does not merely ignore the bound, it drops the read entirely. This silently # broke exactly the small bounded reads this adapter relies on most (including -# the composer-state verification read used by send_text_submit). Workaround: +# the composer-state guard/fallback reads around submit and injection). Workaround: # always request a generous fetch far above any realistic viewport height, then # trim to the caller's requested bound ourselves with `tail`. fm_backend_herdr_capture() { # @@ -595,111 +646,229 @@ fm_backend_herdr_capture() { # printf '%s' "$out" | tail -n "$lines" } -# fm_backend_herdr_composer_state: classify the composer's own row - the -# interior line of its rounded-corner box - 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: it is the only captured line whose TRIMMED -# content both STARTS and ENDS with the same border glyph (│, ┃, or a plain -# ASCII |). The box's own top/bottom rows use rounded corners (╭─…─╮ / ╰─…─╯), -# which never match; popup item rows and horizontal separator rows carry no -# border glyph at all; the footer help line ("Enter:send │ … │ …", verified -# grok 0.2.82) uses │ only as an INTERIOR separator and does not start with -# one, so it never matches either. Scans forward and keeps the LAST match, so -# a border-shaped line earlier in scrollback/a popup can never outrank the -# real (bottom-anchored) composer row. +fm_backend_herdr_capture_ansi() { # + fm_backend_herdr_target_ready "$1" || return 1 + local lines=${2:-200} fetch out + case "$lines" in ''|*[!0-9]*) lines=200 ;; esac + fetch=$lines + case "$fetch" in ''|*[!0-9]*) fetch=200 ;; *) [ "$fetch" -ge 200 ] || fetch=200 ;; esac + out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane read "$FM_BACKEND_HERDR_PANE" --source recent --lines "$fetch" --format ansi 2>/dev/null) || return 1 + printf '%s' "$out" | tail -n "$lines" +} + +# Thin adapter over the shared plain-text stripper (bin/fm-composer-lib.sh), +# used only for STRUCTURAL row/shape detection where ghost text must be kept so +# the box border or bare prompt glyph is still visible. Content extraction uses +# the shared fm_composer_strip_ghost instead. +fm_backend_herdr_strip_ansi() { # + printf '%s' "$1" | fm_composer_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: +# +# bordered - a boxed composer (verified grok 0.2.82): the row's TRIMMED +# content both STARTS and ENDS with the same border glyph (│, ┃, +# or a plain ASCII |). The box's own top/bottom rows use rounded +# corners (╭─…─╮ / ╰─…─╯), which never match; popup item rows and +# horizontal separator rows carry no border glyph at all; the +# footer help line ("Enter:send │ … │ …") uses │ only as an +# INTERIOR separator and does not start with one, so it never +# matches either. +# bare - an UNBORDERED composer (verified real claude 2.x and codex +# 0.142.x, both under herdr 0.7.1, docs/herdr-backend.md +# "Incident (2026-07-07)"): the row's TRIMMED content starts with +# one of the verified agent-specific prompt glyphs but carries no +# closing border at all - claude's own live input row is a bare +# "❯ …" with no surrounding │, and codex's is a bare "› …". Both +# harnesses ALSO render bordered decorative boxes elsewhere (a +# startup welcome banner, an update-available notice) that +# satisfy the bordered shape above; requiring a match on EITHER +# shape and keeping the last (bottom-most) one is what keeps the +# live composer winning over a stale decorative box still sitting +# in the same capture window - a bordered box is only ever +# followed later on screen by the actual live composer, never the +# reverse, in every harness observed so far. The bare shape is +# 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. # -# empty - blank, a bare prompt glyph, or known ghost/placeholder text +# empty - blank, a bare prompt glyph, known ghost/placeholder text # ("Type a message...", verified grok 0.2.82's empty-composer -# placeholder). Safe to treat as submitted. +# placeholder), or only de-emphasised ANSI ghost/placeholder text +# recognized by the shared fm_composer_strip_ghost extractor +# (dim/faint or dark-TRUECOLOR foreground). Safe to treat as +# submitted. # pending - real, unsubmitted text sits in the composer. This deliberately # also covers a slash-command popup that just closed but only # auto-completed or filled an argument-hint placeholder into the # composer (e.g. "/compact" -> "/compact compaction # instructions", verified live against real grok 0.2.82) - that # first Enter is a SELECTION, not a submission. -# unknown - the pane could not be read, or no composer row was found in the -# captured window. +# unknown - the pane could not be read, or no composer row (of either shape) +# was found in the captured window. +# +# Ghost/placeholder note: herdr's ANSI pane read preserves the harness's own +# de-emphasis styling, and the classifier extracts real typed content with the +# shared fm_composer_strip_ghost (bin/fm-composer-lib.sh), which drops dim/faint +# runs (claude's rotating prompt suggestion, codex's idle suggestion after the +# bare `›` prompt) AND dark/muted truecolor foreground runs (grok's placeholder), +# while keeping non-de-emphasised real typed input. This is the same owner the +# tmux adapter routes through, so the two backends cannot drift (task +# afk-herdr-false-pending); it superseded a herdr-only faint byte-pattern check +# that recognized only codex's bold-wrapped bare prompt and missed claude's own +# dim ghost - the overnight away-mode injection wedge on the primary claude pane. FM_BACKEND_HERDR_COMPOSER_LINES=${FM_BACKEND_HERDR_COMPOSER_LINES:-20} # Known ghost/placeholder composer text. Extend this if another # herdr-verified harness needs its own idle placeholder recognized. 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:-'^[❯›]'} fm_backend_herdr_composer_state() { # -> empty|pending|unknown - local target=$1 cap line trimmed stripped="" found=0 - cap=$(fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; } + local target=$1 cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + 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 + # (styled) bytes. Shape detection runs on the plain row (fm_backend_herdr_strip_ansi + # 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 - trimmed="${line#"${line%%[![:space:]]*}"}" + trimmed=$(fm_backend_herdr_strip_ansi "$line") + trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" [ -n "$trimmed" ] || continue case "$trimmed" in - '│'*'│'|'┃'*'┃'|'|'*'|') : ;; - *) continue ;; + '│'*'│'|'┃'*'┃'|'|'*'|') + shape=bordered + raw_match=$line + found=1 + ;; + *) + if printf '%s' "$trimmed" | grep -qE "$FM_BACKEND_HERDR_BARE_PROMPT_RE"; then + shape=bare + raw_match=$line + found=1 + fi + ;; esac - stripped=$trimmed - found=1 done < <(printf '%s\n' "$cap") [ "$found" -eq 1 ] || { printf 'unknown'; return 0; } - # Strip the border glyphs, then trim again. - stripped=${stripped//│/} - stripped=${stripped//┃/} - stripped=${stripped//|/} - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - # A bare prompt glyph = empty composer. - case "$stripped" in - '❯'|'>'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - # Strip a leading prompt glyph before judging what remains. - case "$stripped" in - '❯ '*|'> '*|'$ '*|'% '*|'# '*) stripped=${stripped#??} ;; - '❯'*|'>'*|'$'*|'%'*|'#'*) stripped=${stripped#?} ;; - esac + # Content: extract the real typed text from the raw row with the shared, + # fleet-wide ghost stripper (bin/fm-composer-lib.sh), which drops dim/faint AND + # dark-truecolor ghost/placeholder runs. This replaces the former herdr-only + # faint byte-pattern check (which recognized only Codex's bold-wrapped bare + # prompt and missed claude's own dim prompt-suggestion ghost - the overnight + # afk-herdr-false-pending wedge) and, in a dark theme, drops the composer's own + # dark box border too, which is why the bordered flag was read from the plain + # shape above, not from this ghost-stripped content. + stripped=$(printf '%s\n' "$raw_match" | fm_composer_strip_ghost) stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if printf '%s' "$stripped" | grep -qE "$FM_BACKEND_HERDR_IDLE_RE"; then - printf 'empty'; return 0 + if [ "$shape" = bordered ]; then + bordered=1 + stripped=${stripped//│/} + stripped=${stripped//┃/} + stripped=${stripped//|/} + stripped="${stripped#"${stripped%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" fi - printf 'pending' + # 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' + # via the no-composer-row path above, exactly as before. + fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } # fm_backend_herdr_send_text_submit: type into once (raw, # unsubmitted, via send_literal), then submit with a named Enter key, retried -# (Enter only, never retyped) until the composer's own row reads empty. -# Verified hazard (herdr-verification-p2.md "slash/$ autocomplete popup"): a -# `/`- or `$`-prefixed send opens a completion popup within ~0.1s, exactly -# like tmux's claude/codex popups, so the caller's before the first -# Enter matters here the same way it does for tmux. +# (Enter only, never retyped) until herdr's NATIVE agent-state (agent get) +# confirms a real turn started. Verified hazard (herdr-verification-p2.md +# "slash/$ autocomplete popup"): a `/`- or `$`-prefixed send opens a +# completion popup within ~0.1s, exactly like tmux's claude/codex popups, so +# the caller's before the first Enter matters here the same way it +# does for tmux. +# +# Confirmation signal (rewritten for the 2026-07-07 incident below; +# superseded a composer-content read that itself replaced a delta-based check +# for the 2026-07-03 incident): when the target is legibly idle before Enter, +# submission is confirmed by fm_backend_herdr_wait_for_working observing a +# submit-active agent_status after Enter, NOT by reading the composer's own +# row. This makes the normal confirmation path cross-agent: it is the same +# semantic signal regardless of what text a harness's idle composer happens +# to display. +# +# Incident (2026-07-07, followed up on 2026-07-08): a redelivery loop in the +# away-mode daemon. Root cause: composer-content submit confirmation was too +# sensitive to harness rendering details. Real claude/codex use bare prompt +# rows, and real codex adds dynamic idle suggestions after `›`; the later +# ANSI-aware composer classifier now handles the pre-injection guard for that +# Codex shape, but idle-baseline submit confirmation deliberately stays on +# native agent-state so delivery does not depend on composer text. Composer +# content is retained for other callers (the away-mode daemon's PRE-injection +# empty-box guard, still dispatched via fm_backend_composer_state / +# fm_backend_herdr_composer_state) and for submit attempts whose pre-Enter +# agent-state baseline is not legibly idle. # -# Verification strategy (incident 2026-07-03: two grok/herdr crewmates left a -# fully-typed `/no-mistakes` sitting unsubmitted for minutes, footer still -# reading "Enter:send", while fm-send exited 0): a prior version of this -# function verified submission by diffing raw pane content before/after -# Enter - ANY change counted as "submitted". Live-verified against real grok -# 0.2.82: a slash command's first Enter closes the completion popup and, for -# an argument-taking command, EXPANDS the composer text into an argument-hint -# placeholder ("/compact" -> "/compact compaction instructions") rather than -# submitting - the raw pane content visibly changes (popup gone, text -# different) even though nothing was sent, so the old diff-based check -# false-positived "empty" (submitted) after exactly one Enter, precisely -# matching the incident. A genuine second Enter was required to actually -# submit. fm_backend_herdr_composer_state avoids this by classifying the -# composer's own row specifically: a popup-close-with-placeholder-fill still -# reads as "pending" (real text remains), so the retry loop below correctly -# sends the second Enter instead of stopping early. Echoes -# empty|pending|unknown|send-failed, the SAME vocabulary fm-send.sh already -# branches on for tmux. +# This also still correctly handles the earlier 2026-07-03 incident (a +# slash-command popup selection/placeholder-fill on the FIRST Enter is not a +# genuine submission) without any popup-specific logic at all: filling a +# composer placeholder never starts a turn, so agent_status simply never +# reports "working" for that Enter, and the retry loop below sends a second +# Enter exactly as it did before - the fix generalizes instead of special- +# casing the popup shape. +# +# Failure-mode analysis (the two directions the caller-facing contract must +# not get wrong - see docs/herdr-backend.md "Native agent-state submit +# confirmation" for the empirical timing behind this): +# - Slow transition: fm_backend_herdr_wait_for_working samples repeatedly +# across herdr's per-attempt confirmation budget (not once at the end), so a +# transition landing partway through a window is still caught before this +# loop gives up and sends a needless extra Enter. +# - Instant round-trip (a turn starts AND returns to idle between two +# polls): unavoidable in the absolute, but bounded by how tightly polls +# are packed into the budget; real claude/codex measured first-working +# at 90-490ms, comfortably inside a several-hundred-ms, multiply-sampled +# window, so this has not been observed in practice. On the (unobserved) +# residual chance it happens, the verdict is "pending" and the caller +# never retypes - only re-sends Enter, which lands on an already-empty +# composer and is a no-op, not a duplicate delivery of (see +# fm-send.sh/fm-supervise-daemon.sh: retyping only happens if a caller +# 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"). fm_backend_herdr_send_text_submit() { # - local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 state + 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; } fm_backend_herdr_send_literal "$target" "$text" || { printf 'send-failed'; return 0; } sleep "$settle" + baseline=$(fm_backend_herdr_classify_submit_agent_status \ + "$(fm_backend_herdr_agent_status_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")") + confirm_sleep=$(fm_backend_herdr_submit_confirm_budget "$sleep_s") while :; do fm_backend_herdr_send_key "$target" Enter || true - sleep "$sleep_s" - state=$(fm_backend_herdr_composer_state "$target") - [ "$state" = pending ] || { printf '%s' "$state"; return 0; } + if [ "$baseline" = idle ]; then + verdict=$(fm_backend_herdr_wait_for_working "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" \ + "$confirm_sleep" "$FM_BACKEND_HERDR_SUBMIT_POLLS") + else + sleep "$sleep_s" + verdict=$(fm_backend_herdr_composer_state "$target") + fi + case "$verdict" in + busy) printf 'empty'; return 0 ;; + empty) printf 'empty'; return 0 ;; + unknown) printf 'unknown'; return 0 ;; + esac i=$((i + 1)) [ "$i" -lt "$retries" ] || { printf 'pending'; return 0; } done @@ -713,19 +882,15 @@ fm_backend_herdr_kill() { # fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane close "$FM_BACKEND_HERDR_PANE" >/dev/null 2>&1 || true } -# fm_backend_herdr_busy_state: semantic busy state from herdr's native -# agent-state detection (agent.get), the "first backend where fm_session_busy_state -# gets real semantics" per the design report. working -> busy (actively -# generating); idle/done -> idle; blocked -> idle (a blocked agent is stuck -# waiting on the human, not grinding - the watcher should treat it like a -# stale pane needing attention, not suppress it as busy); unknown/unparseable -# -> unknown, the caller's cue to fall back to pane-regex detection. -fm_backend_herdr_busy_state() { # - fm_backend_herdr_target_ready "$1" || { printf 'unknown'; return 0; } - local out status - out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" agent get "$FM_BACKEND_HERDR_PANE" 2>/dev/null) || { printf 'unknown'; return 0; } - status=$(printf '%s' "$out" | jq -r '.result.agent.agent_status // empty' 2>/dev/null) - case "$status" in +# fm_backend_herdr_classify_agent_status: map a raw `agent get` agent_status +# value to the adapter's watcher busy|idle|unknown vocabulary. working -> +# busy (actively generating); idle/done -> idle; blocked -> idle (a blocked +# agent is stuck waiting on the human, not grinding - the watcher should +# treat it like a stale pane needing attention, not suppress it as busy); +# unknown/unparseable/empty -> unknown, the caller's cue to fall back to +# pane-regex detection. +fm_backend_herdr_classify_agent_status() { # + case "$1" in working) printf 'busy' ;; idle|done) printf 'idle' ;; blocked) printf 'idle' ;; @@ -733,6 +898,117 @@ fm_backend_herdr_busy_state() { # esac } +fm_backend_herdr_classify_submit_agent_status() { # + case "$1" in + working|blocked) printf 'busy' ;; + idle|done) printf 'idle' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_herdr_agent_status_raw: one `agent get` read, echoing the raw +# agent_status string (working/idle/done/blocked/...), or empty on any +# failure. Deliberately skips fm_backend_herdr_target_ready's server-ensure +# round trip (an extra `status --json` call) that fm_backend_herdr_busy_state +# pays on every call: fm_backend_herdr_wait_for_working polls this in a tight +# loop right after a caller has already parsed the target and confirmed the +# server is live (e.g. fm_backend_herdr_send_text_submit, immediately after a +# successful send-text), so re-checking server liveness on every poll would +# only add latency without adding safety. +fm_backend_herdr_agent_status_raw() { # + local session=$1 pane_id=$2 out + out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>/dev/null) || { printf ''; return 0; } + printf '%s' "$out" | jq -r '.result.agent.agent_status // empty' 2>/dev/null +} + +# fm_backend_herdr_busy_state: semantic busy state from herdr's native +# agent-state detection (agent.get), the "first backend where fm_session_busy_state +# gets real semantics" per the design report. See +# fm_backend_herdr_classify_agent_status for the status->busy/idle/unknown +# mapping. +fm_backend_herdr_busy_state() { # + fm_backend_herdr_target_ready "$1" || { printf 'unknown'; return 0; } + fm_backend_herdr_classify_agent_status \ + "$(fm_backend_herdr_agent_status_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")" +} + +# fm_backend_herdr_wait_for_working: poll :'s NATIVE +# agent-state (agent get) up to times spread evenly across +# , returning on stdout the STRONGEST signal observed: +# +# busy - a submit-active status was observed at least once. This is +# confirmation that a real turn started or reached a prompt - +# the submit landed - independent of +# whatever the composer's own text happens to show (docs/ +# herdr-backend.md "Incident (2026-07-07)": composer content is +# what fooled the OLD confirmation on codex's dynamic idle-tip +# text). Returned the INSTANT it is seen, without waiting out the +# rest of the budget. +# idle - the target was legibly read at least once and never reported +# "busy" across the whole window - a genuine "not (yet) +# submitted" signal, not a read failure. The caller retries +# Enter on this verdict. +# unknown - EVERY poll in the window failed to read the target at all (a +# hard I/O failure - pane gone, socket error - not a timing +# race). The caller must not keep retrying Enter against a target +# it cannot even read. +# +# spread across (rather than one check at the end) +# is what makes this robust against a SLOW transition: a caller now gets +# several samples across that window instead of a single one, so a transition +# that lands partway through is not missed just because it had not landed by +# the FIRST sample. +# Empirical evidence (docs/herdr-backend.md "Native agent-state submit +# confirmation"): real claude and codex observed first-working at 90-490ms +# after Enter, so a several-hundred-ms budget sampled repeatedly reliably +# catches it. The remaining, inherent gap - a turn so fast it starts AND +# returns to idle between two samples - is bounded by how tightly is +# packed into ; nothing observed in real testing has come +# close to that, but it is a residual risk, not a mathematical impossibility +# (see the doc section for the full characterization and the failure-mode +# analysis for both directions this must guard). +# FM_BACKEND_HERDR_SUBMIT_POLLS (default 6): how many samples +# fm_backend_herdr_send_text_submit spreads across each Enter attempt's +# confirmation budget. Overridable for tests (a value of 1 +# reproduces the old single-check-at-the-end timing exactly, for byte-for-byte +# call-count assertions). +FM_BACKEND_HERDR_SUBMIT_POLLS=${FM_BACKEND_HERDR_SUBMIT_POLLS:-6} +FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=${FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP:-0.6} + +fm_backend_herdr_submit_confirm_budget() { # + awk -v b="${1:-0}" -v m="$FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP" 'BEGIN { + b += 0 + m += 0 + if (b < 0) b = 0 + if (m < 0) m = 0 + if (m > b) b = m + printf "%.4f", b + }' 2>/dev/null || printf '%s' "${1:-0}" +} + +fm_backend_herdr_wait_for_working() { # + local session=$1 pane_id=$2 budget=$3 polls=${4:-1} i interval raw bs saw_idle=0 + case "$polls" in ''|*[!0-9]*|0) polls=1 ;; esac + interval=$(awk -v b="$budget" -v p="$polls" 'BEGIN { d = p - 1; if (d < 1) d = 1; v = b / d; if (v < 0) v = 0; printf "%.4f", v }' 2>/dev/null) + case "$interval" in ''|*[!0-9.]*) interval=0 ;; esac + for ((i = 0; i < polls; i++)); do + if [ "$polls" -eq 1 ] || [ "$i" -gt 0 ]; then + sleep "$interval" + fi + raw=$(fm_backend_herdr_agent_status_raw "$session" "$pane_id") + bs=$(fm_backend_herdr_classify_submit_agent_status "$raw") + case "$bs" in + busy) printf 'busy'; return 0 ;; + idle) saw_idle=1 ;; + esac + done + if [ "$saw_idle" -eq 1 ]; then + printf 'idle' + else + printf 'unknown' + fi +} + # fm_backend_herdr_pane_for_tab: the root pane id for in # of , via one pane list call filtered by tab_id (never assumes a # tab-number/pane-number correspondence - herdr numbers them independently). @@ -794,3 +1070,259 @@ fm_backend_herdr_list_live() { # printf '%s:%s\t%s\n' "$session" "$pane_id" "$label" done < <(printf '%s' "$tabs" | jq -r '.result.tabs[]? | select(.label | startswith("fm-")) | "\(.tab_id)\t\(.label)"' 2>/dev/null) } + +# --- native event push: pane.agent_status_changed subscriber ----------------- +# +# The push half of the immediate blocked-state escalation (AGENTS.md section 8, +# docs/herdr-backend.md "Native pane.agent_status_changed push escalation"). +# fm_backend_herdr_wait_transition is the watcher's bounded wait primitive for +# herdr homes: instead of a blind sleep, it blocks on herdr's native event +# stream and returns the instant a subscribed pane transitions to `blocked`, so +# a crew waiting on the human wakes its supervisor sub-second instead of after +# the ~240s stale-pane wedge timer. Everything not `blocked` is streamed too +# (the policy, not the subscription, makes `blocked` the sole immediate action) +# so `working` edges clear the per-pane dedupe marker. Polling stays the +# permanent fail-closed backstop: below-capability, a connect/subscribe failure, +# or a missing reader all fall back to the caller sleeping the same budget. + +# fm_backend_herdr_socket_path: the control-socket path for , read from +# `herdr session list --json` (the default session's socket differs from a named +# session's - verified: default -> ~/.config/herdr/herdr.sock, named -> +# ~/.config/herdr/sessions//herdr.sock). Empty on any failure. +fm_backend_herdr_socket_path() { # + local session=$1 + herdr session list --json 2>/dev/null \ + | jq -r --arg name "$session" '.sessions[]? | select(.name == $name) | .socket_path // empty' 2>/dev/null \ + | head -1 +} + +# fm_backend_herdr_events_capable: the version/capability gate for the event +# fast-path (report section 5c trigger 1). Fails closed to the poll loop unless +# ALL hold: herdr+jq present; the raw-socket reader available (python3, unless a +# reader override is configured); client protocol >= FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL; +# and both `events.subscribe` and `pane.agent_status_changed` present in `herdr +# api schema`. FM_BACKEND_HERDR_EVENTS_FORCE overrides the whole verdict for +# tests (1 = capable, 0 = incapable) without touching the real binary. The +# `api schema` read is ~220KB, so callers (the watcher) memoize this per session +# for a process lifetime rather than probing every poll. +fm_backend_herdr_events_capable() { # + local session=$1 protocol schema + case "${FM_BACKEND_HERDR_EVENTS_FORCE:-}" in + 1) return 0 ;; + 0) return 1 ;; + esac + fm_backend_herdr_tool_check || return 1 + if [ -z "${FM_BACKEND_HERDR_EVENT_READER:-}" ]; then + command -v python3 >/dev/null 2>&1 || return 1 + fi + protocol=$(herdr status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in ''|*[!0-9]*) return 1 ;; esac + [ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL" ] || return 1 + schema=$(herdr api schema --json 2>/dev/null) || return 1 + printf '%s' "$schema" | grep -Fq 'events.subscribe' || return 1 + printf '%s' "$schema" | grep -Fq 'pane.agent_status_changed' || return 1 + return 0 +} + +# fm_backend_herdr_normalize_event: THE single normalize point (report section 5 +# refinement: one backend transition shape, one parse point). Both the stream +# reader's projected lines AND the level-reconcile's `agent get` reads flow +# through here into the shared normalized-transition record. herdr's event +# carries no previous status and its stream is edge-triggered, so from_status is +# left empty; to_status drives the policy. +fm_backend_herdr_normalize_event() { # + fm_transition_record "${1:-}" "${2:-}" "" "${3:-}" "${4:-}" +} + +# fm_backend_herdr_event_reader_cmd: emit the reader argv (one word per line) for +# the raw-socket subscriber. Default: `python3 /herdr-eventwait.py`. +# FM_BACKEND_HERDR_EVENT_READER overrides it with a whitespace-split command so +# tests can substitute a fake reader that replays canned stream lines. +fm_backend_herdr_event_reader_cmd() { + local word + if [ -n "${FM_BACKEND_HERDR_EVENT_READER:-}" ]; then + for word in $FM_BACKEND_HERDR_EVENT_READER; do + printf '%s\n' "$word" + done + return 0 + fi + printf 'python3\n' + printf '%s\n' "$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-eventwait.py" +} + +# fm_backend_herdr_escalation_marker: the per-pane dedupe marker path for a +# (":"), keyed identically to the watcher's +# .stale- (tr ':/.' '___'), under . +fm_backend_herdr_escalation_marker() { # + local state=$1 window=$2 key + key=$(printf '%s' "$window" | tr ':/.' '___') + printf '%s/%s%s' "$state" "$FM_BACKEND_HERDR_ESCALATED_PREFIX" "$key" +} + +# fm_backend_herdr_apply_transition: route one normalized record through the +# shared policy table, maintaining the per-pane dedupe marker under . +# On a fresh `actionable` (blocked) edge - policy actionable AND no marker yet - +# it prints the record on stdout and returns 0 (the caller stops and hands the +# record up). The caller commits the marker only after handling the record. +# `absorb` (working) clears the marker and +# returns 1. `defer`/`fallback`, and an already-marked `actionable`, return 1 +# with no output. reconstructs the window (":") for +# the marker key, matching the watcher's own key scheme. +fm_backend_herdr_apply_transition() { # + local state=$1 session=$2 record=$3 pane_id to action window marker + pane_id=$(fm_transition_pane_id "$record") + [ -n "$pane_id" ] || return 1 + to=$(fm_transition_to_status "$record") + action=$(fm_transition_policy "$to") + window="$session:$pane_id" + marker=$(fm_backend_herdr_escalation_marker "$state" "$window") + case "$action" in + actionable) + if [ ! -e "$marker" ]; then + printf '%s' "$record" + return 0 + fi + ;; + absorb) + rm -f "$marker" 2>/dev/null || true + ;; + esac + return 1 +} + +fm_backend_herdr_commit_transition() { # + local state=$1 session=$2 record=$3 pane_id window marker + pane_id=$(fm_transition_pane_id "$record") + [ -n "$pane_id" ] || return 1 + window="$session:$pane_id" + marker=$(fm_backend_herdr_escalation_marker "$state" "$window") + : > "$marker" +} + +fm_backend_herdr_clear_transition() { # + local state=$1 window=$2 marker + [ -n "$window" ] || return 0 + marker=$(fm_backend_herdr_escalation_marker "$state" "$window") + rm -f "$marker" 2>/dev/null || true +} + +# fm_backend_herdr_wait_transition: the bounded event wait. Blocks up to +# for one of (":") to reach a +# fresh `blocked` edge, then prints the normalized record and returns 0. +# Returns 1 on a clean timeout (the reader ran the full budget, no fresh +# actionable edge - the caller has effectively already slept and just continues) +# and 2 when the event path is unusable (not capable, socket unresolved, reader +# failed to run/subscribe - the caller sleeps the budget itself, the fail-closed +# backstop). See the header block above for the full contract. +fm_backend_herdr_wait_transition() { # + local session=$1 timeout=$2 state=$3 + shift 3 + local windows=("$@") + [ "${#windows[@]}" -gt 0 ] || return 2 + if [ "${FM_BACKEND_EVENTS_CAPABILITY_CONFIRMED:-0}" != 1 ]; then + fm_backend_herdr_events_capable "$session" || return 2 + fi + local sock + sock=$(fm_backend_herdr_socket_path "$session") + [ -n "$sock" ] || return 2 + + # Map each window to its herdr pane id (strip the leading ":"). + local w pane_id + local pane_ids=() + for w in "${windows[@]}"; do + pane_id=${w#*:} + if [ -z "$pane_id" ] || [ "$pane_id" = "$w" ]; then + continue + fi + pane_ids+=("$pane_id") + done + [ "${#pane_ids[@]}" -gt 0 ] || return 2 + + # Start the raw-socket reader and wait for its subscription acknowledgement + # before level reconciliation, so edges occurring during reconciliation are + # already buffered in the live stream. + local reader=() + while IFS= read -r w; do + reader+=("$w") + done < <(fm_backend_herdr_event_reader_cmd) + [ "${#reader[@]}" -gt 0 ] || return 2 + + local fifo_dir fifo reader_pid line ws status agent raw record hit rc=1 reader_rc=0 + fifo_dir=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-eventwait.XXXXXX") || return 2 + fifo="$fifo_dir/events" + if ! mkfifo "$fifo" 2>/dev/null; then + rm -rf "$fifo_dir" 2>/dev/null || true + return 2 + fi + "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null & + reader_pid=$! + if ! exec 9< "$fifo"; then + kill "$reader_pid" 2>/dev/null || true + wait "$reader_pid" 2>/dev/null || true + rm -rf "$fifo_dir" 2>/dev/null || true + return 2 + fi + if ! IFS= read -r -u 9 line || [ "$line" != "@subscribed" ]; then + rc=2 + fi + + # Level reconcile on (re)connect (report section 3d): a pane already `blocked` + # during the gap since the last subscription is returned now, once, while + # newer edges accumulate in the active stream. `working` panes clear their + # marker here too. + if [ "$rc" -ne 2 ]; then + for w in "${windows[@]}"; do + pane_id=${w#*:} + if [ -z "$pane_id" ] || [ "$pane_id" = "$w" ]; then + continue + fi + raw=$(fm_backend_herdr_agent_status_raw "$session" "$pane_id") + [ -n "$raw" ] || continue + record=$(fm_backend_herdr_normalize_event "$pane_id" "" "$raw" "") + if hit=$(fm_backend_herdr_apply_transition "$state" "$session" "$record"); then + printf '%s' "$hit" + rc=0 + break + fi + done + fi + + # Drain stream edges until a fresh blocked edge or the timeout. The reader is + # a subprocess of this call (NOT a second watcher), and is killed the instant + # a blocked edge is found. + # Split each raw projected line (pane_id\tworkspace_id\tagent_status\tagent) + # with `cut`, NOT `IFS=$'\t' read`: a tab is IFS-whitespace, so `read` would + # collapse an empty middle field (e.g. an absent workspace_id) and shift the + # status into the wrong column. `cut` preserves empty fields. + while [ "$rc" -eq 1 ] && IFS= read -r line <&9; do + [ -n "$line" ] || continue + pane_id=$(printf '%s' "$line" | cut -f1) + ws=$(printf '%s' "$line" | cut -f2) + status=$(printf '%s' "$line" | cut -f3) + agent=$(printf '%s' "$line" | cut -f4) + [ -n "$pane_id" ] || continue + record=$(fm_backend_herdr_normalize_event "$pane_id" "$ws" "$status" "$agent") + if hit=$(fm_backend_herdr_apply_transition "$state" "$session" "$record"); then + printf '%s' "$hit" + rc=0 + break + fi + done + if [ "$rc" -eq 0 ]; then + kill "$reader_pid" 2>/dev/null || true + fi + if [ "$rc" -eq 2 ]; then + kill "$reader_pid" 2>/dev/null || true + fi + # No actionable edge: distinguish a clean full-budget wait (reader exit 0 -> + # return 1, caller already waited) from a reader error (connect/subscribe + # failure, exit non-zero -> return 2, caller sleeps and counts toward the + # runtime-disable threshold). + wait "$reader_pid" 2>/dev/null || reader_rc=$? + exec 9<&- + rm -rf "$fifo_dir" 2>/dev/null || true + [ "$rc" -eq 0 ] && return 0 + [ "$rc" -eq 2 ] && return 2 + [ "$reader_rc" -eq 0 ] && return 1 + return 2 +} diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index 1a4311a25..dc9307de4 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -6,6 +6,12 @@ # # Target string shape: the Orca terminal id accepted by `orca terminal ...`. +# Shared composer-content classifier (empty|pending|unknown, and the fleet-wide +# dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by +# every backend so the decision cannot drift. +# shellcheck source=bin/fm-composer-lib.sh +. "$(dirname -- "${BASH_SOURCE[0]}")/../fm-composer-lib.sh" + fm_backend_orca_tool_check() { command -v orca >/dev/null 2>&1 || { echo "error: backend=orca selected but the 'orca' CLI is not installed" >&2; return 1; } } @@ -283,20 +289,10 @@ fm_backend_orca_composer_state() { # -> empty|pending|unknown stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" - case "$stripped" in - '❯'|'>'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - case "$stripped" in - '❯ '*|'> '*|'$ '*|'% '*|'# '*) stripped=${stripped#??} ;; - '❯'*|'>'*|'$'*|'%'*|'#'*) stripped=${stripped#?} ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if printf '%s' "$stripped" | grep -qE "$FM_BACKEND_ORCA_IDLE_RE"; then - printf 'empty'; return 0 - fi - printf 'pending' + # A row was found only by the bordered shape above, so content came from a + # genuine composer box - delegate to the shared owner with bordered=1. A bare + # dead-shell prompt has no bordered row and already returned 'unknown' above. + fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_ORCA_IDLE_RE" } fm_backend_orca_send_key() { # diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 062f8b798..7e4988c8f 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -22,7 +22,7 @@ . "$FM_BACKEND_LIB_DIR/fm-tmux-lib.sh" # fm_backend_tmux_resolve_bare_selector: the live-window-listing fallback for a -# selector that is neither "session:window" nor a bare "fm-" routed +# selector that is neither an explicit target nor a task selector routed # through meta - an ad hoc window name with no recorded task. Mirrors the # `tmux list-windows -a ... | grep` pipeline that used to live inline in # fm-send.sh's and fm-peek.sh's own (until now duplicated) resolve(). @@ -39,8 +39,10 @@ fm_backend_tmux_capture() { # } # fm_backend_tmux_send_key: one named key. Mirrors fm-send.sh's --key path: +# `tmux display-message -p -t "$T" '#{pane_id}' >/dev/null`, then # `tmux send-keys -t "$T" "$2"`. fm_backend_tmux_send_key() { # + tmux display-message -p -t "$1" '#{pane_id}' >/dev/null tmux send-keys -t "$1" "$2" } @@ -76,14 +78,28 @@ fm_backend_tmux_container_ensure() { # fm_backend_tmux_create_task: create the task's window in , # refusing an existing in . Mirrors fm-spawn.sh's # duplicate-check-then-new-window sequence, including the exact error text -# (session:window, matching how fm-spawn.sh composed its own $T). -fm_backend_tmux_create_task() { # - local ses=$1 wname=$2 proj_abs=$3 +# (session:window, matching how fm-spawn.sh composed its own $T). Prints the +# created window's stable window id on stdout for the caller to target. +# +# Robustness (fm-spawn tmux window handling under a non-default captain config): +# - Capture a STABLE window id with -P -F '#{window_id}', and let tmux append +# at the next free index by targeting the session with a trailing colon +# ("$ses:"), so a non-default base-index (e.g. base-index 1) cannot collide. +# - PIN the window name by disabling automatic-rename and allow-rename on the +# new window: the captain's tmux may rename the window away from fm- once +# treehouse cd's into the worktree, which would break name-based targeting. +# The returned window id lets callers target the window even if its name is ever +# lost, so worktree discovery cannot fall back to the active client's window. +fm_backend_tmux_create_task() { # -> prints window id + local ses=$1 wname=$2 proj_abs=$3 wid if tmux list-windows -t "$ses" -F '#{window_name}' | grep -qx "$wname"; then echo "error: window $ses:$wname already exists" >&2 return 1 fi - tmux new-window -d -t "$ses" -n "$wname" -c "$proj_abs" + wid=$(tmux new-window -dP -F '#{window_id}' -t "$ses:" -n "$wname" -c "$proj_abs") || return 1 + tmux set-window-option -t "$wid" automatic-rename off 2>/dev/null || true + tmux set-window-option -t "$wid" allow-rename off 2>/dev/null || true + printf '%s\n' "$wid" } # fm_backend_tmux_current_path: the live pane's current working directory, or @@ -114,3 +130,46 @@ fm_backend_tmux_send_literal() { # fm_backend_tmux_kill() { # tmux kill-window -t "$1" 2>/dev/null || true } + +# fm_backend_tmux_current_command: 's live foreground process name - +# tmux's own `#{pane_current_command}`, already resolved from the pty's +# foreground process group (verified empirically with real tmux 3.6a: a +# harness invoked interactively stays the reported command even while it +# shells out to subcommands that do not take over the pty - e.g. `bash -c +# "sleep 30"` alone reports "sleep" because bash execs directly into it, but +# a persisting parent script running `sleep` as a child reports the PARENT's +# own name throughout; the value reverts to the shell's own name only once +# the foreground command actually exits). Empty on any tmux error. +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; } + comm=${comm#-} + case "$comm" in + '') printf 'unknown' ;; + *claude*|*codex*|*opencode*|*grok*) printf 'alive' ;; + zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; + *) printf 'unknown' ;; + esac +} diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 0fdd9f79b..02fc5313f 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -83,8 +83,8 @@ # target. Mitigated: send/capture/cwd ops verify session liveness first # (fm_backend_zellij_session_exists, a passive list-sessions query, never # auto-creating), verify the specific pane still appears in list-panes JSON, -# and, for metadata-routed fm- operations, verify the pane's tab still -# matches the expected caller-facing task label through the home-scoped or +# and, for metadata-routed task selector operations, verify the pane's tab +# still matches the expected caller-facing task label through the home-scoped or # unambiguous legacy title before use. Kill verifies the session and, when # teardown supplies an expected tab label, verifies a tab id still matches # that label before closing it. Output-SHAPE validation (a bare integer tab @@ -491,8 +491,9 @@ fm_backend_zellij_capture() { # [expected-label] # fm_backend_zellij_send_text_submit: type into once (raw, # unsubmitted, via send_literal), then submit with a named Enter key, retried # (Enter only, never retyped) until the pane visibly changes. Unlike herdr's -# current structural composer-row verifier, zellij still uses a content-diff -# strategy because its CLI has no cursor-row/ANSI capture primitive exposed: +# current native agent-state idle-baseline verifier and composer-state +# fallback, zellij still uses a content-diff strategy because its CLI has no +# cursor-row/ANSI capture primitive exposed: # capture the pane right after typing (before any Enter) as the TYPED baseline, # then after each Enter attempt capture again - unchanged means Enter was # swallowed (retry); changed means submitted. This content-diff approach is @@ -594,8 +595,8 @@ fm_backend_zellij_list_live() { # # posture. Rare path in practice (zellij tasks normally carry meta); # best-effort. Not wired into fm_backend_resolve_selector's dispatcher # (bin/fm-backend.sh), mirroring herdr: that bare-selector fallback stays -# tmux-only by design, and zellij/herdr tasks are targeted via fm- meta or -# an explicit recorded target. +# tmux-only by design, and zellij/herdr tasks are targeted via task-selector +# meta or an explicit recorded target. fm_backend_zellij_resolve_bare_selector() { # local name=$1 scoped sessions session tabs tab_id count=0 pane_id bare_session='' bare_tab_id='' scoped=$(fm_backend_zellij_scoped_title "$name") diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh new file mode 100755 index 000000000..61c6a28cb --- /dev/null +++ b/bin/fm-afk-launch.sh @@ -0,0 +1,618 @@ +#!/usr/bin/env bash +# fm-afk-launch.sh - the single owner of the away-mode daemon TERMINAL lifecycle: +# launch it in a NON-VISIBLE tracked terminal per backend, record its exact id, +# tear it down by that exact id, and reconcile a leaked one after a crash. +# +# Why this exists (docs/herdr-backend.md "Away-mode daemon terminal launch"): +# bin/fm-afk-start.sh execs the supervise daemon in the FOREGROUND of whatever +# terminal it is already in. Harnesses with a native in-pane tracked-background +# tool (claude, grok) run it there directly and it is fine. A harness with NO +# native background mechanism (pi) has to manufacture a terminal, and doing that +# by SPLITTING the captain's active pane visibly shrinks it - the regression this +# script fixes. Instead this creates a non-visible tracked terminal (a herdr tab/ +# workspace with --no-focus, or a detached tmux session) that never touches the +# captain's active tab, and NEVER uses shell `&` (which herdr/codex can reap). +# +# Correct supervisor targeting: the daemon finds the captain pane to inject into +# from its OWN inherited env (discover_supervisor_target). Running it in a +# separate terminal would make it discover its OWN pane, so this captures the +# captain pane FIRST (from the pane this script runs in) and passes it in as +# FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND explicitly. +# +# Usage: +# fm-afk-launch.sh start Capture the captain pane, then (unless the daemon +# is already running) launch the daemon in a fresh +# non-visible terminal for the detected backend and +# record it. Idempotent: an already-running daemon +# just refreshes state/.afk; a recorded-but-dead +# terminal is reconciled (closed by id) first. +# fm-afk-launch.sh start-native +# Prepare lifecycle state for a harness-native +# background job and record that no terminal exists. +# fm-afk-launch.sh stop Correct-ordered exit: SIGTERM the daemon so its +# cleanup flushes WHILE state/.afk is still present, +# wait for it, close the recorded terminal by exact +# id, then clear state/.afk last. +# fm-afk-launch.sh reconcile Close a recorded-but-dead daemon terminal by exact +# id and drop the record (recovery after a crash). +# +# Supported backends: herdr, tmux. Others (zellij, orca, cmux) have no verified +# non-visible-launch primitive here yet and refuse loudly. +# +# Test seam: FM_AFK_LAUNCH_ENTRY overrides the command run in the created +# terminal (default bin/fm-afk-start.sh), so a topology test can run a harmless +# placeholder instead of a real daemon. FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND +# override the captured captain pane/backend (an isolated lab pane in tests). +set -u + +FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" +FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" +FM_AFK_LAUNCH_WS_LABEL="firstmate-afk-daemon" + +# shellcheck source=bin/fm-backend.sh +. "$FM_AFK_LAUNCH_DIR/fm-backend.sh" +# shellcheck source=bin/fm-supervisor-target-lib.sh +. "$FM_AFK_LAUNCH_DIR/fm-supervisor-target-lib.sh" +# fm-afk-start.sh provides the daemon-lock liveness helpers and +# fm_afk_clear_stale_artifacts; it is sourceable (BASH_SOURCE guard) and its +# main does not run on source. It sets `set -eu`, so turn errexit back off for +# this script's best-effort flow immediately after. +# shellcheck source=bin/fm-afk-start.sh +. "$FM_AFK_LAUNCH_DIR/fm-afk-start.sh" +set +e + +fm_afk_launch_log() { printf 'fm-afk-launch: %s\n' "$*" >&2; } + +fm_afk_launch_lock_owned() { + local pid expected actual + [ -d "$FM_AFK_LAUNCH_LOCK" ] || return 1 + pid=$(cat "$FM_AFK_LAUNCH_LOCK/pid" 2>/dev/null) || return 1 + expected=$(cat "$FM_AFK_LAUNCH_LOCK/pid-identity" 2>/dev/null) || return 1 + actual=$(fm_pid_identity "$pid" 2>/dev/null) || return 1 + [ -n "$expected" ] && [ "$actual" = "$expected" ] +} + +fm_afk_launch_lock_acquire() { + local i incomplete=0 identity + mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 + for i in $(seq 1 200); do + 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" + return 1 + fi + identity=$(fm_pid_identity "$$" 2>/dev/null) || { + rm -rf "$FM_AFK_LAUNCH_LOCK" + return 1 + } + if [ -z "$identity" ] || ! printf '%s' "$identity" > "$FM_AFK_LAUNCH_LOCK/pid-identity"; then + rm -rf "$FM_AFK_LAUNCH_LOCK" + return 1 + fi + return 0 + fi + if [ ! -s "$FM_AFK_LAUNCH_LOCK/pid" ] || [ ! -s "$FM_AFK_LAUNCH_LOCK/pid-identity" ]; then + incomplete=$((incomplete + 1)) + if [ "$incomplete" -lt 20 ]; then + sleep 0.05 + continue + fi + else + incomplete=0 + fi + if ! fm_afk_launch_lock_owned; then + rm -rf "$FM_AFK_LAUNCH_LOCK" 2>/dev/null || return 1 + incomplete=0 + continue + fi + sleep 0.05 + done + fm_afk_launch_log "timed out waiting for launcher lock" + return 1 +} + +fm_afk_launch_lock_release() { + local pid + pid=$(cat "$FM_AFK_LAUNCH_LOCK/pid" 2>/dev/null || true) + [ "$pid" = "$$" ] || return 0 + rm -rf "$FM_AFK_LAUNCH_LOCK" +} + +fm_afk_launch_usage() { + sed -n '2,34p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +# The command run inside the created terminal. Real launch runs the shared +# daemon entry; a test overrides it with a harmless placeholder. +fm_afk_launch_entry_cmd() { + printf '%s' "${FM_AFK_LAUNCH_ENTRY:-$FM_ROOT/bin/fm-afk-start.sh}" +} + +fm_afk_launch_record_write() { # + local pending + mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 + pending=$(mktemp "$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal.pending.XXXXXX") || return 1 + printf '%s\t%s\t%s\n' "$1" "$2" "$3" > "$pending" || { rm -f "$pending"; return 1; } + mv "$pending" "$FM_AFK_LAUNCH_RECORD" || { rm -f "$pending"; return 1; } +} + +fm_afk_launch_flag_write() { + local pending="$FM_AFK_LAUNCH_STATE/.afk.pending.$$" + date '+%s' > "$pending" || { rm -f "$pending"; return 1; } + mv "$pending" "$FM_AFK_LAUNCH_STATE/.afk" || { rm -f "$pending"; return 1; } +} + +# Read the recorded terminal into FM_AFK_REC_BACKEND/FM_AFK_REC_TARGET. The third +# field (a herdr workspace id, kept for the record's own documentation) is not +# needed to close by id, so it is discarded. Returns 1 when no record exists. +fm_afk_launch_record_read() { + local extra record + FM_AFK_REC_BACKEND=""; FM_AFK_REC_TARGET=""; extra="" + [ -f "$FM_AFK_LAUNCH_RECORD" ] || return 1 + record=$(cat "$FM_AFK_LAUNCH_RECORD" 2>/dev/null) || record="" + IFS=$'\t' read -r FM_AFK_REC_BACKEND FM_AFK_REC_TARGET extra \ + < "$FM_AFK_LAUNCH_RECORD" || true + if ! printf '%s\n' "$record" | awk -F '\t' 'NF != 3 { bad=1 } END { exit !(NR == 1 && !bad) }' \ + || [ -z "$FM_AFK_REC_BACKEND" ] || [ -z "$FM_AFK_REC_TARGET" ]; then + fm_afk_launch_log "daemon terminal record is malformed; refusing to act on it" + return 2 + fi + case "$FM_AFK_REC_BACKEND" in + herdr) [ -n "$extra" ] ;; + tmux) : ;; + none) [ "$FM_AFK_REC_TARGET" = - ] && [ "$extra" = native ] ;; + *) return 2 ;; + esac || { fm_afk_launch_log "daemon terminal record is malformed; refusing to act on it"; return 2; } +} + +fm_afk_launch_record_validate_if_present() { + local result + fm_afk_launch_record_read + result=$? + [ "$result" -ne 2 ] +} + +# Close a recorded terminal by EXACT id (never a broad sweep). The +# recorded workspace id (herdr) needs no separate close: closing the pane takes +# its single-tab dedicated workspace with it. +fm_afk_launch_close_terminal() { # + local backend=$1 target=$2 + case "$backend" in + herdr) + fm_backend_source herdr || return 1 + local session=${target%%:*} pane=${target#*:} + [ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1 + fm_backend_herdr_cli "$session" pane close "$pane" >/dev/null 2>&1 + ;; + tmux) + # target is the dedicated daemon session name - kill exactly it. + tmux kill-session -t "$target" 2>/dev/null + ;; + none) + return 0 + ;; + *) + fm_afk_launch_log "cannot close unknown recorded backend '$backend'" + return 1 + ;; + esac +} + +fm_afk_launch_terminal_absent() { # + local backend=$1 target=$2 session pane out result code + case "$backend" in + herdr) + session=${target%%:*} + pane=${target#*:} + [ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1 + out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>&1) + result=$? + [ "$result" -ne 0 ] || return 1 + code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) || return 1 + [ "$code" = pane_not_found ] + ;; + tmux) + out=$(tmux has-session -t "$target" 2>&1) + result=$? + [ "$result" -eq 1 ] || return 1 + printf '%s' "$out" | grep -Eq "can't find session" + ;; + none) + return 0 + ;; + *) return 1 ;; + esac +} + +fm_afk_launch_close_recorded() { + local close_result=0 + fm_afk_launch_close_terminal "$FM_AFK_REC_BACKEND" "$FM_AFK_REC_TARGET" || close_result=$? + if fm_afk_launch_terminal_absent "$FM_AFK_REC_BACKEND" "$FM_AFK_REC_TARGET"; then + rm -f "$FM_AFK_LAUNCH_RECORD" || return 1 + [ "$close_result" -eq 0 ] || fm_afk_launch_log "terminal close command failed, but exact absence was confirmed" + return 0 + fi + fm_afk_launch_log "recorded terminal teardown is unconfirmed; preserving exact id" + return 1 +} + +fm_afk_launch_terminal_alive() { # + local backend=$1 target=$2 session pane + case "$backend" in + herdr) + session=${target%%:*} + pane=${target#*:} + [ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1 + fm_backend_herdr_cli "$session" pane get "$pane" >/dev/null 2>&1 + ;; + tmux) + tmux has-session -t "$target" 2>/dev/null + ;; + *) return 1 ;; + esac +} + +fm_afk_launch_wait_ready() { # + local backend=$1 target=$2 i + if [ -n "${FM_AFK_LAUNCH_ENTRY:-}" ]; then + fm_afk_launch_terminal_alive "$backend" "$target" + return + fi + for i in $(seq 1 100); do + daemon_lock_held_by_live_daemon && return 0 + fm_afk_launch_terminal_alive "$backend" "$target" || return 1 + sleep 0.05 + done + return 1 +} + +fm_afk_launch_commit_terminal() { # [already-recorded] + local backend=$1 target=$2 extra=$3 already_recorded=${4:-0} + if [ "$already_recorded" -ne 1 ] && ! fm_afk_launch_record_write "$backend" "$target" "$extra"; then + fm_afk_launch_log "failed to persist daemon terminal record; closing $backend:$target" + fm_afk_launch_close_terminal "$backend" "$target" + return 1 + fi + if ! fm_afk_launch_wait_ready "$backend" "$target"; then + fm_afk_launch_log "daemon did not become ready; closing $backend:$target" + FM_AFK_REC_BACKEND=$backend + FM_AFK_REC_TARGET=$target + fm_afk_launch_close_recorded + return 1 + fi +} + +fm_afk_launch_herdr_recover_created() { #