diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 3c480039dbf..aba6e3fb00c 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -84,7 +84,7 @@ The daemon constructs every current injection as the `away-supervisor` kind owne The bare `FM_INJECT_MARK` form remains accepted for legacy daemon escalations during rollout. U+2063 has no normal keyboard keystroke and survives terminal transport as UTF-8 text. This is how firstmate tells a daemon escalation apart from a real message in the same pane. -The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, 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, pi-signed, grok, and kimi. ## Busy-guard and composer guard @@ -92,14 +92,15 @@ The daemon never injects into an in-use pane. Two checks run before every injection, dispatched through `bin/fm-backend.sh` for the supervisor's own backend (tmux or herdr; see "Auto-discovered supervisor pane" below): -- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader. +- **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature. + This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns. - **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 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. +A busy primary pane, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. **Max-defer escape (the daemon must never silently wedge).** @@ -181,14 +182,14 @@ the operational prefix lets firstmate distinguish it from a real captain message - **Single-line digest** - embedded newlines are collapsed to a literal separator before injection, so submission is unambiguous regardless of harness. -- **Composer guard on the supervisor pane** - before injecting, the daemon checks `pane_is_busy` (harness busy footer means agent mid-turn) and reads `fm_backend_composer_state` directly. +- **Busy and composer guards on the supervisor pane** - before injecting, the daemon runs the detected-primary-harness rendered busy guard and reads `fm_backend_composer_state` directly. Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts. Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell. - The shared composer classifier receives a candidate row only after the active backend performs its own capture and structural row recognition. tmux and herdr route their raw styled candidate rows through the shared `fm_composer_strip_ghost` extractor, which removes dim/faint and dark-TRUECOLOR ghost/placeholder text before classification. They read the composer shape from a separately ANSI-stripped plain row because a dark TRUECOLOR border can be stripped with ghost content. A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same. - `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides busy footers. + `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides the rendered delivery guards plus Grok's isolated task-state fallback. - **Max-defer escape** - the daemon must never silently wedge. If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. If that diff --git a/.agents/skills/ask-user-authority/SKILL.md b/.agents/skills/ask-user-authority/SKILL.md index d4b63d525bf..38761e6d98a 100644 --- a/.agents/skills/ask-user-authority/SKILL.md +++ b/.agents/skills/ask-user-authority/SKILL.md @@ -19,7 +19,9 @@ The concise standing authority boundary remains always loaded in `AGENTS.md` sec 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. +3. Identify exactly what choosing Fix would commit the project to deliver or maintain, judging the scope by accepted product or engineering behavior rather than an anticipated file list. + The smallest downstream changes needed to keep that behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within scope even when they touch files not named at intake. + Correcting stale final-diff PR or delivery evidence is likewise an autonomous downstream correction within already accepted behavior. 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. diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index b2804c7290b..42990edd04f 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -1,6 +1,9 @@ --- name: bearings -description: Generate a "pick up where I left off" status report from firstmate's live fleet state. Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". Reads bounded local fleet state cheaply, optionally checks open PRs when requested, composes a scannable dated report to data/status-report-.md, and surfaces a concise version in chat; it is read-mostly and must not tear down, merge, or mutate task state as a side effect of producing the brief. +description: >- + Generate a "pick up where I left off" fleet digest from firstmate's live fleet state. + Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". + Plain /bearings is chat-only by default, while /bearings file explicitly writes the dated data/status-report-.md artifact; live PR enrichment remains opt-in and composes with file mode. user-invocable: true metadata: internal: true @@ -8,42 +11,58 @@ metadata: # bearings -Generate a complete standalone snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. -The deliverable is a dated markdown file plus a concise chat summary that each stand on the current snapshot rather than an earlier report. -This skill is read-mostly. -It reads fleet state and writes exactly one report file. -It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief - those belong to the captain's explicit word and the normal task lifecycle. +Generate a complete current snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. +Plain `/bearings` returns only the concise four-section chat digest. +Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report. +This skill is operationally read-only in both modes. +It never tears down a task, merges a PR, dispatches new work, steers a worker, answers a decision, cleans up work, mutates backlog or task state, or writes any file except the single dated report in explicit file mode. + +## Invocation modes + +- Plain `/bearings` gathers a fresh bounded snapshot and renders the four-section chat digest without creating, deleting, reading, or replacing `data/status-report-.md`. +- `/bearings file` gathers a fresh bounded snapshot, replaces today's `data/status-report-.md` from scratch, and renders the four-section chat digest with a link or path to that report. +- Treat `file` only as an explicit invocation option in the slash command. +- Do not treat natural-language requests such as "write a report", "save this", "persist it", or "make a file" as file mode unless the invocation explicitly includes the standalone `file` option. +- When the captain asks to include PRs, pass the snapshot command's live-PR opt-in. +- `/bearings include PRs` remains chat-only and makes the live-PR opt-in. +- `/bearings file include PRs` writes the dated report and makes the live-PR opt-in. ## What it does 1. **Gather live fleet state with one deterministic command.** - Run `bin/fm-bearings-snapshot.sh` and read its compact output. - It is the single bounded, deterministic source for this report and renders TOON by default. - Do not hand-probe the snapshot schema and do not make ad-hoc `gh-axi`/`gh` calls to assemble fleet facts; this command already assembles them. + Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output. + It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default. + Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query. The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. - When the captain asks to include PRs, use the command's live-PR opt-in; otherwise keep the default local-only read. - If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. - For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. - Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them. - A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. - The `(main-inventory)` gate is an action-free integrity warning rather than queued work: render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. - -2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.** - The gather step is deterministic; your judgment is scoped to the last mile only - ranking the command's facts by what matters right now and writing the scannable prose. + Keep the default local-only read unless the captain asks to include PRs. + For registered secondmates, use the snapshot's structured-home classification and provenance. + A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. + Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`. + Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. + A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. + Until then it stays queued with the reason. + The `(main-inventory)` gate is an action-free integrity warning rather than queued work. + Render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. + +2. **Compose the four-section chat digest from the fresh snapshot.** + The gather step is deterministic; your judgment is scoped to ranking the command's facts by what matters right now and writing scannable captain-facing prose. + The chat response uses the four complete sections in the chat-response contract below, in the same order, each always present. + Plain mode stops here and writes no report artifact. + +3. **In explicit file mode only, compose and replace the detailed report file.** + The report uses the same four complete sections as the chat, in the same order, and adds the detail the chat omits. Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. - The report uses the same four complete sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: + Write the full report to `data/status-report-.md` using today's date. + If today's file already exists, delete it first, then create a new file from scratch. + This is the only write allowed by the skill. + The detailed report includes: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - - **Underway** - each live direct report making progress, with its current state, and the plans / main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). + - **Underway** - each live direct report making progress, with its current state, and the plans or main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - **Charted Next** - queued or gated work, including any main-inventory integrity warning, with each item's blocker, date, or integrity reason. - -3. **Write the dated report file so it persists, then surface the mandatory four-section digest in chat.** - - Write the full report to `data/status-report-.md` using today's date. - This is the required artifact; it lives in gitignored `data/`. - If today's file already exists, delete it first, then create a new file from scratch. - - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section. + For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but only after the required digest is ready. ## Chat-response contract @@ -62,22 +81,27 @@ Every `/bearings` chat response renders EXACTLY these four sections, in THIS ord Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. -- Every report and chat digest is a complete current snapshot, never a delta against a prior report. +- Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. - The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - 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. +- Include the required direct address to the captain inside one item or empty-state sentence. +- Every PR appears as the full `https://...` URL; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same digest. +- The chat follows `AGENTS.md` section 9 and carries one scannable line per item. +- Detailed decisions, plans, full gate reasons, and evidence belong in the file only when file mode is explicit, so plain chat stays concise and file-mode chat stays materially shorter than that file. +- In file mode, include the report path or link inside the four-section digest without adding another heading. ## Tone and content rules -- This report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names - the captain works with these directly and needs them to resume; keep it organized and scannable, not a raw dump. -- Every PR reference is a full `https://...` URL, never a bare `#number`; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same report. +- The optional file-mode report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names. +- The captain works with those directly and needs them to resume; keep the report organized and scannable, not a raw dump. +- Every PR reference is a full `https://...` URL, never a bare `#number`. - Never include PHI or secret values; the report is an operational artifact, but it is still subject to the same security and compliance rules that govern everything else in this fleet. ## Supervision discipline -This skill is read-mostly and changes no fleet state. -Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. +This skill changes no fleet state. +Do not tear down a task, merge a PR, dispatch queued work, steer a worker, answer a queued decision, clean up work, or mutate any `state/` or `data/` file other than the single report file in explicit file mode. 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 977bc8d71b4..477980b8df1 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -20,14 +20,15 @@ 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 firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). -- `TANGLE: ` - the primary checkout is tangled: stranded on a feature branch instead of its default branch, or holding uncommitted changes to tracked files; `AGENTS.md` section 8 explains why this guard exists and what it protects. - The work is safe; restore the primary per the printed remediation: for a branch tangle, `git -C checkout ` then re-validate that branch in a proper worktree; for dirty tracked files, preserve with `git -C switch -c && git -C commit -a -m wip` or `git -C stash`. - This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive operation that strands nothing. -- `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; continue with the normal fallback chain, resolve and pass the chosen fallback harness explicitly while the file remains present, fix the malformed schema, unverified harness name, unknown selector, or invalid harness/effort pair when convenient, and do not select a bad profile. +- `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. + The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. + This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing. +- `STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ` - the visible startup-memory budget is not a safe one-line positive decimal file; do not infer the default or propagate it. Correct the local primary file, then rerun session start so the normal convergence path can deliver the validated value to secondmate homes. +- `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. @@ -44,7 +45,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[ after ]: ` - 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/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 6ddce665a9f..5db5690ebc9 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -24,8 +24,6 @@ Do not close a hold merely because the originating investigation completed, its The hold remains the authoritative Captain's Call item until the captain's answer is durably recorded, dependent work is created in the same backlog and blocked by that hold, and `bin/fm-decision-hold.sh resolve` routes the answer by clearing those dependency edges before closing the hold. Resolved findings, recommendations that need no captain choice, and prose that merely sounds decision-like do not create holds. Bearings reads the resulting structured state and must never compensate by scraping historical reports, visual-review artifacts, terminal output, chat, or other prose. -The gate trusts firstmate's own operating contract, not a provenance proof: it checks only present, locally checkable state (is the decision still unresolved, did routing error, is durable work discoverable), never who wrote a record or when. -A hold the captain answered and closed outside this tool is durably resolved on that basis alone; there is no separate repair step to reconstruct evidence for it. ## Operating sequence diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index c7126ff3583..ac1da34dd84 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -44,7 +44,7 @@ Every contract - a data format, a state machine, a decision procedure - is state Every other mention of it is a one-line cross-reference, never a restatement. A single deliberate one-line reinforcement at a genuine risk point is allowed, for example a "don't forget X" placed exactly where forgetting X is costly. Restating the contract's substance a second time is not allowed: the two copies will drift the moment only one is edited. -When you touch a contract, grep the repo for its other mentions and update the cross-references, not duplicate the change into a second full copy. +When you touch a contract, patch, replace, or prune the owner's existing language rather than appending a new clause or paragraph wherever possible, then grep the repo for its other mentions and update the cross-references, not duplicate the change into a second full copy. ## Inline-stub pattern @@ -61,7 +61,7 @@ Apply the decision tree above to every line you are about to add to `AGENTS.md`. If an addition needs more than a few lines of conditional detail (detail that matters only in a specific situation) or reference detail (a wire format, an exact schema, historical rationale), you are almost certainly adding it to the wrong file. `AGENTS.md`'s token cost is paid by every session of every fleet member, every time, whether or not that session ever hits the situation the new lines describe. A skill's cost is paid only by the sessions that actually load it. -When in doubt, write the fact into the skill or doc first, and add only the one-line trigger to `AGENTS.md`. +When in doubt, write the fact into the skill or doc first by patching that owner's existing language, and add only the one-line trigger to `AGENTS.md`. ## Trigger hygiene @@ -97,6 +97,7 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou - `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. +- Tests must exercise behavior through an executable or public interface and must never assert implementation-source bytes, including through parsers, regexes, snapshots, or indirect wrappers. - 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 0fd8b709be3..d8d50b07b47 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -13,7 +13,7 @@ It does not replace `AGENTS.md`, `docs/orca-backend.md`, or `harness-adapters`. Orca is a runtime backend, not an agent harness. The runtime backend owns the task endpoint and, for Orca, the task worktree. -The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, or `grok`. +The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, or `kimi`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. Implementation details, metadata fields, teardown guarantees, and limitations live in `docs/orca-backend.md`. diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 762afb8d76b..94beca00a1f 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -4,7 +4,8 @@ description: >- Agent-only playbook for handling X mode mentions and follow-ups. Use on an "x-mention " check wake to read the stashed mention, classify it, act autonomously on eligible requests, reply or dismiss, and link spawned work. Also use on an "x-mode-error ..." check wake to report the X-mode configuration blocker instead of answering a mention. - Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, ending terminal outcomes with --final. + Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, using typed promised-final reconciliation when registered and --final otherwise. + Also use on a "public-followup ..." check wake, and whenever a promised final public reply must be created, reconciled, or delivered. Loaded only when X mode is enabled. user-invocable: false metadata: @@ -56,7 +57,7 @@ How the reply lands depends on whether the work finishes during this turn: The exact resolution and fail-safe posting contract is owned by `docs/configuration.md`. If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. 4. **Follow up on genuine milestones, sparingly.** Firstmate gets up to **three** follow-ups per mention, within a 7-day window, chained in the same thread - spend them only on changes the captain would actually want to hear about (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. - The task's final outcome - shipped / reported / merged / failed - is always posted with `--final`, which clears the link regardless of how many follow-ups remain. + A task without a promised-final commitment posts its final outcome - shipped / reported / merged / failed - with `--final`, which clears the link regardless of how many follow-ups remain. A typed promised-final commitment uses the deterministic consumer instead. That posting happens on the task's milestone and completion wakes (see "Completion follow-up" below), not this turn. So every drained mention sorts into one of three cases (the worthiness judgment, widened): @@ -141,7 +142,7 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin **If the request spawned a real, longer-running task** (you ran `bin/fm-spawn.sh`), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` can copy both the mention's reply platform and explicit budget from the still-present inbox payload without a relay lookup. If that local context is incomplete it uses the durable resolution contract in `docs/configuration.md` and warns loudly, while the follow-up path refuses to post unless both values can be resolved authoritatively. - Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final`. + Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final` when no typed promised-final commitment exists. If the work completed in this turn (a backlog item filed, a question answered), there is no task to link and step 2d reports the outcome directly. d. **Compose the reply.** For a **question**, answer `.text` from the fleet state gathered in step 1. For an **actionable request that completed now**, report the outcome of step 2c (what was done, or - for escalated work - that it has been flagged for the captain). For an **actionable request that spawned a linked task**, acknowledge that you have the order and are on it - milestone updates and the final outcome follow later as completion follow-ups, so do not promise a result you do not yet have. Either way keep it short, in firstmate's voice, and public-safe. Conversation continuity: when `in_reply_to` is present this is a conversation reply - read `in_reply_to.text` (what `in_reply_to.author_handle` said just before) as **context** and continue that thread, resolving "it", "that", "and then?" against the parent; for a fresh mention (`in_reply_to` is null) answer on its own. @@ -198,14 +199,50 @@ This skill's own responsibility during the mention-handling turn is linking the - On each such milestone, firstmate checks whether a follow-up is still due with `bin/fm-x-followup.sh --check ` (prints the `request_id` when the link exists, the count is under the cap, and the window has not lapsed; silent otherwise, pruning an exhausted or expired link). - If due, it composes a short, public-safe update and posts it with `bin/fm-x-followup.sh --text-file ` (or stdin), which posts via the relay's follow-up endpoint; a successful non-final post increments the counter and keeps the link so a later milestone can still post against it. When the update carries one real visual artifact, add `--image `; the helper forwards it to `bin/fm-x-reply.sh --followup` so the same image contract used for ordinary replies applies here too. -- On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file `, which always clears the link after that post regardless of how many follow-ups remain under the cap. +- On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file ` only when no promised-final public commitment is registered for that work. When the promised-final procedure above applies, `bin/fm-public-followup.sh consume` and `deliver` own the terminal reply and clear the legacy link at the validated receipt boundary, so do not call `fm-x-followup.sh --final` for the same outcome. If delivery reports that link cleanup needs reconciliation, do not post anything else; `bin/fm-x-followup.sh --clear ` is the clear-only recovery command in the bound work home. - Every follow-up is held to the exact same public-safety bar as every reply here: outcomes only, no task ids, internals, captain-private material, or secrets. Past the window, past the cap, or on the relay's own rejection of an exhausted binding, a follow-up attempt is skipped silently and the link is cleared - never treated as a failure worth retrying. - If either a follow-up's platform or explicit budget cannot be authoritatively resolved from per-request context, inbox payload, or relay answer, `bin/fm-x-followup.sh` does NOT post it: the fail-safe holds it (the link is kept, exit non-zero) rather than use a local default. This is a retryable hold - a later milestone wake retries it once both values are recoverable. +## Promised final replies (the commitment that must survive compaction) + +The follow-up budget above is a courtesy. +A **promised final reply** - "I'll report back when this lands" - is a commitment, and forgetting it is publicly visible. +Never carry one in your head: the moment you promise a specific outcome in a public thread, turn it into durable state and let the scripts reconcile it. +This section is the sole owner of that procedure. +`tasks-axi public-followup --help` owns the typed obligation, its states, and its file contracts; `bin/fm-public-followup.sh --help` owns firstmate's flags; do not restate either here. + +**When you promise a final:** + +1. Create the typed obligation with `tasks-axi public-followup add` and bind the work with `bind-work`, keeping the public-safe summary and the opaque thread binding in the obligation and the full request context where the poll already put it. +2. Register it with `bin/fm-public-followup.sh register --relation --work-home > --work-id --generation `. + This is what makes the commitment reconcilable without you. +3. Put `bin/fm-public-followup.sh brief ` output straight into the worker's brief. + It prints the exact reporting command for that binding. + Never ask a worker to find the thread or post the reply: only this home holds the relay consent and the thread binding. + +**When work reports back, or on a `public-followup ...` check wake, or when the session-start digest lists a public commitment:** + +1. Run `bin/fm-public-followup.sh consume`. + It reconciles every typed terminal result from disk and prints `ready ` for each commitment that became deliverable. + A refusal prints `rejected : ` and quarantines that event; read the reason rather than re-emitting blindly. +2. For each ready commitment, run `bin/fm-public-followup.sh deliver `. + With no `--text-file` it reuses the accepted terminal outcome exactly, which is the preferred path for a landed result. + Only pass `--text-file` when the outcome genuinely needs composing, and hold it to the same public-safety bar as every other reply here. + Delivery clears the bound task's legacy X link at the validated receipt boundary; if it reports a cleanup failure, use its reconciliation message and do not post a legacy final. +3. Read the outcome and stop guessing at anything it refuses: + - "still waiting on its bound work" means the work has not reported a typed terminal result yet - do not post. + - "recorded as retryable" means nothing was posted; retry on a later wake. + - "held" means the thread's platform or budget is unresolvable right now; retry once it is recoverable. + - "mid-delivery" means a previous post started and its outcome was never recorded. Do NOT deliver again. Establish whether that post landed, then either close it with `record-posted --attempt --chunks ` or escalate. Posting again would put a second reply in a public thread. + - "the relay no longer accepts a follow-up" is a captain decision, not a retry. + +Cleanup refuses while a commitment is still owed for that exact work, so never reach for `--force` to get past it. +Treat a commitment as kept only after a validated posted receipt or an explicit captain waiver. + ## Notes - The direct author is always your own captain (owner-only routing), and in live mode you answer and act on eligible requests **autonomously**: enabling X mode is the captain's standing authorization, so never ask the captain before posting and never hold a worthwhile reply for a chat-side OK. For reply-worthy mentions, dry-run (`FMX_DRY_RUN`) is the only non-posting path; pure acknowledgments use the relay dismiss path instead. -- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. +- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one when no typed promised-final commitment exists (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. - Destructive, irreversible, or security-sensitive asks are flagged to the captain through the trusted channel first and never run straight from a mention; the public reply says only that it has been flagged. - One answered mention = one reply (plus up to three completion follow-ups for a spawned task, spent only on genuine milestones); a skipped mention posts no reply but is **dismissed at the relay** (`bin/fm-x-dismiss.sh`) so the relay drops it rather than re-offering it (which would otherwise churn every poll and end in an "offline" auto-reply). A single wake may cover several pending mentions - drain them all. - Conversations: `in_reply_to` carries the parent post for continuity; a pure acknowledgment with nothing to answer is dismissed at the relay and skipped, not replied to. The relay already guards against self-replies and caps replies per conversation, so you only judge "is there something to answer here?". diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index ed8b6927f3d..360171e4dc9 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, and grok. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, and kimi. user-invocable: false metadata: internal: true @@ -12,7 +12,8 @@ Use this reference before any harness-specific firstmate operation: spawn, recov Crewmates default to the same harness firstmate is running on unless `config/crew-harness` records an adapter name. Optional dispatch profiles in `config/crew-dispatch.json` can override that static default for one crewmate or scout dispatch by selecting concrete harness, model, and effort axes at intake. -The captain may override that file at session start or later; a per-task instruction such as "run this one on " overrides it for that dispatch only. +When a matched rule or default is a profile array, load `quota-array-dispatch` for the completion-aware candidate choice after this skill establishes harness and model/provider facts. +The captain may override that file at session start or later; a per-task instruction such as "run this one on codex" overrides it for that dispatch only. `default` means mirror firstmate's own harness. Secondmates have their own harness knob, so a secondmate can run on a different adapter than crewmates. @@ -25,21 +26,21 @@ If `config/crew-harness` is unset or `default`, there is no concrete value to in Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. Each adapter splits into mechanics and knowledge. -The per-task mechanics, including launch command, autonomy flag, and crewmate turn-end hook, live in `bin/fm-spawn.sh`. +The per-task mechanics, including launch command, autonomy flag, and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. -The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. - -Load `crew-dispatch` for dispatch profile schema, precedence rules, best-fit selection algorithm, secondmate-harness model pinning, and config inheritance details. +The supervision knowledge lives here: busy state, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. +Each adapter's `Busy state` row names only which semantic source that harness uses; `bin/fm-busy-lib.sh` owns the contract itself, including verdicts, source attribution, and the verification gates that keep an unverified harness at unknown. Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. Do not pause current work for that future-verification choice, and never launch an unverified adapter. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection `bin/fm-harness.sh` prints firstmate's own harness, using verified env markers first and then process ancestry. +Within the Pi family, only the exact launch-boundary marker `FM_PI_HARNESS=pi-signed` alongside `PI_CODING_AGENT=true` selects the signed identity; unmarked shared launcher ancestry remains `pi`. `bin/fm-harness.sh crew` resolves the effective crewmate harness from `config/crew-harness` (absent or `default` -> own). `bin/fm-harness.sh secondmate` resolves the secondmate-launch harness through the chain `config/secondmate-harness` -> `config/crew-harness` -> own, so an unset `config/secondmate-harness` matches the crew harness. `bin/fm-spawn.sh` uses `crew` mode for a crewmate/scout launch and `secondmate` mode for a `--secondmate` launch, re-resolving on every spawn so the split is durable across respawns; an explicit per-spawn harness arg overrides either. @@ -52,21 +53,22 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. ## Primary turn-end guard -Every verified primary harness has an empirically validated hook path for the "no turn ends blind" guard. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths 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. +`opencode`, `pi`, and `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks. +Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract. +Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. `docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. When changing any primary turn-end hook, validate the real harness behavior in a scratch project or throwaway home before trusting it, then update that doc and the relevant concise fact below. ## Primary pre-arm (PreToolUse) seatbelt -Every verified primary harness also has a wired PreToolUse-equivalent hook that denies a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. `claude` 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`. +`opencode`, `pi`, and `pi-signed` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. When changing any 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. @@ -89,7 +91,7 @@ Full mechanics, scoping, and fail-open behavior live in `docs/sessionstart-nudge - `claude`: verified native `SessionStart` stdout injection; `.claude/settings.json` matches `startup`, `resume`, and `clear`, but not `compact`. - `codex`: verified on 0.144.4; `.codex/hooks.json` receives `source=startup`, and wrapper stdout reaches model context. - `opencode`: verified on 1.17.18; `session.created` plus `client.session.promptAsync` starts the nudge turn in the TUI, while `opencode run` remains fail-open headless. -- `pi`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. +- `pi` and `pi-signed`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. - `grok`: the 0.2.103 project `SessionStart` event fires with `source=new`, but stdout does not reach model context; the tracked project hook remains fail-open, and a global token-guarded fallback requires a captain decision. ## Primary watcher supervision @@ -99,7 +101,7 @@ Do not substitute another harness's wait shape when resuming supervision. Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. -Pi uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. +Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. ## Launch profile axes @@ -122,8 +124,12 @@ The supported launch-profile flags below are verified locally; each row records | claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | -| pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | +| pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +| kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | + +The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. +No script resolves that split for you: establish which credential store a tuple reads from the discovery surfaces below plus `quota-axi auth --json`'s per-provider sources, and show that reasoning rather than inferring it from a harness, model, or source name. ### Model support discovery @@ -136,10 +142,14 @@ Use the discovery surface in the current authenticated environment because suppo | codex | Open the current interactive session's `/model` picker. | | opencode | Run `opencode models [provider]`, which lists available provider/model identifiers. | | pi | Run `pi --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | +| pi-signed | Run `pi-signed --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | | grok | Run `grok models`, which lists the models available to the current Grok installation and account. | +| kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing from a name or prefix. If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate. +A listing that reaches the account and does not contain the model is concrete evidence the model is unsupported: block that candidate and quote the result. +A discovery surface you could not reach establishes nothing; report that as uncertainty rather than turning it into a supported or unsupported verdict. When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -152,14 +162,21 @@ Natural language is acceptable if uncertain. - claude: `/`, for example `/no-mistakes`. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. -- pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. -- grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) already handles this correctly by reading the cursor row; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. +- pi and pi-signed: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. +- grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) handles this through the structural composer reader; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. +- kimi: `/`, for example `/no-mistakes`. + +## Submission acknowledgement hazards -## claude (VERIFIED) +A send or key action reporting success is not proof that the intended action happened. +OpenCode can accept and queue an Enter while leaving text visible, Grok can consume Enter in its slash popup without submitting, and Kimi can silently drop a message sent before readiness even though the send returns success. +The shared symptom is a healthy-looking pane with no work in progress, so each adapter must verify the observable postcondition that is specific to its TUI. + +## claude (VERIFIED; busy-state hooks live-verified 2026-07-28 on Claude Code 2.1.220) | Fact | Value | |---|---| -| Busy-pane signature | `esc to interrupt` | +| Busy state | Owned lifecycle hooks: `UserPromptSubmit` opens a turn, `Stop`, `StopFailure`, and `SessionEnd` close it. Claude fires no hook for a manual interrupt, so a firstmate-initiated interrupt must record the clear itself. | | Exit command | `/exit` | | Interrupt | single Escape | | Skill invocation | `/` (e.g. `/no-mistakes`) | @@ -189,7 +206,7 @@ Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on | Fact | Value | |---|---| -| Busy-pane signature | `esc to interrupt` (shown as `• Working (Xs • esc to interrupt)`) | +| Busy state | Unknown until a semantic source is live-verified: the app-server turn lifecycle is unreachable for a pane worker, and project lifecycle hooks did not fire for a firstmate-launched worker. | | Exit command | `/quit` (slash popup needs about 1 second between text and Enter; `fm-send` handles it) | | Interrupt | single Escape | | Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | @@ -201,13 +218,9 @@ An explicit `session:window` target has no meta, so its harness is unknown and t This is why the validation trigger (`$no-mistakes`) to a codex crew now lands on the first Enter instead of biting the popup. Directory trust dialog on first run per repo root: "Do you trust the contents of this directory?" -`fm-spawn.sh` now auto-accepts this dialog during its spawn readiness step for isolated worktrees, so a firstmate-supervised crewmate never blocks on it. -If driving a Codex crewmate by hand, accept with Enter. +Accept with Enter. The decision persists for the repo, so later worktrees of the same project skip it. -SessionStart hook-review overlay on codex-cli 0.144.x+: Codex gates on an interactive "SessionStart hooks need review" approval overlay when hooks exist in `~/.codex/hooks.json`. -Firstmate launches crewmates and secondmates with `--dangerously-bypass-hook-trust` (via `fm-spawn.sh`) to skip it, since firstmate vets its own hook sources. - Resume after exit with `codex resume `. The session id is printed on quit. @@ -224,7 +237,7 @@ The checkpoint is deliberately foreground and bounded so Codex regains control r | Fact | Value | |---|---| -| Busy-pane signature | `esc interrupt` (dotted spinner footer; note no "to") | +| Busy state | The Firstmate-owned plugin's semantic `session.status`: `busy` and `retry` are active, `idle` is inactive, latched to the worker's own session. | | Exit command | `/exit` | | Interrupt | double Escape; known flaky while a long shell command runs, so a wedged pane may need `/exit` and relaunch | @@ -257,15 +270,20 @@ Throwing from `session.idle` does not block `opencode run`, so the primary adapt The companion `.opencode/plugins/fm-primary-watch-arm.js` owns normal TUI watcher wake supervision and coordinates with the guard plugin before the guard tries a blind-turn follow-up. The follow-up was verified in the interactive TUI; `opencode run` can exit before displaying a queued follow-up, so the adapter is fail-open in headless mode. -## pi (VERIFIED 2026-06-11) +## pi and pi-signed (VERIFIED 2026-07-27) | Fact | Value | |---|---| -| Busy-pane signature | `Working...` (braille spinner prefix; no `esc to interrupt` text) | +| Busy state | The Firstmate-owned extension's `agent_start` (busy) and `agent_settled` confirmed by `ctx.isIdle()` (idle), which covers retries, compaction, tool loops, and queued continuations. | | Exit command | `/quit` | | Interrupt | single Escape | Pi has no permission system, so crewmates are always autonomous. +`pi-signed` is the signed wrapper identity verified on version 0.82.0 and exposes the same CLI and TUI behavior as Pi. +Firstmate launches the selected executable name from `PATH`, records `pi-signed` without normalization, and refuses rather than falling back to `pi` when that wrapper is unavailable. +The observed signed process tree is an exact `pi-signed` wrapper parent with the Pi application as its child, while tmux reports the foreground command as the exact `pi-launcher` name for both selected executables. +The installed plain `pi` command also execs that signed launcher, so `FM_PI_HARNESS=pi-signed` is the authoritative selection marker and shared unmarked ancestry remains `pi`. +Firstmate sets `FM_PI_HARNESS` explicitly for both worker launch identities, and a signed primary uses the README launch command to establish the same boundary. Keep the brief as one positional argument. Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly. @@ -282,8 +300,8 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. -`bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. -When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. +`bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both the turn-end guard and watcher extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback. +When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. ## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) @@ -293,7 +311,7 @@ For Grok's supported reasoning-effort values and omission behavior, see the [lau | Fact | Value | |---|---| -| Busy-pane signature | `Ctrl+c:cancel` (the mid-turn cancel hint in grok's keybind bar, shown iff a turn is running; the spinner line is a braille glyph + `… N.Ns` + `[stop]`, e.g. `⠹ Thinking… 1.1s … [stop]`). Idle keybind bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. The ASCII `Ctrl+c:cancel` is the busy regex (avoids locale fragility of matching braille). | +| Busy state | The one remaining rendered-tail fallback, isolated to Grok until its structured lifecycle is live-verified: `Ctrl+c:cancel`, the mid-turn cancel hint shown in grok's keybind bar iff a turn is running. The idle bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. ASCII is matched rather than the braille spinner to avoid locale fragility. | | Exit command | `/exit` typed into the composer exits the TUI cleanly and prints `Resume this session with: grok --resume `; `Ctrl+Q` double-press within 1000ms remains a fallback; `Ctrl+D` is the quit key in VS Code family terminals; `Ctrl+C` is the interrupt, not the exit. | | Interrupt | single `Ctrl+C` (cancels the current turn; the footer shows `Ctrl+c:cancel` mid-turn). `Esc` only moves focus to the scrollback, it does NOT interrupt. | | Skill invocation | `/` (e.g. `/no-mistakes`), same as claude. Opens a slash-autocomplete popup, so a too-fast Enter selects the popup entry instead of sending. For an argument-taking command that first Enter does not submit at all - it expands the selection into an argument-hint placeholder in the composer (e.g. `/compact` -> `/compact compaction instructions`, live-verified), leaving real text still sitting there unsubmitted; a genuine second Enter is required. `fm-send`'s retried Enter lands it on BOTH backends, but only because each backend's own submit-verification correctly recognizes that placeholder-filled text as still-pending - see the incident below. | @@ -303,8 +321,8 @@ 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` "Composer and injection safety" for the current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. +The tmux backend's structural `fm_tmux_composer_state` read sees placeholder-filled text on any content row as still pending, so its retry loop sends the needed second Enter. +The Herdr adapter (`fm_backend_herdr_composer_state`, `bin/backends/herdr.sh`) classifies the composer's own row structurally instead of diffing raw content; see `docs/herdr-backend.md` "Composer and injection safety" for the current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. Startup dialog: the "Run Grok Build in a project directory?" project picker appears ONLY when grok is launched from a non-project directory (home, Desktop, Downloads, `/tmp`). `fm-spawn` launches inside the treehouse worktree (a git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. @@ -317,11 +335,10 @@ Verified live against grok 0.2.93: real input is the bright `38;2;224;222;244` ( This assumes a dark terminal theme, the fleet reality; the SGR-2 signal stays theme-independent. Regression coverage: `tests/fm-composer-ghost.test.sh` (`test_strip_ghost_drops_dark_truecolor_ghost`, `test_dark_truecolor_ghost_only_composer_is_not_pending`) and `tests/fm-backend-herdr.test.sh` (`test_composer_state_grok_dark_truecolor_placeholder_is_empty`, `test_composer_state_grok_bright_truecolor_real_text_is_pending`). -**Residual gap, tmux-only (unfixed):** -in that same pristine placeholder-only state, tmux's own `#{cursor_y}` points at the composer box's BOTTOM BORDER row, one row below the actual text row (the box appears to render one row lower before any real typing starts); once real text is typed the cursor correctly aligns with the text row again. -This is a row-SELECTION quirk, orthogonal to the styling fix above, and affects only the tmux path (herdr uses a structural composer-row scan, not `cursor_y`, so it is unaffected). -A correct fix needs a row-window read near `cursor_y` rather than the single `cursor_y` row. -In practice `fm-spawn` launches grok with the brief as its initial prompt, so a live task's composer is never observed in this pristine pre-typing state - but this is unverified for every path (e.g. a steer sent before grok's first real turn settles) and needs dedicated investigation before relying on it. +**Tmux bottom-border cursor quirk (fixed):** +In a pristine placeholder-only composer, tmux's `#{cursor_y}` can point at the box's bottom border instead of its text row. +The shared tmux reader now locates the complete box structurally and classifies every content row, so the cursor may sit on a content row or the bottom border without changing the result. +The same structural read covers multi-row composers without fixed cursor offsets, while Herdr retains its own structural composer-row scan. Turn-end hook: grok fires a `Stop` hook at every turn boundary, giving firstmate a precise per-turn wake instead of only stale-pane detection. grok loads PROJECT hooks (`/.grok/hooks/`, `/.claude/settings.local.json`) only after the folder is granted hook-trust in `~/.grok/trusted_folders.toml`, which is not automatic and which firstmate will not establish by editing grok's own managed trust store. @@ -334,10 +351,51 @@ This keeps the hook outside the worktree, needs no trust grant, and writes only `fm-teardown` removes the worktree pointer before returning a pooled worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). -**Primary-session guard fact (verified 2026-07-08, Grok 0.2.91).** +**Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** The firstmate PRIMARY's own `.grok/hooks/fm-primary-turnend-guard.json` invokes `bin/fm-turnend-guard-grok.sh`. -Grok Stop hooks are passive for this purpose: exit 2 does not make the model continue. -The adapter therefore runs the shared predicate and, when it returns 2, forces one same-session follow-up with `grok --resume -p ` while setting `GROK_TURNEND_GUARD_ACTIVE=1` so the nested Stop hook does not recurse. -It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions. +Grok 0.2.112 exposes native same-process Stop continuation in its running payload, while the genuine pre-native 0.2.73 payload omits that capability and still needs one guarded `grok --resume`. +The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. +The tracked Claude Stop hooks skip themselves under `GROK_AGENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. -Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. +Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. + +## kimi (VERIFIED 2026-07-25, kimi 0.29.1) + +Kimi Code CLI launches from the absolute path resolved from `PATH`, falling back to the executable `$HOME/.kimi-code/bin/kimi`. + +| Fact | Value | +|---|---| +| Binary | Executable `kimi` from `PATH`, then executable `$HOME/.kimi-code/bin/kimi`; spawning refuses if neither exists. | +| Launch | Bare interactive TUI with `--auto`, followed by readiness-gated pointer delivery; positional prompts are rejected. | +| Models | `kimi-code/kimi-for-coding` (default), `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`, and `kimi-code/k3-256k`. | +| Busy state | Standalone Kimi is unknown until a semantic source is live-verified; prefer Wire's `prompt` request lifetime, then documented hooks including `Interrupt`. Kimi behind Pi uses Pi's lifecycle. Its moon-phase spinner is not a state source. | +| Exit command | `/exit` | +| Interrupt | Single Escape, which prints `Interrupted by user`. | +| Skill invocation | `/`, for example `/no-mistakes`; firstmate skills are discovered. | +| Autonomy | `--auto`; `-y` and `--yolo` are weaker and are not used. | +| Trust dialog | None on a clean first launch in a fresh pooled worktree. | +| Slash submission | One Enter submits, with no popup swallow or settle hazard. | +| Environment marker | None; detection relies on process ancestry command name `kimi`. | +| Composer | Bordered box with a bare `>` prompt glyph and no observed ghost or placeholder text. | +| Effort | No reasoning-effort flag exists, so requested effort is recorded in task metadata but omitted from launch. | + +`fm-spawn.sh` launches Kimi bare, waits for the composer box or `Welcome to Kimi Code!`, sends only `Read the brief at and follow it exactly.`, and requires a cleared composer plus either the echoed `✨` submission or nonzero context before accepting delivery. +This launch-then-send shape is mandatory because Kimi rejects a positional brief as an unknown command. +Sending before readiness was reproduced as a silent drop with a zero exit status, an empty composer, `context: 0%`, no echoed user message, and a healthy-looking idle pane. +The brief path must be absolute because the brief lives outside the task worktree, and Kimi reads it there without `--add-dir`. + +Observed live spinner captures included optional leading whitespace, a moon-phase glyph, whitespace around `·`, and rotating tip text, with the same shape observed during tool execution. +Because every captured spinner row had whitespace on both sides of `·`, the matcher requires that whitespace, deliberately does not match the never-observed zero-whitespace form, and does not require trailing tip text. +The startup input-readiness window is the established cause of Kimi's first-Enter delivery defect, while the banner is not the cause. +An early Enter can expand Kimi's composer to multiple content rows, leaving the pointer text on the first row and the cursor on an empty later row, which is the same single-cursor-row reading defect exposed by Grok's bottom-border cursor quirk. +The shared tmux reader now locates the complete bordered composer and treats real text on any content row as positive evidence that submission is still pending. +No rendering signal is trustworthy for proving that Kimi will accept input during this window, so delivery retries Enter through the shared submit core and retains the existing postcondition verification rather than relaxing readiness or delivery checks. +Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, which is one reason no Kimi rendered signature is a state source. +The idle status bar can contain lowercase `thinking`, which is the model's effort label rather than a busy signal. +The delivery-only spinner match covers the full moon-phase glyph set rather than one frame, but it remains locale- and emoji-font-sensitive because Kimi exposes no stable ASCII busy token. + +[`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns Kimi's verified global hook surface and captain-approved crew wake integration. +`fm-spawn.sh` installs one marker-delimited Firstmate entry in `$HOME/.kimi-code/config.toml`, one silent always-zero hook script, and one private token registry under `$HOME/.kimi-code/fm-turn-end.d/`. +Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. +A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. +The guarded turn-end signal remains a wake notification; standalone Kimi has no busy-state source until one is live-verified. diff --git a/.agents/skills/pr-merge-board/SKILL.md b/.agents/skills/pr-merge-board/SKILL.md new file mode 100644 index 00000000000..cb115ebf00e --- /dev/null +++ b/.agents/skills/pr-merge-board/SKILL.md @@ -0,0 +1,189 @@ +--- +name: pr-merge-board +description: >- + Agent-only reference for regenerating the captain's Notion PR merge board from live fleet state. + Load when the captain asks to update the merge board, or when a session that touches tracked + PRs starts or ends per the captain's standing instruction to keep the board current. + Reads fleet state, derives confidence scores with stated methods, and writes to the captain's + personal Notion workspace via the claude_ai_Notion MCP connector. +user-invocable: false +metadata: + internal: true +--- + +# pr-merge-board + +Regenerate the captain's Notion PR merge board from live fleet state. +This skill is read-mostly: it reads fleet state and writes to one Notion page. +It never tears down a task, merges a PR, or dispatches new work as a side effect. + +## Notion page + +The captain's PR merge board lives in his personal Notion workspace: +`https://app.notion.com/p/3acaedb38c8b81a59b3afc9ddbdb77a2` + +The page ID is `3acaedb38c8b81a59b3afc9ddbdb77a2`. + +Use the `claude_ai_Notion` MCP connector (tool names like `mcp__claude_ai_Notion__*`) to read and write this page. +This is the captain's OWN Notion workspace - never use `notion-aide`, which is a separate Aide-workspace connector serving a different workspace. + +## Standing captain instructions + +These are baked into the procedure and must be followed every time: + +1. Keep the board current: regenerate it from live state at the start and end of any session that touches tracked PRs. +2. Always show the pipeline's OWN risk value next to firstmate's confidence - they are two different things and must both appear. +3. Never present a confidence score as a bare number. + State the method behind it alongside the score. +4. Reassess a PR's confidence after any PR that corrects it merges into its branch. +5. A stacked PR that can only be meaningfully evaluated after everything else lands stays unassessed until then - do not force a premature confidence score on it. + +## Risk-value capture design + +The no-mistakes review stage emits a risk level (`critical`, `high`, `medium`, `low`) at gate time, BEFORE the PR is created. +Post-hoc log grepping (`~/.no-mistakes/logs//review.log`) is unreliable because not every run writes a risk line to the log. +The risk must be durably stashed at the moment it is emitted so a later board-regeneration pass can read it back. + +### Stash point + +`state/.meta` field `risk=` where value is one of `critical`, `high`, `medium`, or `low`. +This is the same key-value metadata store that already holds `pr=`, `pr_head=`, and other task-level facts. +It survives task teardown and is readable by firstmate at any time. + +### Capture mechanism + +When firstmate triggers no-mistakes validation on a crewmate, firstmate instructs the crewmate to capture the risk value at the review gate: + +> When the review gate completes and no-mistakes emits a risk level, append `risk=` to `/state/.meta`. +> Do this at gate time, immediately after the review stage emits the value. +> The value is `critical`, `high`, `medium`, or `low` exactly as no-mistakes reports it. + +The crewmate, already driving the no-mistakes gate loop, has the risk value in the gate output and writes it to the meta file before proceeding to subsequent stages. + +### Reading at board-regeneration time + +Read `state/.meta` and extract the `risk=` line. +If absent (pre-existing task, or crewmate did not capture it), the board shows `—` in the risk column rather than guessing or grepping logs. + +## Data sources + +The board is assembled from these read-only sources, in order of primary reliance: + +1. **Fleet snapshot** - `bin/fm-fleet-snapshot.sh --json` for structured task state, PR URLs, task ids. +2. **Task meta files** - `state/.meta` for `risk=`, `pr=`, `pr_head=` fields. +3. **GitHub PR checks** - `gh-axi pr view --json statusCheckRollup` for forge check counts. + `gh-axi` owns exact flags; `gh-axi --help` is authoritative. + Run one `gh-axi` call per PR; batch calls where the tool allows. +4. **Backlog** - `data/backlog.md` or `tasks-axi` for ticket references and dependency chains. +5. **Status logs** - `state/.status` tail only for the most recent `done:` or `merged` line, read as wake-event history not current state. + For current state, prefer `bin/fm-crew-state.sh ` per the supervision contract. + +Never re-derive pricing from raw `state/*.meta` files or fabricate a new fleet reader when `bin/fm-fleet-snapshot.sh` already assembles the structured contract. + +## Board table schema + +The Notion page contains a single markdown table with these columns: + +| Ticket / PR | Pipeline Ran | Pipeline Risk | Checks | Confidence | Merge Order | + +Column meanings: + +- **Ticket / PR**: The backlog ticket reference and the full PR URL (`https://...`). +- **Pipeline Ran**: Yes/no - whether no-mistakes validation completed. + If the PR was created without a pipeline run (direct-PR, or captain held it), show "not run". +- **Pipeline Risk**: The pipeline's OWN risk value, captured at gate time per the risk-value capture design above. + Show `—` if not captured. + This is NOT firstmate's confidence - it is a separate column. +- **Checks**: Forge check counts from `gh-axi pr view`. + Show `M/N passing` (M green checks out of N total). +- **Confidence**: Firstmate's assessment with the method stated. + See the confidence method section below. +- **Merge Order**: The recommended merge sequence. + See the merge-order section below. + +## Confidence method + +Confidence is a firstmate judgment, not a tool output. +Always state the method alongside the score so the captain knows how it was derived. + +Consider these factors, weighting pipeline evidence heaviest: + +1. **Pipeline risk** (heaviest weight): the no-mistakes review stage risk level. + `low` strongly increases confidence; `critical` or `high` strongly decreases it. +2. **Check health**: all CI checks green vs some failing or pending. +3. **PR scope and surface**: small, focused changes increase confidence; changes touching auth, security, secret management, data migration, or infrastructure provisioning decrease it. +4. **Correction status**: if this PR corrects a prior merged PR, the confidence is contingent until the correction lands and is verified. + Mark as "reassessed after correction merge" rather than assigning a final score until then. +5. **Stack status**: if stacked on an unmerged PR, mark as "unassessed (stacked)" until the dependency lands. + Do not force a premature score. + +Produce a 5-level rating: Very High, High, Medium, Low, Very Low, or one of the two special markers above. +Always append a one-line statement of the method: which factors drove the rating. + +Example: "Medium - pipeline risk low but PR touches auth layer and is stacked on #22 (unassessed pending that merge)." + +## Merge order + +Merge order is a firstmate judgment derived from: + +1. **Dependency chain**: if PR B depends on PR A landing first, A comes before B. + Detect dependencies from the backlog's `blocked_by` fields and from stacked-branch relationships in `pr_head=` ancestry. +2. **Ready order**: among independent PRs, prefer oldest-first by PR creation date. +3. **Risk priority**: the captain may choose to merge lower-risk PRs first as a risk-management strategy. +4. **Captain override**: the captain can reorder at any time. + The skill should prompt firstmate to present the derived order and ask whether the captain wants changes. + +If no clear order emerges, number the independent ready PRs by creation date and note that the order is provisional. + +## Procedure + +1. **Gather live state.** + Run `bin/fm-fleet-snapshot.sh --json` and filter to tasks with `pr.url != null`. + These are the PRs tracked in the fleet. + For each, read `state/.meta` for `risk=` and `pr_head=`. + +2. **Collect check counts.** + For each PR, run `gh-axi pr view --json statusCheckRollup` and extract the check counts. + If a PR is from a different forge or `gh-axi` cannot reach it, note the limitation in the board. + +3. **Read the current board.** + Use `mcp__claude_ai_Notion__retrieve_page` (or the equivalent retrieve/read tool for the `claude_ai_Notion` connector) with the page ID `3acaedb38c8b81a59b3afc9ddbdb77a2` to get the current page content. + This preserves any captain annotations outside the table. + +4. **Assemble the new table.** + For each tracked PR, fill the cells per the schema above. + Apply the confidence method, noting `unassessed (stacked)` or `reassessed after correction merge` where applicable. + +5. **Derive merge order.** + Apply the merge-order rules. + Present the derived order to the captain for review before writing. + +6. **Write the page.** + Use `replace_content` with the full page body - never `update_content` search-and-replace for table changes. + Notion stores markdown tables as block structures, and `update_content` search-and-replace silently fails to match table rows. + Only `replace_content` with the complete page body reliably updates the table. + Preserve any non-table content from the existing page. + +7. **Report.** + Tell the captain the board is current, list the PRs included, and highlight any that need a decision (pipeline not run, confidence reassessment needed, stacked PR waiting on dependency). + If `risk=` was absent for any PR, note it but do not attempt to recover it from logs. + +## Editing mechanics (do not rediscover) + +- Notion stores markdown tables as block structures. +- `update_content` search-and-replace **silently fails to match table rows** - it looks like it worked but the table does not change. +- Use `replace_content` with the whole page body whenever the table changes. +- Read the existing page first to preserve captain annotations outside the table. + +## Connector warning + +The `claude_ai_Notion` connector serves the captain's OWN personal Notion workspace. +The `notion-aide` connector serves a separate Aide workspace. +Never route board reads or writes through `notion-aide`. +If only `notion-aide` tools are visible, report the connector mismatch to the captain - do not fall back or write to the wrong workspace. + +## Supervision discipline + +This skill is read-mostly and writes only to the Notion page. +It never tears down a task, merges a PR, dispatches queued work, or mutates any `state/` or `data/` file other than the risk capture described above. +If the state you read suggests an action - a PR ready to merge - name it in the report to the captain and leave the merge to the normal lifecycle and configured authority. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index af35d469ee2..f4c62a57901 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -3,6 +3,7 @@ name: project-management description: >- Agent-only procedure for Firstmate project management. Use before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. Owns project add, create, clone, remove, initialization, registry, delivery-mode, autonomy, and outward-consent decisions. user-invocable: false metadata: @@ -12,6 +13,7 @@ metadata: # project-management Use this procedure before adding, creating, removing, or initializing a project. +Cloning or registering a project is add intake and uses the same trigger. This skill is the single owner of Firstmate's project-management procedure. It does not replace `secondmate-provisioning`, which owns project clones inside persistent secondmate homes. @@ -22,6 +24,11 @@ Use the registry format and parser contract owned by the header of `bin/fm-proje Keep each registry description useful for identifying the project, but keep delivery posture, captain-private state, and detailed project knowledge in their existing designated homes. Do not turn the registry into project documentation. +Before adding, cloning, creating, or registering any project in the main home, inspect the authoritative `data/secondmates.md` routing table and judge every existing natural-language `scope:` against the proposed project or domain. +Apply `AGENTS.md` section 7's authoritative secondmate routing rules; if an existing scope owns that domain, route the new-project operation or work there instead of creating or registering a duplicate main-home clone. +Absence from the main `data/projects.md` registry is never evidence that no second mate owns the domain. +If the owning second mate cannot accept the route, report that concrete blocker or obtain an explicit captain redirection rather than silently duplicating the project in the main home. + Resolve the project name, destination, delivery mode, and autonomy posture before changing local or remote state. Keep a newly added clone and its registry entry consistent, and roll back only artifacts created by the incomplete operation when a later initialization step fails and that rollback is safe. Do not overwrite or repurpose an existing path. @@ -70,9 +77,9 @@ If doctor reports an environment, authentication, or daemon problem, resolve tha ## Remove -Project removal is destructive and is not one of Firstmate's current direct-write exceptions under `projects/`. -Never issue a raw removal command from Firstmate. +Project removal is destructive. First obtain the captain's explicit removal decision, then inspect the current digest and authoritative repositories for in-flight or queued work, registered secondmate clones, linked worktrees, dirty files, unpushed commits, and any other unlanded work. -If any dependency or unlanded work exists, stop and report it before changing the registry. -Until a guarded removal helper and corresponding prime-directive exception exist, report that implementation gap instead of bypassing the project-write boundary. -When a clone has already been removed through an approved guarded path, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. +If any dependency or unlanded work exists, stop and report it before changing anything. +Never issue a raw removal command from Firstmate. +Once that preflight confirms none of the above and the captain's approval is concrete, AGENTS.md hard rule 1's captain-approved project operation exception authorizes firstmate to remove the clone directly and update its registry entry to match. +When a clone has already been removed through an approved removal, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md new file mode 100644 index 00000000000..11b84058125 --- /dev/null +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -0,0 +1,113 @@ +--- +name: quota-array-dispatch +description: >- + Agent-only decision procedure for resolving a matched crew-dispatch profile + array from current quota-axi output, including effective headroom and usable-runway evidence. + Load when a dispatch rule or default resolves to more than one profile candidate. +user-invocable: false +metadata: + internal: true +--- + +# quota-array-dispatch + +This skill is the single owner of the completion-aware profile-array selection procedure. +`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. +`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. +`quota-axi` remains data-only, reports whatever granularity the vendor supplies, and never recommends, selects, ranks, or infers a route. +Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. +Deterministic shell owns only schema, configuration, and version validation plus concrete spawn safeguards; every model-to-provider, provider-to-credential, and quota-applicability relation is yours to establish transparently and to show your evidence for. + +## Collect facts + +Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +Do not take a second snapshot to settle a candidate, and read `quota-axi auth --json` when a candidate's credential surface is in question. +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: + +- task/profile fit and required reasoning class +- applicable effective headroom (`effectivePercentRemaining`) from the established provider/model scope +- usable runway status, `usableRunwaySeconds`, `projectedExhaustedAt`, `limitingWindowId`, `projectionConfidence`, `projectionBasis`, and any `unmeasurableWindowIds` +- the task-completion horizon and the evidence and confidence used to estimate it +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) for later diagnostic tie-breaking +- schema notes when runway or pace fields are absent + +Stale raw windows are diagnostic, never headroom or fabricated runway. +Grok's `credits.remaining` is a prepaid balance unrelated to `percentRemaining`; never read it as exhaustion. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, and `unmeasurableWindowIds`. +The compact default output intentionally omits numeric reserve, while `--json` and `--full` retain reserve diagnostics. + +## Establish the provider relation before reading quota + +Deterministic shell must never map a model to a provider, a provider to a credential store, or a name prefix to a family. +You establish those relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. +Name the evidence for each relation you assert so the conclusion is inspectable. + +1. Confirm the catalog lists the candidate's model and record the provider family it reports. + A model the authoritative catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. +2. Apply quota at the granularity the vendor actually supplies. + A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. + A named-model or named-product scope is an additional bound for that model alone and is irrelevant to every other model in the family. + Read `quotaSemantics.description`, which states the vendor's own bounding rule. +3. Record what remains unknown instead of converting it into a verdict. + +## Authentication is scoped to the selected surface + +A candidate authenticates through its own tuple's surface; another harness's CLI can never gate it, and `harness=pi` with `model=xai/grok-*` is Pi using xAI rather than the standalone Grok CLI. +`quota-axi auth --json` lists each provider's credential sources independently, so read the one source the candidate actually uses rather than collapsing a provider to a single status. +A provider can carry a healthy source beside a missing or expired one; the unused source's state is not the candidate's state. +A Pi-hosted family may authenticate through the vendor's own store with no `pi:`-prefixed source at all, which is normal and never evidence against the candidate. + +Uncertainty and ineligibility are different findings: + +- No model-level window, no matching auth source, an absent `state.authStatus`, an unmeasurable or `unknown` scope, or a surface quota-axi does not model at all is disclosed uncertainty. + Keep the candidate eligible, state the unknown, and prefer known sustainable evidence when otherwise comparable. +- An expired credential is a short-lived session token the owning vendor renews on next use, not a sign-out. +- Only concrete contradictory evidence blocks: an authoritative catalog proving the model unsupported, or proof that the credential the candidate actually selects is unusable. +- Reserve login wording for that proven-unusable case, and name the harness, model, surface, and evidence. + +When a credential's local classification is the only thing standing between a candidate and a block, get ground truth before blocking. +`bin/fm-vendor-auth-probe.sh` is the only approved vendor-credential probe; its `--help` owns the registered probes and mechanics. +It takes no harness, model, or provider and returns a fact, not a route: only `authenticated` and `unauthenticated` are ground truth, while `indeterminate`, `timeout`, and `unavailable` establish nothing and must never be read as either outcome. +Never launch a vendor CLI yourself, and never probe a credential store the candidate does not use. + +## Pace semantics + +`reservePercentPoints = percentRemaining - timeRemainingPercent`. +Negative reserve means usage is ahead of reset pace and creates conservation pressure. +Positive reserve means usage is behind reset pace. +`on_pace` is neutral. +Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. + +## Selection order + +Apply only among candidates satisfying required fit and strongest reasoning class. +Never use headroom, runway, pace, or reserve to silently replace that reasoning class. + +1. Concrete contradictory evidence or malformed configuration: stop and report the tuple and that evidence. + Unmeasurable quota, a missing model-level window, an absent runway field, and a credential surface quota-axi does not model are uncertainty, never this rule. +2. Honor any explicit captain instruction that sets a floor for that candidate before the generic comparison. + Do not invent a generic percentage floor or treat a low percentage as an automatic failure. +3. Keep the strongest-reasoning class when every candidate is tight or completion evidence is poor. + Dispatch inside that class when a candidate can proceed, or report that its strongest-class choice cannot proceed rather than downgrading it to conserve quota. +4. Compare comparable-fit candidates on their applicable effective headroom and usable runway. + Eliminate a candidate only when another candidate Pareto-dominates it on both dimensions, with at least one dimension strictly better. + Establish dominance only from comparable known evidence, never by treating absent, `unknown`, or unmeasurable headroom or runway as zero or as a healthy value. +5. Prefer supported runway evidence that projects availability through the inspectable likely-completion horizon. + Known evidence that does not reach that horizon is inferior to known evidence that does, even when its signed reserve is less negative. + Preserve projection confidence and basis, the limiting window, and the horizon estimate in the rationale rather than hiding them in a score or model-specific heuristic. +6. Resolve remaining uncertainty explicitly. + An authenticated candidate with unknown or unmeasurable headroom or runway stays eligible and cannot be silently excluded or assumed sustainable. + Prefer known viable evidence when otherwise comparable, and report uncertainty or ask the captain when it still prevents a justified choice. +7. Use pace and signed reserve only as later diagnostic tie-break evidence among candidates still unresolved after headroom, runway, likely-completion viability, and uncertainty. + Pace and reserve never rescue a clearly inferior completion prospect. + Do not collapse these facts into an opaque composite score. +8. Older schemas or absent runway/pace fields: do not crash, fabricate runway or pace, treat absence as healthy, or silently exclude a candidate. + State which evidence is unavailable, retain the candidate, and apply only the comparisons the snapshot supports. +9. Genuine ties: stop and report every tied candidate for captain choice. + Do not select by array order, harness name, or another arbitrary identity ordering. + Report duplicate concrete profiles as a configuration error. + +Account for every candidate visibly before selecting or escalating, naming its catalog evidence, provider relation, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, effective headroom, usable runway, likely-completion reasoning, and later pace or reserve evidence when used. +A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. +Never conclude with an unexplained "best quota" label. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index cd69b55dfd4..978f4a954aa 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -17,13 +17,14 @@ Keep the always-inline routing rules in `AGENTS.md` authoritative: route by natu ## Routing table -`data/secondmates.md` has one parser-compatible line per persistent domain supervisor: +`data/secondmates.md` has one parser-compatible line per persistent second mate: ```markdown - - (home: ; scope: ; projects: , ; added ) ``` Each registry entry stays concise and single-line: the summary is one sentence naming the durable charter, `scope:` is the natural-language intake responsibility, `projects:` is the non-exclusive clone list, and any extra prose is limited to genuinely domain-specific hard rules that change routing or safety for that secondmate. +Natural-language summary and `scope:` text may contain parentheses and semicolons; keep the generated `(home: ...; scope: ...; projects: ...; added ...)` suffix intact so operational consumers resolve its explicit field markers. The `home:` path points to the seeded home containing `data/charter.md`; no extra registry pointer field is needed. The home-seeded `data/charter.md` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts, so point to that charter rather than restating those contracts in the registry entry. The `scope:` field is used during intake. @@ -78,10 +79,13 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. +Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. +A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. +Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. @@ -96,7 +100,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. @@ -113,7 +117,7 @@ It uses the same live-home discovery and propagation helper as bootstrap, report `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. Direct seed without a preexisting brief requires `FM_SECONDMATE_CHARTER`. -Run `bin/fm-home-seed.sh validate` when checking registry integrity; it refuses duplicate ids, duplicate homes, and nested or overlapping homes. +Run `bin/fm-home-seed.sh validate` when checking registry integrity; its header owns the complete validation and refusal mechanics. Seeding is transactional. If validation, cloning, no-mistakes initialization, or registry update fails, generated briefs, new homes, new project clones, and registry edits are rolled back. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 4c2c2a337ae..672894bd56d 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -10,56 +10,76 @@ metadata: # stow -Sweep this session for durable knowledge that only exists in conversation right now, and write it to the disk locations firstmate already prints in the next session-start context digest. -The goal is a session that is safe to reset or destroy because everything durable has already been captured. +Sweep this session for durable knowledge that exists only in conversation, then leave the next session with a compact current operating map rather than an accumulating journal. +This skill writes only through the existing Firstmate ownership and write boundaries. -## What it does +## Required startup-memory pass -1. **Sweep the session for uncaptured durable knowledge.** - Read back over this conversation and look for: - - Operational learnings: fleet-local facts and gotchas discovered while operating firstmate (a script's sharp edge, a harness quirk, a recurring false alarm and its real cause). - - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through the destination selected by AGENTS.md's knowledge-routing table. - - Project-intrinsic facts discovered: build, test, release, or architecture facts about a project that belong in that project's own `AGENTS.md`. - - Decisions made: a standing choice the captain made this session that should outlive it. - - Undone next steps: anything left open that has not yet been filed as backlog work. +Every `/stow` invocation performs this complete pass, even when the session contains no new finding: -2. **Route each finding using AGENTS.md's knowledge-routing table.** - AGENTS.md (section 6, "Knowledge routing") is the single source of truth for where each kind of knowledge belongs. - Read that table and route each finding there instead of re-deriving the mapping here. +1. Run `bin/fm-startup-memory-budget.sh report` before considering a write. + Record its effective budget and each file's estimated-token total. + The helper's stable estimate is the documented conservative local approximation, not provider-exact accounting. + If it rejects the setting or a memory file, do not infer a default or silently continue. + Report that concrete exception and do not call the session reset-safe. +2. Read every current memory file completely: `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`. + Treat an absent local file as absent, not as an invitation to manufacture content. + In a primary home, all three are curation inputs under their existing ownership rules. + In a secondmate home, `data/captain-shared.md` is a read-only primary-owned input: count it, never edit it, and curate only the editable local files. +3. Build one whole-file retention plan before editing. + Retain, in order: current captain preferences, authority and safety boundaries, and recurring working style; stable home-local operating facts that repeatedly affect future work and are expensive to rediscover; then concise pointers to an existing authoritative report, project document, configuration, or backlog item. + Retain lower-priority material only while budget remains. +4. Consolidate every editable memory file as needed, not only the file apparently related to a new finding. + Prefer one concise current rule or authoritative pointer over duplicate prose. + Remove, merge, or route completed incident and release chronology, stale versions and paths, transient task state, resolved alternatives, old metrics, superseded claims, duplicates, and report-sized procedures. + Do not remove a unique current fact unless it is preserved directly elsewhere through a stronger existing owner. +5. Run `bin/fm-startup-memory-budget.sh report` again after the complete pass. + Finish at or below the effective budget unless a concrete inability remains. + A secondmate must explicitly report `primary-owned-shared-file-alone-exceeds-budget` when the inherited shared file alone exceeds its allowance, because local curation cannot resolve it. + Any other unresolved excess must identify the fact that cannot safely be removed or routed and why. + +A net increase is allowed only for a genuinely new current fact with no stronger owner. +Before allowing it, consolidate enough lower-priority material to remain within budget. +Never describe the session as reset-safe while the memory total is over budget or an exception is unresolved. + +## Knowledge sweep and routing -3. **Write within firstmate's existing write boundaries.** - This skill does not grant any new write permission; it only prompts firstmate to use the boundaries that already exist (AGENTS.md section 1): - - Captain preferences and fleet-local operational facts: hand-write directly to the destination selected by AGENTS.md's knowledge-routing table, using inspect-then-update every time. - Before writing, inspect the destination, find the existing bullet or section the finding duplicates or supersedes, and rewrite it in place rather than adding a new trailing entry. - `data/learnings.md` may not exist yet; create it on first local learning, in the same dated, evidence-backed, curated style as the captain-preference files. - - Project-intrinsic knowledge: never hand-write a project's `AGENTS.md`. - Route it through a normal ship task so a crewmate records it via `bin/fm-ensure-agents-md.sh` and commits it through that project's delivery pipeline, exactly as section 6 describes. - If the fleet is live, delegate this to a crewmate rather than doing it inline. - - Knowledge generalizable to every firstmate user: this repo's own `AGENTS.md` (or other shared, tracked material), shipped through the normal branch -> no-mistakes -> PR -> captain-merge pipeline for this repo (section 1), never hand-committed straight to `main`. - - Task-scoped notes: inspect the relevant backlog item with `tasks-axi show --full`, judge whether the new note is new, duplicate, superseding, or obsolete, then write a considered replacement body with `tasks-axi update --body-file `. - When the replacement intentionally supersedes prior state that should remain recoverable, add `--archive-body` to that update command so the prior body stays recoverable without copying it into the replacement. +1. **Sweep the session for uncaptured durable knowledge.** + Look for operational learnings, captain preferences expressed in passing, project-intrinsic facts, standing decisions, and undone next steps. +2. **Route each finding using AGENTS.md's knowledge-routing table.** + AGENTS.md section 6 is the source of truth for destinations. + Do not re-derive or duplicate that mapping here. +3. **Write within the existing boundaries.** + - Captain preferences and fleet-local operational facts belong in the destination selected by AGENTS.md after the required whole-file curation pass. + Create `data/learnings.md` only for a genuinely new local learning with no stronger owner. + - In a primary home, curate shared captain preferences only under the existing primary-authoritative shared-preference contract. + In a secondmate home, route a newly discovered shared preference to the main firstmate through marked status or a document pointer instead of editing the inherited file. + - Project-intrinsic knowledge never goes directly into a project's `AGENTS.md`. + Route it through a normal ship task so a crewmate records it with `bin/fm-ensure-agents-md.sh` and the project's delivery path. + - Knowledge general to every Firstmate user belongs in this repo's shared tracked material through the normal branch, no-mistakes, PR, and captain-merge path. + - For task-scoped notes, inspect the item with `tasks-axi show --full`, classify the change as new, duplicate, superseding, or obsolete, then use a considered replacement body through `tasks-axi update --body-file `. + Use `--archive-body` when recoverability matters. Never append. - If hand-editing `data/backlog.md` per the active backend, make the same inspect-then-update edit in place. - - Undone next steps: file each as a queued backlog item (section 10), with `blocked-by` recorded if it genuinely depends on something else. + - File each undone next step as a queued backlog item with a genuine `blocked-by` dependency when applicable. +4. **Use inspect-then-update.** + For every retained fact, ask which current statement it supersedes, whether it can be a one-sentence rewrite, and whether a stale entry should be deleted, retired, or routed to an existing stronger owner. + The only graduation moves are promotion to tracked shared material through a PR, folding a learning into the captain-preference destination selected by AGENTS.md, or deletion of a stale entry. + Do not invent another graduation path. + +## Completion receipt + +Report the outcome in plain captain-facing language with all of these facts: -4. **Curate with inspect-then-update.** - Every write starts by reading the current destination and deciding how the finding changes what is already there. - Use this checklist before writing: - - Which existing bullet, section, or task body does this supersede? - - Can this be a one-sentence rewrite instead of a new entry? - - Should an older bullet or note be deleted, retired, or archived because it is now obsolete? - When a finding overlaps or supersedes something already on disk, rewrite or prune the existing entry instead of piling on a new one. - Graduation moves are limited to exactly three: promote a learning to the shared `AGENTS.md` via PR, fold it into the captain-preference destination selected by AGENTS.md, or delete a stale entry. - Do not invent other graduation paths. +- effective startup-memory budget and total estimated tokens before and after; +- one or more actions for each of `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`: `unchanged`, `added`, `rewritten`, `pruned`, or `routed`; +- each durable finding filed outside memory and its authoritative owner; +- every unresolved exception, including a primary-owned shared-file constraint in a secondmate home; +- whether the session is safe to reset, only when all durable findings are captured and the post-pass result is within budget with no exception. -5. **Report to the captain.** - Summarize, in plain outcome language (section 9): what was stowed and where, what was filed to the backlog, and whether the session is now safe to reset or destroy - i.e. whether every durable finding from this sweep now lives on disk rather than only in this conversation. - If something could not be captured yet (for example, project-intrinsic knowledge waiting on a crewmate to land it), say so explicitly rather than reporting the session fully safe. +Do not hide an over-budget result behind a reset-safe claim. ## Scope exclusion: no skill storage -`/stow` must **never** store, create, or edit a skill as a destination for any finding. +`/stow` must never store, create, or edit a skill as a destination for any finding. There is no "graduate this to a skill" move in this skill's routing. -This is a deliberate, standing exclusion, not an oversight: even with the two-tier skill layout, a stow sweep is a memory-routing operation, not a way to author or mutate skills. -Writing learnings into either `.agents/skills/` or public `skills/` would still risk mixing fleet-local material with shared firstmate behavior or standalone installer-facing behavior. -Until a human deliberately scopes a skill change as firstmate repo work, route generalizable knowledge to the shared `AGENTS.md` (or other shared, tracked material) via the pipeline, and fleet-local knowledge to `data/`, never to a skill. +Until a human deliberately scopes a skill change as Firstmate repository work, route generalizable knowledge to shared tracked material through its pipeline and fleet-local knowledge to `data/`, never to `.agents/skills/` or public `skills/`. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 42b6827baaf..de95ed35302 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -45,7 +45,7 @@ This touches only the firstmate repo and its own worktrees, never anything under 4. **Report to the captain in plain outcomes.** Summarize what landed under `AGENTS.md` section 9 without firstmate's internal vocabulary: which parts of the fleet are now on the latest, and which were left as-is and why. - For example: "Captain, firstmate and both domain supervisors are now on the latest." + For example: "Captain, firstmate and both second mates are now on the latest." Surface any skipped target whose reason needs the captain's attention - for instance a home with its own un-landed changes (diverged) or local edits (dirty), which were left untouched on purpose. ## Safety diff --git a/.claude/settings.json b/.claude/settings.json index e77613c98a4..0be379c46b7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -40,11 +40,11 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" }, { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$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 aa0c8318552..8530c5f9b2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -322,21 +322,28 @@ jobs: esac /bin/bash --version | head -1 command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } - /bin/bash -n bin/fm-fleet-snapshot.sh + + shell_inventory="$RUNNER_TEMP/fm-shell-inventory" + bin/fm-lint.sh --list-files > "$shell_inventory" + parse_fail=0 + while IFS= read -r f; do + /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } + done < "$shell_inventory" + [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 16 ] || { - echo "::error::expected 16 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 42 ] || { - echo "::error::expected 42 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 41 ] || { + echo "::error::expected 41 Bearings tests, got $bearings_count" exit 1 } diff --git a/.gitignore b/.gitignore index 372af4735f3..1e5e8642efd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,4 @@ data/ __pycache__/ *.pyc .env -config/crew-harness -config/crew-dispatch.json -config/secondmate-harness -config/backlog-backend -config/backend -config/calm -config/x-mode.env -config/cmux-socket-password -config/wedge-alarm -config/herdr-presentation-spaces +config/ diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 8b98340cfa2..433edb80ab4 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -4,7 +4,11 @@ import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; -const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000; +const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS); const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 484bb9e1601..1fb9cf12c48 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,11 +1,15 @@ // 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. +// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot +// renderers, renderShell: "self", session_start replacement reasons, agent_start and +// agent_settled, ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), setWidget() +// with a disposable component factory, and setHiddenThinkingLabel(). +// ./lib/fm-calm-working-ship.ts owns the animated working presentation this file +// installs. The focused tests pin those assumptions but never reject a +// newer Pi solely for its version. The collapsed-thinking and operational-user +// presentation adapters probe the exact API they patch and degrade independently with a +// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi +// still exposes no global renderer for arbitrary built-in or custom rows. // docs/configuration.md owns the home-local Calm preference contract. import { randomUUID } from "node:crypto"; import { @@ -19,6 +23,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, + ExtensionUIContext, ToolDefinition, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; @@ -35,6 +40,11 @@ import { Box, Container, getKeybindings, type Component } from "@earendil-works/ 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 { + CALM_WORKING_SHIP_WIDGET_KEY, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} from "./lib/fm-calm-working-ship.ts"; import { calmPresentationHides, calmPresentationIsActive, @@ -74,12 +84,53 @@ const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); +// Each presentation adapter probes the exact Pi API it patches. If a future Pi removes +// that API, only the affected adapter degrades; the rest of Calm keeps working. +function installCalmPresentationAdapter(name: string, install: () => void): void { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + export default function (pi: ExtensionAPI) { - installCalmAssistantLayout(); - installCalmOperationalUserLayout(); + installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; + // One logical agent run, tracked from agent_start through agent_settled rather than + // from turns or tool calls, so the boat never flickers between tool calls, automatic + // continuations, retries, or compaction that stay inside the same run. + let agentRunActive = false; + let workingShipShown = false; + // One animation instance per extension lifetime. Hiding the working widget freezes + // this state; the next working period resumes it. session_start resets it so a fresh + // Pi session starts at the normal initial position. Never module-global. + const workingShipAnimation = createCalmWorkingShipAnimation(); + + // Single owner of Calm's working-row presentation choice. The widget is only created + // or removed on a real transition, so repeated starts cannot duplicate its timer. + const applyWorkingPresentation = ( + ui: ExtensionUIContext, + forceStockVisibility = false, + ): void => { + const showShip = agentRunActive && calmPresentationIsActive(); + if (showShip !== workingShipShown) { + workingShipShown = showShip; + ui.setWidget( + CALM_WORKING_SHIP_WIDGET_KEY, + showShip + ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation) + : undefined, + ); + ui.setWorkingVisible(!showShip); + } else if (forceStockVisibility && !showShip) { + ui.setWorkingVisible(true); + } + }; const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config"); @@ -228,7 +279,11 @@ export default function (pi: ExtensionAPI) { setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); publishPresentationState(); - ctx.ui.setWorkingVisible(true); + agentRunActive = false; + workingShipShown = false; + // A genuine new session lifetime starts the boat at the normal initial position. + workingShipAnimation.reset(); + applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); ctx.ui.setStatus("firstmate-calm", undefined); removeTerminalInputHandler?.(); @@ -258,6 +313,22 @@ export default function (pi: ExtensionAPI) { }); }); + pi.on("agent_start", (_event, ctx) => { + agentRunActive = true; + applyWorkingPresentation(ctx.ui); + }); + + // agent_settled is emitted from a finally block, so it also covers abort and failure. + pi.on("agent_settled", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + + pi.on("session_shutdown", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + pi.registerCommand("calm", { description: "Toggle Firstmate's supported conversation-only transcript presentation.", handler: async (_args, ctx) => { @@ -265,7 +336,7 @@ export default function (pi: ExtensionAPI) { persistCalmPreference(active); setCalmPresentation(active); publishPresentationState(); - ctx.ui.setWorkingVisible(true); + applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); ctx.ui.setStatus("firstmate-calm", undefined); diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 2b8ed99f69c..9d5124aff2d 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,4 +1,13 @@ // Firstmate primary watcher bridge for Pi. +// +// Session-generation ownership (stated once here): +// Pi emits session_shutdown for ordinary same-process replacements (/new, /resume, +// /fork, reload) as well as terminal quit. This extension binds one generation per +// session activation. Only the active live generation may start, stop, rearm, or +// clear the arm child. Replacement session_start (or a fresh factory bind) activates +// a new live generation so monitoring can arm again without restarting Pi. Terminal +// quit leaves the final generation stopped so late callbacks cannot rearm. Stale +// callbacks from a prior generation are no-ops against the active replacement. import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -37,6 +46,16 @@ type WatchToolRenderContext = { isPartial: boolean; }; +type SessionGeneration = { + id: number; + stopping: boolean; + child: ChildProcess | null; + retryTimer: ReturnType | null; + retryFailures: number; + restoring: boolean; + seq: number; +}; + function refreshWatchToolShell( state: WatchToolShellState, theme: Theme, @@ -69,16 +88,19 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); -const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const armReadyTimeoutMs = positiveInteger( + "FM_PI_ARM_READY_TIMEOUT_MS", + process.platform === "win32" ? 35000 : 12000, +); const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; +const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; -let child: ChildProcess | null = null; -let retryTimer: ReturnType | null = null; -let retryFailures = 0; -let stopping = false; -let seq = 0; -let restoring = false; +let nextGenerationId = 0; +let activeGeneration: SessionGeneration | null = null; const armReadiness = new WeakMap>(); const armClose = new WeakMap>(); @@ -162,7 +184,43 @@ function classifyClose(stdout: string, stderr: string, code: number | null, sign }; } +function createGeneration(): SessionGeneration { + return { + id: ++nextGenerationId, + stopping: false, + child: null, + retryTimer: null, + retryFailures: 0, + restoring: false, + seq: 0, + }; +} + +function activateGeneration(generation: SessionGeneration): void { + activeGeneration = generation; +} + +function generationIsLive(generation: SessionGeneration): boolean { + return activeGeneration === generation && !generation.stopping; +} + +function stopGeneration(generation: SessionGeneration): void { + generation.stopping = true; + if (generation.retryTimer) clearTimeout(generation.retryTimer); + generation.retryTimer = null; + if (generation.child) generation.child.kill("SIGTERM"); + generation.child = null; +} + +const cleanupOnProcessExit = () => { + if (activeGeneration) stopGeneration(activeGeneration); +}; +process.once("exit", cleanupOnProcessExit); + export default function (pi: ExtensionAPI) { + let generation = createGeneration(); + activateGeneration(generation); + let calmPresentation: CalmPresentationState = { active: false, stockExportRendering: false, @@ -179,20 +237,8 @@ export default function (pi: ExtensionAPI) { !calmPresentation.stockExportRendering && !calmTranscriptClassIsVisible(itemClass); - function stopArm(): void { - stopping = true; - if (retryTimer) clearTimeout(retryTimer); - retryTimer = null; - if (child) child.kill("SIGTERM"); - child = null; - } - - const cleanupOnProcessExit = () => { - stopArm(); - }; - process.once("exit", cleanupOnProcessExit); - - async function sendWake(message: string): Promise { + async function sendWake(owner: SessionGeneration, message: string): Promise { + if (!generationIsLive(owner)) return; const content = encodeFirstmateOperationalInput( "watcher", `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`, @@ -200,8 +246,8 @@ export default function (pi: ExtensionAPI) { await pi.sendUserMessage(content, { deliverAs: "followUp" }); } - function surfaceFailure(message: string): void { - void sendWake(message).catch(() => { + function surfaceFailure(owner: SessionGeneration, message: string): void { + void sendWake(owner, message).catch(() => { // Pi owns delivery errors; continuity restoration never waits on prompting. }); } @@ -245,12 +291,12 @@ export default function (pi: ExtensionAPI) { }); } - async function restoreAfterActionableClose(predecessorArmPid: string): Promise { + async function restoreAfterActionableClose(owner: SessionGeneration, predecessorArmPid: string): Promise { let failure = ""; for (let attempt = 0; attempt <= retryLimit; attempt += 1) { - if (stopping) return ""; - const replacement = startArm(predecessorArmPid); - const successorChild = child; + if (!generationIsLive(owner)) return ""; + const replacement = startArm(owner, predecessorArmPid); + const successorChild = owner.child; if (replacement.ok && successorChild && await waitForReadiness(successorChild)) return ""; if (replacement.ok) { failure = "watcher: FAILED - Pi extension could not verify a ready successor watcher"; @@ -269,31 +315,32 @@ export default function (pi: ExtensionAPI) { return `${failure}\nwatcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries`; } - function scheduleRetry(message: string, predecessorArmPid: string): void { - if (stopping || child || retryTimer) return; + function scheduleRetry(owner: SessionGeneration, message: string, predecessorArmPid: string): void { + if (!generationIsLive(owner) || owner.child || owner.retryTimer) return; const ownership = lockOwnership(); if (ownership !== "owned") { - surfaceFailure(`watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`); + surfaceFailure(owner, `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`); return; } - retryFailures += 1; - if (retryFailures > retryLimit) { - surfaceFailure(`watcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries\n${message}`); + owner.retryFailures += 1; + if (owner.retryFailures > retryLimit) { + surfaceFailure(owner, `watcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries\n${message}`); return; } const timer = setTimeout(() => { - if (retryTimer === timer) retryTimer = null; - const result = startArm(predecessorArmPid); + if (owner.retryTimer === timer) owner.retryTimer = null; + if (!generationIsLive(owner)) return; + const result = startArm(owner, predecessorArmPid); if (!result.ok) { - surfaceFailure(`watcher: FAILED - Pi extension could not launch a continuity retry\n${result.message}`); + surfaceFailure(owner, `watcher: FAILED - Pi extension could not launch a continuity retry\n${result.message}`); } - }, retryDelay(retryFailures)); + }, retryDelay(owner.retryFailures)); timer.unref(); - retryTimer = timer; + owner.retryTimer = timer; } - function startArm(predecessorArmPid = ""): ArmResult { - if (stopping) return { ok: false, message: "watcher: not armed - Pi session is shutting down" }; + function startArm(owner: SessionGeneration, predecessorArmPid = ""): ArmResult { + if (!generationIsLive(owner)) return { ok: false, message: shuttingDownMessage }; const ownership = lockOwnership(); if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; if (ownership === "missing") { @@ -303,19 +350,19 @@ export default function (pi: ExtensionAPI) { }; } markLoaded(); - if (child) { + if (owner.child) { return { ok: true, message: `watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; ${repairOnlyHint}`, }; } - if (retryTimer) { + if (owner.retryTimer) { return { ok: true, message: `watcher: unchanged - Pi extension already owns a scheduled continuity retry; no manual re-arm needed; ${repairOnlyHint}`, }; } - const id = ++seq; + const id = ++owner.seq; const env = { ...process.env, FM_HOME: fmHome, @@ -329,7 +376,7 @@ export default function (pi: ExtensionAPI) { env, stdio: ["ignore", "pipe", "pipe"], }); - child = armChild; + owner.child = armChild; let stdout = ""; let stderr = ""; let settled = false; @@ -355,7 +402,7 @@ export default function (pi: ExtensionAPI) { } }; const releaseChild = (): void => { - if (child === armChild) child = null; + if (owner.child === armChild) owner.child = null; }; armChild.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); @@ -371,24 +418,24 @@ export default function (pi: ExtensionAPI) { resolveClosed(); settleReadiness(false); releaseChild(); - if (stopping) return; + if (!generationIsLive(owner)) return; const classification = classifyClose(stdout, stderr, code, signal); const predecessor = String(armChild.pid ?? ""); if (classification.kind === "actionable") { - retryFailures = 0; - restoring = true; + owner.retryFailures = 0; + owner.restoring = true; void (async () => { - const failure = await restoreAfterActionableClose(predecessor); - restoring = false; - if (stopping) return; + const failure = await restoreAfterActionableClose(owner, predecessor); + if (generationIsLive(owner)) owner.restoring = false; + if (!generationIsLive(owner)) return; const message = failure ? `${classification.message}\n\n${failure}` : classification.message; - await sendWake(message); + await sendWake(owner, message); })().catch(() => { }); return; } - if (restoring) return; - scheduleRetry(classification.message, predecessor); + if (owner.restoring) return; + scheduleRetry(owner, classification.message, predecessor); }); armChild.on("error", (error: Error) => { if (settled) return; @@ -396,9 +443,9 @@ export default function (pi: ExtensionAPI) { resolveClosed(); settleReadiness(false); releaseChild(); - if (stopping) return; - if (restoring) return; - scheduleRetry(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? "")); + if (!generationIsLive(owner)) return; + if (owner.restoring) return; + scheduleRetry(owner, `watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? "")); }); return { ok: true, @@ -407,17 +454,18 @@ export default function (pi: ExtensionAPI) { } pi.on?.("session_start", () => { + if (generation.stopping) generation = createGeneration(); + activateGeneration(generation); markLoaded(); }); pi.on?.("session_shutdown", () => { - stopArm(); - process.off("exit", cleanupOnProcessExit); + stopGeneration(generation); }); pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { - const result = startArm(); + const result = startArm(generation); ctx.ui.notify(result.message, result.ok ? "info" : "warning"); }, }); @@ -458,7 +506,7 @@ export default function (pi: ExtensionAPI) { return new Container(); }, execute: async () => { - const result = startArm(); + const result = startArm(generation); return { content: [{ type: "text", text: result.message }], details: result, diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index d51b6ccf57a..33be71095ed 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,7 +1,12 @@ -import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an +// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic +// instead of blocking Calm or Pi. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; -type AssistantMessage = Parameters[0]; +type AssistantMessage = Parameters[0]; type AssistantMessagePresentationState = { hiddenThinkingLabel: string; @@ -13,6 +18,8 @@ type CalmAssistantLayoutPatch = { hidesThinking: () => boolean; }; +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. const CALM_ASSISTANT_LAYOUT_PATCH = Symbol.for( "firstmate:calm-assistant-layout:pi-0.81.1", ); @@ -29,6 +36,10 @@ export function installCalmAssistantLayout(): void { } const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; if (typeof originalUpdateContent !== "function") { throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts index fe2eeae60fd..ca9b0bbcc0a 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,13 +1,14 @@ -// 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"; +// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// together via InteractiveMode.addMessageToChat. This adapter probes that exact method +// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never +// message delivery. +import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; -type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageConstructorArgs = ConstructorParameters; type UserMessageLike = { role: string; content: unknown; @@ -18,7 +19,7 @@ type AddMessageOptions = { type InteractiveModePresentation = { chatContainer: { children: unknown[]; - addChild(component: UserMessageComponent): void; + addChild(component: PiUserMessageComponent): void; }; editor: { addToHistory?(text: string): void; @@ -39,6 +40,8 @@ type CalmOperationalUserLayoutPatch = { isOperationalInput: (text: string) => boolean; }; +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. const CALM_OPERATIONAL_USER_LAYOUT_PATCH = Symbol.for( "firstmate:calm-operational-user-layout:pi-0.81.1", ); @@ -79,12 +82,20 @@ export function installCalmOperationalUserLayout(): void { hidesOperationalInput, isOperationalInput, }; + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; const originalAddMessageToChat = prototype.addMessageToChat; if (typeof originalAddMessageToChat !== "function") { throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); } + const UserMessageComponent = PiCodingAgent.UserMessageComponent; + if (typeof UserMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi UserMessageComponent"); + } class CalmOperationalUserMessageComponent extends UserMessageComponent { private readonly hasLeadingSpacer: boolean; diff --git a/.pi/extensions/lib/fm-calm-working-ship.ts b/.pi/extensions/lib/fm-calm-working-ship.ts new file mode 100644 index 00000000000..390e28baebf --- /dev/null +++ b/.pi/extensions/lib/fm-calm-working-ship.ts @@ -0,0 +1,243 @@ +// Firstmate's Calm-only animated working presentation. +// +// Calm replaces Pi's stock working row with a tiny SSHHIP-derived boat while one +// logical agent run is active. This module owns only the sprite geometry, the bounce +// track, the two animation cadences, the session-scoped freeze/resume state, and the +// temporary TUI widget; `.pi/extensions/fm-calm.ts` owns when the presentation is +// installed and removed, and stays the sole caller of setWorkingVisible(). +// docs/calm.md owns the captain-facing contract. +// +// Cadence: one scheduler drives two logically independent clocks. Every tick advances +// the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the +// boat, so the water visibly ripples several times between boat steps and the boat +// itself reads as calm. Both clocks stop together when the widget is disposed. Ticks, +// not wall-clock timestamps, drive every state change, so tests can seek time exactly. +// +// Continuity: one extension-owned animation instance survives hide/show within the same +// Pi process and Calm extension lifetime. Disposing the widget freezes column, +// direction, water phase, and tick cadence without advancing them for hidden wall +// time. The next working period resumes from that exact logical state. A fresh session +// or new extension lifetime calls reset() and starts at the normal initial position. +// State is never a module-level or process-global singleton. +// +// Verified against Pi 0.81.1 declarations and the Pi 0.82.0 CLI, which expose +// ExtensionUIContext.setWidget() with a component factory, per-widget dispose(), and +// TUI.requestRender(). Pi renders a widget through Component.render(width), so this +// module recomputes its track from that width on every frame instead of caching a +// terminal size that a resize would invalidate. A resize while the boat is hidden is +// applied on the first resumed frame through the same clamp path. +import type { Component, TUI } from "@earendil-works/pi-tui"; + +// The hull is symmetric and replaces waves on its row rather than adding a third row. +const HULL = "\\__/"; +// A mainsail extends aft of the mast, so it trails behind the bow relative to travel. +const SAIL_RIGHT = "<|"; +const SAIL_LEFT = "|>"; +// Centers the two-cell sail over the four-cell hull. +const SAIL_OFFSET = 1; +const HULL_WIDTH = HULL.length; +const SAIL_WIDTH = SAIL_RIGHT.length; + +// Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so +// advancing the phase ripples the surface without changing visible width or row count. +const WAVE_CYCLE = ["~", "~", "-", "~"] as const; + +// Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB. +const BLUE = "\u001b[34m"; +const YELLOW = "\u001b[33m"; +// Restores the default foreground so color never bleeds into padding or later frames. +const RESET = "\u001b[39m"; + +export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship"; +/** Scheduler period. One tick advances the water by one phase. */ +export const CALM_WORKING_SHIP_TICK_MS = 220; +/** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */ +export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4; + +export type CalmWorkingShipAnimation = { + /** Render one frame that exactly fits `width`, clamping the track to it first. */ + render(width: number): string[]; + /** Advance one scheduler tick: water every tick, boat on its slower cadence. */ + tick(): void; + restoreLastRendered(): void; + /** Restore the normal initial column, direction, water phase, and cadence. */ + reset(): void; + /** + * Clamp the frozen column and direction to `width` without advancing time. + * Used when a terminal resize lands while the working presentation is hidden. + */ + clampToWidth(width: number): void; + /** Current hull column, exposed for deterministic motion assertions. */ + position(): number; + /** Current travel direction: 1 travelling right, -1 travelling left. */ + direction(): number; + /** Current water phase, exposed for deterministic ripple assertions. */ + waterPhase(): number; +}; + +/** Longest hull start column that still fits the sprite in `width` usable cells. */ +function trackSpan(width: number): number { + if (width >= HULL_WIDTH) return width - HULL_WIDTH; + if (width >= SAIL_WIDTH) return width - SAIL_WIDTH; + return 0; +} + +export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation { + let position = 0; + let direction = 1; + let span = 0; + let phase = 0; + let ticks = 0; + let renderedPosition = position; + let renderedDirection = direction; + let renderedSpan = span; + let renderedPhase = phase; + let renderedTicks = ticks; + + // Reversing the moment the boat lands on an endpoint means the endpoint frame itself + // already shows the new heading, so no frame at or after a bounce shows the old sail. + const settleDirectionAtEdges = (): void => { + if (span <= 0) return; + if (position >= span) direction = -1; + else if (position <= 0) direction = 1; + }; + + const applyWidth = (width: number): void => { + if (width <= 0) { + span = 0; + position = 0; + return; + } + span = trackSpan(width); + position = Math.min(position, span); + settleDirectionAtEdges(); + }; + + const commitRenderedState = (): void => { + renderedPosition = position; + renderedDirection = direction; + renderedSpan = span; + renderedPhase = phase; + renderedTicks = ticks; + }; + + const restoreLastRenderedState = (): void => { + position = renderedPosition; + direction = renderedDirection; + span = renderedSpan; + phase = renderedPhase; + ticks = renderedTicks; + }; + + /** One colored run of water covering absolute columns [from, from + count). */ + const water = (from: number, count: number): string => { + if (count <= 0) return ""; + let cells = ""; + for (let column = from; column < from + count; column += 1) { + cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length]; + } + return `${BLUE}${cells}${RESET}`; + }; + + const boat = (text: string): string => `${YELLOW}${text}${RESET}`; + + return { + position: () => position, + direction: () => direction, + waterPhase: () => phase, + + restoreLastRendered: restoreLastRenderedState, + + reset(): void { + position = 0; + direction = 1; + span = 0; + phase = 0; + ticks = 0; + commitRenderedState(); + }, + + clampToWidth(width: number): void { + applyWidth(width); + }, + + tick(): void { + ticks += 1; + phase = (phase + 1) % WAVE_CYCLE.length; + if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return; + if (span <= 0) { + position = 0; + return; + } + position = Math.min(span, Math.max(0, position + direction)); + settleDirectionAtEdges(); + }, + + render(width: number): string[] { + if (width <= 0) return []; + + // A resize lands here before the next frame, so recompute and clamp the track + // immediately rather than trusting a position measured against the old width. + applyWidth(width); + + const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT; + + let frame: string[]; + if (width < SAIL_WIDTH) { + // Too narrow for even the sail: a deterministic single row of water. + frame = [water(0, width)]; + } else if (width < HULL_WIDTH) { + // Too narrow for the hull: the sail alone rides the water row. + frame = [ + water(0, position) + + boat(sail) + + water(position + SAIL_WIDTH, width - position - SAIL_WIDTH), + ]; + } else { + frame = [ + " ".repeat(position + SAIL_OFFSET) + boat(sail), + water(0, position) + + boat(HULL) + + water(position + HULL_WIDTH, width - position - HULL_WIDTH), + ]; + } + + commitRenderedState(); + return frame; + }, + }; +} + +/** + * Build the temporary Calm working widget bound to one caller-owned animation. + * Pi disposes the previous component before installing a replacement under the same + * key and when it clears extension widgets, so the single scheduler driving both + * cadences cannot outlive the widget or duplicate. Disposing freezes the shared + * animation in place; the next widget bound to the same animation resumes without + * applying hidden wall time. + */ +export function createCalmWorkingShipWidget( + tui: TUI, + animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(), +): Component & { dispose(): void } { + let disposed = false; + const timer = setInterval(() => { + if (disposed) return; + animation.tick(); + tui.requestRender(); + }, CALM_WORKING_SHIP_TICK_MS); + // The animation must never keep Pi's process alive on its own. + timer.unref?.(); + + return { + render: (width) => (disposed ? [] : animation.render(width)), + // Every frame is rebuilt from fixed standard ANSI codes, so there is no cache. + invalidate: () => {}, + dispose: () => { + if (disposed) return; + disposed = true; + clearInterval(timer); + animation.restoreLastRendered(); + }, + }; +} diff --git a/AGENTS.md b/AGENTS.md index 37b40cf72c2..9dc4fdaef05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,18 +14,19 @@ For captain-facing escalation style and outcome phrasing, see section 9. ## 1. Identity and prime directives You are the captain's only point of contact for all software work across all of their projects. -You do not do project-specific work yourself. -Delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. +Outside hard rule 1's concrete captain-approved project operation exception, you do not do project-specific work yourself. +For all other project-specific work, delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. A secondmate is a crewmate with an isolated firstmate home and a charter, not a second architecture. Hard rules, in priority order: 1. **Never write to a project.** Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them. - The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. + 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, each owned by its referenced skill or script, plus a concrete captain-approved project operation governed directly by this rule. Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. + Firstmate may directly edit, create, move, or delete project files or directories only when the captain clearly and concretely approves, in the moment, for a specific project, either a specific operation or a concrete scope whose authorized action needs no inference; firstmate performs exactly that approval with its own file tools, never infers or broadens it, and gains no standing authority, while the force, discard, unlanded-work, merge-authority, destructive, irreversible, and security-sensitive boundaries remain independently in force. 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, and section 7 owns its exact bounds; 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 delivery and merge defaults, while the captain-instruction precedence rule below owns when a current explicit captain instruction overrides a conflicting Firstmate-written standing rule within its exact scope. 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. @@ -97,21 +98,24 @@ After the digest, run recovery (section 5). Then arm the watcher (section 8). ## 4. Harness adapters -Verified adapter names: `claude`, `codex`, `opencode`, `pi`, `grok`. +Verified adapter names: `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `kimi`. **Never dispatch a crewmate or secondmate on an unverified adapter.** If configured harness data names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. **Load `harness-adapters` before any spawn, recovery, trust-dialog handling, harness-specific skill invocation, interrupt, exit, resume, or adapter verification.** It owns per-adapter supervision knowledge (busy signature, exit, interrupt, dialogs, quirks, resume) and the launch profile axes table. +Model discovery is harness-specific: `pi` uses `pi --list-models [search]`, while each other harness uses its own authoritative model catalog. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-dispatch-select.sh` owns selector mechanics, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. -Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose the candidate with the most real headroom. +Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. +Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose the candidate with the most real headroom, using inspectable usable runway, pace, and reserve only later when needed. Account for every candidate; if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. Preserve malformed profile configuration as an actionable error rather than selecting around it. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. Break genuine headroom ties without array-order or harness bias. -`quota-axi` owns how model or product windows relate to bounding account windows; this is an explicitly interim rule until successor `quota-axi-interpretation-hints-h3` lands. +`quota-axi` owns how model or product windows relate to bounding account windows and remains data-only; this is an explicitly interim rule until successor `quota-axi-interpretation-hints-h3` lands. +Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the completion-aware selection procedure. The static crewmate harness default lives in `config/crew-harness` (absent or `default` = mirror your own harness). Resolve `default` with `bin/fm-harness.sh`; resolve the active static crewmate harness with `bin/fm-harness.sh crew`. @@ -483,7 +487,20 @@ That token is consent for public replies and normal reversible lifecycle actions An X-only home still requires the live supervision cycle so mentions can wake it without fleet work. On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respond`, which owns classification, public-safety policy, reply or dismissal, task linking, and follow-ups. -For every X-linked terminal outcome, load that owner and post the final completion follow-up before teardown, regardless of earlier milestone follow-ups. +For every X-linked terminal outcome, load that owner and use the promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up before teardown. + +A promised final public reply is durable state, never conversation memory. +Load `fmx-respond` before promising one, on a `public-followup ...` check wake, and whenever the session-start digest lists a public commitment awaiting delivery. +Only the home holding the relay consent and thread binding ever posts it, so never ask a secondmate or crewmate to find the thread or send the reply, and never recover a terminal result by reading a `done:` sentence. + +## Captain instruction precedence + +A current, explicit, concrete captain instruction overrides any conflicting standing rule written above. +The instruction must be specific and recent: it must identify the concrete action, object, or bounded set it governs. +Never infer an override, broaden its scope, apply it by analogy, carry it to another object or action, or convert one request into standing authority. +Ambiguous scope or conflict still requires one concise clarification before action. +Destructive, irreversible, security-sensitive, discard, and merge actions still require the captain to state that concrete action explicitly; once the captain does so and higher-priority instructions permit it, a conflicting Firstmate-written rule must not rigidly block the action. +Standing `yolo` authority is not a substitute for a current explicit captain instruction where an explicit action is required. ## Maintaining this file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90f002029..effd31a8912 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Test scripts and helpers in `tests/` are plain bash too. `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 harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `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) 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. @@ -71,7 +71,7 @@ That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even whe Check and test the toolbelt before pushing: ```sh -for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt +while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the canonical shell surface bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) @@ -93,7 +93,7 @@ Its header and `--help` own the flags, family labels, lanes, and changed-file ma Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. -CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and macOS snapshot compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. diff --git a/README.md b/README.md index c62e47bba48..ac54cf70259 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. -- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority. +- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live. +- **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). @@ -58,7 +58,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements -- A verified agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. +- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, or OpenCode. - Git and the GitHub CLI, authenticated through `gh auth login`. - The CLI and dependencies for your selected runtime backend; tmux is the reference default. @@ -67,7 +67,7 @@ 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, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session, with `pi-signed` supported as Pi's distinct signed-wrapper identity. Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses background-notify wake cycles, and Pi uses its tracked primary watcher extension. All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. @@ -100,11 +100,13 @@ grok --trust ```sh pi +# or, when the signed wrapper is installed +FM_PI_HARNESS=pi-signed pi-signed ``` For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so 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. +Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving 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). @@ -169,10 +171,17 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/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 | +| `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today's dated report in `data/`, and add `include PRs` when live PR enrichment is wanted | | `/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 | +Bearings invocation examples: + +- `/bearings` returns the fresh four-section digest in chat only. +- `/bearings include PRs` keeps chat-only mode and opts into live PR enrichment. +- `/bearings file` replaces today's `data/status-report-.md` from scratch and links it from the four-section chat digest. +- `/bearings file include PRs` combines the dated report with live PR enrichment. + Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). ### Two-tier skill layout @@ -201,7 +210,7 @@ Firstmate's skills live in two separate places with different audiences: - [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/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [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. diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 69dc0b53bde..12dc7629eb6 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -581,8 +581,8 @@ fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending # has since moved its own confirmation to a native agent-state read instead # (docs/herdr-backend.md "Native agent-state submit confirmation"); cmux has # no analogous native primitive, so this composer-row approach remains -# cmux's own confirmation strategy. Echoes empty|pending|unknown|send-failed, the -# SAME vocabulary every existing backend already speaks. +# cmux's own confirmation strategy. Echoes empty|pending|unknown|send-failed, a +# subset of the proof-carrying submit vocabulary. fm_backend_cmux_send_text_submit() { # [expected-label] local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} i=0 state fm_backend_cmux_parse_target "$target" || { printf 'unknown'; return 0; } diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 432311d6710..cb677be0cfd 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -27,9 +27,17 @@ # duplicate-agent risk is independently absent. # Target resolution stays parallel to the tmux adapter in both layouts. # Projected create, move, and cleanup operations capture the named session's -# exact active workspace and tab. Herdr 0.7.4's last-pane close can focus an -# unrelated neighbor, so projected cleanup serializes and restores only the -# exact pre-close tab id, while refusing to close the active tab itself. +# exact active workspace and tab. On Herdr 0.7.5, an explicit close that +# empties a non-focused workspace moves focus to that workspace's neighbor +# (upstream discussion #1328, fixed by PR #1877), while a pane-death removal +# preserves focus exactly when the dying workspace sits behind the focused +# one or the focused one is last (upstream issue #1621, fixed by PR #1912); +# both fixes are merged upstream but in no release. Projected cleanup +# therefore serializes under the session lock, repositions a doomed workspace +# behind the focused one when needed, and ends its verified lone idle shell +# so Herdr removes the emptied workspace through the focus-preserving +# pane-death path, with the exact pre-close tab restore as the backstop and a +# refusal to close the active tab itself. # # Target string shape: ":", e.g. "default:w1:p2" (the # pane id itself contains a colon; the session is always the FIRST field, the @@ -462,18 +470,15 @@ fm_backend_herdr_presentation_lock_namespace_valid() { # it would turn JSON null into the literal string "null"). Canonicalizes the # parent directory when that directory exists so symlink parents such as /tmp # -> /private/tmp cannot yield two lock identities for the same socket. -fm_backend_herdr_presentation_session_socket_path() { # - local session=$1 sessions socket sock_dir sock_base - [ -n "$session" ] || return 1 - sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 - socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' - [.sessions[]? - | select(.name == $want and .running == true) - | select((.socket_path | type) == "string") - | select((.socket_path | length) > 0) - | .socket_path] - | if length == 1 then .[0] else empty end - ' 2>/dev/null) || return 1 +# fm_backend_herdr_canonical_socket_path: normalize one absolute Unix-socket +# path so two spellings of the same socket compare equal. Refuses a relative +# or empty path. An unresolvable directory is left as-is rather than treated as +# a failure, so a socket whose directory was removed still compares by its own +# literal path. Single owner for every socket-identity comparison in this +# adapter (the presentation session lock and the launcher-identity same-session +# proof both use it). +fm_backend_herdr_canonical_socket_path() { # + local socket=$1 sock_dir sock_base [ -n "$socket" ] || return 1 case "$socket" in /*) ;; @@ -489,6 +494,21 @@ fm_backend_herdr_presentation_session_socket_path() { # printf '%s' "$socket" } +fm_backend_herdr_presentation_session_socket_path() { # + local session=$1 sessions socket + [ -n "$session" ] || return 1 + sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 + socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' + [.sessions[]? + | select(.name == $want and .running == true) + | select((.socket_path | type) == "string") + | select((.socket_path | length) > 0) + | .socket_path] + | if length == 1 then .[0] else empty end + ' 2>/dev/null) || return 1 + fm_backend_herdr_canonical_socket_path "$socket" +} + fm_backend_herdr_presentation_session_lock_path() { # local session=$1 socket key dir hash [ -n "$session" ] || return 1 @@ -546,8 +566,11 @@ fm_backend_herdr_projection_focus_snapshot() { # # fm_backend_herdr_projection_focus_restore: verify that one presentation # mutation preserved the exact active workspace and tab captured immediately # before it. -# Herdr 0.7.4's pane.close can focus an unrelated neighboring workspace when -# it removes a non-focused workspace's last pane. +# This is the backstop for every focus-unsafe instant: on Herdr 0.7.5 an +# explicit pane.close that empties a non-focused workspace moves focus to +# that workspace's neighbor (upstream #1328/#1877), and a pane-death removal +# before a non-last focused workspace moves focus to the focused workspace's +# right neighbor (upstream #1621/#1912); both fixes are unreleased. # A single tab.focus on the exact response-independent pre-operation tab id # restores both the workspace and tab atomically. fm_backend_herdr_projection_focus_restore() { # @@ -587,9 +610,17 @@ fm_backend_herdr_projection_focus_restore() { # [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 + local before active_tab info target_pane target_tab target_ws close_status state plan plan_shell_pid plan_move_record workspace_presence FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE="" [ -n "$pane_id" ] || return 0 before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { @@ -603,6 +634,7 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # /dev/null) target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) if [ "$target_pane" != "$pane_id" ] || [ -z "$target_tab" ]; then echo "warning: herdr presentation cleanup received an ambiguous exact-pane response; refusing focus-unsafe pane close" >&2 return 1 @@ -616,20 +648,380 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # /dev/null 2>&1; then + plan=plain + plan_shell_pid= + plan_move_record= + if [ -n "$target_ws" ]; then + plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane_id" "$target_ws" "$target_tab" "${before%%$'\t'*}") + case "$plan" in + moved$'\t'*) + plan_move_record=${plan%%$'\n'*} + plan=${plan##*$'\n'} + ;; + esac + case "$plan" in + death\ *) + plan_shell_pid=${plan#death } + plan=death + ;; + *) + plan=plain + ;; + esac + fi + if [ "$plan" = death ]; then + if fm_backend_herdr_death_close_pane "$session" "$pane_id" "$plan_shell_pid"; then + close_status=0 + elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then + close_status=0 + else + close_status=1 + fi + elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then close_status=0 else - close_status=$? + close_status=1 + fi + if [ "$close_status" -eq 0 ] && [ -n "$plan_move_record" ]; then + workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws") + if [ "$workspace_presence" != dead ]; then + echo "warning: herdr presentation cleanup did not confirm removal of the repositioned workspace" >&2 + close_status=1 + fi + fi + if [ "$close_status" -ne 0 ]; then + fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true fi fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || return 2 [ "$close_status" -eq 0 ] } +# Herdr 0.7.5 workspace-removal focus rules (verified against the installed +# 0.7.5 binary, its v0.7.5 tag source, and the isolated named lab): +# - An EXPLICIT close that empties a workspace (API pane.close of its last +# pane, tab close, or workspace close) routes through +# close_selected_workspace, which assigns focus to the closing workspace's +# right neighbor (or the new last workspace when it was last), ignoring the +# previously focused workspace entirely (upstream discussion #1328, fixed +# by PR #1877, commit 165dca45). +# - A PANE-DEATH removal (handle_pane_died) keeps the focused index stale, +# which preserves the exact focused workspace whenever the dying workspace +# sat behind it (or the focused workspace was last), and moves focus to the +# focused workspace's right neighbor otherwise (upstream issue #1621, fixed +# by PR #1912, commit a979916). +# Both fixes are merged upstream but in no release as of 2026-07-28. +# Firstmate therefore removes a doomed non-focused workspace by ending its +# verified lone idle shell (the pane-death path), repositioning it behind the +# focused workspace first when needed. Moving it to the end preserves every +# other workspace's relative order, so no presentation ordering change +# persists. A release carrying both fixes preserves focus on both paths, so +# this stays safe without any version gate. + +# fm_backend_herdr_workspace_move_capable: verify that one guarded raw +# workspace.move request is possible in : python3 for the transport, +# the minimum protocol, and the exact whitelisted method and parameter +# schema. Silent; each caller owns its own warning wording. +# Return codes: 1 python3 missing, 2 protocol unreadable, 3 protocol too old, +# 4 schema unreadable, 5 method or parameter schema unsupported. +fm_backend_herdr_workspace_move_capable() { # + local session=$1 protocol schema + command -v python3 >/dev/null 2>&1 || return 1 + protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in + ''|*[!0-9]*) return 2 ;; + esac + [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ] && return 3 + schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || return 4 + printf '%s' "$schema" | jq -e ' + any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") + and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] + and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" + ' >/dev/null 2>&1 || return 5 +} + +# fm_backend_herdr_emptying_close_plan: choose the focus-safe removal for one +# exact pane. The LAST echoed line is the plan: "plain" (use the ordinary +# explicit close; the exact-tab restore backstop masks 0.7.5's focus move) +# or "death " (end the proved lone idle shell so Herdr removes +# the emptied workspace through its focus-preserving pane-death path). +# Whenever the repositioning mover was invoked, a preceding +# "moved" +# record line is echoed first so the caller can hand it to +# fm_backend_herdr_emptying_move_rollback when removal is not confirmed. +# Never fails; every ambiguity plans "plain". +# The death plan requires the close to empty the workspace (exactly one tab +# and one pane, both the target), the target workspace to sit behind the +# focused one (repositioned to the end first when it does not, with the move +# verified against the server-returned order and focus), and the exact pane +# to hold one provably lone idle recognized shell. +fm_backend_herdr_emptying_close_plan() { # + local session=$1 pane_id=$2 ws_id=$3 tab_id=$4 focused_ws=$5 + local tabs panes list indices r rest a len capable socket mover response move_status shell_pid before_order + [ -n "$ws_id" ] && [ -n "$tab_id" ] && [ -n "$focused_ws" ] || { printf 'plain\n'; return 0; } + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; } + printf '%s' "$tabs" | jq -e --arg tab "$tab_id" ' + (.result.tabs | type) == "array" and (.result.tabs | length) == 1 + and .result.tabs[0].tab_id == $tab + ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; } + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; } + printf '%s' "$panes" | jq -e --arg pane "$pane_id" ' + (.result.panes | type) == "array" and (.result.panes | length) == 1 + and .result.panes[0].pane_id == $pane + ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; } + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { printf 'plain\n'; return 0; } + indices=$(printf '%s' "$list" | jq -r --arg ws "$ws_id" --arg focused "$focused_ws" ' + (.result.workspaces // null) as $s + | select(($s | type) == "array" and ($s | length) > 1) + | ([range(0; $s | length) | select($s[.].workspace_id == $ws)]) as $w + | ([range(0; $s | length) | select($s[.].workspace_id == $focused)]) as $f + | select(($w | length) == 1 and ($f | length) == 1 and $w[0] != $f[0]) + | "\($w[0])\t\($f[0])\t\($s | length)" + ' 2>/dev/null) || indices= + if [ -z "$indices" ]; then + printf 'plain\n' + return 0 + fi + r=${indices%%$'\t'*} + rest=${indices#*$'\t'} + a=${rest%%$'\t'*} + len=${rest#*$'\t'} + case "$r:$a:$len" in + *[!0-9:]*) + printf 'plain\n' + return 0 + ;; + esac + if [ "$r" -lt "$a" ] && [ "$a" -lt $((len - 1)) ]; then + # The doomed workspace sits before the focused one, where the pane-death + # path would land focus on the focused workspace's right neighbor. + # Reposition it behind everything first: insert_index equal to the list + # length is the verified move-to-last form, and removing the moved + # workspace afterward leaves every other relative order untouched. + if fm_backend_herdr_workspace_move_capable "$session"; then + capable=0 + else + capable=$? + fi + if [ "$capable" -ne 0 ]; then + echo "warning: herdr presentation cleanup could not verify workspace.move support; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + fi + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "warning: herdr presentation cleanup found an ambiguous named session socket; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + } + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + before_order=$(printf '%s' "$list" | jq -c '[.result.workspaces[].workspace_id]' 2>/dev/null) + if response=$("$mover" "$socket" "$ws_id" "$len" 2>/dev/null); then + move_status=0 + else + move_status=$? + fi + # Every mover invocation is recorded, even an unverified one, so a later + # unconfirmed removal can restore the exact original order; restoring an + # unmoved workspace to its own position is a verified no-op. + printf 'moved\t%s\t%s\t%s\t%s\t%s\n' "$ws_id" "$r" "$socket" "$focused_ws" "$before_order" + if [ "$move_status" -ne 0 ] \ + || ! printf '%s' "$response" | jq -e --arg ws "$ws_id" --arg focused "$focused_ws" \ + --argjson before "$before_order" ' + ($before | map(select(. != $ws)) + [$ws]) as $expected + | .result.type == "workspace_list" + and ([.result.workspaces[].workspace_id] == $expected) + and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused]) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation cleanup could not move the doomed workspace behind the focused one; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + fi + fi + if shell_pid=$(fm_backend_herdr_pane_idle_shell_pid "$session" "$pane_id"); then + printf 'death %s\n' "$shell_pid" + else + printf 'plain\n' + fi +} + +# fm_backend_herdr_emptying_move_rollback: restore the exact pre-move +# workspace order recorded by an emptying-close plan whose removal was not +# confirmed, under the caller's still-held session lock. +# is the plan's tab-separated +# "moved" +# line, or empty for a no-op when no move was attempted. +# The rollback is verified against the mover's returned order and focus and +# warns on any failure, so a lasting reorder is never silent. +fm_backend_herdr_emptying_move_rollback() { # + local record=$1 marker ws index socket focused order mover response + [ -n "$record" ] || return 0 + IFS=$'\t' read -r marker ws index socket focused order <&2 + return 1 + fi + case "$index" in + ''|*[!0-9]*) + echo "warning: herdr presentation cleanup has a malformed move record after a failed removal; the workspace order may remain changed" >&2 + return 1 + ;; + esac + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + if ! response=$("$mover" "$socket" "$ws" "$index" 2>/dev/null) \ + || ! printf '%s' "$response" | jq -e --argjson expected "$order" --arg focused "$focused" ' + .result.type == "workspace_list" + and ([.result.workspaces[].workspace_id] == $expected) + and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused]) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation cleanup could not restore the original workspace order after a failed removal" >&2 + return 1 + fi +} + +# fm_backend_herdr_death_close_pane: end the exact pane's proved lone idle +# shell so Herdr removes the emptied workspace through its focus-preserving +# pane-death path, then confirm the pane is gone. +# Each signal is sent only while the exact pane still owns the recorded pid +# as its lone idle shell: SIGHUP relies on the proof taken just before, and +# the SIGKILL escalation re-reads the pane's process information and refuses +# unless the same pid is still the pane's strict bare idle shell, so an +# exited or reused pid is never signaled. +# Returns 0 only when the pane is confirmed gone. +fm_backend_herdr_death_close_pane() { # + local session=$1 pane_id=$2 shell_pid=$3 ps_bin attempt max_attempts presence resampled_pid + ps_bin=${FM_HERDR_PS_BIN:-ps} + case "$shell_pid" in + ''|*[!0-9]*) return 1 ;; + esac + command -v "$ps_bin" >/dev/null 2>&1 || return 1 + max_attempts=${FM_BACKEND_HERDR_DEATH_CLOSE_POLLS:-40} + fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1 + kill -HUP "$shell_pid" 2>/dev/null || true + attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + # SIGKILL escalation revalidates exact pane ownership, not just the pid: a + # fresh strict pane sample must still name the SAME shell pid, so a pid + # that exited and was reused by an unrelated process is never signaled. + resampled_pid=$(fm_backend_herdr_pane_idle_shell_sample "$session" "$pane_id") || return 1 + [ "$resampled_pid" = "$shell_pid" ] || return 1 + fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1 + kill -KILL "$shell_pid" 2>/dev/null || true + attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 +} + +# fm_backend_herdr_pid_is_bare_shell: currently resolves to a bare +# recognized shell process per . +# BSD ps reports comm as argv0, so a login shell arrives as "-zsh"; strip the +# login dash exactly like the idle-shell proof's argv0 normalization. +fm_backend_herdr_pid_is_bare_shell() { # + local comm + comm=$("$1" -p "$2" -o comm= 2>/dev/null) || return 1 + comm=$(printf '%s' "$comm" | tr -d '[:space:]') + comm=${comm#-} + comm=${comm##*/} + case "$comm" in sh|bash|zsh|dash|ksh|fish) return 0 ;; esac + return 1 +} + +# fm_backend_herdr_pane_idle_shell_pid: print the shell pid of only +# when the exact pane provably holds one lone idle recognized shell: pane +# process-info agrees on the pane id, the shell pid is both the foreground +# process group and the sole foreground process, the foreground process name +# and argv0 resolve to the same recognized shell, the operating-system +# process table shows exactly that one shell row with no child process, and +# the shell sits in a sleeping or idle state. +# An idle interactive shell transiently hosts short-lived prompt helpers +# (verified on the real 0.7.5 lab: a workspace.move relayout makes zsh redraw +# its prompt, spawning starship as a second foreground process for a few +# samples), so the proof retries strict single samples for a bounded settle +# window and succeeds on the first fully clean one; a genuinely busy pane +# fails every sample and still refuses. +# This is the single owner of the idle-shell proof; the session-start +# projection cleanup and every pane-death close path both rely on it. +fm_backend_herdr_pane_idle_shell_pid() { # + local attempt=0 max_attempts=${FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS:-10} + while :; do + if fm_backend_herdr_pane_idle_shell_sample "$1" "$2"; then + return 0 + fi + attempt=$((attempt + 1)) + [ "$attempt" -lt "$max_attempts" ] || return 1 + sleep 0.1 + done +} + +# fm_backend_herdr_pane_idle_shell_sample: one strict instantaneous +# observation for fm_backend_herdr_pane_idle_shell_pid, which owns the proof +# contract and the settle retry. +fm_backend_herdr_pane_idle_shell_sample() { # + local session=$1 pane=$2 info shell_pid foreground_pgid count + local process_pid name argv0 shell_name rows stat ps_bin + info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane" 2>/dev/null) || return 1 + printf '%s' "$info" | jq -e --arg pane "$pane" ' + .result.type == "pane_process_info" + and .result.process_info.pane_id == $pane + ' >/dev/null 2>&1 || return 1 + shell_pid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 + foreground_pgid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_process_group_id | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 + [ "$foreground_pgid" = "$shell_pid" ] || return 1 + count=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) || return 1 + [ "$count" -eq 1 ] || return 1 + process_pid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes[0].pid | select(type == "number") | floor' 2>/dev/null) || return 1 + [ "$process_pid" = "$shell_pid" ] || return 1 + name=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes[0].name | select(type == "string" and length > 0)' 2>/dev/null) || return 1 + argv0=$(printf '%s' "$info" | jq -er ' + .result.process_info.foreground_processes[0] as $process + | ($process.argv0 // $process.argv[0]) + | select(type == "string" and length > 0) + ' 2>/dev/null) || return 1 + shell_name=${name##*/} + argv0=${argv0#-} + argv0=${argv0##*/} + [ "$argv0" = "$shell_name" ] || return 1 + case "$shell_name" in sh|bash|zsh|dash|ksh|fish) ;; *) return 1 ;; esac + + ps_bin=${FM_HERDR_PS_BIN:-ps} + command -v "$ps_bin" >/dev/null 2>&1 || return 1 + rows=$("$ps_bin" -axo pid=,ppid= 2>/dev/null) || return 1 + printf '%s\n' "$rows" | awk -v shell="$shell_pid" ' + $1 == shell { found++ } + $2 == shell { child++ } + END { exit(found == 1 && child == 0 ? 0 : 1) } + ' || return 1 + stat=$("$ps_bin" -p "$shell_pid" -o stat= 2>/dev/null | tr -d '[:space:]') || return 1 + case "$stat" in S*|I*) ;; *) return 1 ;; esac + printf '%s\n' "$shell_pid" +} + # fm_backend_herdr_projection_order_best_effort: place the exact workspace id # returned by THIS projected create immediately after its owning parent's # contiguous child block and before the next parent. # # is the owning FM_HOME label (firstmate or 2ndmate-). +# Optional is that parent's EXACT id, which the caller +# already resolved from the launching agent's own herdr identity. When given it +# anchors the owning parent by id, so two workspaces sharing the home label no +# longer make the whole layout ambiguous; when omitted the parent is located by +# label exactly as before. With a unique label the two select the same +# workspace, so ordering behavior is unchanged in the ordinary case. # New-format └ ... · p: children and, for compatibility only, already # adjacent old-format firstmate/... or 2ndmate-/... projections may extend # the block read-only; they are never renamed or moved. @@ -644,8 +1036,8 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # - local session=$1 created=$2 parent=$3 list analysis current desired protocol schema socket mover response move_status focus_before +fm_backend_herdr_projection_order_best_effort() { # [] + local session=$1 created=$2 parent=$3 parent_ws=${4:-} list analysis current desired socket mover response move_status focus_before move_capable local before_existing after_existing [ -n "$parent" ] || { echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2 @@ -655,9 +1047,12 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 } - analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" ' + analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" ' def is_parent: - (.label | type) == "string" and .label == $parent; + if ($parent_ws | length) > 0 + then .workspace_id == $parent_ws + else (.label | type) == "string" and .label == $parent + end; def is_top_level_parent: (.label | type) == "string" and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$"))); @@ -732,33 +1127,34 @@ fm_backend_herdr_projection_order_best_effort() { # /dev/null 2>&1 || { - echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 - return 0 - } - protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) - case "$protocol" in - ''|*[!0-9]*) + if fm_backend_herdr_workspace_move_capable "$session"; then + move_capable=0 + else + move_capable=$? + fi + case "$move_capable" in + 0) ;; + 1) + echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 + return 0 + ;; + 2) echo "warning: herdr presentation ordering could not verify the client protocol; leaving worker in Herdr's current order" >&2 return 0 ;; + 3) + echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 + return 0 + ;; + 4) + echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 + return 0 + ;; + *) + echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 + return 0 + ;; esac - if [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ]; then - echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 - return 0 - fi - schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || { - echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 - return 0 - } - if ! printf '%s' "$schema" | jq -e ' - any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") - and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] - and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" - ' >/dev/null 2>&1; then - echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 - return 0 - fi socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { echo "warning: herdr presentation ordering found an ambiguous named session socket; leaving worker in Herdr's current order" >&2 return 0 @@ -779,14 +1175,19 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 fi - if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --argjson desired "$desired" ' + if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" --argjson desired "$desired" ' + def is_parent: + if ($parent_ws | length) > 0 + then .workspace_id == $parent_ws + else (.label | type) == "string" and .label == $parent + end; .result.type == "workspace_list" and (.result.workspaces | type) == "array" and .result.workspaces[$desired].workspace_id == $created - and ([.result.workspaces[] | select(.label == $parent)] | length) == 1 + and ([.result.workspaces[] | select(is_parent)] | length) == 1 and ( [range(0; .result.workspaces | length) as $i - | select(.result.workspaces[$i].label == $parent) + | select(.result.workspaces[$i] | is_parent) | $i][0] < $desired ) ' >/dev/null 2>&1; then @@ -821,14 +1222,20 @@ fm_backend_herdr_server_ensure() { # return 1 } -# fm_backend_herdr_workspace_find: this HOME's own workspace id inside -# (fm_backend_herdr_workspace_label), or empty (never creates). -# Read-only, safe for recovery/list paths. Label-collision semantics -# (docs/herdr-backend.md "Label collisions"): herdr enforces no label -# uniqueness at all, so this adopts the FIRST matching workspace `jq` returns -# (list order, normally creation order/oldest) rather than disambiguating - -# identical in spirit to the pre-existing tab duplicate-label check below. -fm_backend_herdr_workspace_find() { # +# fm_backend_herdr_workspace_find_all: EVERY workspace id inside +# whose label equals this HOME's own label (fm_backend_herdr_workspace_label), +# one per line, in herdr's own list order (normally creation order, oldest +# first). Empty when none match. Never creates anything. +# +# Single owner of the home-label workspace query. Herdr enforces no workspace +# label uniqueness at all (docs/herdr-backend.md "Label collisions"), so this +# can legitimately return MORE THAN ONE id: a captain-owned workspace can +# collide by label, a cwd-basename-derived label can coincide, and concurrent +# first spawns can mint two same-labeled home workspaces. Callers decide what a +# duplicate means for them - fm_backend_herdr_workspace_ensure refuses to guess +# which one is the caller's, while the read-only recovery path below keeps its +# historical first-match behavior. +fm_backend_herdr_workspace_find_all() { # local session=$1 label list label=$(fm_backend_herdr_workspace_label) list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 0 @@ -838,7 +1245,143 @@ fm_backend_herdr_workspace_find() { # # ALWAYS return empty and every spawn mint a fresh "firstmate" workspace # (the workspace leak). printf '%s' "$list" | jq -r --arg want "$label" \ - '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null | head -1 + '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null +} + +# fm_backend_herdr_workspace_find: this HOME's own workspace id inside +# , or empty (never creates). Read-only, safe for recovery/list +# paths, which address panes they already recorded and only need a container +# to scan. Keeps the historical FIRST-match behavior on a label collision - +# identical in spirit to the pre-existing tab duplicate-label check below. +# NOT the spawn-time resolver: placing a new worker by first label match is +# exactly the defect fm_backend_herdr_workspace_ensure now refuses. +fm_backend_herdr_workspace_find() { # + fm_backend_herdr_workspace_find_all "$1" | head -1 +} + +# fm_backend_herdr_launcher_identity: the EXACT herdr workspace that the +# process making this spawn is itself running in. +# +# Herdr 0.7.5 injects HERDR_ENV=1, HERDR_PANE_ID, HERDR_SESSION, +# HERDR_SOCKET_PATH, HERDR_TAB_ID, and HERDR_WORKSPACE_ID into every process it +# manages a pane for (docs/verification/runtime-backends.md), and a firstmate +# or secondmate agent's own tool calls inherit them. Older injection shapes are +# unverified and cannot establish launcher ancestry without both pane and +# socket identity. Workspace LABELS are mutable and herdr enforces no +# uniqueness on them, so a label search cannot tell one `firstmate` workspace +# from another, and herdr's globally focused workspace is whatever the captain +# happens to be looking at, not the launcher's. +# +# The injected HERDR_TAB_ID/HERDR_WORKSPACE_ID are deliberately NOT read as the +# answer. They are a snapshot taken when the pane's process started, and herdr +# can move a pane between tabs and workspaces afterwards without being able to +# rewrite a running process's environment. Only a live read is the CURRENT +# parent, which is what placement has to bind to. +# +# Sets, only on a 0 return: +# FM_BACKEND_HERDR_LAUNCHER_PANE_ID +# FM_BACKEND_HERDR_LAUNCHER_TAB_ID +# FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID +# +# Returns: +# 0 - one exact, self-consistent launcher pane/tab/workspace in . +# 2 - this process is NOT running in a herdr pane (no HERDR_PANE_ID at all), +# so there is no launcher workspace to inherit and the caller falls back +# to its per-home container. HERDR_ENV=1 on its own is only a backend +# SELECTION marker (bin/fm-backend.sh's fm_backend_detect), never a +# parent binding - herdr always injects the pane id alongside it. +# 1 - a launcher pane IS claimed but its binding is missing, stale, +# contradictory, or belongs to another herdr session. The caller must +# refuse before creating or publishing any worker endpoint rather than +# degrading to a label search. +fm_backend_herdr_launcher_identity() { # + local session=$1 pane=${HERDR_PANE_ID:-} claimed_session claimed_socket session_socket + local pane_out tab_out list tab workspace + FM_BACKEND_HERDR_LAUNCHER_PANE_ID="" + FM_BACKEND_HERDR_LAUNCHER_TAB_ID="" + FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID="" + [ -n "$pane" ] || return 2 + + # Same-session proof, before the pane id is trusted at all: herdr pane ids + # ("w2:p1") restart at the same low numbers in every session, so a pane id + # borrowed from another session can silently resolve to a real but unrelated + # workspace here. The injected socket path is the server identity herdr + # exposes, and the session name independently binds the named session. + claimed_session=$(fm_backend_herdr_session) + if [ "$claimed_session" != "$session" ]; then + echo "error: herdr launcher pane '$pane' reports session '$claimed_session' but this spawn targets session '$session'; refusing to place a worker from a cross-session parent identity" >&2 + return 1 + fi + claimed_socket=${HERDR_SOCKET_PATH:-} + if [ -z "$claimed_socket" ]; then + echo "error: herdr launcher pane '$pane' has no injected socket identity; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + fi + claimed_socket=$(fm_backend_herdr_canonical_socket_path "$claimed_socket") || { + echo "error: herdr launcher pane '$pane' reports an unusable socket path; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + } + session_socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "error: herdr session '$session' has no unambiguous socket to match against the launcher pane's own; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + } + if [ "$claimed_socket" != "$session_socket" ]; then + echo "error: herdr launcher pane '$pane' belongs to the server at '$claimed_socket', not session '$session' at '$session_socket'; refusing to place a worker from a cross-session parent identity" >&2 + return 1 + fi + + pane_out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || { + echo "error: herdr launcher pane '$pane' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + tab=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" ' + select(.result.pane.pane_id == $pane) + | select((.result.pane.tab_id | type) == "string" and (.result.pane.tab_id | length) > 0) + | .result.pane.tab_id + ' 2>/dev/null) + workspace=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" ' + select(.result.pane.pane_id == $pane) + | select((.result.pane.workspace_id | type) == "string" and (.result.pane.workspace_id | length) > 0) + | .result.pane.workspace_id + ' 2>/dev/null) + if [ -z "$tab" ] || [ -z "$workspace" ]; then + echo "error: herdr launcher pane '$pane' returned an ambiguous tab or workspace identity in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + fi + + # Independent second read: the tab must agree that it lives in the same + # workspace the pane just claimed. A restored-but-stale pane record that + # disagrees with its own tab is exactly the contradictory binding this must + # refuse rather than resolve. + tab_out=$(fm_backend_herdr_cli "$session" tab get "$tab" 2>/dev/null) || { + echo "error: herdr launcher tab '$tab' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + if ! printf '%s' "$tab_out" | jq -e --arg tab "$tab" --arg workspace "$workspace" ' + .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace + ' >/dev/null 2>&1; then + echo "error: herdr launcher pane '$pane' and tab '$tab' disagree about their workspace in session '$session'; refusing to place a worker from a contradictory parent identity" >&2 + return 1 + fi + + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { + echo "error: could not list herdr workspaces in session '$session' to confirm the launcher's own workspace '$workspace'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + if ! printf '%s' "$list" | jq -e --arg workspace "$workspace" ' + (.result.workspaces | type) == "array" + and ([.result.workspaces[] | select(.workspace_id == $workspace)] | length) == 1 + ' >/dev/null 2>&1; then + echo "error: herdr launcher workspace '$workspace' is missing or duplicated in session '$session'; refusing to place a worker from a stale parent identity" >&2 + return 1 + fi + + # shellcheck disable=SC2034 # callers consume the verified binding's parts + FM_BACKEND_HERDR_LAUNCHER_PANE_ID=$pane + # shellcheck disable=SC2034 # callers consume the verified binding's parts + FM_BACKEND_HERDR_LAUNCHER_TAB_ID=$tab + FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID=$workspace + return 0 } # fm_backend_herdr_workspace_prune_seeded_default_tab: close EXACTLY @@ -903,11 +1446,13 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # , creating it in if absent. Must be called as a PLAIN -# STATEMENT, never through command substitution ($(...)) - it communicates -# through these globals, not solely through stdout, and a command -# substitution forks a subshell that would discard them: +# fm_backend_herdr_workspace_ensure: the workspace this spawn's task tab +# belongs in inside - the launching agent's own exact workspace when +# it has one, otherwise this HOME's persistent workspace, created in if +# absent. Must be called as a PLAIN STATEMENT, never through command +# substitution ($(...)) - it communicates through these globals, not solely +# through stdout, and a command substitution forks a subshell that would +# discard them: # FM_BACKEND_HERDR_WS_ID - the resolved workspace_id (also echoed, # for callers that only need the id) # FM_BACKEND_HERDR_WS_SEEDED_TAB_ID - non-empty ONLY when THIS call just @@ -919,11 +1464,14 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # - local session=$1 cwd=$2 wsid out label +# +# (3rd arg, default "launcher-home") says whether the +# container being ensured belongs to the SAME firstmate home as the process +# calling this: +# launcher-home - a crewmate or scout for the caller's own home. When the +# caller is itself running in a herdr pane, the worker MUST +# land in that exact workspace +# (fm_backend_herdr_launcher_identity), never in whichever +# same-labeled workspace happens to sort first. +# other-home - a --secondmate launch, which stands up a DIFFERENT home's +# own per-home workspace by design. The launcher's workspace +# is deliberately not inherited here. +# With no herdr ancestry at all there is no launcher workspace to inherit, so +# the per-home label lookup below stays the resolver - but it must then resolve +# to exactly ONE workspace. Two same-labeled home workspaces with no launcher +# identity to disambiguate them is an unresolvable placement, and adopting +# either one is the very defect this refuses. +# +# Returns 0 on success, 3 for a refusal whose exact reason is already on +# stderr, and 1 for a failed or unparseable herdr call. +fm_backend_herdr_workspace_ensure() { # [] + local session=$1 cwd=$2 relationship=${3:-launcher-home} wsid out label matches count status FM_BACKEND_HERDR_WS_ID="" FM_BACKEND_HERDR_WS_SEEDED_TAB_ID="" - wsid=$(fm_backend_herdr_workspace_find "$session") + if [ "$relationship" = launcher-home ]; then + fm_backend_herdr_launcher_identity "$session" && status=0 || status=$? + case "$status" in + 0) + FM_BACKEND_HERDR_WS_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID + printf '%s' "$FM_BACKEND_HERDR_WS_ID" + return 0 + ;; + 2) ;; + *) return 3 ;; + esac + fi + label=$(fm_backend_herdr_workspace_label) + matches=$(fm_backend_herdr_workspace_find_all "$session") + count=$(printf '%s' "$matches" | grep -c '[^[:space:]]' || true) + if [ "$count" -gt 1 ]; then + echo "error: ${count} herdr workspaces in session '$session' are labeled '$label' (${matches//$'\n'/ }) and this spawn has no herdr parent pane to identify which one is its own; rename or close the extras, or run firstmate inside the workspace its workers belong in" >&2 + return 3 + fi + wsid=${matches%%$'\n'*} if [ -n "$wsid" ]; then FM_BACKEND_HERDR_WS_ID=$wsid printf '%s' "$wsid" return 0 fi - label=$(fm_backend_herdr_workspace_label) out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" --label "$label" --no-focus 2>/dev/null) || return 1 wsid=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null) [ -n "$wsid" ] || return 1 @@ -974,13 +1560,18 @@ fm_backend_herdr_workspace_ensure() { # # CONTAINER=${RAW%%$'\t'*}; SEEDED_TAB_ID=${RAW#*$'\t'}. The seeded tab id # must be threaded through to fm_backend_herdr_create_task, which is the only # function allowed to prune it (fm_backend_herdr_workspace_prune_seeded_default_tab). -fm_backend_herdr_container_ensure() { # - local cwd=${1:-$PWD} session label +# is passed straight through to +# fm_backend_herdr_workspace_ensure, which owns its meaning. +fm_backend_herdr_container_ensure() { # [] + local cwd=${1:-$PWD} relationship=${2:-launcher-home} session label status fm_backend_herdr_version_check || return 1 session=$(fm_backend_herdr_session) fm_backend_herdr_server_ensure "$session" || return 1 - fm_backend_herdr_workspace_ensure "$session" "$cwd" >/dev/null || { label=$(fm_backend_herdr_workspace_label); echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2; return 1; } - if [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then + fm_backend_herdr_workspace_ensure "$session" "$cwd" "$relationship" >/dev/null && status=0 || status=$? + # A 3 already reported the exact placement it refused to guess at; adding the + # generic message here would bury it. + [ "$status" -ne 3 ] || return 1 + if [ "$status" -ne 0 ] || [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then label=$(fm_backend_herdr_workspace_label) echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2 return 1 @@ -988,6 +1579,43 @@ fm_backend_herdr_container_ensure() { # printf '%s:%s\t%s' "$session" "$FM_BACKEND_HERDR_WS_ID" "$FM_BACKEND_HERDR_WS_SEEDED_TAB_ID" } +# fm_backend_herdr_pane_presence_state: classify one exact pane get response +# as dead|present|unknown from its JSON body, never from process exit status. +fm_backend_herdr_pane_presence_state() { # + local session=$1 pane_id=$2 out code pid + out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1) + code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) + if [ -n "$code" ]; then + [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown' + return 0 + fi + pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + [ "$pid" = "$pane_id" ] && printf 'present' || printf 'unknown' +} + +fm_backend_herdr_workspace_presence_state() { # + local session=$1 workspace_id=$2 out matches + out=$(fm_backend_herdr_cli "$session" workspace list 2>&1) + matches=$(printf '%s' "$out" | jq -r --arg workspace "$workspace_id" ' + select((.result.workspaces | type) == "array") + | [.result.workspaces[] | select(.workspace_id == $workspace)] | length + ' 2>/dev/null) || matches= + case "$matches" in + 0) printf 'dead' ;; + 1) printf 'present' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_herdr_explicit_close_pane_confirmed: issue one explicit close and +# succeed only when a structured follow-up proves the exact pane is gone. +fm_backend_herdr_explicit_close_pane_confirmed() { # + local session=$1 pane_id=$2 presence + fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || return 1 + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] +} + # fm_backend_herdr_pane_agent_state: classify in as one of # dead|no-agent|live|unknown, purely from the JSON body of two read-only # calls - never from process exit status, since a business-logic "not found" @@ -1020,24 +1648,13 @@ fm_backend_herdr_container_ensure() { # # refusal here, never toward closing - this is the conservative # backstop the husk check depends on. fm_backend_herdr_pane_agent_state() { # - local session=$1 pane_id=$2 out code pid status - # 2>&1, not 2>/dev/null: verified empirically that real herdr 0.7.1 writes - # an error response's JSON body to STDERR (success bodies go to stdout), so - # discarding stderr here would blind this function to exactly the - # error.code values (pane_not_found, agent_not_found) it exists to read - - # every OTHER call site in this file discards stderr safely only because - # its caller collapses both the error and the not-an-error paths to the - # same final answer, which this function's dead/no-agent/live/unknown - # distinction cannot afford to do. - out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1) - code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) - if [ -n "$code" ]; then - [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown' - return 0 - fi - pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) - if [ "$pid" != "$pane_id" ]; then - printf 'unknown' + local session=$1 pane_id=$2 out code presence status + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + if [ "$presence" != present ]; then + case "$presence" in + dead|unknown) printf '%s' "$presence" ;; + *) printf 'unknown' ;; + esac return 0 fi out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>&1) @@ -1347,7 +1964,7 @@ fm_backend_herdr_projection_parent_workspace_exact() { # local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6 @@ -1373,7 +1990,6 @@ fm_backend_herdr_projection_live_binding_matches() { # $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX +# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match +# individual BYTES rather than whole multibyte characters, so `[❯›]` silently +# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches +# ANY multibyte glyph in that range - including box-drawing corners like ╰, +# misclassifying a bordered composer's bottom border row as the bare shape. +# An alternation's branches are matched as whole literal byte sequences and +# stay correct regardless of locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -1995,7 +2619,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } @@ -2057,10 +2681,10 @@ EOF # re-invokes this function from scratch with the same text after seeing # an error, which is a human/escalation decision, not an automatic # retry). -# Echoes empty|pending|unknown|send-failed, the SAME vocabulary fm-send.sh -# already branches on for tmux ("empty" means "confirmed submitted" for every -# backend; how each backend confirms it is an internal decision - herdr's is -# no longer literally "the composer read empty"). +# Echoes empty|pending|unknown|send-failed, a subset of the proof-carrying +# submit vocabulary. Empty means confirmed submitted for every backend; how +# each backend confirms it is an internal decision, and herdr's is no longer +# literally "the composer read empty". fm_backend_herdr_send_text_submit() { # local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } @@ -2091,9 +2715,101 @@ fm_backend_herdr_send_text_submit() { # # fm_backend_herdr_kill: remove the task's pane, best-effort (mirrors # tmux-kill-window's `|| true` contract). Verified: closing a tab's only pane # closes the tab too, so a separate tab close is unnecessary. +# When the close would empty a non-focused workspace, Herdr 0.7.5's explicit +# close moves focus to that workspace's neighbor with no restore anywhere in +# this path, so the kill follows the same focus-safe removal plan as +# projected cleanup (a verified pane-death removal with the doomed workspace +# repositioned behind the focused one when needed), keeping the exact-tab +# restore as the backstop. A close that empties the FOCUSED workspace moves +# focus legitimately, and every in-lock planning ambiguity or failure falls +# back to the plain close, matching the pre-hardening contract. +fm_backend_herdr_kill_serialized() { # + local session=$1 pane=$2 + local before active_tab info target_pane target_tab target_ws plan shell_pid plan_move_record close_failed workspace_presence + before=$(fm_backend_herdr_projection_focus_snapshot "$session") || before= + if [ -n "$before" ]; then + active_tab=${before#*$'\t'} + info=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || info= + target_pane=$(printf '%s' "$info" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) + if [ "$target_pane" = "$pane" ] && [ -n "$target_tab" ] && [ "$target_tab" != "$active_tab" ]; then + plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane" "$target_ws" "$target_tab" "${before%%$'\t'*}") + plan_move_record= + case "$plan" in + moved$'\t'*) + plan_move_record=${plan%%$'\n'*} + plan=${plan##*$'\n'} + ;; + esac + close_failed=0 + case "$plan" in + death\ *) + shell_pid=${plan#death } + if ! fm_backend_herdr_death_close_pane "$session" "$pane" "$shell_pid" \ + && ! fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane"; then + close_failed=1 + fi + ;; + *) + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || close_failed=1 + ;; + esac + if [ "$close_failed" = 0 ] && [ -n "$plan_move_record" ]; then + workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws") + if [ "$workspace_presence" != dead ]; then + echo "warning: herdr task kill did not confirm removal of the repositioned workspace" >&2 + close_failed=1 + fi + fi + if [ "$close_failed" = 1 ]; then + fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true + fi + fm_backend_herdr_projection_focus_restore "$session" "$before" "task kill" || true + return 0 + fi + fi + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || true +} + fm_backend_herdr_kill() { # fm_backend_herdr_target_ready "$1" || return 0 - fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane close "$FM_BACKEND_HERDR_PANE" >/dev/null 2>&1 || true + local session=$FM_BACKEND_HERDR_SESSION pane=$FM_BACKEND_HERDR_PANE + local lock_path attempt=0 lock_held=0 + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then + # shellcheck source=bin/fm-wake-lib.sh + . "$FM_BACKEND_HERDR_ROOT/bin/fm-wake-lib.sh" + fi + if lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session"); then + while [ "$attempt" -lt 50 ]; do + if fm_lock_try_acquire "$lock_path"; then + lock_held=1 + break + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + fi + if [ "$lock_held" = 1 ]; then + fm_backend_herdr_kill_serialized "$session" "$pane" + fm_lock_release "$lock_path" || true + else + echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2 + fi +} + +# fm_backend_herdr_endpoint_confirmed_gone: gate durable-record removal on +# the exact recorded pane's structured presence +# (fm_backend_herdr_pane_presence_state), read-only, so a refused, skipped, +# or failed close never erases a live task's endpoint identity. +# Only a structured pane_not_found proves the endpoint gone; present and +# unknown presence refuse after every close path, and a missing or malformed +# target identity is ambiguity that also refuses, never proof of a gone pane. +fm_backend_herdr_endpoint_confirmed_gone() { # + local presence + fm_backend_herdr_parse_target "$1" || return 1 + presence=$(fm_backend_herdr_pane_presence_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE") + [ "$presence" = dead ] } # fm_backend_herdr_classify_agent_status: map a raw `agent get` agent_status diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index e806528890b..ad665f23738 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -126,10 +126,22 @@ fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" } -# fm_backend_tmux_kill: remove the task's window, best-effort. Mirrors -# fm-teardown.sh's `tmux kill-window -t "$T" 2>/dev/null || true`. +# fm_backend_tmux_kill: remove one explicitly named task window, best-effort. +# Empty, omitted, and malformed targets return nonzero before invoking tmux so +# tmux can never interpret an empty target as the caller's current window. fm_backend_tmux_kill() { # - tmux kill-window -t "$1" 2>/dev/null || true + local target=${1:-} session window + case "$target" in + *:*) + session=${target%%:*} + window=${target#*:} + ;; + *) return 1 ;; + esac + case "$session:$window" in + :*|*:|*:*:*) return 1 ;; + esac + tmux kill-window -t "=$session:=$window" 2>/dev/null || true } # fm_backend_tmux_current_command: 's live foreground process name - @@ -190,7 +202,7 @@ fm_backend_tmux_agent_state() { # } comm=${comm#-} case "$comm" in - *claude*|*codex*|*opencode*|*grok*) printf 'alive' ;; + *claude*|*codex*|*opencode*|*grok*|*kimi*|pi|pi-signed|pi-launcher|Pi) printf 'alive' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; '') printf 'unreadable' ;; *) printf 'ambiguous' ;; @@ -206,3 +218,4 @@ fm_backend_tmux_agent_alive() { # *) printf 'unknown' ;; esac } + diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 162ea5477b6..20d53a3c2de 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -501,8 +501,8 @@ fm_backend_zellij_capture() { # [expected-label] # also the load-bearing defense against the # unconditional-exit-0 CLI quirk documented in the file header: a truly dead # target never shows a change, so it correctly reports pending/unknown rather -# than a false "sent". Echoes empty|pending|unknown|send-failed, the SAME -# vocabulary fm-send.sh already branches on for tmux and herdr. +# than a false "sent". Echoes empty|pending|unknown|send-failed, a subset of the +# proof-carrying submit vocabulary. fm_backend_zellij_send_text_submit() { # [expected-label] local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} typed after i=0 fm_backend_zellij_send_literal "$target" "$text" "$expected_label" || { printf 'send-failed'; return 0; } diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 57b7f6590db..4be7d6a349f 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -48,6 +48,28 @@ set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +case "$FM_HOME" in + /*) ;; + *) + FM_AFK_LAUNCH_HOME_INPUT=$FM_HOME + FM_HOME=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_HOME_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_HOME directory cannot be resolved: $FM_AFK_LAUNCH_HOME_INPUT" >&2 + exit 1 + } + ;; +esac +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + case "$FM_STATE_OVERRIDE" in + /*) ;; + *) + FM_AFK_LAUNCH_STATE_INPUT=$FM_STATE_OVERRIDE + FM_STATE_OVERRIDE=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_STATE_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_STATE_OVERRIDE directory cannot be resolved: $FM_AFK_LAUNCH_STATE_INPUT" >&2 + exit 1 + } + ;; + esac +fi FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" @@ -606,10 +628,15 @@ fm_afk_launch_stop() { fm_afk_launch_main() { local result - fm_afk_launch_lock_acquire || return 1 + # Traps first, lock second. Acquiring before the handlers exist leaves a + # window where a signal terminates this process by default action and leaks + # the lock directory, which then blocks the next away-mode launch until the + # stale-owner reclaim path clears it. fm_afk_launch_lock_release only removes + # a lock this process owns, so arming it before acquisition is safe. trap fm_afk_launch_lock_release EXIT trap 'exit 130' INT trap 'exit 143' TERM + fm_afk_launch_lock_acquire || return 1 case "${1:-start}" in start) fm_afk_launch_start ;; start-native) fm_afk_launch_start_native ;; diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 6df28776368..e505b99f757 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -360,6 +360,177 @@ fm_backend_target_of_meta() { # [ -n "$window" ] && printf '%s' "$window" } +# fm_backend_validate_task_endpoint: validate a task cleanup record entirely +# from its durable metadata before any runtime command or cleanup mutation. +# The validation binds the exact task id, selected backend, target, project, +# and worktree. New non-tmux records carry endpoint_task_id because their +# opaque runtime ids do not encode the task label. Legacy tmux records remain +# valid only when their window name itself is exactly fm-. +# On success, sets FM_BACKEND_VALIDATED_BACKEND and +# FM_BACKEND_VALIDATED_TARGET. On failure, prints one refusal and returns 1. +fm_backend_meta_exact_value() { # + local meta=$1 key=$2 count value + count=$(grep -c "^$key=" "$meta" 2>/dev/null || true) + [ "$count" -eq 1 ] || return 1 + value=$(grep "^$key=" "$meta" | cut -d= -f2-) + [ -n "$value" ] || return 1 + printf '%s' "$value" +} + +fm_backend_endpoint_atom_valid() { # + case "$1" in + ''|*[!A-Za-z0-9._@%+-]*) return 1 ;; + esac +} + +fm_backend_validate_task_endpoint() { # + local meta=$1 id=$2 backend_count backend window worktree project binding_count binding + local session pane recorded_session workspace tab terminal worktree_id surface + FM_BACKEND_VALIDATED_BACKEND= + FM_BACKEND_VALIDATED_TARGET= + [ -f "$meta" ] && [ ! -L "$meta" ] || { + echo "REFUSED: task $id has no regular endpoint metadata at $meta; preserving task state." >&2 + return 1 + } + case "$id" in ''|*[!A-Za-z0-9._-]*) + echo "REFUSED: task endpoint identity has an invalid task id; preserving task state." >&2 + return 1 + esac + window=$(fm_backend_meta_exact_value "$meta" window) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous window endpoint; preserving task state." >&2 + return 1 + } + worktree=$(fm_backend_meta_exact_value "$meta" worktree) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous worktree identity; preserving task state." >&2 + return 1 + } + project=$(fm_backend_meta_exact_value "$meta" project) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous project identity; preserving task state." >&2 + return 1 + } + case "$worktree$project$window" in *$'\n'*|*$'\r'*|*$'\t'*) + echo "REFUSED: task $id has malformed endpoint metadata; preserving task state." >&2 + return 1 + esac + backend_count=$(grep -c '^backend=' "$meta" 2>/dev/null || true) + case "$backend_count" in + 0) backend=tmux ;; + 1) backend=$(fm_backend_meta_exact_value "$meta" backend) || backend= ;; + *) backend= ;; + esac + if [ -z "$backend" ] || ! fm_backend_is_known "$backend"; then + echo "REFUSED: task $id has a missing, ambiguous, or unknown backend identity; preserving task state." >&2 + return 1 + fi + binding_count=$(grep -c '^endpoint_task_id=' "$meta" 2>/dev/null || true) + case "$binding_count" in + 0) binding= ;; + 1) + binding=$(fm_backend_meta_exact_value "$meta" endpoint_task_id) || { + echo "REFUSED: task $id has an empty endpoint task binding; preserving task state." >&2 + return 1 + } + ;; + *) + echo "REFUSED: task $id has an ambiguous endpoint task binding; preserving task state." >&2 + return 1 + ;; + esac + if [ -n "$binding" ] && [ "$binding" != "$id" ]; then + echo "REFUSED: endpoint metadata belongs to task $binding, not $id; preserving task state." >&2 + return 1 + fi + + case "$backend" in + tmux) + session=${window%%:*} + pane=${window#*:} + if [ "$pane" = "$window" ] || [ "$pane" != "fm-$id" ] \ + || [ -z "$session" ]; then + echo "REFUSED: tmux endpoint '$window' is malformed or does not belong to task $id; preserving task state." >&2 + return 1 + fi + ;; + herdr) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Herdr endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" herdr_session) || recorded_session= + workspace=$(fm_backend_meta_exact_value "$meta" herdr_workspace_id) || workspace= + tab=$(fm_backend_meta_exact_value "$meta" herdr_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" herdr_pane_id) || pane= + if [ -z "$recorded_session" ] || [ -z "$workspace" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session" \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "${tab//:/_}" \ + || ! fm_backend_endpoint_atom_valid "${pane//:/_}"; then + echo "REFUSED: Herdr endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + zellij) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Zellij endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" zellij_session) || recorded_session= + tab=$(fm_backend_meta_exact_value "$meta" zellij_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" zellij_pane_id) || pane= + case "$tab:$pane" in *[!0-9:]*) tab= ;; esac + if [ -z "$recorded_session" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session"; then + echo "REFUSED: Zellij endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + orca) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Orca endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + terminal=$(fm_backend_meta_exact_value "$meta" terminal) || terminal= + worktree_id=$(fm_backend_meta_exact_value "$meta" orca_worktree_id) || worktree_id= + [ -n "$terminal" ] || { + echo "REFUSED: missing terminal in $meta; cannot close Orca endpoint; preserving task state." >&2 + return 1 + } + [ -n "$worktree_id" ] || { + echo "REFUSED: missing orca_worktree_id in $meta; cannot remove Orca worktree; preserving task state." >&2 + return 1 + } + if [ "$window" != "fm-$id" ] \ + || ! fm_backend_endpoint_atom_valid "$terminal" \ + || ! fm_backend_endpoint_atom_valid "$worktree_id"; then + echo "REFUSED: Orca endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + window=$terminal + ;; + cmux) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy cmux endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + workspace=$(fm_backend_meta_exact_value "$meta" cmux_workspace_id) || workspace= + surface=$(fm_backend_meta_exact_value "$meta" cmux_surface_id) || surface= + if [ -z "$workspace" ] || [ -z "$surface" ] || [ "$window" != "$workspace:$surface" ] \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "$surface"; then + echo "REFUSED: cmux endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + esac + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_BACKEND=$backend + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_TARGET=$window + return 0 +} + fm_backend_meta_for_window() { # local target=$1 state=$2 meta window terminal for meta in "$state"/*.meta; do @@ -407,12 +578,7 @@ fm_backend_of_selector() { # meta=$(fm_backend_meta_for_window "$resolved" "$state" 2>/dev/null || true) [ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } fi - # Neither metadata lookup resolved a task (e.g. a firstmate session's own - # pane): fall back through the home's normal backend resolution - # (env, then config/backend, then runtime detection, then tmux) instead of - # hardcoding tmux, so a herdr/zellij/orca/cmux home peeks its own pane - # correctly. - fm_backend_name + printf 'tmux' } fm_backend_expected_label_of_selector() { # @@ -556,8 +722,8 @@ fm_backend_send_key() { # [expected-label] } # fm_backend_send_text_submit: type text once, then submit and verify, -# retrying only the submission (never retyping). Echoes the verdict -# (empty|pending|unknown|send-failed for submit-verifying adapters). +# retrying only the submission (never retyping). Echoes the backend's +# proof-carrying verdict; callers require exact empty for confirmed delivery. fm_backend_send_text_submit() { # [expected-label] local backend=$1 shift @@ -578,6 +744,7 @@ fm_backend_send_text_submit() { # local backend=$1 shift + [ -n "${1:-}" ] || { echo "error: refusing empty backend kill target" >&2; return 1; } fm_backend_source "$backend" || return 1 case "$backend" in tmux) fm_backend_tmux_kill "$@" ;; @@ -613,9 +780,9 @@ fm_backend_worktree_path() { # # native agent-state (herdr-addendum "busy state" row - the first backend # where this gets real semantics beyond pane-regex). Backends with no such # primitive (tmux) report unknown. Callers own the fallback policy: fm-watch.sh -# uses unknown as the cue for its pane-hash + FM_BUSY_REGEX detection, while -# fm-crew-state.sh also corroborates native idle verdicts before treating a -# no-run crew as not busy. +# uses unknown as the cue for harness-scoped pane-tail detection, while +# fm-crew-state.sh also corroborates native idle verdicts with the recorded +# harness's signature before treating a no-run crew as not busy. fm_backend_busy_state() { # local backend=$1 shift @@ -627,8 +794,8 @@ fm_backend_busy_state() { # } # fm_backend_composer_state: classify the composer/input row of as -# empty|pending|unknown for callers that need a pre-submit pending-input guard -# or an adapter's conservative submit fallback. It is exposed generically so a +# empty|pending|pending-unproven|unknown for callers that need a pre-submit +# input guard or an adapter's conservative submit fallback. It is exposed so a # caller other than the send path (the away-mode daemon's supervisor-pane # pending-input guard, bin/fm-supervise-daemon.sh) can ask the same question # without duplicating per-backend composer-reading logic. tmux and herdr both @@ -638,7 +805,7 @@ fm_backend_busy_state() { # # submit path uses an internal content-diff approach with no separately named # classifier, so it reports unknown here - callers fall back to their own # policy, exactly as an unknown fm_backend_busy_state already does. -fm_backend_composer_state() { # -> empty|pending|unknown +fm_backend_composer_state() { # -> empty|pending|pending-unproven|unknown local backend=$1 shift fm_backend_source "$backend" || { printf 'unknown'; return 0; } diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 66da67b8a87..29ab9cd2d66 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -53,21 +53,19 @@ REG="$DATA/secondmates.md" MAIN_BACKLOG="$DATA/backlog.md" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" [ $# -ge 2 ] || { echo "usage: fm-backlog-handoff.sh ..." >&2; exit 1; } ID=$1 shift secondmate_home() { - local id=$1 line + local id=$1 home [ -f "$REG" ] || { echo "error: no secondmate registry at $REG" >&2; return 1; } - line=$(grep -E "^- $id( |$)" "$REG" | tail -1 || true) - [ -n "$line" ] || { echo "error: secondmate $id is not registered in $REG" >&2; return 1; } - # Match the (home: ...) field itself; do not require zero parentheses before it. - # Summary/scope prose often contains parentheticals (e.g. "(id is legacy)"), and - # ^[^(]* would leave those entries looking like "has no home". Greedy prefix so the - # last (home: ...) on the line wins. Empty when the field is absent. - printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;)]*\);.*/\1/p' | sed 's/[[:space:]]*$//' + home=$(secondmate_registry_field "$REG" "$id" home || true) + [ -n "$home" ] || { echo "error: secondmate $id has no home in $REG" >&2; return 1; } + printf '%s\n' "$home" } path_is_ancestor_of() { diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 9eded56b991..17ad086f113 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -8,6 +8,7 @@ # Lines: "MISSING: (install: )", # "MISSING_MANUAL: (instructions: )", "NEEDS_GH_AUTH", # "BACKEND_INVALID: (known: )", +# "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "PR_CHECK_MIGRATION: ", @@ -52,8 +53,16 @@ # lavish-axi). tasks-axi is also version and feature gated (0.1.1+ # with update --archive-body and mv [...]); an installed but # incompatible build reports MISSING like no-mistakes. A compatible -# tasks-axi default backend is silent. quota-axi is required for the -# agent-owned dispatch-profile array procedure in AGENTS.md section 4. +# tasks-axi default backend is silent. quota-axi is required for the agent-owned dispatch-profile array procedure in AGENTS.md section 4. +# and .agents/skills/quota-array-dispatch/SKILL.md, and is also version +# gated by fm-quota-axi-lib.sh, which owns that floor and its rationale. +# An older build reports MISSING like no-mistakes rather than passing +# silently while emitting auth semantics dispatch cannot scope. +# On a primary home, the locked mutable path materializes the visible +# default config/startup-memory-budget=7500 when absent. It never +# guesses at malformed or unsafe existing files, and secondmate homes +# await the primary-authoritative inherited value instead of creating +# their own. # X mode is OPTIONAL and inert unless FM_HOME/.env has a non-empty # FMX_PAIRING_TOKEN. When opted in, bootstrap requires curl+jq, writes # the relay poll shim and 30s cadence config, and prints an FMX line. @@ -90,12 +99,16 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-quota-axi-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-quota-axi-lib.sh" # shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-ff-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-config-inherit-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-startup-memory-budget-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" # shellcheck source=bin/fm-x-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-backend.sh disable=SC1091 @@ -436,7 +449,7 @@ secondmate_liveness_sweep() { [ -n "$target" ] || target="$window" agent_state=$(fm_backend_agent_state "$backend" "$target" 2>/dev/null) || agent_state=unreadable case "$harness" in - claude|codex|opencode|pi|grok) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi) ;; *) case "$agent_state" in dead|missing) agent_state=unverified-harness ;; esac ;; @@ -521,31 +534,31 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then BACKEND_TOOLS="" fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" -NO_MISTAKES_MIN_MAJOR=1 -NO_MISTAKES_MIN_MINOR=31 -NO_MISTAKES_MIN_PATCH=2 +NO_MISTAKES_MIN=1.31.2 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' } -no_mistakes_version_parts() { - local output - command -v no-mistakes >/dev/null 2>&1 || return 1 - output=$(no-mistakes --version 2>/dev/null) || return 1 - printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1 -} - -no_mistakes_compatible() { - local parts major minor patch extra - parts=$(no_mistakes_version_parts) || return 1 +# Shared semantic-version floor for the tool gates below. A version string that +# cannot be parsed into exactly one major.minor.patch triple is incompatible, +# never assumed current, so a development or vendored build cannot pass a floor +# it was never checked against. +tool_version_at_least() { # + local tool=$1 min=$2 output parts major minor patch extra + local min_major min_minor min_patch min_extra + command -v "$tool" >/dev/null 2>&1 || return 1 + output=$("$tool" --version 2>/dev/null) || return 1 + parts=$(printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1) IFS=' ' read -r major minor patch extra <<< "$parts" [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 - [ "$major" -gt "$NO_MISTAKES_MIN_MAJOR" ] && return 0 - [ "$major" -eq "$NO_MISTAKES_MIN_MAJOR" ] || return 1 - [ "$minor" -gt "$NO_MISTAKES_MIN_MINOR" ] && return 0 - [ "$minor" -eq "$NO_MISTAKES_MIN_MINOR" ] || return 1 - [ "$patch" -ge "$NO_MISTAKES_MIN_PATCH" ] + IFS='.' read -r min_major min_minor min_patch min_extra <<< "$min" + [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 + [ "$major" -gt "$min_major" ] && return 0 + [ "$major" -eq "$min_major" ] || return 1 + [ "$minor" -gt "$min_minor" ] && return 0 + [ "$minor" -eq "$min_minor" ] || return 1 + [ "$patch" -ge "$min_patch" ] } x_mode_write_if_changed() { @@ -619,7 +632,7 @@ x_mode_remove_artifact() { # applying a cadence transition to a running watcher is the caller's job via # the emitted harness-aware supervision repair instruction. x_mode_setup() { - local env_file token shim cadence shim_body cadence_body tool missing + local env_file token shim cadence shim_body cadence_body tool missing shim_home env_file="$FM_HOME/.env" shim="$STATE/x-watch.check.sh" cadence="$CONFIG/x-mode.env" @@ -682,9 +695,16 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(fmx_poll_shim_content "$FM_HOME" "$FM_ROOT") + case "$FM_HOME" in + /*) shim_home=$FM_HOME ;; + *) + shim_home=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) \ + || { fmx_arm_failed; return 0; } + ;; + esac + shim_body=$(fmx_poll_shim_content "$shim_home" "$FM_ROOT") x_mode_write_if_changed "$shim" "$shim_body" 700 || { fmx_arm_failed; return 0; } - fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" \ + fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \ || { fmx_arm_failed; return 0; } cadence_body=$(cat <<'EOF' @@ -713,15 +733,15 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","grok"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false elif $h == "claude" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) - elif $h == "pi" then (["low","medium","high","xhigh","max"] | index($e)) - elif $h == "opencode" then false + elif $h == "pi" or $h == "pi-signed" then (["low","medium","high","xhigh","max"] | index($e)) + elif $h == "opencode" or $h == "kimi" then false else true end; def profiles($value): @@ -797,6 +817,18 @@ crew_dispatch_validate() { fi } +startup_memory_budget_setup() { + # Primary bootstrap owns default publication. A secondmate is deliberately + # passive here because its setting must converge from the primary through the + # inherited-local-material contract rather than becoming a local authority. + if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + return 0 + fi + if ! fm_startup_memory_budget_materialize "$CONFIG"; then + echo "STARTUP_MEMORY_BUDGET: invalid config/$FM_STARTUP_MEMORY_BUDGET_FILE - $FM_STARTUP_MEMORY_BUDGET_ERROR" + fi +} + if [ "${1:-}" = "install" ]; then shift [ $# -gt 0 ] || { echo "usage: fm-bootstrap.sh install ..." >&2; exit 1; } @@ -819,6 +851,7 @@ fi # runnable. Detect-only sessions never touch state. if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then "$SCRIPT_DIR/fm-pr-check-migrate.sh" || true + startup_memory_budget_setup fi if [ "$BACKEND_VALID" -eq 0 ]; then @@ -838,17 +871,19 @@ if fm_backend_list_contains "$TOOLS" treehouse \ && command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi -if command -v no-mistakes >/dev/null 2>&1 && ! no_mistakes_compatible; then +if command -v no-mistakes >/dev/null 2>&1 && ! tool_version_at_least no-mistakes "$NO_MISTAKES_MIN"; then echo "MISSING: no-mistakes (install: $(install_cmd no-mistakes))" fi +if command -v quota-axi >/dev/null 2>&1 && ! fm_quota_axi_compatible; then + echo "MISSING: quota-axi (install: $(install_cmd quota-axi))" +fi if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH" -# Worktree-tangle checks: the firstmate primary checkout (FM_ROOT) must sit on -# its default branch and must not have uncommitted changes to tracked files (see -# fm-tangle-lib.sh). Both checks are scoped to the primary only; detached-HEAD -# worktrees and secondmate homes never trip either. +# Worktree-tangle check: the firstmate primary checkout (FM_ROOT) must sit on its +# default branch, not a feature branch (see fm-tangle-lib.sh). Scoped to the +# primary only; detached-HEAD worktrees and secondmate homes never trip it. tangle_branch=$(fm_primary_tangle_branch "$FM_ROOT" 2>/dev/null || true) if [ -n "$tangle_branch" ]; then tangle_default=$(fm_default_branch "$FM_ROOT" 2>/dev/null || echo main) @@ -858,14 +893,6 @@ if [ -n "$tangle_branch" ]; then echo "TANGLE: primary checkout on feature branch '$tangle_branch' (expected '$tangle_default'); the work is safe on that ref - restore the primary with: git -C $FM_ROOT checkout $tangle_default, then re-validate the branch in a proper worktree" fi fi -tangle_dirty=$(fm_primary_tangle_dirty "$FM_ROOT" 2>/dev/null || true) -if [ -n "$tangle_dirty" ]; then - if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" = 1 ]; then - echo "TANGLE: primary checkout has uncommitted changes to tracked files; the work is safe - read-only session must leave restore work to the session holding the fleet lock" - else - echo "TANGLE: primary checkout has uncommitted changes to tracked files; the work is safe - create a branch and commit: git -C $FM_ROOT switch -c && git -C $FM_ROOT commit -a -m wip, or stash: git -C $FM_ROOT stash" - fi -fi crew= [ -f "$CONFIG/crew-harness" ] && crew=$(tr -d '[:space:]' < "$CONFIG/crew-harness" || true) if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && [ -n "$crew" ] && [ "$crew" != "default" ]; then diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 9d26187181a..fc289dd6e86 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -6,7 +6,7 @@ # description, acceptance criteria, and context, and may adjust other sections # when the task genuinely deviates (e.g. working an existing external PR instead # of shipping a new one). -# Usage: fm-brief.sh [--scout] [--herdr-lab] [--harness ] +# Usage: fm-brief.sh [--scout] [--herdr-lab] # fm-brief.sh --secondmate {...|--no-projects} # --scout writes the scout contract instead: the deliverable is a report at # data//report.md (no branch, no push, no PR) and the worktree is scratch. @@ -26,10 +26,6 @@ # The flag must be explicit because {TASK} is filled after scaffolding and the # caller-supplied repo string cannot reliably identify this repo. Briefs made # without it carry a loud declaration so an omitted contract cannot be silent. -# --harness selects the target crew harness (claude, codex, opencode, pi, grok; -# absent = claude-compatible default). Renders skill invocations with the correct -# prefix ($ for codex, / for others) in generated briefs. Applies only to ship and -# scout briefs; secondmate charters are harness-agnostic. # For ship tasks, the definition of done is shaped by the project's delivery mode # (data/projects.md via fm-project-mode.sh; see the project-management skill # and AGENTS.md task lifecycle): @@ -39,8 +35,6 @@ # captain approves, firstmate merges to local main # Ship briefs begin with a worktree-isolation assertion before the branch step. # Scout tasks ignore mode - their deliverable is a report, not a merge. -# They still carry the same crash-durability rule: keep notes or scratch -# artifacts current and commit each completed slice before moving on. # Every scaffold's status protocol distinguishes the configured # declared-external-wait verb (FM_CLASSIFY_PAUSED_VERB, default "paused") from # "blocked:": pause for a known external wait expected to clear on its own, @@ -50,11 +44,6 @@ # it carries the AGENTS.md authoring bar (widely useful knowledge only, pointers # over copied detail) and has the crewmate add the fm-ensure-agents-md.sh # self-governance section when a touched project AGENTS.md lacks it. -# Ship and scout tasks must keep crash-durable progress on disk as they work. -# Status appends stay reserved for supervisor-actionable phase changes, while -# the crewmate's own notes, scratch artifacts, and slice commits carry the work. -# A committed slice is context you can safely forget, and trivial one-file work -# should stay a single slice instead of being forced into artificial commits. # Refuses to overwrite an existing brief. set -eu @@ -77,23 +66,42 @@ esac # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" -DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" -STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +FM_HOME=$(resolve_directory_input FM_HOME "${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}") || exit 1 +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + DATA=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +else + DATA="$FM_HOME/data" +fi +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + STATE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +else + STATE="$FM_HOME/state" +fi KIND=ship HERDR_LAB=0 NO_PROJECTS=0 -HARNESS=claude POS=() -while [ "$#" -gt 0 ]; do - case "$1" in - --scout) KIND=scout; shift ;; - --secondmate) KIND=secondmate; shift ;; - --herdr-lab) HERDR_LAB=1; shift ;; - --no-projects) NO_PROJECTS=1; shift ;; - --harness) HARNESS="$2"; shift 2 ;; - *) POS+=("$1"); shift ;; +for a in "$@"; do + case "$a" in + --scout) KIND=scout ;; + --secondmate) KIND=secondmate ;; + --herdr-lab) HERDR_LAB=1 ;; + --no-projects) NO_PROJECTS=1 ;; + *) POS+=("$a") ;; esac done ID=${POS[0]} @@ -108,21 +116,6 @@ if [ "$NO_PROJECTS" -eq 1 ] && [ "$KIND" != secondmate ]; then exit 1 fi -case "$HARNESS" in - claude|codex|opencode|pi|grok) ;; - *) - echo "error: invalid harness '$HARNESS' (must be one of: claude, codex, opencode, pi, grok)" >&2 - exit 1 - ;; -esac - -if [ "$HARNESS" = codex ]; then - SKILL_PREFIX='$' -else - SKILL_PREFIX='/' -fi - - BRIEF="$DATA/$ID/brief.md" [ -e "$BRIEF" ] && { echo "error: $BRIEF already exists" >&2; exit 1; } mkdir -p "$DATA/$ID" @@ -222,59 +215,6 @@ fi REPO=${POS[1]} -# Detect if the target project is firstmate itself. This can carry a -# supervisor manual that can confuse a worker about its role, so it needs -# detecting independent of whether this home registers firstmate under -# projects/ at all -- most homes do not, since firstmate is not normally -# a project of itself. IS_FIRSTMATE fires when any of three signals -# resolves to the same real directory as $FM_ROOT, always compared as -# fully resolved physical paths (never a raw string or basename-only -# comparison): -# 1. the existing projects/$REPO registry entry, when one exists -# 2. an absolute or relative path argument that itself resolves to $FM_ROOT -# 3. the normal case -- a bare repo name naming firstmate's own checkout -# directory, confirmed by resolving that name as a sibling of $FM_ROOT -IS_FIRSTMATE=0 -ROOT_REAL=$(cd "$FM_ROOT" 2>/dev/null && pwd -P) || true -if [ -n "$ROOT_REAL" ]; then - FIRSTMATE_PROJ_PATH="$FM_HOME/projects/$REPO" - if [ -e "$FIRSTMATE_PROJ_PATH" ]; then - PROJ_REAL=$(cd "$FIRSTMATE_PROJ_PATH" 2>/dev/null && pwd -P) || true - if [ -n "$PROJ_REAL" ] && [ "$PROJ_REAL" = "$ROOT_REAL" ]; then - IS_FIRSTMATE=1 - fi - fi - if [ "$IS_FIRSTMATE" -eq 0 ] && [ -e "$REPO" ]; then - REPO_REAL=$(cd "$REPO" 2>/dev/null && pwd -P) || true - if [ -n "$REPO_REAL" ] && [ "$REPO_REAL" = "$ROOT_REAL" ]; then - IS_FIRSTMATE=1 - fi - fi - if [ "$IS_FIRSTMATE" -eq 0 ]; then - SIBLING_PATH="$(dirname "$ROOT_REAL")/$REPO" - if [ -e "$SIBLING_PATH" ]; then - SIBLING_REAL=$(cd "$SIBLING_PATH" 2>/dev/null && pwd -P) || true - if [ -n "$SIBLING_REAL" ] && [ "$SIBLING_REAL" = "$ROOT_REAL" ]; then - IS_FIRSTMATE=1 - fi - fi - fi -fi - -FIRSTMATE_DISCLAIMER="" -if [ "$IS_FIRSTMATE" -eq 1 ]; then - FIRSTMATE_DISCLAIMER=$(cat <<'EOF' - -# Working on the firstmate repository - read this first - -**AGENTS.md and CLAUDE.md document the SUPERVISOR role. That role is not yours.** You are a worker. -Never run `bin/fm-session-start.sh` or any `bin/fm-*.sh` fleet command, never acquire the fleet lock, never operate "read-only because another session holds the lock". -A treehouse pool path, Orca-managed worktree, or any other path under a pool or scratch directory IS your isolated worktree and is NOT the primary checkout. -Read `AGENTS.md` and `CLAUDE.md` only as documentation of the code you are changing. -EOF -) -fi - if [ "$HERDR_LAB" -eq 1 ]; then HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh") # shellcheck disable=SC2016 # single quotes are deliberate: these lines are literal brief text whose backtick-wrapped $(...) and "$HERDR_LAB_SESSION" snippets must reach the reading agent verbatim, not expand at scaffold time; only the '"$VAR"' break-outs interpolate. @@ -298,19 +238,18 @@ HERDR_SECTION=$(printf '%s\n' \ 'Never bypass the helper, even for a read-only lifecycle probe or cleanup after failure.' \ 'The captain fleet uses the running `default` session.') else -HERDR_SECTION=$(cat <<'EOF' +IFS= read -r -d '' HERDR_SECTION <<'EOF' || true # Herdr lifecycle declaration - NOT ENABLED **HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later. If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch. Do not add Herdr lifecycle commands to this unguarded brief by hand. EOF -) +HERDR_SECTION=${HERDR_SECTION%$'\n'} fi if [ "$KIND" = scout ]; then cat > "$BRIEF" </dev/null || true) - case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' - DOD=$(cat < "$BRIEF" < [--state busy|idle|unknown] [--source S] [--event E] +# Mint a fresh incarnation gen token, write the gen sidecar, and seed +# the record at seq=1 (default: busy, source fm-spawn, event +# launch-brief - the launch prompt IS a submitted turn). Prints the +# minted gen on stdout so the caller can embed it into adapter wiring. +# Arming again replaces the previous incarnation: late events carrying +# the old gen are rejected as stale from then on. +# +# apply (--gen G | --current-gen) +# --source S --event E +# Append one lifecycle event: validate the gen against the armed +# sidecar, advance seq under the lock, atomically replace the record. +# Adapter wiring passes the exact --gen embedded at arm time, so a +# hook that outlives its incarnation fails closed here. Firstmate-owned +# paths (fm-interrupt, fm-recovery) may pass --current-gen to bind to +# whatever incarnation is armed right now. +# +# retire (--gen G | --current-gen) +# Remove one incarnation's sidecar and record while holding the same +# writer lock used by arm and apply. An exact gen prevents teardown for +# an old task from retiring a newly armed incarnation. A missing sidecar +# is already retired, so any orphan record is removed idempotently. +# +# Exit codes: 0 applied; 1 refused (stale gen, unarmed task, lock timeout, +# invalid input); 2 usage. Adapter hook command lines append `|| true` so a +# refusal never breaks the harness's own lifecycle. +set -u + +usage() { + cat >&2 <<'EOF' +usage: + fm-busy-event.sh arm [--state busy|idle|unknown] [--source S] [--event E] + fm-busy-event.sh apply (--gen G | --current-gen) --source S --event E + fm-busy-event.sh retire (--gen G | --current-gen) +See the header comment for the full contract. +EOF + exit 2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" + +CMD=${1:-} +case "$CMD" in + arm|apply|retire) shift ;; + *) usage ;; +esac + +STATE=${1:-} +ID=${2:-} +[ -n "$STATE" ] && [ -n "$ID" ] || usage +shift 2 +case "$ID" in *[!A-Za-z0-9._-]*) echo "error: invalid task id" >&2; exit 1 ;; esac +[ -d "$STATE" ] || { echo "error: state dir not found: $STATE" >&2; exit 1; } + +NEW_STATE= +GEN= +USE_CURRENT_GEN=0 +SOURCE= +EVENT= +if [ "$CMD" = apply ]; then + NEW_STATE=${1:-} + case "$NEW_STATE" in busy|idle|unknown) shift ;; *) usage ;; esac +elif [ "$CMD" = arm ]; then + NEW_STATE=busy + SOURCE=fm-spawn + EVENT=launch-brief +fi +while [ $# -gt 0 ]; do + case "$1" in + --state) NEW_STATE=${2:-}; shift 2 || usage ;; + --gen) GEN=${2:-}; shift 2 || usage ;; + --current-gen) USE_CURRENT_GEN=1; shift ;; + --source) SOURCE=${2:-}; shift 2 || usage ;; + --event) EVENT=${2:-}; shift 2 || usage ;; + *) usage ;; + esac +done +if [ "$CMD" != retire ]; then + case "$NEW_STATE" in busy|idle|unknown) : ;; *) usage ;; esac + fm_busy_token_valid "$SOURCE" || { echo "error: invalid --source" >&2; exit 1; } + fm_busy_token_valid "$EVENT" || { echo "error: invalid --event" >&2; exit 1; } +fi + +REC=$(fm_busy_record_path "$STATE" "$ID") +GEN_FILE=$(fm_busy_gen_path "$STATE" "$ID") +LOCK="$REC.lock" + +# Serialize writers. The lock protects seq advancement and the sidecar/record +# pair; a holder that died mid-write is broken after FM_BUSY_LOCK_STALE_SECS. +lock_acquire() { + local tries=0 now mtime age + while ! mkdir "$LOCK" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -ge 40 ]; then + now=$(date +%s) + mtime=$(stat -f %m "$LOCK" 2>/dev/null || stat -c %Y "$LOCK" 2>/dev/null || echo "$now") + age=$((now - mtime)) + if [ "$age" -ge "${FM_BUSY_LOCK_STALE_SECS:-5}" ]; then + rmdir "$LOCK" 2>/dev/null || rm -rf "$LOCK" 2>/dev/null || true + mkdir "$LOCK" 2>/dev/null && break + fi + echo "error: busy-state lock timeout for $ID" >&2 + return 1 + fi + sleep 0.05 + done + return 0 +} +lock_release() { rmdir "$LOCK" 2>/dev/null || true; } + +write_record() { # + local tmp + tmp="$REC.tmp.$$" + printf 'v1 gen=%s seq=%s state=%s source=%s event=%s ts=%s\n' \ + "$1" "$2" "$NEW_STATE" "$SOURCE" "$EVENT" "$(date +%s)" > "$tmp" || return 1 + mv -f "$tmp" "$REC" +} + +old_umask=$(umask) +umask 077 + +if [ "$CMD" = arm ]; then + GEN="g$(date +%s).$$.$RANDOM" + lock_acquire || exit 1 + { + printf '%s\n' "$GEN" > "$GEN_FILE.tmp.$$" && mv -f "$GEN_FILE.tmp.$$" "$GEN_FILE" \ + && write_record "$GEN" 1 + } || { lock_release; umask "$old_umask"; echo "error: arm failed for $ID" >&2; exit 1; } + lock_release + umask "$old_umask" + printf '%s\n' "$GEN" + exit 0 +fi + +# apply / retire +if [ "$USE_CURRENT_GEN" = 1 ] && [ "$CMD" != retire ]; then + GEN=$(fm_busy_current_gen "$STATE" "$ID") || { + umask "$old_umask" + echo "error: no armed busy-state gen for $ID" >&2 + exit 1 + } +fi +if [ "$USE_CURRENT_GEN" != 1 ] || [ "$CMD" != retire ]; then + fm_busy_token_valid "$GEN" || { umask "$old_umask"; echo "error: invalid --gen" >&2; exit 1; } +fi + +lock_acquire || { umask "$old_umask"; exit 1; } +CURRENT=$(fm_busy_current_gen "$STATE" "$ID") || { + if [ "$CMD" = retire ] && [ ! -e "$GEN_FILE" ] && [ ! -L "$GEN_FILE" ]; then + rm -f "$REC" || { + lock_release + umask "$old_umask" + echo "error: busy-state retirement failed for $ID" >&2 + exit 1 + } + lock_release + umask "$old_umask" + exit 0 + fi + lock_release + umask "$old_umask" + echo "error: no armed busy-state gen for $ID" >&2 + exit 1 +} +if [ "$CMD" = retire ] && [ "$USE_CURRENT_GEN" = 1 ]; then + GEN=$CURRENT +fi +if [ "$GEN" != "$CURRENT" ]; then + lock_release + umask "$old_umask" + echo "error: stale busy-state gen for $ID (event rejected)" >&2 + exit 1 +fi +if [ "$CMD" = retire ]; then + rm -f "$GEN_FILE" "$REC" || { + lock_release + umask "$old_umask" + echo "error: busy-state retirement failed for $ID" >&2 + exit 1 + } + lock_release + umask "$old_umask" + exit 0 +fi +OLD_SEQ=0 +if [ -f "$REC" ]; then + old_line=$(head -n 1 "$REC" 2>/dev/null || true) + case "$old_line" in + *" gen=$GEN "*) + old_seq_field=${old_line##* seq=} + old_seq_field=${old_seq_field%% *} + case "$old_seq_field" in + ''|*[!0-9]*) OLD_SEQ=0 ;; + *) OLD_SEQ=$old_seq_field ;; + esac + ;; + esac +fi +write_record "$GEN" $((OLD_SEQ + 1)) || { + lock_release + umask "$old_umask" + echo "error: record write failed for $ID" >&2 + exit 1 +} +lock_release +umask "$old_umask" +exit 0 diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh new file mode 100755 index 00000000000..d12cebc3041 --- /dev/null +++ b/bin/fm-busy-lib.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# fm-busy-lib.sh - the ONE owner of firstmate's semantic busy-state contract. +# +# Design source: the captain-approved semantic busy-state redesign +# (2026-07-28): each harness adapter reports turn lifecycle through a +# machine-readable semantic source it owns, classification always exposes +# which source produced it, and missing, malformed, stale, unsupported, or +# unverified semantic data is UNKNOWN - never idle. Endpoint death is the only +# process-level override and yields dead, never busy. Child processes, CPU, +# process sleep state, marker mtimes, and the old global UI-regex OR are not +# state signals here; state/.turn-ended files remain wake NOTIFICATIONS +# owned by the watcher, not current-state truth. +# +# Record file: state/.busy-state - exactly one line, atomically replaced +# by bin/fm-busy-event.sh (the only writer): +# +# v1 gen= seq= state= source= event= ts= +# +# Gen sidecar: state/.busy-gen - one token minted when the task's busy +# wiring is armed (fm-spawn, or a documented recovery re-arm). Every event +# must present the current gen; an event or record carrying any other gen is +# a stale incarnation and is rejected (written events) or classified unknown +# (read records). seq is a strictly increasing integer per gen, advanced +# under the writer's lock, so an out-of-order apply can never regress a +# newer record. +# +# Semantic sources written by adapters (fm_busy_sources_for_harness owns the +# per-harness trust table; a record whose source is not trusted for the +# task's recorded harness classifies unknown, so one adapter's writer can +# never classify another adapter): +# pi-ext Pi/pi-signed per-task extension (agent_start/agent_settled) +# opencode-plugin OpenCode per-task plugin (session.status) +# claude-hook Claude lifecycle hooks (UserPromptSubmit/Stop/StopFailure/SessionEnd) +# codex-hook, codex-appserver reserved: Codex, gated by +# fm_busy_codex_semantic_source +# kimi-wire, kimi-hook reserved: standalone Kimi, gated by fm_busy_kimi_verified +# Firstmate-owned sources accepted for every converted adapter: +# fm-spawn the launch-brief turn seeded at spawn +# fm-interrupt a firstmate-controlled interruption of the worker +# fm-recovery a documented recovery reset after relaunch +# Classifier-only sources (never written into a record): +# endpoint-gone, herdr-native, grok-regex, missing, malformed, +# gen-mismatch, source-mismatch, kimi-unverified, codex-unverified, +# capture-failed, no-target +# +# Classification (fm_busy_classify): busy | idle | unknown | dead, always +# with the producing source as the second token. Precedence: +# 1. dead endpoint (fm_busy_classify_live only) -> dead endpoint-gone +# 2. standalone Kimi before verification -> unknown kimi-unverified +# 3. a valid, gen-matching, source-trusted record -> its state and source +# 4. no record at all: herdr's native busy verdict is trusted as busy +# (generation state is sufficient for busy, not for idle), then the +# Grok-only temporary regex fallback classifies a grok task from its +# rendered tail, then unknown missing +# 5. malformed, stale, or untrusted records -> unknown, never a fallback +# The Grok arm is the ONLY rendered-text classification that survives the +# redesign, because Grok's structured lifecycle was not credited-live-verified +# in the approved audit; it is scoped to harness=grok and can never classify +# another adapter. The delivery guards in bin/fm-tmux-lib.sh match rendered +# footers for submit acknowledgement and away-mode supervisor injection only; +# neither is a recorded worker state source. +# +# Codex negotiation (fm_busy_codex_appserver_observable, +# fm_busy_codex_hooks_verified): the approved contract prefers Codex's +# app-server turn lifecycle with capability negotiation, and sanctions its +# stable lifecycle hooks as the intermediate. Neither is usable on the +# installed binary, so Codex classifies unknown codex-unverified rather than +# falling back to idle, and fm-spawn installs no Codex busy wiring. +# docs/verification/supervision.md owns the evidence for both probes. +# +# Sourcing: set -u and set -e safe; no subshell-unfriendly globals. + +FM_BUSY_LIB_VERSION=v1 + +# Standalone-Kimi verification gate. Empty means no installed Kimi version +# has passed live verification, so every standalone Kimi task classifies +# unknown kimi-unverified and fm-spawn wires no Kimi busy events. Kimi's +# rendered moon-phase spinner is deliberately NOT a state source here: the +# approved redesign forbids inventing a Kimi UI signature, and that spinner +# is locale- and emoji-font-sensitive. +# +# Preferred source, in order: Wire mode's JSON-RPC `prompt` request lifetime, +# whose outstanding request exactly brackets a turn and returns finished, +# cancelled, or max_steps_reached (so it covers interruption, which `Stop` +# does not); then the documented lifecycle hooks, which must include +# `Interrupt` because Kimi documents that `Stop` does not fire on interrupts. +# +# To open the gate: install Kimi, live-verify the chosen source brackets a +# real turn on a firstmate-launched worker including the interrupt path, +# record the version, exact commands, and observed output in +# docs/verification/supervision.md, add the verified version string(s) here, +# and land the wiring in fm-spawn behind this same gate in the same change. +FM_BUSY_KIMI_VERIFIED_VERSIONS="" + +fm_busy_kimi_verified() { + [ -n "$FM_BUSY_KIMI_VERIFIED_VERSIONS" ] +} + +# fm_busy_codex_appserver_observable: capability/version negotiation for the +# Codex app-server turn lifecycle. Returns 0 only when a pane worker's turns +# are observable through the app-server protocol on the installed binary. +# codex-cli 0.145.0 verdict (live, 2026-07-28): NOT observable. The v2 +# protocol does define the needed turn lifecycle (turn/started plus a +# turn/completed status of completed, interrupted, failed, or inProgress), +# but an interactive TUI worker neither starts nor attaches to the +# app-server daemon, and `codex app-server daemon start` refuses outside the +# managed standalone install, so no client can observe a pane worker's turns. +fm_busy_codex_appserver_observable() { + return 1 +} + +# fm_busy_codex_hooks_verified: the sanctioned intermediate - Codex's stable +# hooks engine (UserPromptSubmit to open a turn, Stop and SessionEnd to close +# it). Returns 0 only once those hooks are live-verified to fire for a +# firstmate-launched worker. codex-cli 0.145.0 verdict (live, 2026-07-28): +# NOT verified. Firstmate-written project hooks under /.codex/ +# never fired in an interactive pane whose directory trust was granted, nor +# under `codex exec`, in either case with --dangerously-bypass-hook-trust, +# while global hooks fired in the same runs. Codex additionally exposes no +# StopFailure hook, so an API-error turn end would need separate coverage +# even after the discovery problem is solved. +fm_busy_codex_hooks_verified() { + return 1 +} + +# fm_busy_codex_semantic_source: 0 when ANY verified Codex semantic source +# exists. fm-spawn arms and wires Codex only behind this gate, and the +# classifier reports unknown codex-unverified until it opens. +fm_busy_codex_semantic_source() { + fm_busy_codex_appserver_observable || fm_busy_codex_hooks_verified +} + +fm_busy_record_path() { # + printf '%s/%s.busy-state' "$1" "$2" +} + +fm_busy_gen_path() { # + printf '%s/%s.busy-gen' "$1" "$2" +} + +# fm_busy_token_valid: conservative token charset shared by gen, source, and +# event fields. Anything else is malformed. +fm_busy_token_valid() { # + case "${1:-}" in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +# fm_busy_current_gen: the task's armed gen token, or failure when the busy +# contract has never been armed for this task. +fm_busy_current_gen() { # + local gen_file gen + gen_file=$(fm_busy_gen_path "$1" "$2") + [ -f "$gen_file" ] || return 1 + IFS= read -r gen < "$gen_file" 2>/dev/null || gen= + fm_busy_token_valid "$gen" || return 1 + printf '%s' "$gen" +} + +# fm_busy_sources_for_harness: the semantic sources trusted to classify a +# task recorded with . One line, space-separated, possibly empty. +# The firstmate-owned sources are appended for every converted adapter. +# Grok deliberately trusts nothing: it has no semantic writer yet, and its +# temporary rendered-tail fallback lives in the classifier, not in records. +fm_busy_sources_for_harness() { # + local adapter= + case "${1:-}" in + claude*) adapter=claude-hook ;; + codex*) + fm_busy_codex_semantic_source || { printf ''; return 0; } + adapter='codex-hook codex-appserver' + ;; + opencode*) adapter=opencode-plugin ;; + pi|pi-signed) adapter=pi-ext ;; + kimi*) + fm_busy_kimi_verified || { printf ''; return 0; } + adapter='kimi-wire kimi-hook' + ;; + *) printf ''; return 0 ;; + esac + printf '%s fm-spawn fm-interrupt fm-recovery' "$adapter" +} + +fm_busy_source_trusted() { # + local trusted + trusted=$(fm_busy_sources_for_harness "$1") + case " $trusted " in + *" $2 "*) return 0 ;; + esac + return 1 +} + +# fm_busy_record_read: parse and validate state/.busy-state against the +# armed gen. Prints " " for a valid record. +# Non-zero returns name the reason on stdout instead: +# missing no record file (or no armed gen and no record) +# malformed unparseable line, bad tokens, or a missing armed gen for an +# existing record +# gen-mismatch a record from a stale incarnation +fm_busy_record_read() { # + local state=$1 id=$2 rec gen line extra ver f + local r_gen='' r_seq='' r_state='' r_source='' r_event='' r_ts='' + rec=$(fm_busy_record_path "$state" "$id") + if [ ! -f "$rec" ]; then + printf 'missing' + return 1 + fi + if ! gen=$(fm_busy_current_gen "$state" "$id"); then + # A record without an armed gen has no incarnation to bind to. + printf 'malformed' + return 1 + fi + # shellcheck disable=SC2034 # extra exists only to prove the record is one line + { IFS= read -r line && ! IFS= read -r extra; } < "$rec" 2>/dev/null || { + printf 'malformed' + return 1 + } + # `read -a` rather than `set --`: it never glob-expands a field and never + # touches the caller's positional parameters or shell options. + local -a fields + IFS=' ' read -r -a fields <<< "$line" + ver=${fields[0]:-} + [ "$ver" = "$FM_BUSY_LIB_VERSION" ] || { printf 'malformed'; return 1; } + for f in "${fields[@]:1}"; do + case "$f" in + gen=*) r_gen=${f#gen=} ;; + seq=*) r_seq=${f#seq=} ;; + state=*) r_state=${f#state=} ;; + source=*) r_source=${f#source=} ;; + event=*) r_event=${f#event=} ;; + ts=*) r_ts=${f#ts=} ;; + *) printf 'malformed'; return 1 ;; + esac + done + fm_busy_token_valid "$r_gen" || { printf 'malformed'; return 1; } + fm_busy_token_valid "$r_source" || { printf 'malformed'; return 1; } + fm_busy_token_valid "$r_event" || { printf 'malformed'; return 1; } + case "$r_seq" in ''|*[!0-9]*) printf 'malformed'; return 1 ;; esac + case "$r_ts" in ''|*[!0-9]*) printf 'malformed'; return 1 ;; esac + case "$r_state" in busy|idle|unknown) : ;; *) printf 'malformed'; return 1 ;; esac + if [ "$r_gen" != "$gen" ]; then + printf 'gen-mismatch' + return 1 + fi + printf '%s %s %s %s' "$r_state" "$r_source" "$r_event" "$r_seq" +} + +# fm_busy_grok_tail_busy: the Grok-only temporary rendered-tail fallback. +# Consumes the tail on stdin; 0 when Grok's verified busy signature matches. +# FM_BUSY_REGEX still globally overrides the signature, mirroring the +# historical operator escape hatch. +fm_busy_grok_tail_busy() { + grep -v '^[[:space:]]*$' | tail -12 \ + | grep -qiE "${FM_BUSY_REGEX:-${FM_TMUX_GROK_BUSY_REGEX_DEFAULT:-Ctrl\\+c:cancel}}" +} + +# fm_busy_classify: semantic classification for a task whose endpoint the +# caller has already established as present. Prints " ": +# busy|idle|unknown plus the producing source (see header). Never probes +# process state. is optional pre-captured plain output used only by +# the Grok arm; when absent the Grok arm captures through fm_backend_capture +# if available, else reports unknown capture-failed. +fm_busy_classify() { # [tail40] + local backend=$1 target=$2 harness=$3 id=$4 state=$5 tail40=${6-} + local out rc r_state r_source native + case "$harness" in + kimi*) + if ! fm_busy_kimi_verified; then + printf 'unknown kimi-unverified' + return 0 + fi + ;; + codex*) + if ! fm_busy_codex_semantic_source; then + printf 'unknown codex-unverified' + return 0 + fi + ;; + esac + out=$(fm_busy_record_read "$state" "$id") && rc=0 || rc=$? + if [ "$rc" = 0 ]; then + r_state=${out%% *} + out=${out#* } + r_source=${out%% *} + if fm_busy_source_trusted "$harness" "$r_source"; then + printf '%s %s' "$r_state" "$r_source" + else + printf 'unknown source-mismatch' + fi + return 0 + fi + case "$out" in + malformed|gen-mismatch) + printf 'unknown %s' "$out" + return 0 + ;; + esac + # No record at all. A native herdr busy verdict is semantic enough to trust + # for BUSY (streaming means a turn is running); native idle is narrower + # than turn state (a long foreground tool call reads idle) and stays + # unknown here. + if [ "$backend" = herdr ] && command -v fm_backend_busy_state >/dev/null 2>&1; then + native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || true) + if [ "$native" = busy ]; then + printf 'busy herdr-native' + return 0 + fi + fi + case "$harness" in + grok*) + if [ -z "$tail40" ]; then + if command -v fm_backend_capture >/dev/null 2>&1; then + tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || { + printf 'unknown capture-failed' + return 0 + } + else + printf 'unknown capture-failed' + return 0 + fi + fi + if printf '%s' "$tail40" | fm_busy_grok_tail_busy; then + printf 'busy grok-regex' + else + printf 'idle grok-regex' + fi + return 0 + ;; + esac + printf 'unknown missing' +} + +# fm_busy_classify_live: fm_busy_classify behind the one process-level +# override - a gone endpoint is dead, never busy. Requires fm-backend.sh to +# be sourced for fm_backend_target_exists. +fm_busy_classify_live() { # [expected-label] + local backend=$1 target=$2 harness=$3 id=$4 state=$5 label=${6-} + if [ -z "$target" ]; then + printf 'unknown no-target' + return 0 + fi + if ! fm_backend_target_exists "$backend" "$target" "$label" 2>/dev/null; then + printf 'dead endpoint-gone' + return 0 + fi + fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" +} + +# fm_busy_classify_meta: classify a task from its recorded metadata, so every +# consumer resolves backend, target, and harness the same way instead of +# re-deriving them. Requires fm-backend.sh to be sourced. is +# optional pre-captured plain output reused by the Grok arm. +fm_busy_classify_meta() { # [tail40] + local meta=$1 id=$2 state=$3 tail40=${4-} backend target harness + [ -f "$meta" ] || { printf 'unknown missing'; return 0; } + backend=$(fm_backend_of_meta "$meta") + target=$(fm_backend_target_of_meta "$meta") + harness=$(fm_meta_get "$meta" harness) + if [ -z "$target" ]; then + printf 'unknown no-target' + return 0 + fi + fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" "$tail40" +} + +# fm_busy_is_busy: boolean view for callers that only gate on provable +# activity. 0 iff the classification verdict is exactly busy; idle, unknown, +# and dead all return 1, so an unknown can never be silently promoted to +# either boolean pole - callers that must distinguish idle from unknown read +# the full classification instead. +fm_busy_is_busy() { # [tail40] + local verdict + verdict=$(fm_busy_classify "$@") + [ "${verdict%% *}" = busy ] +} diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 437b8c68977..6e2509ec2c1 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -39,11 +39,12 @@ # bold-wrapped) and no adapter covered grok's truecolor placeholder at all. # # Each adapter still owns its own CAPTURE and structural row-finding, because -# those use genuinely different primitives (tmux's cursor-row read, herdr's ANSI -# tail scan, orca/cmux's plain read-screen). Once an adapter has a candidate -# composer row it hands the RAW styled row to fm_composer_strip_ghost for the -# real-typed-content extraction, strips the box borders, trims, and hands the -# result plus a flag to fm_composer_classify_content for the shared +# those use genuinely different primitives (tmux's visible-pane box scan, +# herdr's ANSI tail scan, orca/cmux's plain read-screen). Once an adapter has a +# candidate composer row it hands the RAW styled row to +# fm_composer_strip_ghost for the real-typed-content extraction, strips the box +# borders, trims, and hands the result plus a flag to +# fm_composer_classify_content for the shared # empty|pending|unknown verdict. orca/cmux read a plain (unstyled) screen so # they have no ghost styling to strip and rely on the idle-placeholder match # below. Re-sourcing is a cheap idempotent redefinition, so this file needs no diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 2b3bd680941..bffbd5234d7 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -3,12 +3,15 @@ # set of LOCAL (gitignored) config items down into each secondmate home's # config/, so a secondmate's OWN crewmates inherit the primary's settings # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch -# profile rules, primary config/crew-harness= makes a secondmate's crewmates -# spawn on too, primary config/backlog-backend=manual makes that home -# hand-edit backlog files too, and primary config/herdr-presentation-spaces -# enables the same default-off Herdr presentation projection). It also pushes -# the one primary-authoritative shared captain-preference file, -# data/captain-shared.md, into each secondmate home's data/ as a read-only copy. +# profile rules, primary config/crew-harness=codex makes a secondmate's crewmates +# spawn on codex too, primary config/backlog-backend=manual makes that home +# hand-edit backlog files too, primary config/backend pins that home's local +# runtime-backend default for future spawns, primary config/startup-memory-budget +# bounds that home's startup-memory curation, and primary +# config/herdr-presentation-spaces enables the same default-off Herdr presentation +# projection). It also pushes the one primary-authoritative shared +# captain-preference file, data/captain-shared.md, into each secondmate home's +# data/ as a read-only copy. # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -30,6 +33,9 @@ # is deliberately NOT in the list: it is the primary's own setting for launching # secondmates, and a secondmate never spawns secondmates, so it must not flow # downstream. +# +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-startup-memory-budget-lib.sh" # The one shared data file in this inheritance contract. There is deliberately # no shared learnings file. @@ -40,7 +46,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend herdr-presentation-spaces}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget}" fm_inherit_file_mode() { if [ "$(uname)" = Darwin ]; then @@ -399,6 +405,47 @@ propagate_inheritable_config() { esac src="$src_config/$item" dest="$dest_config/$item" + # This one scalar config is consumed as a local safety boundary, so reject + # every unsafe or malformed source/destination artifact before the generic + # byte-copy behavior below can treat it as ordinary inherited material. + if [ "$item" = "$FM_STARTUP_MEMORY_BUDGET_FILE" ]; then + if [ -e "$src_config" ] || [ -L "$src_config" ]; then + if ! fm_startup_memory_budget_config_dir_safe "$src_config"; then + reason="unsafe primary config directory: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$src_config" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$dest_config" ] || [ -L "$dest_config" ]; then + if ! fm_startup_memory_budget_config_dir_safe "$dest_config"; then + reason="unsafe destination config directory: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$dest_config" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$src" ] || [ -L "$src" ]; then + if ! fm_startup_memory_budget_file_valid "$src"; then + reason="unsafe or invalid primary source: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$src" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$dest" ] || [ -L "$dest" ]; then + if ! fm_startup_memory_budget_file_valid "$dest"; then + reason="unsafe or invalid destination: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$dest" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + fi if [ -f "$src" ]; then if ! destination_allows_inherited_item "$dest_config" "$item"; then reason=$(inheritable_config_skip_reason) @@ -564,7 +611,7 @@ fm_config_reread_new_retry_stage_path() { rm -f "$sequence_tmp" return 1 fi - generation=$(date -u +%Y%m%dT%H%M%S.%N 2>/dev/null) || generation=$(date -u +%Y%m%dT%H%M%S 2>/dev/null) || return 1 + generation=$(date -u +%Y%m%dT%H%M%S 2>/dev/null) || return 1 generation="$generation.$(printf '%08d' "$sequence")" stage=$(umask 077; mktemp "$retry_dir/.fm-inherited-config-reread.$generation.XXXXXX" 2>/dev/null) || return 1 printf '%s\n' "$stage" diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index b4056744bc7..b760666dd1b 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -3,8 +3,8 @@ # Usage: fm-config-push.sh [--help] # # Mid-session convergence for inherited local material such as -# config/crew-dispatch.json edits or data/captain-shared.md updates. This -# discovers live secondmate homes from state/*.meta, backfills +# config/crew-dispatch.json, config/backend, or data/captain-shared.md updates. +# This discovers live secondmate homes from state/*.meta, backfills # home= from data/secondmates.md for older meta records, and reuses the same # propagation machinery as bootstrap, but deliberately does not # fast-forward tracked files. diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 3281a99b1b2..30fc7b7236e 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -62,6 +62,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" ID=${1:-} [ -n "$ID" ] || { echo "usage: fm-crew-state.sh " >&2; exit 2; } @@ -96,6 +98,7 @@ meta_value() { # WT=$(meta_value worktree) KIND=$(meta_value kind) +HARNESS=$(meta_value harness) [ -n "$KIND" ] || KIND=ship # A torn-down (or never-created) worktree has no current state to read. @@ -148,46 +151,19 @@ pane_readable() { # *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; esac } -# crew_pane_is_busy: the busy-signature fallback, backend-aware the same way - -# fm_backend_busy_state's native semantic state (herdr's agent.get) when -# available, else the shared tmux pane-regex reader (fm_pane_is_busy, -# bin/fm-tmux-lib.sh) unchanged for tmux/unknown. -# -# `busy` alone is trusted outright. Both `idle` and unknown/unparseable fall -# through to the shared tail-regex corroboration, NOT just unknown: herdr's -# agent.get reports generation state ("working" while the model is streaming -# a turn, "done"/"idle" once it is not - docs/herdr-backend.md "Busy state"), -# which is a narrower signal than "this crew's turn/tool call is still in -# progress". A crew blocked on its own long-running foreground tool call (e.g. -# `no-mistakes axi run` without --yes, which blocks synchronously until a gate -# or outcome - AGENTS.md section 7) is not generating for that whole span, so -# agent.get can read idle/blocked (bin/backends/herdr.sh maps both to `idle`) -# while the pane's own rendered text still shows the harness's busy banner -# (BUSY_REGEX, e.g. "esc to interrupt") for the entire tool call, exactly like -# tmux's regex-only reader would correctly report. Trusting herdr's `idle` -# outright (skipping that corroboration) is what let a still-working crew read -# as not-busy here, and - combined with a no-mistakes run-step lookup that also -# missed attribution (see nm_runs_status_for_branch) - as not provably working in -# fm-classify-lib.sh, triggering an immediate (non-wedge) stale wake instead of -# the absorb-then-escalate path. A genuinely human-blocked agent (a permission -# dialog, not mid-tool-call) does not render the busy banner, so this -# corroboration does not mask that case: it stays correctly not-busy. -crew_pane_is_busy() { # - case "$TASK_BACKEND" in - tmux) fm_pane_is_busy "$1" ;; - *) - local bs tail40 - bs=$(fm_backend_busy_state "$TASK_BACKEND" "$1" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - *) - tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || return 1 - printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -6 \ - | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}" - ;; - esac - ;; +# crew_busy_verdict: the crew's semantic busy state from the one contract +# owner (bin/fm-busy-lib.sh), as " ". A converted +# adapter answers from its own lifecycle record; Grok answers from its +# isolated rendered-tail fallback; a herdr crew's native `busy` is accepted +# when no record exists, but its native `idle` is NOT, because agent.get +# reports generation state (idle while a crew blocks on its own long-running +# foreground tool call) rather than turn state. +crew_busy_verdict() { # + local tail40='' + case "$HARNESS" in + grok*) tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || tail40='' ;; esac + fm_busy_classify "$TASK_BACKEND" "$1" "$HARNESS" "$ID" "$STATE" "$tail40" } # --- no-mistakes run lookup (authoritative when a run matches this branch) -- @@ -604,9 +580,17 @@ fi pane_readable "$BACKEND_TARGET" || emit unknown none "backend target gone: $BACKEND_TARGET" # Secondmates idle on their own watcher (idle pane = healthy), so the busy -# signature is not meaningful for them; read their state from the status log only. -if [ "$KIND" != secondmate ] && crew_pane_is_busy "$BACKEND_TARGET"; then - emit working pane "harness busy" +# state is not meaningful for them; read their state from the status log only. +# Only an exact busy verdict reports working here, and only an exact idle +# verdict permits the status-log fallback below. Missing, malformed, stale, or +# unverified semantic state remains unknown. +if [ "$KIND" != secondmate ]; then + BUSY_VERDICT=$(crew_busy_verdict "$BACKEND_TARGET") + case "${BUSY_VERDICT%% *}" in + busy) emit working pane "harness busy (${BUSY_VERDICT#* })" ;; + idle) ;; + *) emit unknown pane "harness state unavailable ($BUSY_VERDICT)" ;; + esac fi # Fall back to the status log's last line, but ONLY when its verb maps to a real diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 8a438e6e6eb..aeb140a296a 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -36,16 +36,7 @@ # `resolve` requires every --routed-to task to exist and to be blocked by the hold. # It writes the captain decision and routed identities into the hold body, clears # those dependency edges, and only then marks the hold Done. A failure before the -# final step leaves the captain hold open. Once a hold is Done, resolve is -# idempotent and returns success without re-checking the original decision text -# or routed set, however the hold reached Done. -# -# This script trusts firstmate's own operating contract, not cryptographic or -# historical proof: it verifies only present, locally checkable state (is a -# decision still unresolved, did routing report an error, is durable work -# discoverable), never a claim about who wrote a record or when. A hold closed -# outside this tool (for example a decision answered and closed directly in the -# backlog) counts as durably resolved on that basis alone. +# final step leaves the captain hold open. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -89,6 +80,16 @@ validate_one_line() { #