diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 77f1b0d..3c48003 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -41,8 +41,8 @@ batched digest rather than per-wake injections. in as `FM_SUPERVISOR_TARGET` so the daemon injects into the captain, not its own new pane. **Never manufacture a terminal by splitting the captain's active pane** (`herdr pane split`): a split co-tenants the tab and visibly - shrinks the captain's pane (docs/herdr-backend.md "Away-mode daemon terminal - launch"). + shrinks the captain's pane (docs/herdr-backend.md "Away-mode supervisor + support"). Both paths share `bin/fm-afk-start.sh` as the daemon entry. The native path tells it that the launcher already prepared lifecycle state; the terminal-backed path lets the entry perform its existing state setup inside the new terminal. It exits immediately if the identity-backed daemon lock already names a live process, otherwise it execs `bin/fm-supervise-daemon.sh` in the foreground. @@ -58,13 +58,13 @@ batched digest rather than per-wake injections. 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. +- A message **without** the current operational prefix or a legacy bare marker, and **not** starting with `/afk` -> the captain is back. Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back. That script owns correct-ordered daemon shutdown, durable wake draining, escalation and wedge evidence, and the return-catch-up gate. If it reports a firstmate-actionable `blocked:` event, remediate it immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`. Once the daemon stops, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait. Do not answer a Bearings request or perform any other ordinary captain work until the check exits successfully. -- A message **with** the sentinel marker (`FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR) -> it is a daemon escalation; stay afk and process it. +- A message **with** the current operational prefix (`FM_OPERATIONAL_PREFIX`, U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), or a legacy bare `FM_INJECT_MARK` daemon escalation -> stay afk and process it. - Re-invoking `/afk` while already away -> stay afk (refresh the flag); this does **not** trigger an exit. @@ -73,16 +73,18 @@ a false exit is self-correcting (the captain re-runs `/afk`). ## Orthogonal to approval authority -afk changes how aggressively firstmate surfaces things, **not who approves -what**. "Away" never means "approves more." A PR ready for merge, a -needs-decision finding, or anything destructive still waits for the captain's -explicit word - the daemon just batches the notification. +afk changes how aggressively firstmate surfaces things, **not who approves what**. +"Away" never means "approves more" or "approves less." +A PR ready for merge or a needs-decision finding keeps the same configured authority and exceptions from `AGENTS.md` section 7, while anything requiring the captain still waits for the captain's explicit word. +The daemon only batches the notification. -## Sentinel marker contract +## Operational prefix contract -The daemon prefixes every injection with `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR), which has no normal keyboard keystroke and survives terminal transport as UTF-8 text. +The daemon constructs every current injection as the `away-supervisor` kind owned by `bin/fm-operational-input.sh`, beginning with `FM_OPERATIONAL_PREFIX`: `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR) followed by the stable `FIRSTMATE_OP: ` label. +The bare `FM_INJECT_MARK` form remains accepted for legacy daemon escalations during rollout. +U+2063 has no normal keyboard keystroke and survives terminal transport as UTF-8 text. This is how firstmate tells a daemon escalation apart from a real message in the same pane. -The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok. +The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok. ## Busy-guard and composer guard @@ -94,7 +96,7 @@ backend (tmux or herdr; see "Auto-discovered supervisor pane" below): - **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. `pending` means real unsubmitted text, while `unknown` includes an unreadable pane and a bare shell prompt left after the agent exits, so both defer. The shared `bin/fm-composer-lib.sh` owns the content decision after each backend captures and structurally identifies its own composer row. - It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer-emptiness safety" for the complete contract. + It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer and injection safety" for the complete contract. `pane_input_pending` remains the tested predicate for callers that only need to know whether real unsubmitted text is present, but it is insufficient for an injection-safety decision because it cannot distinguish `empty` from `unknown`. Either condition, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. @@ -108,7 +110,7 @@ If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm: an ERROR in the daemon log, a durable `state/.subsuper-inject-wedged` marker (surface it on the "while you were out" catch-up if present), a tmux status-line flash when applicable, and a configurable backend-independent active alert. -`docs/wedge-alarm.md` owns the alert channel setup and verification record. +`docs/wedge-alarm.md` owns the alert channel setup, and `docs/verification/supervision.md` "Wedge-alarm channels" owns active evidence. So a guard false-positive becomes a visible stall, never an unbounded silent no-op. ## Submit model @@ -169,10 +171,10 @@ Classify each wake this way: - Unknown reason, or any uncertainty -> escalate fail-safe. Escalations are buffered up to `FM_ESCALATE_BATCH_SECS` (default 90s; 0 = -immediate) and flushed as one single-line digest prefixed with the sentinel -marker, carrying pre-read status summaries and a recommended action. +immediate) and flushed as one single-line digest prefixed with the current +operational prefix, carrying pre-read status summaries and a recommended action. The single-line format makes the submission unambiguous across harnesses, and -the marker lets firstmate distinguish it from a real captain message. +the operational prefix lets firstmate distinguish it from a real captain message. ## Injection hardening @@ -203,8 +205,9 @@ the marker lets firstmate distinguish it from a real captain message. cleared. For herdr's normal idle-baseline path it means native agent-state observed a real turn start; herdr uses the ANSI-aware structural classifier for the pre-injection composer guard and fallback paths. This lets ghost-only or bordered-empty composers count as empty where a composer read is the active confirmation signal. -- **Marker strip** - `strip_injection_marker` removes the sentinel prefix before - classification or relay, so the digest text firstmate sees is clean. +- **Marker strip** - `strip_injection_marker` removes the current operational + prefix or legacy bare marker before classification or relay, so the digest + text firstmate sees is clean. - **Portable singleton lock** - the daemon uses the repo's portable lock helper (`fm-wake-lib.sh`) instead of `flock`, which is absent on macOS. - **Dedupe across signal/stale/scan** - `classify_signal` and terminal `classify_stale` paths check the seen-status marker before escalating, so a captain-relevant status escalated by one path is not re-escalated by another in the same digest. @@ -222,14 +225,14 @@ the marker lets firstmate distinguish it from a real captain message. backends, including zellij, orca, and cmux, are not yet supported as supervisor backends; the daemon refuses loudly at startup instead of misapplying tmux primitives to a pane that isn't one - (docs/herdr-backend.md "Away-mode daemon: herdr supervisor-pane support"). + (docs/herdr-backend.md "Away-mode supervisor support"). ## Stale-artifact lifecycle Treat `state/.subsuper-escalations`, its `.since` sidecar, and `state/.subsuper-inject-wedged` as session-scoped delivery artifacts, not as the durable work record. Always enter through `bin/fm-afk-launch.sh`, which clears prior-session artifacts only for a fresh entry and preserves the current session's buffer on refresh. Always exit through `bin/fm-afk-launch.sh stop`, which keeps `state/.afk` present through the daemon's shutdown flush and clears it last. -`docs/herdr-backend.md` "Stale-artifact lifecycle fix" owns the mechanism and verification evidence. +`docs/herdr-backend.md` "Away-mode supervisor support" owns the current mechanism, and `docs/verification/runtime-backends.md` "Away-mode transport" owns active evidence. ## Reliability properties diff --git a/.agents/skills/ahoy/SKILL.md b/.agents/skills/ahoy/SKILL.md new file mode 100644 index 0000000..e8f8dce --- /dev/null +++ b/.agents/skills/ahoy/SKILL.md @@ -0,0 +1,45 @@ +--- +name: ahoy +description: Recap visible session events since the prior real captain message plus visibly unanswered captain decisions when the captain explicitly invokes /ahoy, with a Bearings fallback when /ahoy is the session's first real captain message. +user-invocable: true +metadata: + internal: true +--- + +# ahoy + +Give the captain a concise session-only recap without gathering fresh state. + +1. Inspect only conversation or session history already visible to the current first mate. +2. Find the most recent real captain-authored message before the current `/ahoy` invocation. + A captain boundary is an ordinary user-role message unless it matches one of the narrow operational exclusions below. + Exclude messages that begin with the current U+2063 `FIRSTMATE_OP:` injection prefix. + Exclude legacy bare-marker away-mode injections only when U+2063 is immediately followed by `Supervisor escalate (`. + Exclude the exact legacy unmarked session-start payload ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` + Custom-role messages such as Pi's `firstmate-sessionstart-nudge` are not captain messages. + System, developer, tool, watcher, guard, away-mode, and other injected operational messages are not captain messages. + Never infer captain authorship merely because a synthetic message appears in the user-role transcript. + Do not exclude an ordinary captain message merely because it begins with U+2063 followed by other text, contains ASCII `FIRSTMATE_OP:` without a leading U+2063, quotes or embeds a current operational message after ordinary captain text, quotes or mentions the legacy session-start payload, or adds any text to that payload. + Apply the current exclusion only when U+2063 `FIRSTMATE_OP:` begins at the first character of the whole message: `Captain quote: ` followed by that current prefix is a captain boundary. + Apply the legacy startup exclusion as a literal whole-message match: ``Captain quote: Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` is a captain boundary. +3. If no prior real captain message exists, load [`../bearings/SKILL.md`](../bearings/SKILL.md) and follow it exactly. + Bearings alone owns its gathering, artifact, and response contract. + Do not restate that contract or combine a session recap with Bearings output. +4. If a prior real captain message exists, preserve the ordinary recap interval: recap what happened after that message and before the current invocation. + Include concrete outcomes, landed work, failures, decisions made, new decisions needed, and work still running only when those events appear in that visible interval. + Use captain-facing outcome language and preserve every full PR URL present in that interval. +5. Additionally inspect the entire session history visible to the current first mate before the current invocation for every explicit captain decision that remains unanswered, including decisions raised before the ordinary recap boundary. + A later unrelated captain message establishes a recap boundary but does not close an earlier decision. + Treat a decision as closed only when a later visible response substantively resolves it, chooses an option, declines it, grants or denies the requested approval, or otherwise directly addresses that decision. + Include every visibly supported open decision once, and deduplicate by the decision's substance when the ordinary interval recap already represents it or its wording differs. +6. The normal recap branch is session-history-only. + Do not call Bearings, shell commands, fleet snapshots, status readers, GitHub or browser APIs, tools, or file reads or writes. + Create no report, persist nothing, and do not guess current live state beyond the last visible event. +7. If no ordinary events occurred after the previous captain message but an older visibly open decision exists, report that decision instead of claiming nothing happened. + If neither ordinary events nor visibly open decisions exist, say directly in one sentence that nothing happened after the previous captain message. + +The current `/ahoy` message is outside the recap interval. +A previous `/ahoy` is a real captain message and may be the next interval boundary. +If context compaction makes the prior boundary unavailable, state that the exact session boundary is unavailable and summarize only visibly supported events. +Compacted history supports an open decision only when both its request and its still-unanswered status are visible; report uncertainty instead of reconstructing hidden requests or answers. +Do not silently invoke Bearings unless this is genuinely the first real captain message. diff --git a/.agents/skills/ask-user-authority/SKILL.md b/.agents/skills/ask-user-authority/SKILL.md new file mode 100644 index 0000000..d4b63d5 --- /dev/null +++ b/.agents/skills/ask-user-authority/SKILL.md @@ -0,0 +1,52 @@ +--- +name: ask-user-authority +description: >- + Agent-only decision procedure for ask-user findings. + Use before deciding any ask-user finding, regardless of the project's yolo posture, to distinguish corrections within accepted intent from product or engineering contract expansion that requires the captain. +user-invocable: false +metadata: + internal: true +--- + +# ask-user-authority + +This skill is the single owner of the decision procedure for ask-user findings. +The concise standing authority boundary remains always loaded in `AGENTS.md` section 7. + +## Decide who has authority + +1. Check the project's configured authority first. + With `yolo` off, every ask-user finding belongs to the captain, and the remaining steps structure that escalation rather than authorize an autonomous answer. +2. Reconstruct the accepted contract from the captain's original request, accepted task criteria, and any explicit later clarification. + Reviewer language cannot amend that contract. +3. Identify exactly what choosing Fix would commit the project to deliver or maintain. +4. Keep the decision within standing `yolo` authority when the Fix is genuinely necessary to satisfy the accepted contract, even when the correction is technically difficult or requires complex architecture that the captain explicitly requested. +5. Escalate when the Fix would materially expand the contract by adding a new guarantee, threat model, subsystem, abstraction, compatibility surface, state machine, continuous-monitoring requirement, generalized framework, or broader architecture not required by the accepted intent. +6. Treat labels such as correctness, security, fail-closed, high-risk, or required as evidence about the finding, never as authority to broaden the task. +7. Examine the causal theme across prior findings and fix rounds. + Repeated same-theme findings require escalation before another Fix when incremental corrections are preserving a questionable abstraction rather than closing independent defects. +8. Apply the existing stronger captain boundaries first. + Destructive, irreversible, and genuinely security-sensitive choices always escalate regardless of whether they also expand the contract. + +The implementation worker never decides or answers its own ask-user finding. +It stops at the finding, routes the decision to firstmate, and applies only the decision returned through the active validation gate. + +## Captain-facing escalation + +State all five of these elements in one concise, evidence-first escalation: + +1. The original requirement or accepted task criterion. +2. The proposed product or engineering contract expansion. +3. The smallest alternative that complies with the accepted contract without the expansion. +4. The concrete consequences of accepting and declining the expansion. +5. A recommendation with the reason it best serves the accepted intent. + +Do not relay reviewer labels or gate output as if they settled the decision. + +## Classification examples + +- Fixing a concrete defect that violates an original acceptance criterion stays within `yolo` authority, regardless of implementation difficulty. +- Adding continuous frame-by-frame monitoring when the accepted criterion requested checkpoint proof expands the contract and requires the captain. +- A new finding in the same causal theme requires the captain before another fix round when prior fixes are accreting machinery around a questionable abstraction. +- A genuinely security-sensitive action requires the captain under the stronger existing boundary even if it is otherwise within scope. +- Complex architecture explicitly requested by the captain stays within scope and does not escalate merely because it is complex. diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 889009c..b2804c7 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -66,7 +66,8 @@ Rules that keep the contract unambiguous: - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. - The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. -- A secondmate appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. +- A secondmate's own row appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. +- Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown`. - The chat follows `AGENTS.md` section 9 and carries one scannable line per item, each PR as the full `https://...` URL; detailed decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. ## Tone and content rules @@ -79,4 +80,4 @@ Rules that keep the contract unambiguous: This skill is read-mostly and changes no fleet state. Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. -If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, a needs-decision finding - name it in its section (a captain action under "Captain's Call", queued or gated work under "Charted Next") and let the captain decide, rather than taking the action from inside this skill. +If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, or a needs-decision finding - name it in its section and leave the action to the normal lifecycle and configured authority rather than taking it from inside this skill. diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 102071b..5379ec9 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -20,7 +20,7 @@ When any diagnostic needs captain attention, report the plain consequence and re 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 verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. - For `quota-axi`, bootstrap requires it because crew-dispatch `quota-balanced` may call it; `bin/fm-dispatch-select.sh` still degrades at runtime when quota data is unavailable. + For `quota-axi`, bootstrap requires it because every crew-dispatch profile array calls it automatically; `bin/fm-dispatch-select.sh` still selects uniformly from valid candidates with OS-backed randomness when quota data is unavailable. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). @@ -44,7 +44,7 @@ When any diagnostic needs captain attention, report the plain consequence and re - Any other `PR_CHECK_MIGRATION:` refusal means migration did not complete safely, whether because watcher exclusion, a private path, a diagnostic, quarantine validation, or marker publication could not be proved. Keep each affected poll unavailable, inspect the named private state path, and do not bypass the migration or execute a quarantined artifact; a completed safe-scan marker allows unrelated authenticated polls to continue while private repair remains pending. - `SECONDMATE_SYNC: secondmate : skipped: ` - the local-HEAD secondmate sync left a live secondmate home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing the primary target commit, or otherwise not fast-forwardable, or because inherited local-material propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions, inherited settings, or shared captain preferences may be stale after a primary update. -- `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed: ` - the session-start liveness sweep could not guarantee that a live secondmate's recorded endpoint is running a real agent process. +- `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : ` - the session-start liveness sweep could not guarantee that the registered secondmate is running a real agent process. Investigate the reason because that secondmate is not guaranteed live. - `NUDGE_SECONDMATES: secondmate : send failed: ` - the secondmate sweep fast-forwarded a running secondmate home and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, but the deterministic `fm-send.sh fm-` re-read nudge failed. Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send. diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index b58e6ea..c7126ff 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -3,7 +3,7 @@ name: firstmate-coding-guidelines description: >- Agent-only reference for changing firstmate's shared, tracked material per AGENTS.md section 1. Use before editing any of that material, whether working as firstmate directly or as a crewmate briefed on a firstmate-repo task. - Covers the knowledge-placement decision tree, the one-owner rule for contracts, the inline-stub pattern for content moved into a skill, AGENTS.md size discipline, trigger hygiene for new skills, and repo style rules (one sentence per line, plain dash, no agent co-author, shellcheck-clean bin scripts, colocated tests, and backend-verification evidence). + Covers the knowledge-placement decision tree, the one-owner rule for contracts, the inline-stub pattern for content moved into a skill, AGENTS.md size discipline, trigger hygiene for new skills, and repo style rules (one sentence per line, plain dash, no agent co-author, shellcheck-clean bin scripts, colocated tests, and maintainer-verification evidence). user-invocable: false metadata: internal: true @@ -23,13 +23,20 @@ Before writing a new fact anywhere in this repo, ask where it belongs, in this o If yes: `AGENTS.md`, inline. 2. Does the agent need it only in a nameable situation - a spawn, a recovery, a specific wake type, a specific lifecycle step? If yes: an agent-only skill under `.agents/skills/`, plus a one-line trigger pointer left inline in `AGENTS.md` (usually section 13). -3. Is it human/reference detail - a wire format, a verification record, a mechanism narrative, an incident writeup? - If yes: `docs/`. -4. Is it mechanics - exact flags, exact commands, exact paths? - If yes: the script's own header comment plus its `--help` output, not prose in `AGENTS.md` or a skill. +3. Is it public product, setup, or user/operator reference? + If yes: the surface classified for that audience in [`docs/documentation-audiences.md`](../../../docs/documentation-audiences.md), limited to current behavior, setup, supported limits, stable invariants, concise rationale, and current verification entry points. +4. Is it contributor/maintainer architecture? + If yes: the classified maintainer-architecture owner for stable ownership, extension points, mechanism boundaries, and safety rationale. +5. Is it active reusable verification for a current guarantee? + If yes: an explicitly classified maintainer-verification record may keep current dates, versions, exact commands, and exact output. +6. Is it task or incident evidence - chronology, transcripts, branches, temporary paths, failed hypotheses, or delivery proof? + If yes: keep it in the private task report or PR evidence by default, after distilling every unique current fact into its authoritative owner. +7. Is it mechanics - exact flags, exact commands, exact paths? + If yes: the script's own header comment plus its `--help` output, not prose in `AGENTS.md`, a skill, or a second documentation owner. Stop at the first tier that answers yes. Do not place a fact at a more convenient tier than the one this tree gives you. +The machine-consumed inventory in [`docs/documentation-audiences.json`](../../../docs/documentation-audiences.json) is the single classification owner for maintained prose surfaces; do not add parallel front matter or a second audience list. ## One-owner rule @@ -74,6 +81,13 @@ Mark an axis not applicable only after inspecting its integration surface, and u For critical safety, routing, startup, and supervision infrastructure, prefer deterministic and idempotent enforcement over relying on agent memory alone. Keep instructions as the authority and discovery layer, but make repeated execution converge safely and make invalid or unsafe states fail closed wherever the runtime can enforce them. +## Documentation change review + +For every changed maintained prose surface, identify its inventory audience, authoritative owner, current-behavior relevance, destination for supporting evidence, and any unique safety fact that removal could lose. +Move or delete evidence only after the current owner and regression pointer are verified. +After all documentation, review-fix, and lint-fix commits, review the complete branch diff again against those criteria rather than reviewing only the latest commit. +Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup routing, local link targets, and owner pointers without keyword-linting legitimate evidence prose. + ## Repo style rules - Put one full sentence per line in tracked Markdown. @@ -83,6 +97,6 @@ Keep instructions as the authority and discovery layer, but make repeated execut - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. - Colocate tests with the existing pattern in `tests/`, name them `.test.sh`, and extend an existing script rather than inventing a new runner. -- A backend-verification doc (`docs/*-backend.md`) records empirical facts, not assumptions. -- Include the date, version, exact commands run, and exact output. -- Write incidents the same way, as evidence, not narrative alone. +- A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. +- Include the date, version, exact commands run, and exact output needed to support the current guarantee. +- Keep incident chronology and delivery evidence in private task reports or PR evidence unless a concise rationale is required to maintain a current safety boundary. diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index 854d497..0fd8b70 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -16,7 +16,8 @@ The runtime backend owns the task endpoint and, for Orca, the task worktree. The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, or `grok`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. -Implementation details, metadata fields, teardown guarantees, limitations, and smoke evidence live in `docs/orca-backend.md`. +Implementation details, metadata fields, teardown guarantees, and limitations live in `docs/orca-backend.md`. +`docs/verification/runtime-backends.md` "Orca" owns active smoke evidence. Prefer the `bin/fm-*` helpers over raw `orca` commands. Use raw `orca` only when the helper surface cannot answer the inspection question, and keep the recorded firstmate metadata as the task identity. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 761f07d..c284ce3 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -53,7 +53,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. Every verified primary harness has an empirically validated hook path for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. `opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. -The exact hook files, commands, validation transcripts, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. +The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. +`docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. When changing any primary turn-end hook, validate the real harness behavior in a scratch project or throwaway home before trusting it, then update that doc and the relevant concise fact below. ## Primary pre-arm (PreToolUse) seatbelt @@ -62,13 +63,25 @@ Every verified primary harness also has a wired PreToolUse-equivalent hook that `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. +When changing any watcher-arm PreToolUse hook, validate the real harness behavior in a scratch project before trusting it, then update that doc. +## Primary delegation-shape guard + +Claude exposes built-in delegation, scheduling, and worktree tools that a primary session can use to create work with no `state/.meta`, which makes the whole guard stack inert because every guard counts that metadata. +The shipped mechanism is `bin/fm-subagent-pretool-check.sh`, a primary-home PreToolUse guard that denies a delegation-SHAPED tool name. +Claude primaries should also use an untracked per-home local `permissions.deny` list as hardening for known Claude delegation tools, because it removes them from the model's schema so they are never offered. +That deny list must not ship in tracked `.claude/settings.json` because it is Claude-only rather than harness-agnostic, and because tracked project settings propagate into linked worktrees where they disarm legitimate crewmates. +`docs/subagent-guard.md` owns the full contract, the local deny-list recommendation, the `FM_ALLOW_SUBAGENT=1` escape hatch, and the per-harness applicability review. + +Two verified facts worth pinning here. +The subagent tool presents to the model as `Agent`, and on Claude Code 2.1.217 both `Agent` and `Task` work as `permissions.deny` keys, verified by an A/B with a nonsense-name control. +`permissions.allow` is a pre-approval list rather than an availability list, so there is no fail-closed positive allowlist. ## Primary session-start nudge AGENTS.md section 3 remains the behavioral owner for session start, while tracked native adapters invoke `bin/fm-sessionstart-nudge.sh` as an idempotent enforcement layer. -The wrapper prints only the instruction to run `bin/fm-session-start.sh`; it never runs the digest, wake drain, bootstrap sweeps, lock, or supervision arm itself. -Full mechanics, scoping, dated commands, payloads, and fail-open evidence live in `docs/sessionstart-nudge.md`. +The wrapper prints one canonically typed `session-start` instruction to run `bin/fm-session-start.sh`; it never runs the digest, wake drain, bootstrap sweeps, lock, or supervision arm itself. +Full mechanics, scoping, and fail-open behavior live in `docs/sessionstart-nudge.md`. +`docs/verification/supervision.md` "Native session-start delivery" owns active dated commands, payloads, and evidence. - `claude`: verified native `SessionStart` stdout injection; `.claude/settings.json` matches `startup`, `resume`, and `clear`, but not `compact`. - `codex`: verified on 0.144.4; `.codex/hooks.json` receives `source=startup`, and wrapper stdout reaches model context. @@ -80,7 +93,7 @@ Full mechanics, scoping, dated commands, payloads, and fail-open evidence live i At session start, `bin/fm-session-start.sh` prints exactly one watcher supervision block for the detected primary harness. Do not substitute another harness's wait shape when resuming supervision. -Claude and Grok use tracked background-notify cycles around `bin/fm-watch-arm.sh`. +Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. Pi uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. @@ -141,17 +154,17 @@ A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config. The CLI's `--prompt-suggestions` flag is print/SDK-mode only and does not suppress the interactive composer ghost text, verified empirically on v2.1.186. As defense in depth for any pane that flag cannot reach, including the captain's own firstmate composer that away-mode reads, the shared `fm_composer_strip_ghost` extractor in `bin/fm-composer-lib.sh` removes dim/faint SGR 2 ghost runs before pending-input classification on both ANSI-capable readers (tmux and herdr). -Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md`'s 2026-07-10 incident record. +Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md` "Composer and injection safety", with active captures in `docs/verification/runtime-backends.md`. That styled capture is internal to the boolean detector only. `fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes. -**Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204).** +**Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204; Stop-owned auto-arm revalidated 2026-07-24, Claude Code 2.1.219).** This is separate from the per-task crewmate turn-end hook above (that one just `touch`es a marker file in a task's own `.claude/settings.local.json`). -The firstmate PRIMARY's own `.claude/settings.json` registers `bin/fm-turnend-guard.sh` as a Stop hook, and exiting with status 2 plus stderr reliably forces the model to continue. -Claude Code's stdin payload to a Stop hook carries a `stop_hook_active` boolean that is `true` exactly when the current stop attempt is itself a forced continuation from an earlier block this turn; a hook can and should use that as its own loop-guard (always allow the stop when it is already `true`) rather than tracking state itself. +The firstmate PRIMARY's own `.claude/settings.json` registers two Stop hooks: `bin/fm-turnend-guard.sh --claude` and the Stop-owned auto-arm `bin/fm-claude-stop-autoarm.sh` (`asyncRewake: true`, `timeout: 28800`), and exiting the guard with status 2 plus stderr reliably forces the model to continue. +Claude Code's stdin payload to a Stop hook carries a `stop_hook_active` boolean that is `true` when the current stop attempt follows ANY stop-hook-driven continuation, including `asyncRewake` rewakes; the primary guard therefore ignores it in `--claude` mode and uses the cooperative claim/epoch check plus a bounded re-block budget instead, while the codex-mode default still treats it as a one-block loop guard. A project-level `.claude/settings.json` only takes effect when Claude Code's project root is that exact directory - it does not walk up from a subdirectory looking for one, so firstmate launches the primary from the repo root. -After those settings are loaded, hook command resolution is still cwd-sensitive because Claude Code runs commands through `/bin/sh` against the session's current cwd; keep the tracked command anchored through `"$CLAUDE_PROJECT_DIR"/bin/fm-turnend-guard.sh` and see `docs/turnend-guard.md` for the verified Stop-hook details. -Claude Code's primary watcher protocol is the lowest-friction path: run `bin/fm-watch-arm.sh` as its own Claude Code background task and treat background-task completion as the wake. +After those settings are loaded, hook command resolution is still cwd-sensitive because Claude Code runs commands through `/bin/sh` against the session's current cwd; keep the tracked commands anchored through `"$CLAUDE_PROJECT_DIR"/bin/...` and see `docs/turnend-guard.md` for the verified Stop-hook details. +Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on every Stop and foregrounds `bin/fm-watch-arm.sh` when the home is eligible and still needs supervision, and its exit-2 `asyncRewake` rewake is the wake; the model drains and handles wakes but never runs a routine re-arm command. ## codex (VERIFIED 2026-06-11, codex-cli 0.139.0) @@ -268,7 +281,7 @@ For Grok's supported reasoning-effort values and omission behavior, see the [lau **Incident (2026-07-03, herdr backend only, grok 0.2.82):** two grok/herdr crewmates were sent `/no-mistakes` via `fm-send`; both left it fully typed but unsubmitted in the composer for minutes (footer still `Enter:send`), and `fm-send` exited 0 with no error. Reproduced live: the herdr adapter's submit-verification at the time treated ANY pane-content change after Enter as "submitted", and the popup-close-with-placeholder-fill described above IS a visible content change even though nothing was actually sent. The tmux backend was never affected - `fm_tmux_composer_state` reads the actual cursor row, correctly sees the placeholder text as still-pending, and its retry loop already sends the needed second Enter. -Fixed in the herdr adapter (`fm_backend_herdr_composer_state`, `bin/backends/herdr.sh`) by classifying the composer's own row structurally instead of diffing raw content; see `docs/herdr-backend.md`'s "Incident (2026-07-03)" section for the full account and `tests/fm-backend-herdr.test.sh` for the regression coverage. +Fixed in the Herdr adapter (`fm_backend_herdr_composer_state`, `bin/backends/herdr.sh`) by classifying the composer's own row structurally instead of diffing raw content; see `docs/herdr-backend.md` "Composer and injection safety" for the current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. Startup dialog: the "Run Grok Build in a project directory?" project picker appears ONLY when grok is launched from a non-project directory (home, Desktop, Downloads, `/tmp`). `fm-spawn` launches inside the treehouse worktree (a git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 54ab841..af35d46 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -36,7 +36,7 @@ Choose the delivery mode when adding or creating the project: The optional `+yolo` posture changes routine approval authority but does not change the delivery mode. Default it off, and enable it only on the captain's explicit instruction. -Destructive, irreversible, and security-sensitive decisions still require captain approval when it is on. +`AGENTS.md` section 7 owns the complete authority boundary and exceptions when it is on. ## Add or clone an existing project diff --git a/.claude/settings.json b/.claude/settings.json index 37a535e..9f145c8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -28,6 +28,15 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-continuity-pretool-check.sh" } ] + }, + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-subagent-pretool-check.sh --claude" + } + ] } ], "Stop": [ @@ -35,7 +44,13 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh" + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "asyncRewake": true, + "timeout": 28800 } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c4fb5..c04c096 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: set -eu bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install tasks-axi + run: | + set -eu + npm install -g tasks-axi + tasks-axi --version - name: Run portable parallel shard 1 run: | set -eu @@ -78,6 +83,11 @@ jobs: set -eu bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install tasks-axi + run: | + set -eu + npm install -g tasks-axi + tasks-axi --version - name: Run portable parallel shard 2 run: | set -eu @@ -317,16 +327,16 @@ jobs: snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 14 ] || { - echo "::error::expected 14 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 15 ] || { + echo "::error::expected 15 snapshot/fleet-view tests, got $snapshot_count" exit 1 } bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 39 ] || { - echo "::error::expected 39 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 42 ] || { + echo "::error::expected 42 Bearings tests, got $bearings_count" exit 1 } diff --git a/.github/workflows/no-mistakes-required.yml b/.github/workflows/no-mistakes-required.yml index ab1224d..f56afee 100644 --- a/.github/workflows/no-mistakes-required.yml +++ b/.github/workflows/no-mistakes-required.yml @@ -1,4 +1,5 @@ name: Require no-mistakes +run-name: "PR #${{ github.event.pull_request.number }} body compliance - ${{ github.event.action }} - event ${{ github.run_number }} (run ${{ github.run_id }})" on: pull_request: @@ -9,8 +10,12 @@ on: permissions: contents: read +# GitHub concurrency groups retain at most one pending run, replacing older +# pending runs even when cancel-in-progress is false. Give body-bearing events +# an immutable per-event group so first-time-fork approvals can never collapse +# opened/edited checks. Keep synchronize/reopened coalescing as before. concurrency: - group: no-mistakes-required-${{ github.event.pull_request.number }} + group: no-mistakes-required-${{ github.event.pull_request.number }}-${{ (github.event.action == 'opened' || github.event.action == 'edited') && github.run_id || 'head-change' }} cancel-in-progress: true jobs: diff --git a/.gitignore b/.gitignore index 2b17922..372af47 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ config/crew-dispatch.json config/secondmate-harness config/backlog-backend config/backend +config/calm config/x-mode.env config/cmux-socket-password config/wedge-alarm diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index f5d9412..02e6128 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -9,14 +9,24 @@ # HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary." disable_project_settings: true -# Pin lint to the same deterministic command CI runs, instead of leaving it 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). +# Trusted documentation placement policy for the Document step. +# The audience inventory and coding guideline own the detail; keep this as a +# pointer so gate instructions cannot become a second prose policy. +document: + instructions: | + Read docs/documentation-audiences.md and its machine-consumed + docs/documentation-audiences.json inventory before changing documentation. + Apply the knowledge-placement policy in + .agents/skills/firstmate-coding-guidelines/SKILL.md. + For changed prose, verify audience, authoritative owner, current relevance, + evidence destination, and unique safety facts, then review the complete + branch diff again after every documentation or lint fix. + +# Pin lint to the same owner CI runs instead of leaving it to no-mistakes' +# default handling, which does not invoke the repository's canonical lint gate. +# `bin/fm-lint.sh` owns the complete lint definition and +# `.github/workflows/ci.yml` invokes it directly, with parity asserted by +# `tests/fm-lint.test.sh`. # # Do not set commands.test to a complete tests/*.test.sh walk. Local no-mistakes # Test is intent-targeted validation of whether the change meets its brief; diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js index f0ff562..fb8f42e 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { resolve } from "node:path"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; @@ -74,18 +75,17 @@ export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => if (result.code !== 2) return; try { + const text = await encodeFirstmateOperationalInput( + root, + "turn-end-guard", + "TURN WOULD END BLIND - supervision is off. " + + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + + result.stderr, + ); await client.session.promptAsync({ path: { id: sessionID }, body: { - parts: [ - { - type: "text", - text: - "TURN WOULD END BLIND - supervision is off. " + - "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + - result.stderr, - }, - ], + parts: [{ type: "text", text }], }, }); skipNextIdle = true; diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 15a772d..8b98340 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); @@ -178,16 +179,12 @@ function observeArmOutput(stdout, stderr, settleReadiness) { } } -async function sendPrompt(client, sessionID, text) { +async function sendPrompt(paths, client, sessionID, text) { + const encoded = await encodeFirstmateOperationalInput(paths.root, "watcher", text); await client.session.promptAsync({ path: { id: sessionID }, body: { - parts: [ - { - type: "text", - text, - }, - ], + parts: [{ type: "text", text: encoded }], }, }); } @@ -196,8 +193,8 @@ function wakePrompt(reason) { return `WATCHER FIRED - drain queued wakes with bin/fm-wake-drain.sh and handle the reported wake. Watcher continuity is plugin-owned.\n\n${reason}`; } -function surfaceFailure(client, sessionID, reason) { - void sendPrompt(client, sessionID, wakePrompt(reason)).catch(() => { +function surfaceFailure(paths, client, sessionID, reason) { + void sendPrompt(paths, client, sessionID, wakePrompt(reason)).catch(() => { }); } @@ -259,13 +256,13 @@ async function scheduleRetry(paths, sessionID, client, reason, predecessorArmPid if (child || retryTimer) return; if (!(await sessionOwnsLock(paths))) { setArmStatus("failed"); - surfaceFailure(client, sessionID, `watcher: FAILED - OpenCode cannot restore continuity because this session no longer owns the lock\n${reason}`); + surfaceFailure(paths, client, sessionID, `watcher: FAILED - OpenCode cannot restore continuity because this session no longer owns the lock\n${reason}`); return; } retryFailures += 1; if (retryFailures > REARM_RETRY_LIMIT) { setArmStatus("failed"); - surfaceFailure(client, sessionID, `watcher: FAILED - OpenCode could not restore watcher continuity after ${REARM_RETRY_LIMIT} retries\n${reason}`); + surfaceFailure(paths, client, sessionID, `watcher: FAILED - OpenCode could not restore watcher continuity after ${REARM_RETRY_LIMIT} retries\n${reason}`); return; } setArmStatus("retrying"); @@ -273,7 +270,7 @@ async function scheduleRetry(paths, sessionID, client, reason, predecessorArmPid if (retryTimer === timer) retryTimer = null; void ensureArm(paths, sessionID, client, predecessorArmPid).then((status) => { if (["armed", "starting", "wake"].includes(status)) return; - surfaceFailure(client, sessionID, `watcher: FAILED - OpenCode could not launch a continuity retry (${status})`); + surfaceFailure(paths, client, sessionID, `watcher: FAILED - OpenCode could not launch a continuity retry (${status})`); }); }, retryDelay(retryFailures)); timer.unref(); @@ -344,7 +341,7 @@ function spawnArm(paths, sessionID, client, predecessorArmPid = "") { void restoration.then((failure) => { if (restorationInFlight === restoration) restorationInFlight = null; const message = failure ? `${classification.message}\n\n${failure}` : classification.message; - return sendPrompt(client, sessionID, wakePrompt(message)); + return sendPrompt(paths, client, sessionID, wakePrompt(message)); }).catch(() => { }); return; diff --git a/.opencode/plugins/lib/fm-operational-input.js b/.opencode/plugins/lib/fm-operational-input.js new file mode 100644 index 0000000..f0d05c1 --- /dev/null +++ b/.opencode/plugins/lib/fm-operational-input.js @@ -0,0 +1,37 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +// Cross-language adapter only. bin/fm-operational-input.sh owns the protocol, +// accepted kinds, marker bytes, and serialization grammar. +export function encodeFirstmateOperationalInput(root, kind, content) { + return new Promise((resolveResult, reject) => { + const requested = `${root}/bin/fm-operational-input.sh`; + const script = existsSync(requested) + ? requested + : `${adapterRoot}/bin/fm-operational-input.sh`; + const child = spawn(script, ["encode", kind], { + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0 && stdout) { + resolveResult(stdout); + return; + } + reject(new Error(stderr.trim() || `operational-input encoder exited ${code ?? "unknown"}`)); + }); + child.stdin.end(content); + }); +} diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts new file mode 100644 index 0000000..484bb9e --- /dev/null +++ b/.pi/extensions/fm-calm.ts @@ -0,0 +1,277 @@ +// Firstmate's home-persistent Pi transcript presentation toggle. +// +// Compatibility boundary: Pi 0.81.1 exposes built-in ToolDefinitions, per-slot +// renderers, renderShell: "self", session_start replacement reasons, +// ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and +// setHiddenThinkingLabel(). The focused tests pin those assumptions. Exact-version +// presentation adapters cover collapsed assistant thinking and operational user rows; +// Pi still exposes no global renderer for arbitrary built-in or custom rows. +// docs/configuration.md owns the home-local Calm preference contract. +import { randomUUID } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + ExtensionAPI, + ToolDefinition, + ToolRenderResultOptions, +} from "@earendil-works/pi-coding-agent"; +import { + createBashToolDefinition, + createEditToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, + createLsToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { Box, Container, getKeybindings, type Component } from "@earendil-works/pi-tui"; +import type { TSchema } from "typebox"; +import { installCalmAssistantLayout } from "./lib/fm-calm-assistant-layout.ts"; +import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts"; +import { + calmPresentationHides, + calmPresentationIsActive, + FIRSTMATE_CALM_PRESENTATION_EVENT, + registerFirstmateSyntheticPresentation, + setCalmPresentation, + setCalmStockExportRendering, +} from "./lib/fm-calm-visibility.ts"; + +type DefinitionFactory = ( + cwd: string, +) => ToolDefinition; + +type RenderContext = Parameters< + NonNullable["renderCall"]> +>[2]; + +type RenderArgs = Parameters< + NonNullable["renderCall"]> +>[0]; + +type RenderTheme = Parameters< + NonNullable["renderCall"]> +>[1]; + +type RenderResult = Parameters< + NonNullable["renderResult"]> +>[0]; + +type StandardShellState = { + shell?: Box; + call?: Component; + result?: Component; +}; + +const extensionFile = fileURLToPath(import.meta.url); +const extensionDir = dirname(extensionFile); +const root = resolve(extensionDir, "../.."); + +export default function (pi: ExtensionAPI) { + installCalmAssistantLayout(); + installCalmOperationalUserLayout(); + + let exportRendering = false; + let removeTerminalInputHandler: (() => void) | undefined; + + const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; + const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config"); + const calmPreferencePath = resolve(configDirectory, "calm"); + const loadCalmPreference = (): boolean => { + try { + return readFileSync(calmPreferencePath, "utf8").trim() === "on"; + } catch { + return false; + } + }; + const persistCalmPreference = (active: boolean): void => { + mkdirSync(dirname(calmPreferencePath), { recursive: true }); + const temporaryPath = `${calmPreferencePath}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryPath, active ? "on\n" : "off\n", { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, calmPreferencePath); + } finally { + rmSync(temporaryPath, { force: true }); + } + }; + + const publishPresentationState = (): void => { + pi.events.emit(FIRSTMATE_CALM_PRESENTATION_EVENT, { + active: calmPresentationIsActive(), + stockExportRendering: exportRendering, + }); + }; + + registerFirstmateSyntheticPresentation(pi); + + function registerBuiltIn( + factory: DefinitionFactory, + ): void { + const definitions = new Map>(); + const definitionFor = (cwd: string): ToolDefinition => { + let definition = definitions.get(cwd); + if (!definition) { + definition = factory(cwd); + definitions.set(cwd, definition); + } + return definition; + }; + + const original = definitionFor(process.cwd()); + const originalRenderCall = original.renderCall; + const originalRenderResult = original.renderResult; + const originalSelfShell = original.renderShell === "self"; + const standardShells = new WeakMap(); + + if (!originalRenderCall || !originalRenderResult) { + throw new Error(`Firstmate calm mode requires both render slots for Pi built-in tool ${original.name}`); + } + + const shellStateFor = ( + context: RenderContext, + ): StandardShellState => { + const rowState = context.state as object; + let shellState = standardShells.get(rowState); + if (!shellState) { + shellState = {}; + standardShells.set(rowState, shellState); + } + return shellState; + }; + + const refreshStandardShell = ( + state: StandardShellState, + theme: RenderTheme, + context: RenderContext, + ): Box => { + const background = context.isPartial + ? (text: string) => theme.bg("toolPendingBg", text) + : context.isError + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const shell = state.shell ?? new Box(1, 1, background); + state.shell = shell; + shell.setBgFn(background); + shell.clear(); + if (state.call) shell.addChild(state.call); + if (state.result) shell.addChild(state.result); + return shell; + }; + + pi.registerTool({ + ...original, + renderShell: "self", + + async execute(toolCallId, params, signal, onUpdate, ctx) { + return definitionFor(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx); + }, + + renderCall( + args: RenderArgs, + theme: RenderTheme, + context: RenderContext, + ) { + if (exportRendering) return originalRenderCall(args, theme, context); + if (calmPresentationHides("assistant-tool-call")) return new Container(); + if (originalSelfShell) return originalRenderCall(args, theme, context); + + const state = shellStateFor(context); + state.call = originalRenderCall(args, theme, { + ...context, + lastComponent: state.call, + }); + return refreshStandardShell(state, theme, context); + }, + + renderResult( + result: RenderResult, + options: ToolRenderResultOptions, + theme: RenderTheme, + context: RenderContext, + ) { + if (exportRendering) return originalRenderResult(result, options, theme, context); + if (calmPresentationHides("tool-result")) return new Container(); + if (originalSelfShell) return originalRenderResult(result, options, theme, context); + + const state = shellStateFor(context); + state.result = originalRenderResult(result, options, theme, { + ...context, + lastComponent: state.result, + }); + refreshStandardShell(state, theme, context); + return new Container(); + }, + }); + } + + registerBuiltIn(createReadToolDefinition); + registerBuiltIn(createBashToolDefinition); + registerBuiltIn(createEditToolDefinition); + registerBuiltIn(createWriteToolDefinition); + registerBuiltIn(createGrepToolDefinition); + registerBuiltIn(createFindToolDefinition); + registerBuiltIn(createLsToolDefinition); + + pi.on("session_start", (_event, ctx) => { + exportRendering = false; + setCalmPresentation(loadCalmPreference()); + setCalmStockExportRendering(false); + publishPresentationState(); + ctx.ui.setWorkingVisible(true); + ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); + ctx.ui.setStatus("firstmate-calm", undefined); + removeTerminalInputHandler?.(); + removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => { + if (!getKeybindings().matches(data, "tui.input.submit")) return; + + const input = ctx.ui.getEditorText().trim(); + if ( + input !== "/share" && + input !== "/export" && + !input.startsWith("/export ") + ) { + return; + } + + exportRendering = true; + setCalmStockExportRendering(true); + publishPresentationState(); + setTimeout(() => { + exportRendering = false; + setCalmStockExportRendering(false); + publishPresentationState(); + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }, 0); + }); + }); + + pi.registerCommand("calm", { + description: "Toggle Firstmate's supported conversation-only transcript presentation.", + handler: async (_args, ctx) => { + const active = !calmPresentationIsActive(); + persistCalmPreference(active); + setCalmPresentation(active); + publishPresentationState(); + ctx.ui.setWorkingVisible(true); + ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); + ctx.ui.setStatus("firstmate-calm", undefined); + + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }, + }); +} diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 0f7f403..2b8ed99 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -4,8 +4,15 @@ import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; +import { Box, Container, Text, type Component } from "@earendil-works/pi-tui"; import { Type } from "typebox"; +import { + type CalmPresentationState, + calmTranscriptClassIsVisible, + FIRSTMATE_CALM_PRESENTATION_EVENT, +} from "./lib/fm-calm-visibility.ts"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; type ArmResult = { ok: boolean; @@ -19,6 +26,36 @@ type CloseClassification = { message: string; }; +type WatchToolShellState = { + shell?: Box; + call?: Component; + result?: Component; +}; + +type WatchToolRenderContext = { + isError: boolean; + isPartial: boolean; +}; + +function refreshWatchToolShell( + state: WatchToolShellState, + theme: Theme, + context: WatchToolRenderContext, +): Box { + const background = context.isPartial + ? (text: string) => theme.bg("toolPendingBg", text) + : context.isError + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const shell = state.shell ?? new Box(1, 1, background); + state.shell = shell; + shell.setBgFn(background); + shell.clear(); + if (state.call) shell.addChild(state.call); + if (state.result) shell.addChild(state.result); + return shell; +} + const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); @@ -34,6 +71,7 @@ const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); +const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; let child: ChildProcess | null = null; let retryTimer: ReturnType | null = null; @@ -125,6 +163,22 @@ function classifyClose(stdout: string, stderr: string, code: number | null, sign } export default function (pi: ExtensionAPI) { + let calmPresentation: CalmPresentationState = { + active: false, + stockExportRendering: false, + }; + pi.events?.on?.(FIRSTMATE_CALM_PRESENTATION_EVENT, (data) => { + const next = data as Partial; + calmPresentation = { + active: next.active === true, + stockExportRendering: next.stockExportRendering === true, + }; + }); + const calmHides = (itemClass: Parameters[0]): boolean => + calmPresentation.active && + !calmPresentation.stockExportRendering && + !calmTranscriptClassIsVisible(itemClass); + function stopArm(): void { stopping = true; if (retryTimer) clearTimeout(retryTimer); @@ -139,10 +193,11 @@ export default function (pi: ExtensionAPI) { process.once("exit", cleanupOnProcessExit); async function sendWake(message: string): Promise { - await pi.sendUserMessage( + const content = encodeFirstmateOperationalInput( + "watcher", `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`, - { deliverAs: "followUp" }, ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); } function surfaceFailure(message: string): void { @@ -248,8 +303,18 @@ export default function (pi: ExtensionAPI) { }; } markLoaded(); - if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; - if (retryTimer) return { ok: true, message: "watcher: continuity retry already scheduled by the Pi extension" }; + if (child) { + return { + ok: true, + message: `watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; ${repairOnlyHint}`, + }; + } + if (retryTimer) { + return { + ok: true, + message: `watcher: unchanged - Pi extension already owns a scheduled continuity retry; no manual re-arm needed; ${repairOnlyHint}`, + }; + } const id = ++seq; const env = { ...process.env, @@ -335,7 +400,10 @@ export default function (pi: ExtensionAPI) { if (restoring) return; scheduleRetry(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? "")); }); - return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + return { + ok: true, + message: `watcher: started Pi extension arm child ${id}; future ordinary re-arms are automatic; ${repairOnlyHint}`, + }; } pi.on?.("session_start", () => { @@ -357,12 +425,38 @@ export default function (pi: ExtensionAPI) { pi.registerTool?.({ name: "fm_watch_arm_pi", label: "Arm firstmate watcher", - description: "Arm Pi watcher supervision. Always use this tool instead of running bin/fm-watch-arm.sh through bash.", - promptSnippet: "Arm firstmate watcher supervision through Pi without a foreground bash arm.", + description: "Start the first required Pi watcher cycle, or repair one only after a notification says the cycle is missing, failed, or unhealthy. Do not call after ordinary work or ordinary notifications; the Pi extension re-arms automatically. Never run bin/fm-watch-arm.sh through bash.", + promptSnippet: "Start the first required Pi watcher cycle or repair a cycle reported missing, failed, or unhealthy; ordinary re-arming is automatic.", promptGuidelines: [ - "For Pi watcher supervision, call fm_watch_arm_pi instead of running bin/fm-watch-arm.sh through bash.", + "Call fm_watch_arm_pi only for the first required cycle or after a notification says the cycle is missing, failed, or unhealthy. Do not call it after ordinary work, turn completion, or ordinary signal, stale, check, or heartbeat handling because the Pi extension owns re-arming. Never run bin/fm-watch-arm.sh through bash.", ], parameters: Type.Object({}), + renderShell: "self", + renderCall: (_args, theme, context) => { + if (calmHides("assistant-tool-call")) return new Container(); + if (calmPresentation.stockExportRendering) { + return new Text(theme.fg("toolTitle", theme.bold("fm_watch_arm_pi")), 0, 0); + } + const state = context.state as WatchToolShellState; + state.call = new Text(theme.fg("toolTitle", theme.bold("fm_watch_arm_pi")), 0, 0); + return refreshWatchToolShell(state, theme, context); + }, + renderResult: (result, _options, theme, context) => { + if (calmHides("tool-result")) return new Container(); + const output = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (calmPresentation.stockExportRendering) { + return new Text(theme.fg("toolOutput", output), 0, 0); + } + const state = context.state as WatchToolShellState; + state.result = output + ? new Text(theme.fg("toolOutput", output), 0, 0) + : new Container(); + refreshWatchToolShell(state, theme, context); + return new Container(); + }, execute: async () => { const result = startArm(); return { diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 6d80fa9..113a1bc 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; let guardFollowupActive = false; @@ -111,7 +112,12 @@ export default function (pi: ExtensionAPI) { markLoaded(); if (!nudge) return; try { - pi.sendMessage({ customType: "firstmate-sessionstart-nudge", content: nudge, display: false }); + pi.sendMessage({ + customType: "firstmate-sessionstart-nudge", + content: nudge, + display: false, + details: { kind: "session-start" }, + }); } catch { } }); @@ -140,12 +146,13 @@ export default function (pi: ExtensionAPI) { guardFollowupActive = true; try { - await pi.sendUserMessage( + const content = encodeFirstmateOperationalInput( + "turn-end-guard", "TURN WOULD END BLIND - supervision is off. " + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + result.stderr, - { deliverAs: "followUp" }, ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { guardFollowupActive = false; } diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts new file mode 100644 index 0000000..d51b6cc --- /dev/null +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -0,0 +1,57 @@ +import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import { calmPresentationHides } from "./fm-calm-visibility.ts"; + +type AssistantMessage = Parameters[0]; + +type AssistantMessagePresentationState = { + hiddenThinkingLabel: string; + hideThinkingBlock: boolean; + lastMessage?: AssistantMessage; +}; + +type CalmAssistantLayoutPatch = { + hidesThinking: () => boolean; +}; + +const CALM_ASSISTANT_LAYOUT_PATCH = Symbol.for( + "firstmate:calm-assistant-layout:pi-0.81.1", +); + +export function installCalmAssistantLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmAssistantLayoutPatch | undefined; + }; + const hidesThinking = (): boolean => calmPresentationHides("assistant-thinking"); + const installed = registry[CALM_ASSISTANT_LAYOUT_PATCH]; + if (installed) { + installed.hidesThinking = hidesThinking; + return; + } + + const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; + if (typeof originalUpdateContent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); + } + + AssistantMessageComponent.prototype.updateContent = function ( + message: AssistantMessage, + ): void { + const state = this as unknown as AssistantMessagePresentationState; + const hideThinking = + state.hiddenThinkingLabel === "" && + state.hideThinkingBlock && + patch.hidesThinking(); + const presentationMessage = hideThinking + ? { + ...message, + content: message.content.filter((block) => block.type !== "thinking"), + } + : message; + + originalUpdateContent.call(this, presentationMessage); + if (presentationMessage !== message) state.lastMessage = message; + }; + + registry[CALM_ASSISTANT_LAYOUT_PATCH] = patch; +} diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts new file mode 100644 index 0000000..fe2eeae --- /dev/null +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -0,0 +1,134 @@ +// Pi 0.81.1's transcript owner adds the ordinary-user spacer and row together. +// This exact-version adapter changes only that presentation and never message delivery. +import { + InteractiveMode, + UserMessageComponent, +} from "@earendil-works/pi-coding-agent"; +import { calmPresentationHides } from "./fm-calm-visibility.ts"; +import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; + +type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageLike = { + role: string; + content: unknown; +}; +type AddMessageOptions = { + populateHistory?: boolean; +}; +type InteractiveModePresentation = { + chatContainer: { + children: unknown[]; + addChild(component: UserMessageComponent): void; + }; + editor: { + addToHistory?(text: string): void; + }; + getMarkdownThemeWithSettings(): UserMessageConstructorArgs[1]; + getUserMessageText(message: UserMessageLike): string; + outputPad: number; +}; +type InteractiveModePrototype = { + addMessageToChat( + this: InteractiveModePresentation, + message: UserMessageLike, + options?: AddMessageOptions, + ): void; +}; +type CalmOperationalUserLayoutPatch = { + hidesOperationalInput: () => boolean; + isOperationalInput: (text: string) => boolean; +}; + +const CALM_OPERATIONAL_USER_LAYOUT_PATCH = Symbol.for( + "firstmate:calm-operational-user-layout:pi-0.81.1", +); +const LEGACY_CALM_OPERATIONAL_PREFIX = "\u2063Supervisor escalate ("; + +function contentIsTextOnly(content: unknown): boolean { + if (typeof content === "string") return true; + if (!Array.isArray(content) || content.length === 0) return false; + return content.every( + (block) => + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string", + ); +} + +export function installCalmOperationalUserLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmOperationalUserLayoutPatch | undefined; + }; + const hidesOperationalInput = (): boolean => calmPresentationHides("synthetic-user"); + const isOperationalInput = (text: string): boolean => { + if (!text.includes("\u2063")) return false; + return ( + classifyFirstmateCurrentOperationalText(text) !== undefined || + text.startsWith(LEGACY_CALM_OPERATIONAL_PREFIX) + ); + }; + const installed = registry[CALM_OPERATIONAL_USER_LAYOUT_PATCH]; + if (installed) { + installed.hidesOperationalInput = hidesOperationalInput; + installed.isOperationalInput = isOperationalInput; + return; + } + + const patch: CalmOperationalUserLayoutPatch = { + hidesOperationalInput, + isOperationalInput, + }; + const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + const originalAddMessageToChat = prototype.addMessageToChat; + if (typeof originalAddMessageToChat !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); + } + + class CalmOperationalUserMessageComponent extends UserMessageComponent { + private readonly hasLeadingSpacer: boolean; + + constructor( + text: UserMessageConstructorArgs[0], + markdownTheme: UserMessageConstructorArgs[1], + outputPad: number, + hasLeadingSpacer: boolean, + ) { + super(text, markdownTheme, outputPad); + this.hasLeadingSpacer = hasLeadingSpacer; + } + + override render(width: number): string[] { + if (patch.hidesOperationalInput()) return []; + const lines = super.render(width); + return this.hasLeadingSpacer ? ["", ...lines] : lines; + } + } + + prototype.addMessageToChat = function ( + message: UserMessageLike, + options?: AddMessageOptions, + ): void { + if (message.role !== "user" || !contentIsTextOnly(message.content)) { + originalAddMessageToChat.call(this, message, options); + return; + } + + const text = this.getUserMessageText(message); + if (!text || !patch.isOperationalInput(text)) { + originalAddMessageToChat.call(this, message, options); + return; + } + + const component = new CalmOperationalUserMessageComponent( + text, + this.getMarkdownThemeWithSettings(), + this.outputPad, + this.chatContainer.children.length > 0, + ); + this.chatContainer.addChild(component); + if (options?.populateHistory) this.editor.addToHistory?.(text); + }; + + registry[CALM_OPERATIONAL_USER_LAYOUT_PATCH] = patch; +} diff --git a/.pi/extensions/lib/fm-calm-visibility.ts b/.pi/extensions/lib/fm-calm-visibility.ts new file mode 100644 index 0000000..27a03f0 --- /dev/null +++ b/.pi/extensions/lib/fm-calm-visibility.ts @@ -0,0 +1,96 @@ +import { + getMarkdownTheme, + type ExtensionAPI, + UserMessageComponent, +} from "@earendil-works/pi-coding-agent"; +export const CALM_TRANSCRIPT_CLASSES = [ + "genuine-user-prompt", + "genuine-agent-response", + "assistant-thinking", + "assistant-tool-call", + "tool-result", + "tool-image", + "user-bash", + "skill-invocation", + "custom-message", + "custom-entry", + "compaction-summary", + "branch-summary", + "working-status", + "command-status", + "system-notice", + "cache-notice", + "project-trust-warning", + "synthetic-user", + "synthetic-assistant", + "unknown", +] as const; + +export type CalmTranscriptClass = (typeof CALM_TRANSCRIPT_CLASSES)[number]; + +const CALM_VISIBLE_CLASSES = new Set([ + "genuine-user-prompt", + "genuine-agent-response", + "working-status", +]); + +// Legacy session entries from Calm versions before 2026-07-23 retain this +// presentation type. New operational input stays user-role and is never rerouted. +export const FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE = "firstmate-synthetic-input-presentation"; +export const FIRSTMATE_CALM_PRESENTATION_EVENT = "firstmate:calm-presentation"; + +export type CalmPresentationState = { + active: boolean; + stockExportRendering: boolean; +}; + +export const FIRSTMATE_SYNTHETIC_KINDS = [ + "session-start", + "watcher", + "turn-end-guard", + "away-supervisor", + "from-firstmate", + "launch-brief", + "legacy-operational", +] as const; + +export type FirstmateSyntheticKind = (typeof FIRSTMATE_SYNTHETIC_KINDS)[number]; +type FirstmateSyntheticPresentation = { + content: string; + kind: FirstmateSyntheticKind; +}; + +let calm = false; +let stockExportRendering = false; + +export function calmTranscriptClassIsVisible(itemClass: CalmTranscriptClass): boolean { + return CALM_VISIBLE_CLASSES.has(itemClass); +} + +export function setCalmPresentation(active: boolean): void { + calm = active; +} + +export function setCalmStockExportRendering(active: boolean): void { + stockExportRendering = active; +} + +export function calmPresentationIsActive(): boolean { + return calm; +} + +export function calmPresentationHides(itemClass: CalmTranscriptClass): boolean { + return calm && !stockExportRendering && !calmTranscriptClassIsVisible(itemClass); +} + +export function registerFirstmateSyntheticPresentation(pi: ExtensionAPI): void { + pi.registerEntryRenderer( + FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE, + (entry) => { + if (calmPresentationHides("synthetic-user")) return undefined; + const data = entry.data; + if (!data || typeof data.content !== "string") return undefined; + return new UserMessageComponent(data.content, getMarkdownTheme()); + }, + ); +} diff --git a/.pi/extensions/lib/fm-operational-input.ts b/.pi/extensions/lib/fm-operational-input.ts new file mode 100644 index 0000000..338312d --- /dev/null +++ b/.pi/extensions/lib/fm-operational-input.ts @@ -0,0 +1,55 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const operationalInputScript = + process.env.FM_OPERATIONAL_INPUT_SCRIPT || + resolve(dirname(fileURLToPath(import.meta.url)), "../../../bin/fm-operational-input.sh"); + +export const FIRSTMATE_CURRENT_OPERATIONAL_KINDS = [ + "session-start", + "watcher", + "turn-end-guard", + "away-supervisor", + "from-firstmate", + "launch-brief", +] as const; + +export type FirstmateCurrentOperationalKind = + (typeof FIRSTMATE_CURRENT_OPERATIONAL_KINDS)[number]; + +function runOperationalInputCommand( + command: "encode" | "classify" | "kind", + content: string, + kind?: FirstmateCurrentOperationalKind, +): string | undefined { + const args = command === "encode" ? [command, kind ?? ""] : [command]; + const result = spawnSync(operationalInputScript, args, { + encoding: "utf8", + input: content, + maxBuffer: 1024 * 1024, + }); + if (result.status !== 0) return undefined; + return command === "classify" ? result.stdout.replace(/\n$/, "") : result.stdout; +} + +export function encodeFirstmateOperationalInput( + kind: FirstmateCurrentOperationalKind, + content: string, +): string { + const encoded = runOperationalInputCommand("encode", content, kind); + if (encoded === undefined) { + throw new Error(`could not encode Firstmate operational input kind ${kind}`); + } + return encoded; +} + +export function classifyFirstmateOperationalText(content: string): string | undefined { + return runOperationalInputCommand("classify", content); +} + +export function classifyFirstmateCurrentOperationalText( + content: string, +): string | undefined { + return runOperationalInputCommand("kind", content); +} diff --git a/AGENTS.md b/AGENTS.md index e93d8be..e71ac4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Hard rules, in priority order: The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. 2. **Never merge a PR without the captain's explicit word.** - A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; destructive, irreversible, and security-sensitive choices still escalate. + A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. 3. **Never tear down unlanded work.** Uncommitted changes are never landed, and `bin/fm-teardown.sh` owns the complete landed-work test. Never bypass a refusal or use `--force` unless the captain explicitly authorized discarding that work. @@ -68,7 +68,8 @@ config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignore config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes -config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional disposable single-task presentation spaces" +config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" +config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present @@ -76,6 +77,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning + inbox-cards.md optional captain-private plain-English copy for the inbox board; see docs/inbox-board.md 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) @@ -87,23 +89,26 @@ state/ volatile runtime signals; gitignored .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, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) - .herdr-presentation quarantinable attempt journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional disposable single-task presentation spaces" + .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check .pr-poll private validated data sidecar for the byte-static PR merge poll .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication + .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire .pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) + inbox-answers/ generated captain-inbox Lavish feedback drops; inspect after the registered inbox check wakes firstmate (docs/inbox-board.md) x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks + .claude-autoarm.lock .claude-autoarm-epoch .turnend-claude-blocks Claude Stop auto-arm single-flight, epoch, and guard-budget records; never touch .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it @@ -120,22 +125,22 @@ Run `bin/fm-session-start.sh` exactly once at session start. Its header is the single owner of composed commands, ordering, and digest contents. `bin/fm-supervision-instructions.sh` renders the emitted supervision block from `docs/supervision-protocols/`. Do not reimplement it by separately running its lock, bootstrap, or initial wake-drain components. -Tracked native session-open adapters only nudge this command; `docs/sessionstart-nudge.md` owns their enforcement mechanics and verification evidence. +Tracked native session-open adapters only nudge this command; `docs/sessionstart-nudge.md` owns their current behavior and compatibility. Read the complete digest once and trust it as this turn's startup and recovery input. Do not separately re-read the context, backlog, metadata, or bulk status inputs it just printed unless a source was reported absent or corrupt, older history is specifically needed, or a targeted workflow must inspect before writing. An `ABSENT` captain, shared-captain, secondmate, or learnings file means the firstmate repo's built-in defaults, no shared captain preferences, no registered secondmates, or no captured learnings; rebuild an absent or stale project registry from the clones before dispatch. -If the session lock is refused, tell the captain another active session is managing the fleet and remain read-only. +If the session lock cannot be acquired and verified, report its exact diagnostic and remain read-only; another active session is only one possible cause. A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. 2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - The five MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. - The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence), respawns only on a confident dead reading, and reports only skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). + Home-local stale Herdr projection cleanup and the five bootstrap MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + The secondmate liveness sweep deterministically accounts for every registered secondmate: it relaunches only from the recovery-grade `dead` or `missing` states, preserves ambiguous or unreadable targets, and reports skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_state`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the raw records prominently as this turn's first work queue; a bounded, clearly labeled historical status-event annotation may follow a valid `signal` record but never replaces it or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. - When the lock could not be acquired, 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. + When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. 4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, each clearly delimited. A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). 5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. @@ -224,18 +229,22 @@ Route by the nature of the work against each registered secondmate scope, not by Keep `local-only` work in the main home. Send in-scope work to the fitting secondmate unless it is blocked or the captain explicitly redirects it; do not read the secondmate's chat because marked routed replies return through its status or referenced document. If no secondmate scope fits, use the main home or discuss creating an appropriate persistent secondmate. +For one-off or infrequent operational work, start with the simplest direct end-to-end path. +Do not build wrappers, control planes, policy layers, custom verifiers, or automation unless the direct path exposes a concrete blocker or repeated need that justifies the added machinery. +Before commissioning an investigation, consult existing reports and established evidence. Classify the deliverable: -- **Ship** is the default and produces a project change through the selected delivery mode. -- **Scout** produces knowledge in `data//report.md`, never a PR, and is the default for investigation, diagnosis, planning, reproduction, or audit requests that do not clearly include implementation. +- **Ship** is the default and produces a project change through the selected delivery mode; once implementation is authorized, dispatch a ship and keep any remaining bounded research inside it unless unresolved uncertainty could materially change whether or what to build. +- **Scout** produces knowledge in `data//report.md`, never a PR, and is appropriate for investigation, diagnosis, planning, reproduction, or audit work when the captain explicitly requests a separate knowledge or design deliverable or unresolved uncertainty could materially change whether or what to build. +If established evidence already answers an informational question, relay it without a design-only scout; when implementation intent is unclear, answer and ask one concise implementation question when useful rather than dispatching speculative design work. +Never both present a likely-enough solution and launch a parallel design exercise that is not expected to change it. A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code. -Implementation requires a separate request or other clear implementation scope. Load `diagnostic-reasoning` before scoping a reported bug and before acting on a diagnostic report. -Classify work as dispatchable when it does not overlap work under way, or queued and blocked when it touches the same project subsystem or depends on unlanded work. -Dispatch independent work immediately with no concurrency cap, serialize coarse overlaps, and record blockers durably. +Treat file or subsystem overlap as a risk signal rather than an automatic reason to wait, and dispatch isolated work immediately with no concurrency cap when each change can be independently implemented and validated and the selected delivery path can reconcile ordinary rebases or conflicts. +Serialize only for a true semantic dependency, shared mutable external state, incompatible concurrent migration, or another concrete condition that makes independent progress or reconciliation unsafe; same-file editing alone is insufficient, and genuine blockers remain durable. Write the task-specific brief under section 11 before spawning. ### Dispatch and supervision handoff @@ -265,7 +274,10 @@ The path's worker, automated gates, and captain approval remain authoritative: Delivery mode and `yolo` are orthogonal. With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. -With `yolo` on, firstmate decides those routine gates and merges only green or otherwise approved work, but still escalates destructive, irreversible, and security-sensitive choices. +With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green or otherwise approved work. +Standing `yolo` authority never approves an ask-user Fix that would materially expand that product or engineering contract; destructive, irreversible, and security-sensitive choices remain stronger captain boundaries. +Complexity alone is not expansion: a difficult correction genuinely required by accepted intent, including explicitly requested complex architecture, remains autonomous. +Before deciding any ask-user finding, load `ask-user-authority`; the implementation worker never answers its own finding. Never merge a red PR. Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. After an autonomous merge, give the captain a one-line full-URL or local-main outcome. @@ -304,13 +316,11 @@ Retire one only on an explicit captain or main-firstmate decision, after loading ### Scout outcome and promotion -A completed scout must leave a self-contained report before its scratch worktree can be discarded. -Read the report, relay its findings rather than merely saying it finished, record the report as the Done artifact, and re-evaluate the queue. +A completed scout must leave a self-contained report before its scratch worktree can be discarded; read and relay its findings, record the report as the Done artifact, and re-evaluate the queue. A report may recommend implementation but does not authorize it. Before treating the investigation or any visual review as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. -The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path. -Scratch commits and debug edits never ride along, and a reproduced bug becomes the regression test. +The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. ## 8. Supervision protocol @@ -352,7 +362,7 @@ Harness-aware turn-end guards are structural backstops, not permission to omit t Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. The skill owns the daemon procedure; these safety facts remain inline: -- Every daemon injection starts with `FM_INJECT_MARK` plus U+2063 INVISIBLE SEPARATOR, which distinguishes internal escalation from captain input. +- Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. - A marked message while away mode is active is internal escalation and does not exit away mode. - A message beginning `/afk` refreshes away mode. @@ -403,6 +413,7 @@ Reach the captain immediately for: - A needed credential or login. Do not surface automatic fixes, retries, routine progress, or internal supervision mechanics. +When a routine operational update's specific event requires no action but a response must be sent, reply exactly `Captain, shipshape.` without characterizing the visible session's unrelated decisions. Batch non-urgent updates into the next natural reply. Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface. Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference. @@ -455,6 +466,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. +- `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. - `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. - `project-management` - load before adding, creating, removing, or initializing a project. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8eaf10..7f90f00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,8 @@ We require this to reduce the maintainer's burden of reviewing and merging contr Pushing through it runs an AI-driven review/test/lint pipeline in an isolated worktree, forwards the push upstream only after every check passes, and opens a clean PR automatically. A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and fails if the body is missing the deterministic signature that no-mistakes writes. -Dependency bots are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. +It evaluates every PR opening and body edit independently, so a later edit cannot replace an earlier pending compliance check. +GitHub Actions and Dependabot are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. ## Workflow @@ -47,7 +48,8 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. - Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. -- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) need empirical adapter notes in the relevant backend guide: `docs/tmux-backend.md`, `docs/herdr-backend.md`, `docs/zellij-backend.md`, `docs/orca-backend.md`, `docs/cmux-backend.md`, or `docs/codex-app-backend.md` for blocked Codex App transport work. +- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). +- [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. - In Markdown, put each full sentence on its own line. - `README.md` stays a concise overview plus pointers: it never carries a wall of inline detail. Route detail to the most specific `docs/` file (architecture, configuration, or a backend guide) and link to it instead. @@ -61,7 +63,7 @@ There is no reliable way for `bin/fm-brief.sh`'s scaffold to detect that a task' A crewmate picking up such a brief should load the skill even if the brief predates this instruction. When supervising live crewmates, keep firstmate's own long validation or build commands in the background so watcher wakes can still be handled. Crewmate validation follows the installed no-mistakes version's SKILL.md and live `axi` help instead of duplicating gate mechanics in firstmate docs. -Firstmate's wrapper still matters: `ask-user` findings route to the captain through firstmate, and crewmates avoid `--yes` because it silently resolves captain-owned decisions without escalation. +Firstmate's wrapper still matters: crewmates route every `ask-user` finding to firstmate, which applies the authority contract in `AGENTS.md`, and crewmates avoid `--yes` because it would bypass that check and any required captain escalation. Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint command to `bin/fm-lint.sh`, matching the Linux CI lint job. Local no-mistakes Test is intent-targeted and must not re-run every `tests/*.test.sh`; `.github/workflows/ci.yml` owns the broad behavior suite plus platform-specific compatibility lanes. That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even when another no-mistakes-managed target project keeps committed PR evidence. @@ -95,7 +97,7 @@ CI owns broad regression across required portable parallel shards, the portable Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. -The [Herdr backend guide](docs/herdr-backend.md) owns the lane's safety and isolation rationale, including why live harness credential tests remain opt-in. +The [Herdr backend guide](docs/herdr-backend.md#destructive-lab-safety) owns the lane's isolation boundary, while [runtime backend verification](docs/verification/runtime-backends.md#herdr) owns active empirical evidence; live harness credential tests remain opt-in. ## Questions diff --git a/README.md b/README.md index 6c958d8..a9dcf15 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. -- **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report. +- **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. @@ -60,14 +60,15 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m - 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 CLI and dependencies for your selected runtime backend; tmux is the reference default. -The first mate detects and offers to install everything else. +The first mate detects and offers to install supported missing tools after you approve. +Backend-specific setup is linked in [Documentation](#documentation). ### Recommended harnesses **Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session. -Claude Code and Grok use background-notify wake cycles; Pi uses its tracked primary watcher extension. +Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses background-notify wake cycles, and Pi uses its tracked primary watcher extension. All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. @@ -102,7 +103,11 @@ 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. +For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. +Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, while retaining native working activity and all model context and session data. +The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. +The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. +[Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). ### Talk to it @@ -110,8 +115,7 @@ For Pi, approve the project trust prompt once per clone on first launch so both > ahoy! look at my github project xyz, then fix the flaky login test and add dark mode # firstmate checks its toolchain (asking your consent before installing anything), -# clones the project under projects/, and spawns two crewmates in the active backend -# fm-fix-login-k3 and fm-dark-mode-p7. +# clones the project under projects/ and spawns two isolated workers in the active backend. # Minutes later: PR ready for review, captain: https://github.com/you/xyz/pull/42 @@ -164,6 +168,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | Skill | What it does | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | +| `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, falling back to Bearings when invoked as the session's first real captain message | | `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | @@ -182,19 +187,24 @@ Firstmate's skills live in two separate places with different audiences: ## Documentation -- [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, and project modes work. +- [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. +- [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. +- [docs/inbox-board.md](docs/inbox-board.md) - the captain decision-and-review board, its answer relay, and current verification pointers. - [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. -- [docs/tmux-backend.md](docs/tmux-backend.md) - setup guide for the tmux reference backend: prerequisites, attaching, and watching crew windows. -- [docs/herdr-backend.md](docs/herdr-backend.md) - setup guide for the experimental herdr backend, plus its verification notes and known gaps. -- [docs/zellij-backend.md](docs/zellij-backend.md) - setup guide for the experimental zellij backend, plus its verification notes and known gaps. -- [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps. -- [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. -- [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract. -- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - how the merge watch follows a GitLab merge request on any instance, and the evidence behind it. -- [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/tmux-backend.md](docs/tmux-backend.md) - current setup and limits for the tmux reference backend. +- [docs/herdr-backend.md](docs/herdr-backend.md) - current setup, safety boundaries, and limits for the experimental Herdr backend. +- [docs/zellij-backend.md](docs/zellij-backend.md) - current setup and limits for the experimental Zellij backend. +- [docs/orca-backend.md](docs/orca-backend.md) - current setup and limits for the experimental Orca backend. +- [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend. +- [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract. +- [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees. +- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances. +- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits. +- [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations. - [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. +- [docs/documentation-audiences.md](docs/documentation-audiences.md) - documentation audiences and the machine-checked placement boundary. - [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index cecb7e9..432311d 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -18,11 +18,14 @@ # per task inside its home's workspace. An optional, default-off presentation # flag creates a disposable workspace for a clean fresh task instead. That # workspace is a non-authoritative visual projection containing only the normal -# task pane. Its random token and journal never authorize lookup, adoption, -# reuse, closure, deletion, task ownership, or endpoint selection. Ambiguous or -# recovered launches use the default flat home workspace when duplicate-agent -# risk is independently absent. Target resolution stays parallel to the tmux -# adapter in both layouts. +# task pane. Its random token and mutable label never authorize lookup, +# adoption, reuse, closure, deletion, task ownership, or endpoint selection. +# A version 2 journal can participate in replacing only its exact same-identity +# endpoint after metadata, home, session, workspace, tab, pane, parent, shape, +# focus, and agent-absence checks all agree under the session lock. +# Every ambiguous recovered launch uses the default flat home workspace when +# duplicate-agent risk is independently absent. +# Target resolution stays parallel to the tmux adapter in both layouts. # Projected create, move, and cleanup operations capture the named session's # exact active workspace and tab. Herdr 0.7.4's last-pane close can focus an # unrelated neighbor, so projected cleanup serializes and restores only the @@ -99,9 +102,12 @@ FM_BACKEND_HERDR_ESCALATED_PREFIX=".herdr-escalated-" FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" # The default-off presentation projection is intentionally separate from the # authoritative task endpoint record. -# A per-task journal lives under state/ as .herdr-presentation and records -# only the attempted projection's random correlator. -# No send, capture, kill, recovery, Treehouse, or ownership path reads it. +# A per-task journal lives under state/ as .herdr-presentation. +# Version 1 records only the attempted projection's random correlator. +# Version 2 additionally binds the successful projection's exact home, +# session, workspace, tab, pane, parent, and presentation labels so a resumed +# spawn can replace one verified agent-free husk under the session lock. +# No send, capture, Treehouse, or general task-ownership path reads it. FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace @@ -250,21 +256,145 @@ fm_backend_herdr_projection_journal_create() { # printf '%s' "$token" } -# fm_backend_herdr_projection_journal_token: validate and read a projection -# journal without sourcing it as shell code. -fm_backend_herdr_projection_journal_token() { # - local journal=$1 id=$2 version recorded_id token lines +fm_backend_herdr_projection_journal_field() { # + local journal=$1 key=$2 count + count=$(grep -c "^${key}=" "$journal" 2>/dev/null || true) + [ "$count" = 1 ] || return 1 + grep "^${key}=" "$journal" 2>/dev/null | cut -d= -f2- +} + +# fm_backend_herdr_projection_journal_snapshot: validate a version 1 attempt +# journal or a version 2 exact projection binding without sourcing shell code. +# Version 2 sets FM_BACKEND_HERDR_JOURNAL_* globals for same-process callers. +fm_backend_herdr_projection_journal_snapshot() { # + local journal=$1 id=$2 lines expected_label expected_task_label exact + FM_BACKEND_HERDR_JOURNAL_VERSION="" + FM_BACKEND_HERDR_JOURNAL_TASK_ID="" + FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID="" + FM_BACKEND_HERDR_JOURNAL_HOME="" + FM_BACKEND_HERDR_JOURNAL_SESSION="" + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID="" + FM_BACKEND_HERDR_JOURNAL_TAB_ID="" + FM_BACKEND_HERDR_JOURNAL_PANE_ID="" + FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID="" + FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL="" + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL="" + FM_BACKEND_HERDR_JOURNAL_TASK_LABEL="" [ -f "$journal" ] && [ ! -L "$journal" ] || return 1 lines=$(wc -l < "$journal" 2>/dev/null | tr -d '[:space:]') - [ "$lines" = 3 ] || return 1 - version=$(grep '^version=' "$journal" 2>/dev/null | cut -d= -f2- || true) - recorded_id=$(grep '^task_id=' "$journal" 2>/dev/null | cut -d= -f2- || true) - token=$(grep '^projection_id=' "$journal" 2>/dev/null | cut -d= -f2- || true) - [ "$version" = 1 ] && [ "$recorded_id" = "$id" ] && [ "${#token}" -eq 22 ] || return 1 - case "$token" in + FM_BACKEND_HERDR_JOURNAL_VERSION=$(fm_backend_herdr_projection_journal_field "$journal" version) || return 1 + FM_BACKEND_HERDR_JOURNAL_TASK_ID=$(fm_backend_herdr_projection_journal_field "$journal" task_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID=$(fm_backend_herdr_projection_journal_field "$journal" projection_id) || return 1 + [ "$FM_BACKEND_HERDR_JOURNAL_TASK_ID" = "$id" ] || return 1 + [ "${#FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID}" -eq 22 ] || return 1 + case "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" in *[!A-Za-z0-9_-]*) return 1 ;; esac - printf '%s' "$token" + case "$FM_BACKEND_HERDR_JOURNAL_VERSION:$lines" in + 1:3) return 0 ;; + 2:12) ;; + *) return 1 ;; + esac + FM_BACKEND_HERDR_JOURNAL_HOME=$(fm_backend_herdr_projection_journal_field "$journal" home) || return 1 + FM_BACKEND_HERDR_JOURNAL_SESSION=$(fm_backend_herdr_projection_journal_field "$journal" session) || return 1 + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID=$(fm_backend_herdr_projection_journal_field "$journal" workspace_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_TAB_ID=$(fm_backend_herdr_projection_journal_field "$journal" tab_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PANE_ID=$(fm_backend_herdr_projection_journal_field "$journal" pane_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_journal_field "$journal" parent_workspace_id) || return 1 + FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" parent_label) || return 1 + FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" workspace_label) || return 1 + FM_BACKEND_HERDR_JOURNAL_TASK_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" task_label) || return 1 + case "$FM_BACKEND_HERDR_JOURNAL_HOME" in + /*) ;; + *) return 1 ;; + esac + for exact in \ + "$FM_BACKEND_HERDR_JOURNAL_SESSION" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID"; do + case "$exact" in + ''|*[[:space:]]*) return 1 ;; + esac + done + [ -n "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" ] \ + && [ -n "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" ] \ + && [ -n "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" ] || return 1 + expected_label=$(fm_backend_herdr_projection_workspace_label "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID") + expected_task_label="fm-$id" + [ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" = "$expected_label" ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" = "$expected_task_label" ] +} + +# fm_backend_herdr_projection_journal_token: validate and read either journal +# version's non-authoritative visual correlator. +fm_backend_herdr_projection_journal_token() { # + fm_backend_herdr_projection_journal_snapshot "$1" "$2" || return 1 + printf '%s' "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" +} + +fm_backend_herdr_projection_home_identity() { # + local home=$1 + [ -d "$home" ] || return 1 + (cd "$home" 2>/dev/null && pwd -P) +} + +fm_backend_herdr_projection_journal_write_v2() { # + local journal=$1 id=$2 token=$3 home=$4 session=$5 workspace=$6 tab=$7 pane=$8 + local parent_workspace=$9 parent_label=${10} workspace_label=${11} task_label=${12} state tmp + state=$(dirname "$journal") + tmp=$(mktemp "$state/.${id}.herdr-presentation.bind.XXXXXX") || return 1 + chmod 0600 "$tmp" || { rm -f "$tmp"; return 1; } + if ! { + printf 'version=2\n' + printf 'task_id=%s\n' "$id" + printf 'projection_id=%s\n' "$token" + printf 'home=%s\n' "$home" + printf 'session=%s\n' "$session" + printf 'workspace_id=%s\n' "$workspace" + printf 'tab_id=%s\n' "$tab" + printf 'pane_id=%s\n' "$pane" + printf 'parent_workspace_id=%s\n' "$parent_workspace" + printf 'parent_label=%s\n' "$parent_label" + printf 'workspace_label=%s\n' "$workspace_label" + printf 'task_label=%s\n' "$task_label" + } > "$tmp"; then + rm -f "$tmp" + return 1 + fi + [ -f "$journal" ] && [ ! -L "$journal" ] || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$journal" +} + +# fm_backend_herdr_projection_journal_bind: upgrade one exact version 1 +# attempt to a version 2 binding after the live projection and parent relation +# have both been verified under the session lock. +fm_backend_herdr_projection_journal_bind() { # + local journal=$1 id=$2 home=$3 session=$4 workspace=$5 tab=$6 pane=$7 + local parent_workspace=$8 parent_label=$9 workspace_label=${10} task_label=${11} token + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 1 ] || return 1 + token=$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID + fm_backend_herdr_projection_journal_write_v2 \ + "$journal" "$id" "$token" "$home" "$session" "$workspace" "$tab" "$pane" \ + "$parent_workspace" "$parent_label" "$workspace_label" "$task_label" +} + +# fm_backend_herdr_projection_journal_replace_endpoint: atomically advance one +# exact version 2 binding after its old husk was replaced successfully. +fm_backend_herdr_projection_journal_replace_endpoint() { # + local journal=$1 id=$2 old_tab=$3 old_pane=$4 new_tab=$5 new_pane=$6 + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 2 ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" = "$old_tab" ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" = "$old_pane" ] || return 1 + fm_backend_herdr_projection_journal_write_v2 \ + "$journal" "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \ + "$FM_BACKEND_HERDR_JOURNAL_HOME" "$FM_BACKEND_HERDR_JOURNAL_SESSION" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" "$new_tab" "$new_pane" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" } # fm_backend_herdr_projection_concise_task_label: strip redundant owner @@ -457,8 +587,10 @@ fm_backend_herdr_projection_focus_restore() { # - local session=$1 pane_id=$2 before active_tab info target_pane target_tab close_status +fm_backend_herdr_projection_close_pane_focus_preserving() { # [required-agent-state] + local session=$1 pane_id=$2 required_agent_state=${3:-} + local before active_tab info target_pane target_tab close_status state + FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE="" [ -n "$pane_id" ] || return 0 before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { echo "warning: herdr presentation cleanup could not capture exact active workspace and tab; refusing focus-unsafe pane close" >&2 @@ -479,13 +611,18 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # &2 return 1 fi + if [ -n "$required_agent_state" ]; then + state=$(fm_backend_herdr_pane_agent_state "$session" "$pane_id") + FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE=$state + [ "$state" = "$required_agent_state" ] || return 1 + fi if fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1; then close_status=0 else close_status=$? fi - fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || true - return "$close_status" + fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || return 2 + [ "$close_status" -eq 0 ] } # fm_backend_herdr_projection_order_best_effort: place the exact workspace id @@ -928,24 +1065,28 @@ fm_backend_herdr_tab_is_husk() { # esac } -# fm_backend_herdr_agent_alive: CONFIDENT liveness of a live harness-agent -# PROCESS under (":"), for the same -# session-start secondmate-liveness sweep fm_backend_tmux_agent_alive serves -# (bin/fm-bootstrap.sh; docs/herdr-backend.md "Agent liveness probe reuses the -# husk classifier"). Reuses fm_backend_herdr_pane_agent_state, the -# already-verified husk classifier ("Respawn idempotency" above): `dead` -# (structurally gone pane) and `no-agent` (a restored, agent-less bare shell -# - EXACTLY the shape a dead secondmate leaves behind) both collapse to -# `dead`; `live` (a real registered agent_status, including idle/blocked) -# maps to `alive`; `unknown` stays `unknown` - fail-safe toward refusal, -# exactly like the husk check itself. Callers must never treat `unknown` as a -# confirmed-dead signal. -fm_backend_herdr_agent_alive() { # +# fm_backend_herdr_agent_state: recovery-grade state for the same session-start +# sweep as the tmux classifier. It reuses the husk classifier rather than +# creating a second Herdr state machine: a structurally gone pane is `missing`, +# a confirmed agent-less pane is `dead`, a registered agent is `alive`, and an +# unexpected or failed API read is `unreadable`. +fm_backend_herdr_agent_state() { # local target=$1 - fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + fm_backend_herdr_parse_target "$target" || { printf 'unreadable'; return 0; } case "$(fm_backend_herdr_pane_agent_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")" in - dead|no-agent) printf 'dead' ;; + dead) printf 'missing' ;; + no-agent) printf 'dead' ;; live) printf 'alive' ;; + *) printf 'unreadable' ;; + esac +} + +# Backward-compatible three-state view for callers that only need a yes/no +# agent verdict. The detailed state contract is owned by fm_backend_agent_state. +fm_backend_herdr_agent_alive() { # + case "$(fm_backend_herdr_agent_state "$1")" in + alive) printf 'alive' ;; + dead|missing) printf 'dead' ;; *) printf 'unknown' ;; esac } @@ -1186,6 +1327,241 @@ fm_backend_herdr_projection_cleanup_exact() { # + local session=$1 parent_label=$2 list + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + printf '%s' "$list" | jq -er --arg parent_label "$parent_label" ' + (.result.workspaces // null) as $spaces + | select(($spaces | type) == "array") + | [$spaces[]? | select(.label == $parent_label)] + | if length == 1 + and (.[0].workspace_id | type) == "string" + and (.[0].workspace_id | length) > 0 + then .[0].workspace_id + else empty + end + ' 2>/dev/null +} + +# fm_backend_herdr_projection_live_binding_matches: verify one exact projected +# workspace, its single task tab/pane, its unique token label, and its current +# position inside the exact parent's contiguous child block. +# This read-only predicate grants no mutation authority by itself. +fm_backend_herdr_projection_live_binding_matches() { # + local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6 + local parent_label=$7 workspace_label=$8 task_label=$9 list tabs panes + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + printf '%s' "$list" | jq -e \ + --arg token "$token" \ + --arg workspace "$workspace" \ + --arg parent_workspace "$parent_workspace" \ + --arg parent_label "$parent_label" \ + --arg workspace_label "$workspace_label" ' + def is_new_child: + (.label | type) == "string" + and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$")); + def is_legacy_child_for($owner): + (.label | type) == "string" + and (.label | test("^(firstmate|2ndmate-[^/]+)/.+ · p:[A-Za-z0-9_-]{22}$")) + and (.label | startswith($owner + "/")); + (.result.workspaces // null) as $spaces + | select(($spaces | type) == "array") + | select(([$spaces[]? | select(.workspace_id == $workspace)] | length) == 1) + | select(([$spaces[]? | select(.workspace_id == $workspace and .label == $workspace_label)] | length) == 1) + | select(([$spaces[]? | select((.label | type) == "string" and (.label | endswith(" · p:" + $token)))] | length) == 1) + | select(([$spaces[]? | select((.label | type) == "string" and (.label | endswith(" · p:" + $token)) and .workspace_id == $workspace)] | length) == 1) + | select(([$spaces[]? | select(.workspace_id == $parent_workspace and .label == $parent_label)] | length) == 1) + | select(([$spaces[]? | select(.label == $parent_label)] | length) == 1) + | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $parent_workspace)]) as $parents + | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $workspace)]) as $children + | select(($parents | length) == 1 and ($children | length) == 1) + | ($parents[0]) as $parent_index + | ($children[0]) as $child_index + | select($child_index > $parent_index) + | reduce range($parent_index + 1; $child_index) as $i + (true; . and (($spaces[$i] | is_new_child) or ($spaces[$i] | is_legacy_child_for($parent_label)))) + | select(. == true) + ' >/dev/null 2>&1 || return 1 + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$workspace" 2>/dev/null) || return 1 + printf '%s' "$tabs" | jq -e --arg tab "$tab" --arg task_label "$task_label" ' + (.result.tabs | type) == "array" + and (.result.tabs | length) == 1 + and .result.tabs[0].tab_id == $tab + and .result.tabs[0].label == $task_label + ' >/dev/null 2>&1 || return 1 + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$workspace" 2>/dev/null) || return 1 + printf '%s' "$panes" | jq -e --arg tab "$tab" --arg pane "$pane" ' + (.result.panes | type) == "array" + and (.result.panes | length) == 1 + and .result.panes[0].pane_id == $pane + and .result.panes[0].tab_id == $tab + ' >/dev/null 2>&1 +} + +fm_backend_herdr_projection_reclaim_rollback() { # + local session=$1 new_pane=$2 state + state=$(fm_backend_herdr_pane_agent_state "$session" "$new_pane") + case "$state" in + dead) return 0 ;; + no-agent) ;; + live|unknown) return 1 ;; + esac + fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$new_pane" no-agent || return 1 + [ "$(fm_backend_herdr_pane_agent_state "$session" "$new_pane")" = dead ] +} + +# fm_backend_herdr_projection_reclaim_task: replace one exact agent-free +# restored projection husk inside its original workspace. +# The caller holds the session presentation lock and has already established +# that flat fallback is safe across every token match. +# Return 0 means exact reclaim, 2 means non-mutating or exactly rolled-back +# refusal with flat fallback permitted, and 1 means a live/unknown or +# post-mutation uncertainty that must refuse the launch. +fm_backend_herdr_projection_reclaim_task() { # + local session=$1 journal=$2 id=$3 home=$4 meta_workspace=$5 meta_tab=$6 meta_pane=$7 + local parent_label=$8 task_label=$9 cwd=${10} canonical_home state focus_before active_tab out new_tab new_pane info close_status + FM_BACKEND_HERDR_PROJECTION_TAB_ID="" + FM_BACKEND_HERDR_PROJECTION_PANE_ID="" + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + if [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" != 2 ]; then + echo "warning: herdr presentation journal for $id has no exact restart binding; spawning flat" >&2 + return 2 + fi + canonical_home=$(fm_backend_herdr_projection_home_identity "$home") || { + echo "warning: herdr presentation home for $id could not be resolved exactly; spawning flat" >&2 + return 2 + } + if [ "$FM_BACKEND_HERDR_JOURNAL_HOME" != "$canonical_home" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_SESSION" != "$session" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" != "$meta_workspace" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" != "$meta_tab" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" != "$meta_pane" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" != "$parent_label" ] \ + || [ "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" != "$task_label" ]; then + echo "warning: herdr presentation binding for $id does not match its exact home, endpoint, or parent; spawning flat" >&2 + return 2 + fi + if ! fm_backend_herdr_projection_live_binding_matches \ + "$session" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \ + "$meta_workspace" "$meta_tab" "$meta_pane" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$parent_label" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$task_label"; then + echo "warning: herdr presentation binding for $id has an ambiguous, renamed, foreign, or non-nested live shape; spawning flat" >&2 + return 2 + fi + state=$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane") + case "$state" in + no-agent) ;; + dead) + echo "warning: exact herdr presentation pane for $id is gone; spawning flat" >&2 + return 2 + ;; + live|unknown) + echo "error: exact herdr presentation pane for $id is $state; refusing duplicate launch" >&2 + return 1 + ;; + esac + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { + echo "warning: herdr presentation reclaim for $id could not capture exact focus; spawning flat" >&2 + return 2 + } + active_tab=${focus_before#*$'\t'} + if [ "$active_tab" = "$meta_tab" ]; then + echo "warning: herdr presentation reclaim for $id would replace the active tab; spawning flat" >&2 + return 2 + fi + if ! out=$(fm_backend_herdr_cli "$session" tab create \ + --workspace "$meta_workspace" --cwd "$cwd" --label "$task_label" --no-focus 2>/dev/null); then + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1 + echo "warning: herdr presentation reclaim for $id could not create an exact replacement; spawning flat" >&2 + return 2 + fi + new_tab=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + new_pane=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null) + if [ -z "$new_tab" ] || [ -z "$new_pane" ]; then + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1 + echo "warning: herdr presentation reclaim for $id returned ambiguous replacement ids; spawning flat" >&2 + return 2 + fi + fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1 + info=$(fm_backend_herdr_cli "$session" tab get "$new_tab" 2>/dev/null) || info= + if ! printf '%s' "$info" | jq -e --arg tab "$new_tab" --arg workspace "$meta_workspace" ' + .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace + ' >/dev/null 2>&1; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id could not verify its replacement tab; spawning flat" >&2 + return 2 + fi + info=$(fm_backend_herdr_cli "$session" pane get "$new_pane" 2>/dev/null) || info= + if ! printf '%s' "$info" | jq -e --arg pane "$new_pane" --arg tab "$new_tab" --arg workspace "$meta_workspace" ' + .result.pane.pane_id == $pane + and .result.pane.tab_id == $tab + and .result.pane.workspace_id == $workspace + ' >/dev/null 2>&1; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id could not verify its replacement pane; spawning flat" >&2 + return 2 + fi + state=$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane") + case "$state" in + no-agent) ;; + live|unknown) + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "error: herdr presentation pane for $id became $state during reclaim; refusing duplicate launch" >&2 + return 1 + ;; + dead) + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation pane for $id disappeared during reclaim; spawning flat" >&2 + return 2 + ;; + esac + if fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$meta_pane" no-agent; then + close_status=0 + else + close_status=$? + fi + if [ "$close_status" -ne 0 ]; then + if [ "$close_status" -eq 2 ]; then + return 1 + fi + state=$FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + case "$state" in + live|unknown) + echo "error: herdr presentation pane for $id became $state at the close boundary; refusing duplicate launch" >&2 + return 1 + ;; + esac + echo "warning: herdr presentation reclaim for $id could not close the exact old husk; spawning flat" >&2 + return 2 + fi + if [ "$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane")" != dead ]; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + return 1 + fi + if ! fm_backend_herdr_projection_live_binding_matches \ + "$session" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \ + "$meta_workspace" "$new_tab" "$new_pane" \ + "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$parent_label" \ + "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$task_label"; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id did not converge exactly; spawning flat" >&2 + return 2 + fi + if ! fm_backend_herdr_projection_journal_replace_endpoint \ + "$journal" "$id" "$meta_tab" "$meta_pane" "$new_tab" "$new_pane"; then + fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1 + echo "warning: herdr presentation reclaim for $id could not publish its replacement binding; spawning flat" >&2 + return 2 + fi + FM_BACKEND_HERDR_PROJECTION_TAB_ID=$new_tab + FM_BACKEND_HERDR_PROJECTION_PANE_ID=$new_pane + return 0 +} + # fm_backend_herdr_projection_recovery_allows_flat: inspect an existing # journal's exact token matches without adopting, reusing, renaming, closing, # or deleting anything. @@ -1247,7 +1623,7 @@ EOF done <&2 + echo "warning: quarantined herdr presentation for $id is dead or agent-free; exact bound reclaim may proceed, otherwise spawning flat" >&2 return 0 } diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dee5813..ab93b88 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -136,32 +136,64 @@ fm_backend_tmux_current_command() { # tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null } -# fm_backend_tmux_agent_alive: CONFIDENT liveness of a live harness-agent -# PROCESS in 's pane, distinct from fm_backend_target_exists's -# pane-PRESENCE-only check (a pane that still exists but is sitting at a bare -# idle shell passes THAT check as "alive" - the secondmate-liveness gap -# AGENTS.md's session-start guarantee closes). See docs/tmux-backend.md -# "Agent liveness probe" for the empirical basis. Prints one of: -# alive - the foreground command is one of the verified harness binaries -# (claude, codex, opencode, grok - each confirmed to run as its -# own process name, never wrapped by a generic interpreter). -# dead - the foreground command is a bare shell: nothing is running in -# the pane, so a prior agent process has exited. -# unknown - anything else, INCLUDING a bare "node"/"python" interpreter -# name (pi's own launcher execs into a generic "node" process -# with no reliable way to attribute it back to pi from outside -# the pane - docs/tmux-backend.md "Known gaps"), or an unreadable -# pane. Callers must never treat unknown as a confirmed-dead -# signal (bin/fm-bootstrap.sh's secondmate-liveness sweep gates a -# respawn on `dead` only). -fm_backend_tmux_agent_alive() { # - local target=$1 comm - comm=$(fm_backend_tmux_current_command "$target") || { printf 'unknown'; return 0; } +# fm_backend_tmux_agent_state: recovery-grade harness-agent state for one +# recorded target. See bin/fm-backend.sh's fm_backend_agent_state for the +# shared state vocabulary and docs/tmux-backend.md "Agent liveness probe" for +# the empirical basis. Tmux silently falls back to the active window when a +# named target is absent, so the exact recorded window must appear in a +# successful session inventory before its foreground command can be trusted. +# An omitted window or a definitive missing-session/server response is +# `missing`; any other inventory or pane read failure is `unreadable`, so a +# transient tmux problem never licenses a duplicate. +fm_backend_tmux_agent_state() { # + local target=$1 comm session window windows inventory_status + case "$target" in + *:*:*|'':*|*:'') printf 'unreadable'; return 0 ;; + *:*) ;; + *) printf 'unreadable'; return 0 ;; + esac + session=${target%%:*} + window=${target#*:} + if windows=$(LC_ALL=C tmux list-windows -t "$session" -F '#{window_name}' 2>&1); then + inventory_status=0 + else + inventory_status=$? + fi + if [ "$inventory_status" -ne 0 ]; then + case "$windows" in + *"can't find session:"*|*"no server running on "*|*"error connecting to "*" (No such file or directory)"|*"error connecting to "*" (Connection refused)") + printf 'missing' + ;; + *) + printf 'unreadable' + ;; + esac + return 0 + fi + if ! printf '%s\n' "$windows" | grep -Fqx "$window"; then + printf 'missing' + return 0 + fi + + comm=$(fm_backend_tmux_current_command "$target") || { + printf 'unreadable' + return 0 + } comm=${comm#-} case "$comm" in - '') printf 'unknown' ;; *claude*|*codex*|*opencode*|*grok*) printf 'alive' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; + '') printf 'unreadable' ;; + *) printf 'ambiguous' ;; + esac +} + +# Backward-compatible three-state view for callers that only need a yes/no +# agent verdict. The detailed state contract is owned by fm_backend_agent_state. +fm_backend_tmux_agent_alive() { # + case "$(fm_backend_tmux_agent_state "$1")" in + alive) printf 'alive' ;; + dead|missing) printf 'dead' ;; *) printf 'unknown' ;; esac } diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index a6ddcbc..57b7f65 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -77,9 +77,10 @@ fm_afk_launch_lock_owned() { } fm_afk_launch_lock_acquire() { - local i incomplete=0 identity + local attempt=0 incomplete=0 identity mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 - for i in $(seq 1 200); do + while [ "$attempt" -lt 200 ]; do + attempt=$((attempt + 1)) if mkdir "$FM_AFK_LAUNCH_LOCK" 2>/dev/null; then if ! printf '%s' "$$" > "$FM_AFK_LAUNCH_LOCK/pid"; then rm -rf "$FM_AFK_LAUNCH_LOCK" @@ -257,12 +258,13 @@ fm_afk_launch_terminal_alive() { # } fm_afk_launch_wait_ready() { # - local backend=$1 target=$2 i + local backend=$1 target=$2 attempt=0 if [ -n "${FM_AFK_LAUNCH_ENTRY:-}" ]; then fm_afk_launch_terminal_alive "$backend" "$target" return fi - for i in $(seq 1 100); do + while [ "$attempt" -lt 100 ]; do + attempt=$((attempt + 1)) daemon_lock_held_by_live_daemon && return 0 fm_afk_launch_terminal_alive "$backend" "$target" || return 1 sleep 0.05 @@ -287,8 +289,9 @@ fm_afk_launch_commit_terminal() { # [already-recorde } fm_afk_launch_herdr_recover_created() { #