diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 95f64b11e0..aba6e3fb00 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -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 d4b63d525b..38761e6d98 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 b2804c7290..42990edd04 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 2b70879941..2164a4f9b7 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: @@ -19,7 +19,8 @@ When any diagnostic needs captain attention, report the plain consequence and re - `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `. For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. - For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. + 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` or `config/backlog-backend=beads` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. + For `task` (the beads CLI), this reports the missing tool only when `config/backlog-backend=beads` is set. 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. @@ -27,6 +28,7 @@ When any diagnostic needs captain attention, report the plain consequence and re - `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. diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 5db5690ebc..b0ca2339ee 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -14,7 +14,8 @@ This skill is the single policy owner for unresolved captain decisions discovere ## Policy -Every unresolved decision that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must become a structured captain-held work item in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete. +Every unresolved decision that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must become a structured captain-held work item in the authoritative tasks-axi backlog of the home that owns the originating work before that work or review may be treated as complete. +Decision holds require the tasks-axi backlog backend and are not supported when `config/backlog-backend=beads` or `config/backlog-backend=manual` is set. The agent performs the semantic inventory because scripts must not infer decisions from report prose, visual-review artifacts, terminal output, or chat. Give each distinct unresolved decision a stable privacy-safe key, register it through `bin/fm-decision-hold.sh hold`, and use the same key on retry so registration is idempotent while different decisions retain different durable identities. After inventorying the whole report and review surface, run `bin/fm-decision-hold.sh complete` with every unresolved key, or with `--none` only when the reviewed surface contains no unresolved captain decision. diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index c7126ff358..8bbb275dae 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -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/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 762afb8d76..94beca00a1 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 429907041a..afe9eb4440 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -29,12 +29,13 @@ Each adapter splits into mechanics and knowledge. 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. +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 @@ -127,6 +128,9 @@ The supported launch-profile flags below are verified locally; each row records | 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 Treat model and provider knowledge as current source-of-truth discovery, not as a permanent namespace or provider mapping. @@ -142,7 +146,8 @@ Use the discovery surface in the current authenticated environment because suppo | 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. @@ -165,11 +170,11 @@ A send or key action reporting success is not proof that the intended action hap 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 signature re-verified 2026-07-25 on Claude Code 2.1.220) +## claude (VERIFIED; busy-state hooks live-verified 2026-07-28 on Claude Code 2.1.220) | Fact | Value | |---|---| -| Busy-pane signature | Current turns match the harness-scoped `…[[:space:]]+\([0-9]+[smh]` shape after a rotating glyph and word, for example `✢ Pollinating… (16s · ...)`; legacy `esc to interrupt` remains accepted, while `Worked for 31s` is idle. | +| 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`) | @@ -199,7 +204,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" | @@ -230,7 +235,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 | @@ -267,7 +272,7 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor | 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 | @@ -304,7 +309,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. | @@ -361,7 +366,7 @@ Kimi Code CLI launches from the absolute path resolved from `PATH`, falling back | Binary | Executable `kimi` from `PATH`, then executable `$HOME/.kimi-code/bin/kimi`; spawning refuses if neither exists. | | Launch | Bare interactive TUI with `--auto`, followed by readiness-gated pointer delivery; positional prompts are rejected. | | Models | `kimi-code/kimi-for-coding` (default), `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`, and `kimi-code/k3-256k`. | -| Busy-pane signature | A transient line with optional leading whitespace, a rotating moon-phase glyph, required whitespace on both sides of `·`, and optional trailing content; the line is absent when idle. | +| 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. | @@ -383,12 +388,12 @@ The startup input-readiness window is the established cause of Kimi's first-Ente An early Enter can expand Kimi's composer to multiple content rows, leaving the pointer text on the first row and the cursor on an empty later row, which is the same single-cursor-row reading defect exposed by Grok's bottom-border cursor quirk. The shared tmux reader now locates the complete bordered composer and treats real text on any content row as positive evidence that submission is still pending. No rendering signal is trustworthy for proving that Kimi will accept input during this window, so delivery retries Enter through the shared submit core and retains the existing postcondition verification rather than relaxing readiness or delivery checks. -Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, so tip text is never used as its busy signature without the leading moon-plus-middot spinner structure. +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 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. +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 supplements the pane busy signature, whose locale- and emoji-font-sensitive limits still apply while a turn is running. +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/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index af35d469ee..f4c62a5790 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 index d9de90ffba..725082e2f7 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -14,22 +14,59 @@ metadata: This skill is the single owner of the pace-aware profile-array selection procedure. `AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. `harness-adapters` owns harness verification, model/provider discovery, and effort fallback. -`quota-axi` remains data-only and never recommends a route. +`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. -For each candidate, establish the harness/model/provider relationship from `harness-adapters`, then record only inspectable facts: +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 -- raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) -- effective pace status, signed reserve per applicable window, and worst applicable reserve (`worstReservePercentPoints` when present, else the minimum signed reserve) -- whether any applicable window or effective summary is ahead of reset, or any applicable pace is `unknown` +- raw applicable headroom (`effectivePercentRemaining` or tightest applicable percentage) +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) +- whether applicable windows/summary are ahead, or pace is `unknown` - schema note when pace fields are absent -Stale raw windows are diagnostic only, never current headroom. -Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. +Stale raw windows are diagnostic, never headroom. +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`, and `unknownWindowIds`. + +## 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 @@ -37,27 +74,30 @@ Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWind 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 when effective pace status is `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or any applicable bounding window itself has pace status `ahead`. -`unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. +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 that already satisfy required fit and the strongest reasoning class the request needs. +Apply only among candidates satisfying required fit and strongest reasoning class. Never use pace or raw headroom to silently replace that reasoning class. -1. Unresolved relationship or quota data: stop and report the blocked candidate. -2. All-tight: keep the strongest-reasoning class; dispatch inside it or stop and report that the tight choice cannot proceed. -3. When fit and reasoning are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. +1. Concrete contradictory evidence or malformed configuration: stop and report the tuple and that evidence. + Unmeasurable quota, a missing model-level window, and a credential surface quota-axi does not model are uncertainty, never this rule. +2. All-tight: keep strongest reasoning; dispatch inside it or report if blocked. +3. Comparable fit/reasoning: prefer no ahead pressure over pressure, even with higher raw headroom. 4. Among pressured candidates, prefer the least-negative worst applicable reserve. -5. Among sustainable candidates, use known behind/on-pace evidence plus raw headroom transparently. - Prefer known sustainable evidence over `unknown` pace when otherwise comparable. +5. Sustainable candidates: use known pace plus raw headroom. + Prefer known sustainable evidence over `unknown` when comparable. + An authenticated candidate whose headroom is unmeasurable stays eligible at lower preference; disclose that unmeasured headroom in the dispatch record. Do not collapse those facts into an opaque composite score. -6. If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. -7. Absent pace or older schema: do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. - Compare raw applicable headroom only, state that pace is unavailable, and keep every other safety rule. +6. If unresolved pace changes the choice, report uncertainty. +7. Absent pace or older schema: do not crash, fabricate pace, or treat absence as healthy/`on_pace`. + Compare raw headroom only, state pace is unavailable, and keep safety rules. 8. Genuine ties: stop and report every tied candidate for captain choice. Do not select by array order, harness name, or another arbitrary identity ordering. Report duplicate concrete profiles as a configuration error. -Name the inspectable facts used for every candidate. +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, and pace and headroom. +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 f9e68937ab..8beb390931 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -78,7 +78,7 @@ 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/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. @@ -99,7 +99,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/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. @@ -137,8 +137,9 @@ bin/fm-backlog-handoff.sh ... ``` After seeding, run this handoff for the new secondmate's in-scope queued items. -The helper resolves and validates the secondmate home from `data/secondmates.md`, then delegates the item move to `tasks-axi mv` (the single owner of the backlog format), which moves each named item - and a whole connected set, blocker plus dependents, atomically - from the main `data/backlog.md` into the secondmate home's `data/backlog.md`. +The helper resolves and validates the secondmate home from `data/secondmates.md`, then delegates the item move to `tasks-axi mv` (the single owner of the backlog format for the tasks-axi backend), which moves each named item - and a whole connected set, blocker plus dependents, atomically - from the main `data/backlog.md` into the secondmate home's `data/backlog.md`. This delegated route remains required when `config/backlog-backend=manual`, which controls only routine firstmate backlog edits. +Handoff with the beads backend is not yet supported and requires a different mechanism; secondmates using the beads backend remain without handoff support until that mechanism is implemented. It moves each queued item's whole block - the `- [ ] ...` header plus every following two-or-more-space-indented body line and blank separator, up to the next item or column-0 section heading - byte-exact under the same section, treating an indented `## ...` line as body rather than a section boundary, so neither the header nor its body is duplicated or orphaned. It refuses a selected item with a single-space or tab-indented continuation rather than risk leaving content orphaned in the main backlog. It accepts in-scope `## Queued` entries only and refuses `## In flight` and historical `## Done` entries. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 4c2c2a337a..672894bd56 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 407a29c88f..8530c5f9b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,8 +342,8 @@ jobs: 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 372af4735f..1e5e8642ef 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 8b98340cfa..433edb80ab 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 f78c1b5acd..1fb9cf12c4 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,9 +1,11 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // // Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot -// renderers, renderShell: "self", session_start replacement reasons, -// ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and -// setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a +// 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 @@ -21,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"; @@ -37,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, @@ -93,6 +101,36 @@ export default function (pi: ExtensionAPI) { 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"); @@ -241,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?.(); @@ -271,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) => { @@ -278,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 92c39e4fd2..9d5124aff2 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -88,7 +88,13 @@ 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"; 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 0000000000..390e28baeb --- /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 e9a36aa908..743c14bea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,16 +14,17 @@ 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; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. 3. **Never tear down unlanded work.** @@ -50,7 +51,7 @@ Never add an agent name as a commit co-author. Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. -Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate. +Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate except under hard rule 1's concrete captain-approved project operation exception. ``` AGENTS.md this file (CLAUDE.md is a symlink to it) @@ -66,9 +67,10 @@ bin/ helper scripts, committed; read each script's header before config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) -config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) +config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing, "beads" = use beads federated task store; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" +config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md @@ -82,7 +84,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate /report.md scout task deliverable, written by the crewmate; survives teardown -projects/ cloned repos; gitignored; READ-ONLY for you +projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks @@ -104,6 +106,7 @@ state/ volatile runtime signals; gitignored x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) + public-followup/ generated private transport for promised public replies: commitment registrations, typed terminal-result inbox, accepted/rejected ledgers (section 14; bin/fm-public-followup.sh) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) @@ -166,7 +169,10 @@ If static `config/crew-harness` or `config/secondmate-harness` names an unverifi When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable real headroom including quota-window pace. -Account for every candidate; if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. +Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and pace and headroom used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. +Establish model support and provider family from that harness's own authoritative catalog, then read `quota-axi` at the granularity the vendor actually supplies: provider-level or all-model evidence applies to every model established in that family, and a named-model window bounds only that model. +Missing model-level quota, a missing authentication source, unmeasurable headroom, or unmodeled authentication is disclosed uncertainty that keeps a candidate eligible, never a credential or login escalation. +Only concrete contradictory evidence blocks a candidate, such as an authoritative catalog proving the model unsupported or proof that the credential selected for that surface is unusable; never infer a credential store, provider family, or quota mapping from a harness, model, or source name, and never launch another harness's CLI to judge a candidate. 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. @@ -197,8 +203,9 @@ A restart must be a non-event because durable state and live backend inventory, ## 6. Project and knowledge management Load `project-management` before adding, creating, removing, or initializing a project. -That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal refusal. -Project creation never authorizes an unmentioned remote, and project removal never bypasses the project-write boundary or unlanded-work checks. +Cloning or registering a project is add intake and uses the same trigger. +That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal preflight. +Project creation never authorizes an unmentioned remote, and project removal never bypasses that preflight or unlanded-work checks; hard rule 1's concrete captain-approved project operation exception remains available when its exact conditions are met. Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. Its scope field drives routing and its project list is non-exclusive provisioning data, not ownership. @@ -294,6 +301,7 @@ After an autonomous merge, give the captain a one-line full-URL or local-main ou For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. +Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; however, the smallest downstream changes needed to keep already accepted product or engineering behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within the current task even when they touch files not named at intake, and corrections required to satisfy already accepted intent are not new requirements. An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. @@ -352,7 +360,7 @@ Handle actionable wakes as follows: 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. -When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. +When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, use its promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up so the link clears even if earlier follow-ups were spent. A secondmate's idle endpoint is healthy, and parent supervision relies on its routed status rather than treating a quiet pane as stale. Waiting on a healthy supervision cycle is silent; empty polls, elapsed time, and no-change updates are not captain-facing progress. @@ -428,16 +436,17 @@ Mention cost as a courtesy when unusually much work is running, but never block ## 10. Backlog contract -`data/backlog.md` is the durable queue. +`data/backlog.md` is the durable queue (for tasks-axi and manual backends); when `config/backlog-backend=beads` is set, the beads federated task store is the queue source instead. It tracks work items only, never agents; persistent secondmates never appear as backlog items. Work routed to a secondmate is recorded in that secondmate home's own backlog, not the main backlog. When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item; use `tasks-axi hold --reason "" --kind captain` for a captain-gated thread. +Captain-held decision threads are not supported when `config/backlog-backend=beads` or `config/backlog-backend=manual` is set; they require the tasks-axi backend. Unresolved decisions discovered by investigations or visual reviews follow `decision-hold-lifecycle`, which owns their mandatory backlog lifecycle. Update the backlog on every dispatch, completion, and decision for a work item. Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. -`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax. -Use compatible `tasks-axi` when the configured backend selects it and the documented manual path otherwise; keep only the configured recent Done entries. +`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax for the default and manual backends; `docs/configuration.md` also documents the beads backend option and its mechanics. +Use compatible `tasks-axi` when the configured backend selects it, the beads store when beads is configured, and the documented manual path otherwise; keep only the configured recent Done entries. `secondmate-provisioning` and `bin/fm-backlog-handoff.sh` own cross-home handoff safety. Keep free-form notes free of temporary paths, moving versions, ephemeral identifiers, and copied state that will rot. @@ -471,17 +480,18 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. - `project-management` - load before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. - `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. - `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. - `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. -- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. +- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. @@ -493,7 +503,11 @@ 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. ## Maintaining this file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c3a1cab18..bca6d682eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star `.agents/skills/` holds agent-loaded skills that assume a live firstmate home and carry `metadata.internal: true` so installers such as [skills.sh](https://skills.sh) hide them from discovery; `skills/` holds standalone, installer-facing public skills with no firstmate dependency (see the README's "Two-tier skill layout"). Everything personal to one captain's fleet (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) is gitignored; never commit it. The root `.tasks.toml` is tracked `tasks-axi` config for `data/backlog.md`; compatible `tasks-axi` is the default backend for routine backlog mutations, with the compatibility definition owned by [`docs/configuration.md`](docs/configuration.md) ("Backlog backend"). - A local `config/backlog-backend=manual` opt-out forces firstmate's routine backlog updates to hand-editing and stays gitignored; validated secondmate handoffs still delegate through `tasks-axi mv`. + A local `config/backlog-backend=manual` opt-out forces firstmate's routine backlog updates to hand-editing; a local `config/backlog-backend=beads` uses the federated task store instead. Both stay gitignored; validated secondmate handoffs delegate through `tasks-axi mv` when on the tasks-axi backend. A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; spawn-supported values are `tmux` plus experimental `herdr`, `zellij`, `orca`, and `cmux`, while `codex-app` is documented only in `docs/codex-app-backend.md`. It does not make `data/` tracked. - Helper scripts in `bin/` are plain bash. @@ -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. diff --git a/README.md b/README.md index a7f69e39c2..ac54cf7025 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). @@ -106,7 +106,7 @@ 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). @@ -171,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 diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 3a28daaa33..30f420d07b 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -462,18 +462,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 +486,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 @@ -630,6 +642,12 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # 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 +662,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 protocol schema socket mover response move_status focus_before local before_existing after_existing [ -n "$parent" ] || { echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2 @@ -655,9 +673,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-[^/]+$"))); @@ -779,14 +800,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 +847,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 +870,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 +1071,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 +1089,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 +1185,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 @@ -1347,7 +1563,7 @@ fm_backend_herdr_projection_parent_workspace_exact() { # local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6 @@ -1373,7 +1589,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 +2218,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" } diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 57b7f6590d..4be7d6a349 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-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 66da67b8a8..64b7ced827 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -5,6 +5,10 @@ # so the secondmate owns its queue from day one instead of the item staying # stranded in the main backlog. # +# Handoff works only with the tasks-axi backend. Handoff with the beads backend +# is not yet supported and requires a different mechanism; secondmates using the +# beads backend remain without handoff support until that mechanism is implemented. +# # Scope-matching is firstmate's JUDGMENT: you pass the task-id keys you have # already judged in-scope for the secondmate. This script performs only the # fleet-level validation that the backlog backend cannot know, then DELEGATES @@ -37,11 +41,11 @@ # item with a single-space or tab-indented continuation rather than risk leaving # it orphaned, because tasks-axi treats only two-or-more-space lines as body. # The move needs compatible `tasks-axi` on PATH, including atomic multi-ID `mv` -# (introduced in 0.2.2). Bootstrap requires it fleet-wide, so this works -# everywhere; the `config/backlog-backend=manual` knob only governs firstmate's -# own hand-editing of its own backlog, not this validated helper. Idempotent: -# re-running converges. Atomic: on any move failure nothing moves. -# See AGENTS.md project management and task lifecycle. +# (introduced in 0.2.2). Bootstrap requires it fleet-wide, so this works for the +# tasks-axi backend everywhere; the `config/backlog-backend=manual` knob only +# governs firstmate's own hand-editing of its own backlog, not this validated +# helper. Idempotent: re-running converges. Atomic: on any move failure nothing +# moves. See AGENTS.md project management and task lifecycle. # Usage: fm-backlog-handoff.sh ... set -eu @@ -232,6 +236,10 @@ backlog_key_noncanonical_body_lines() { RAW_HOME=$(secondmate_home "$ID") || exit 1 [ -n "$RAW_HOME" ] || { echo "error: secondmate $ID has no home in $REG" >&2; exit 1; } SUB_HOME=$(validate_secondmate_home "$ID" "$RAW_HOME") || exit 1 +if [ "$(fm_backlog_backend_value "$SUB_HOME/config")" = beads ]; then + echo "error: secondmate $ID uses beads backend; backlog handoff is not supported with beads" >&2 + exit 1 +fi SUB_BACKLOG="$SUB_HOME/data/backlog.md" validate_backlog_file "main backlog" "$MAIN_BACKLOG" || exit 1 validate_backlog_file "secondmate backlog" "$SUB_BACKLOG" || exit 1 diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c86b7e839a..933ed7cb21 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: ", @@ -54,7 +55,15 @@ # 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 -# and .agents/skills/quota-array-dispatch/SKILL.md. +# 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. @@ -91,12 +100,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 @@ -489,6 +502,7 @@ install_cmd() { no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; gh-axi|chrome-devtools-axi|lavish-axi) echo "npm install -g $1 && $1 setup hooks" ;; tasks-axi|quota-axi) echo "npm install -g $1" ;; + task) echo "go install github.com/steveyegge/beads/cmd/bd@latest && ln -sf bd ~/go/bin/task" ;; *) return 1 ;; esac } @@ -522,31 +536,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() { @@ -620,7 +634,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" @@ -683,9 +697,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' @@ -798,6 +819,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; } @@ -820,6 +853,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 @@ -839,9 +873,12 @@ 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 @@ -864,10 +901,26 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && [ -n "$crew" ] && [ "$crew" != echo "BOOTSTRAP_INFO: crew harness override active: $crew" fi crew_dispatch_validate -if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ - && ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then - echo "BOOTSTRAP_INFO: tasks-axi available" -fi +backlog_backend=$(fm_backlog_backend_value "$CONFIG") +case "$backlog_backend" in + beads) + if ! command -v task >/dev/null 2>&1; then + echo "MISSING: task CLI (beads store; install: $(install_cmd task))" + elif ! task list --limit 1 >/dev/null 2>&1; then + echo "MISSING: task store is unreachable or broken (beads backend configured, cannot run 'task list')" + elif [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ]; then + echo "BOOTSTRAP_INFO: beads task store available" + fi + ;; + manual) + : # manual backend requires no validation + ;; + *) + if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && fm_tasks_axi_compatible; then + echo "BOOTSTRAP_INFO: tasks-axi available" + fi + ;; +esac if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then secondmate_liveness_sweep secondmate_sync diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 8125aa2e9e..9c98723b01 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -66,10 +66,31 @@ 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 diff --git a/bin/fm-busy-event.sh b/bin/fm-busy-event.sh new file mode 100755 index 0000000000..d5484c9d5c --- /dev/null +++ b/bin/fm-busy-event.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# fm-busy-event.sh - the ONLY writer of the semantic busy-state contract +# owned by bin/fm-busy-lib.sh (record format, gen binding, and classification +# live there; this script owns mutation mechanics only). +# +# Subcommands: +# +# arm [--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 0000000000..d12cebc304 --- /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-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 22109aa87a..bffbd5234d 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -6,7 +6,8 @@ # 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, and primary +# 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 @@ -32,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. @@ -42,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 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 @@ -401,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) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 32dff23668..30fc7b7236 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; } @@ -149,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 harness-scoped pane-regex reader -# (fm_pane_is_busy, bin/fm-tmux-lib.sh). -# -# `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 that recorded harness's busy -# signature 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" "$HARNESS" ;; - *) - 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 -12 \ - | fm_busy_lines_match "$HARNESS" - ;; - 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) -- @@ -605,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 aeb140a296..0a4c857a0f 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -52,6 +52,11 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +require_tasks_axi_backend() { + [ "$(fm_backlog_backend_value "$FM_HOME/config")" != beads ] \ + || fail "decision holds are not supported with beads backend" +} + usage() { awk ' NR == 1 { next } @@ -246,6 +251,7 @@ command_hold() { validate_one_line title "$title" validate_one_line reason "$reason" case "$reason" in *'('*|*')'*) fail "reason must not contain parentheses (tasks-axi hold contract)" ;; esac + require_tasks_axi_backend require_tasks_axi origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME" id=$(hold_id "$origin" "$key") @@ -281,6 +287,7 @@ command_complete() { shift meta="$STATE/$origin.meta" [ -f "$meta" ] && has_meta=1 + require_tasks_axi_backend require_tasks_axi origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME" if [ "$#" -eq 1 ] && [ "$1" = --none ]; then @@ -391,6 +398,7 @@ command_resolve() { routed=$(printf '%s\n' "$routed" | tr ' ' '\n' | sed '/^$/d' | LC_ALL=C sort -u | paste -sd' ' -) routed_csv=$(printf '%s\n' "$routed" | tr ' ' ',') decision_digest=$(sha256_text "$decision") + require_tasks_axi_backend require_tasks_axi id=$(hold_id "$origin" "$key") if verify_hold_resolved "$id"; then diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index f2ee8fe7e8..824b95804d 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -48,7 +48,7 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + case "$(basename -- "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 5d04b65d67..a45c8378ea 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -573,6 +573,22 @@ fm_pending_reply_fallback_idle_eligible() { # [ "$age" -ge "$grace" ] } +# fm_pending_reply_backend_observation: one busy/idle observation of a +# SECONDMATE endpoint, without ever reading its conversation. +# +# Deliberately NOT the semantic busy-state contract (bin/fm-busy-lib.sh). +# That contract covers ordinary task workers, whose turn lifecycle firstmate +# wires at spawn; a secondmate has no such wiring because an idle secondmate +# pane is healthy and it runs no supervised turn sequence of its own. This +# observation exists only to notice a busy-then-idle transition around one +# delivered request, so it is a delivery-confirmation signal in the same +# category as the submit acknowledgement in bin/fm-tmux-lib.sh - never task +# state, and never a source consumers can confuse with semantic state. +# +# It stays harness-scoped (fm_busy_lines_match with the recorded harness, no +# global OR of every vendor signature), so one harness's output cannot make +# another read busy, and a weak rendered idle degrades to `fallback-idle`, +# which the caller accepts as idle only after its grace window. fm_pending_reply_backend_observation() { # [expected-label] [harness] local backend=$1 target=$2 expected_label=${3-} harness=${4-} native tail40 native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || printf 'unknown') diff --git a/bin/fm-public-followup-emit.sh b/bin/fm-public-followup-emit.sh new file mode 100755 index 0000000000..c7510e9b33 --- /dev/null +++ b/bin/fm-public-followup-emit.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# fm-public-followup-emit.sh - emit ONE structured terminal work result for work +# bound to a public commitment, into the owning home's private event inbox. +# +# WHY THIS EXISTS: a public promise is kept by the home that owns the relay +# consent and the thread binding. The home doing the work only has to report a +# TYPED result. Firstmate must never recover the source home, work id, outcome, +# or deliverables by parsing a free-form "done: ..." status sentence, so this +# script is the structured channel that carries them. +# +# WHAT IT DOES NOT DO: it never posts anything, never reads relay credentials, +# and never resolves a public thread. Outward delivery stays with the owning +# home (bin/fm-public-followup.sh deliver). +# +# Usage: +# fm-public-followup-emit.sh --home \ +# --obligation --relation \ +# --source-home > --work-id \ +# --generation --outcome \ +# [--deliverable =]... \ +# (--outcome-text | --outcome-text-file | --outcome-text -) +# +# Options: +# --home The home that owns the public commitment (the primary +# that took the mention). Must already have a +# registration for --obligation; see +# `fm-public-followup.sh register`. +# --obligation tasks-axi public-followup obligation id. +# --relation The relation_id this work fulfills or contributes to. +# --source-home This worker's stable home identity, exactly as bound: +# "main" or "secondmate:". +# --work-id This worker's exact task id, exactly as bound. +# --generation The bound relation generation (integer >= 1). +# --outcome Typed outcome. tasks-axi owns the vocabulary and +# refuses anything it does not accept; this script only +# checks the token is a safe slug. +# --deliverable k=v Repeatable safe deliverable (for example +# pr_url=https://...). tasks-axi owns which keys a given +# expected-final type permits. +# --outcome-text ... Public-safe outcome sentence, from an argument, a +# file, or stdin ("-"). Collapsed to one line; the +# event builder bounds it by codepoint, so control +# characters cannot survive. +# +# Output: the event id on stdout. Exit 0 on a published or already-present event +# (both are successes: the id is derived, so re-emitting the same terminal result +# is a no-op), 2 on a usage or validation error, 1 on a publication failure. +# +# IDEMPOTENCY: the event id is a digest of the identity tuple (obligation, +# relation, source home, work id, generation, outcome type, deliverables), so a +# retry, a duplicate report, or a rerun after restart resolves to the same file +# and the first published copy wins. Nothing here needs coordination. +# +# SAFETY: the event is published through the shared private-artifact primitive - +# atomic rename into place, single link, mode 0600 (never executable), inside a +# 0700 directory this script refuses to create. The owning home must already have +# registered the obligation, so a home that never opted into the relay can never +# be given public-followup artifacts by a child. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" + +usage() { + cat >&2 <<'EOF' +usage: fm-public-followup-emit.sh --home --obligation --relation + --source-home > --work-id --generation + --outcome [--deliverable =]... + (--outcome-text | --outcome-text-file | --outcome-text -) +EOF +} + +# The header comment IS the help text, so the two can never drift apart. +help() { + sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//' +} + +die() { printf 'fm-public-followup-emit: %s\n' "$1" >&2; exit "${2:-2}"; } + +HOME_DIR= +OBLIGATION= +RELATION= +SOURCE_HOME= +WORK_ID= +GENERATION= +OUTCOME= +TEXT_SOURCE= +TEXT_MODE= +DELIVERABLE_KEYS=() +DELIVERABLE_VALUES=() + +case "${1:-}" in + --help|-h) help; exit 0 ;; + '') usage; exit 2 ;; +esac + +while [ "$#" -gt 0 ]; do + case "$1" in + --home) shift; HOME_DIR=${1:-} ;; + --obligation) shift; OBLIGATION=${1:-} ;; + --relation) shift; RELATION=${1:-} ;; + --source-home) shift; SOURCE_HOME=${1:-} ;; + --work-id) shift; WORK_ID=${1:-} ;; + --generation) shift; GENERATION=${1:-} ;; + --outcome) shift; OUTCOME=${1:-} ;; + --outcome-text) shift; TEXT_MODE='inline'; TEXT_SOURCE=${1:-} ;; + --outcome-text-file) shift; TEXT_MODE='file'; TEXT_SOURCE=${1:-} ;; + --deliverable) + shift + case "${1:-}" in + *=*) ;; + *) die "--deliverable needs =, got '${1:-}'" ;; + esac + DELIVERABLE_KEYS+=("${1%%=*}") + DELIVERABLE_VALUES+=("${1#*=}") + ;; + --help|-h) help; exit 0 ;; + *) die "unknown argument '$1'" ;; + esac + shift || true +done + +[ -n "$HOME_DIR" ] || { usage; exit 2; } +[ -n "$OBLIGATION" ] || { usage; exit 2; } +[ -n "$RELATION" ] || { usage; exit 2; } +[ -n "$SOURCE_HOME" ] || { usage; exit 2; } +[ -n "$WORK_ID" ] || { usage; exit 2; } +[ -n "$GENERATION" ] || { usage; exit 2; } +[ -n "$OUTCOME" ] || { usage; exit 2; } +[ -n "$TEXT_MODE" ] || { usage; exit 2; } + +fm_pf_slug_valid "$OBLIGATION" || die "unsafe obligation id: $OBLIGATION" +fm_pf_slug_valid "$RELATION" || die "unsafe relation id: $RELATION" +fm_pf_slug_valid "$WORK_ID" || die "unsafe work id: $WORK_ID" +fm_pf_slug_valid "$OUTCOME" || die "unsafe outcome type: $OUTCOME" +fm_pf_home_id_valid "$SOURCE_HOME" \ + || die "source home must be 'main' or 'secondmate:', got '$SOURCE_HOME'" +case "$GENERATION" in + ''|*[!0-9]*) die "generation must be a positive integer, got '$GENERATION'" ;; +esac +[ "$GENERATION" -ge 1 ] || die "generation must be >= 1, got '$GENERATION'" + +i=0 +while [ "$i" -lt "${#DELIVERABLE_KEYS[@]}" ]; do + key=${DELIVERABLE_KEYS[$i]} + case "$key" in + ''|*[!a-z0-9_]*) die "deliverable key must be lowercase [a-z0-9_], got '$key'" ;; + esac + [ "${#DELIVERABLE_VALUES[$i]}" -le 512 ] \ + || die "deliverable '$key' exceeds 512 characters" + case "${DELIVERABLE_VALUES[$i]}" in + *[[:cntrl:]]*) die "deliverable '$key' must be single-line text with no control characters" ;; + esac + i=$((i + 1)) +done + +# Resolve the owning home to a real absolute directory before composing any path +# under it, so a relative or symlinked argument cannot make the destination +# ambiguous in a later message or write. +case "$HOME_DIR" in + /*) ;; + *) HOME_DIR=$(CDPATH='' cd -- "$HOME_DIR" 2>/dev/null && pwd -P) \ + || die "--home is not a reachable directory: $1" ;; +esac +[ -d "$HOME_DIR" ] && [ ! -L "$HOME_DIR" ] \ + || die "--home must name an existing directory, got '$HOME_DIR'" + +fm_pf_relay_active "$HOME_DIR" || exit 0 +command -v jq >/dev/null 2>&1 || die "jq is required to build a typed terminal event" 1 + +STATE="$HOME_DIR/state" +REGISTRY="$(fm_pf_registry_dir "$STATE")/$OBLIGATION" +if [ ! -f "$REGISTRY" ] || [ -L "$REGISTRY" ]; then + die "home '$HOME_DIR' has no public-followup registration for '$OBLIGATION'; the owning home registers a commitment before its work can report one" 1 +fi + +# The registration is the owning home's own record of what it bound, so checking +# the identity tuple against it catches a mis-briefed worker at the edge with a +# clear message. tasks-axi still re-validates everything at consume time and +# remains the authority; this is a cheap early refusal, not a second gatekeeper. +reg_mismatch() { + local field=$1 expected=$2 got=$3 + [ -z "$expected" ] || [ "$expected" = "$got" ] \ + || die "event $field '$got' does not match this home's registration ('$expected')" +} +reg_mismatch relation "$(fm_pf_registry_get "$STATE" "$OBLIGATION" relation_id)" "$RELATION" +reg_mismatch source-home "$(fm_pf_registry_get "$STATE" "$OBLIGATION" work_home)" "$SOURCE_HOME" +reg_mismatch work-id "$(fm_pf_registry_get "$STATE" "$OBLIGATION" work_id)" "$WORK_ID" +reg_mismatch generation "$(fm_pf_registry_get "$STATE" "$OBLIGATION" generation)" "$GENERATION" + +case "$TEXT_MODE" in + inline) OUTCOME_TEXT=$(printf '%s' "$TEXT_SOURCE" | fm_pf_clean_outcome_text) ;; + file) + if [ "$TEXT_SOURCE" = '-' ]; then + OUTCOME_TEXT=$(fm_pf_clean_outcome_text) + else + [ -f "$TEXT_SOURCE" ] || die "outcome text file not found: $TEXT_SOURCE" + OUTCOME_TEXT=$(fm_pf_clean_outcome_text < "$TEXT_SOURCE") + fi + ;; +esac +[ -n "$OUTCOME_TEXT" ] || die "outcome text is empty once whitespace and control characters are removed" + +# Canonical deliverables object: sorted keys, compact, so the same deliverables +# always hash to the same identity regardless of flag order. +DELIVERABLES_JSON=$( + { + i=0 + while [ "$i" -lt "${#DELIVERABLE_KEYS[@]}" ]; do + printf '%s\n%s\n' "${DELIVERABLE_KEYS[$i]}" "${DELIVERABLE_VALUES[$i]}" + i=$((i + 1)) + done + } | jq -Rsc 'split("\n") | .[:-1] | [range(0; length; 2) as $i | {key: .[$i], value: .[$i+1]}] | from_entries | to_entries | sort_by(.key) | from_entries' +) || die "could not encode deliverables" 1 + +EVENT_ID=$(fm_pf_event_id \ + "$OBLIGATION" "$RELATION" "$SOURCE_HOME" "$WORK_ID" "$GENERATION" "$OUTCOME" \ + "$DELIVERABLES_JSON") || die "sha256 (shasum or sha256sum) is required" 1 +# The derived id becomes a filename, so require the exact digest shape rather +# than trusting whatever the hashing tool printed. +case "$EVENT_ID" in + *[!0-9a-f]*|'') die "could not derive a usable event id" 1 ;; +esac +[ "${#EVENT_ID}" -eq 64 ] || die "could not derive a usable event id" 1 + +# jq bounds the outcome text by codepoint, so a long or non-ASCII sentence is +# capped without ever splitting a multi-byte character. +EVENT_JSON=$(jq -Sc -n \ + --argjson schema_version "$FM_PF_EVENT_SCHEMA_VERSION" \ + --arg event_id "$EVENT_ID" \ + --arg obligation_id "$OBLIGATION" \ + --arg relation_id "$RELATION" \ + --arg work_id "$WORK_ID" \ + --argjson generation "$GENERATION" \ + --arg source_home_id "$SOURCE_HOME" \ + --arg outcome_type "$OUTCOME" \ + --argjson deliverables "$DELIVERABLES_JSON" \ + --arg public_safe_outcome "$OUTCOME_TEXT" \ + --argjson outcome_max "$FM_PF_OUTCOME_TEXT_MAX" \ + --arg occurred_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{schema_version:$schema_version, event_id:$event_id, obligation_id:$obligation_id, + relation_id:$relation_id, work_id:$work_id, generation:$generation, + source_home_id:$source_home_id, outcome_type:$outcome_type, + deliverables:$deliverables, + public_safe_outcome:($public_safe_outcome[0:$outcome_max]), + occurred_at:$occurred_at, successor:null}') \ + || die "could not build the typed terminal event" 1 + +EVENT_BYTES=$(printf '%s\n' "$EVENT_JSON" | LC_ALL=C wc -c | tr -d ' ') \ + || die "could not measure the typed terminal event" 1 +[ "$EVENT_BYTES" -le "$FM_PF_EVENT_BYTES_MAX" ] \ + || die "typed terminal event exceeds $FM_PF_EVENT_BYTES_MAX bytes" 2 + +printf '%s\n' "$EVENT_JSON" \ + | fmx_private_artifact_publish_stdin_once "$(fm_pf_events_dir "$STATE")" "$EVENT_ID.json" 600 +case $? in + 0|1) printf '%s\n' "$EVENT_ID" ;; + *) die "could not publish the terminal event into $HOME_DIR" 1 ;; +esac diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh new file mode 100644 index 0000000000..dc7153d53c --- /dev/null +++ b/bin/fm-public-followup-lib.sh @@ -0,0 +1,247 @@ +# shellcheck shell=bash +# fm-public-followup-lib.sh - shared gating and private-transport helpers for the +# deterministic public-followup consumer. +# +# Firstmate promises a public final reply when a myfirstmate relay mention (X or +# Discord) asks for work. `tasks-axi public-followup` is the sole owner of that +# typed obligation and its state machine; state/x-context/ is the sole owner of +# the private full request context. This library owns only the small Firstmate +# side: the activation gate, the private per-home transport directories, and the +# deterministic terminal-event identity. +# +# Sourced, never executed. No side effects on source (it creates nothing), which +# is what keeps a relay-disabled home free of public-followup artifacts. +# set -u / set -e safe. +# +# GATE ORDER - the acceptance criterion for relay-disabled homes: +# 1. fm_pf_relay_active the authoritative myfirstmate activation +# contract, a non-empty FMX_PAIRING_TOKEN in +# /.env. There is no second flag. When +# /.env is absent this is a single +# [ -f ] test and nothing else runs. +# 2. fm_pf_has_registrations O(1) presence check on the registry created +# / fm_pf_has_events only by the relay path (fm-public-followup.sh +# register). Relay-enabled homes with no +# public commitments stop here, so no +# tasks-axi call and no backlog scan happens. +# +# Private transport layout, all under /state/public-followup (mode 0700, +# created only by `fm-public-followup.sh register`): +# registry/ registration record: the bounded public-safe +# binding (obligation, relation, work ref, +# generation, platform, request id). Presence hint +# and reverse work->obligation index only; the +# obligation itself always remains tasks-axi truth. +# events/.json inbound typed terminal events awaiting +# reconciliation, one file per event id. +# consumed/ idempotency ledger: an accepted event id is never +# replayed, so duplicate emits and restart replay +# are no-ops. +# rejected/.json events tasks-axi refused, kept with a +# rejected/.reason one-line reason so a refusal is inspectable and +# never retried in a loop. +# surfaced last surfaced pending-event signature, so the +# existing relay poll wakes once per new event set +# instead of every cycle. +# +# Event identity is DERIVED, never random: fm_pf_event_id hashes the canonical +# identity tuple, so re-emitting the same terminal result produces the same +# event id and the same destination path. Idempotency therefore holds across +# retries, restarts, and duplicate child reports without any coordination. +# +# Depends on bin/fm-x-lib.sh for .env reading and the private-artifact +# publication primitives (atomic, single-link, mode-validated, non-executable); +# those remain that file's contract and are not restated here. + +_FM_PF_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_PF_LIB_DIR="." +# shellcheck source=bin/fm-x-lib.sh +. "$_FM_PF_LIB_DIR/fm-x-lib.sh" + +FM_PF_DIRNAME='public-followup' +# Consumed by the sourcing scripts, not by this library. +# shellcheck disable=SC2034 +FM_PF_EVENT_SCHEMA_VERSION=1 +# Bounded so a public-safe outcome line can never carry a raw public message, +# and so one event file stays small enough to read and validate cheaply. +FM_PF_OUTCOME_TEXT_MAX=${FM_PF_OUTCOME_TEXT_MAX:-600} +FM_PF_EVENT_BYTES_MAX=${FM_PF_EVENT_BYTES_MAX:-8192} + +# --- gate 1: the authoritative relay activation contract -------------------- + +# fm_pf_relay_active : 0 when this home has opted into the myfirstmate +# relay, 1 otherwise. Identical contract to bootstrap's X-mode activation - a +# non-empty FMX_PAIRING_TOKEN in /.env - so no second activation flag +# exists to drift. FMX_PAIRING_TOKEN in the environment wins, matching +# fmx_load_config, so a direct client call and this gate agree. +fm_pf_relay_active() { + local home=$1 token + if [ -n "${FMX_PAIRING_TOKEN+x}" ]; then + [ -n "${FMX_PAIRING_TOKEN-}" ] + return $? + fi + [ -f "$home/.env" ] || return 1 + token=$(fmx_env_get FMX_PAIRING_TOKEN "$home/.env") + [ -n "$token" ] +} + +# --- gate 2: O(1) presence checks on relay-path-owned registrations --------- + +fm_pf_root() { printf '%s\n' "$1/$FM_PF_DIRNAME"; } +fm_pf_registry_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/registry"; } +fm_pf_events_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/events"; } +fm_pf_consumed_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/consumed"; } +fm_pf_rejected_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/rejected"; } + +# fm_pf_dir_has_entry : 0 when is a real directory holding at least +# one non-dot entry. Stops at the first hit, so cost does not grow with the +# directory's size. +fm_pf_dir_has_entry() { + local dir=$1 entry + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + for entry in "$dir"/*; do + [ -e "$entry" ] || continue + return 0 + done + return 1 +} + +fm_pf_has_registrations() { fm_pf_dir_has_entry "$(fm_pf_registry_dir "$1")"; } +fm_pf_has_events() { fm_pf_dir_has_entry "$(fm_pf_events_dir "$1")"; } + +# fm_pf_active : both gates, in order. The single predicate every +# caller outside the relay path should use before doing any public-followup work. +fm_pf_active() { + fm_pf_relay_active "$1" || return 1 + fm_pf_has_registrations "$2" || fm_pf_has_events "$2" +} + +# --- identifiers ------------------------------------------------------------ + +# fm_pf_slug_valid : obligation ids, relation ids, work ids, and request +# ids all compose filenames. They arrive from tasks-axi, the relay, and child +# homes, so every one is checked against a conservative slug before use. +fm_pf_slug_valid() { + local v=$1 + case "$v" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#v}" -le 128 ] +} + +# fm_pf_home_id_valid : tasks-axi accepts "main" or +# "secondmate:" as a work_ref home. Validate the same shape here so a +# malformed source home is refused before it reaches a filename or a CLI call. +fm_pf_home_id_valid() { + local v=$1 + case "$v" in + main) return 0 ;; + secondmate:*) fm_pf_slug_valid "${v#secondmate:}" ;; + *) return 1 ;; + esac +} + +fm_pf_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +# fm_pf_event_id +# +# The stable idempotency identity. Derived from the identity tuple only, so the +# same terminal result always yields the same id no matter who emits it or how +# often. Public-safe outcome text is deliberately excluded: rewording the same +# landed outcome must not create a second event. +fm_pf_event_id() { + printf '%s\037%s\037%s\037%s\037%s\037%s\037%s' "$1" "$2" "$3" "$4" "$5" "$6" "$7" \ + | fm_pf_sha256 +} + +# --- bounded public-safe text ---------------------------------------------- + +# fm_pf_clean_outcome_text: read stdin, drop control characters, collapse every +# whitespace run to a single space, and trim. An event line therefore stays +# single-line and a raw pasted public message cannot ride along inside it. +# Deliberately does NOT truncate: a byte-wise cut would split a multi-byte +# character, so length bounding happens where it can count codepoints - jq, at +# the point the typed event is built. +fm_pf_clean_outcome_text() { + LC_ALL=C tr -d '\000-\010\013\014\016-\037\177' \ + | LC_ALL=C tr '\011\012\015' ' ' \ + | LC_ALL=C tr -s ' ' \ + | sed 's/^ //; s/ $//' +} + +# fm_pf_bound_bytes : hard byte cap for text that never becomes JSON, such +# as a quarantined event's one-line refusal reason. +fm_pf_bound_bytes() { + LC_ALL=C cut -b "1-$1" +} + +# --- registry records ------------------------------------------------------- + +# fm_pf_registry_get : read one key=value line from +# a registration record. Prints nothing and succeeds when absent. +fm_pf_registry_get() { + local state=$1 id=$2 key=$3 file line + fm_pf_slug_valid "$id" || return 1 + file="$(fm_pf_registry_dir "$state")/$id" + [ -f "$file" ] && [ ! -L "$file" ] || return 0 + line=$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1) || return 0 + printf '%s' "${line#*=}" +} + +# fm_pf_registry_ids : every registered obligation id, one per line. +# The registry only ever holds this home's live public commitments, so this stays +# a bounded listing rather than a backlog scan. +fm_pf_registry_ids() { + local dir entry + dir=$(fm_pf_registry_dir "$1") + [ -d "$dir" ] && [ ! -L "$dir" ] || return 0 + for entry in "$dir"/*; do + [ -f "$entry" ] && [ ! -L "$entry" ] || continue + basename "$entry" + done +} + +# fm_pf_registry_ids_for_work : the obligations +# this home registered against one exact work relation. Used by the completion +# guard so cleanup cannot declare bound work finished while its public promise is +# still open. +fm_pf_registry_ids_for_work() { + local state=$1 home_id=$2 work_id=$3 id + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" work_home)" = "$home_id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" work_id)" = "$work_id" ] || continue + printf '%s\n' "$id" + done <: a stable digest of the pending event id set. +# The relay poll compares it against the surfaced record so an unconsumed event +# wakes firstmate once per new event, not once per poll cycle. +fm_pf_events_signature() { + local dir entry names= + dir=$(fm_pf_events_dir "$1") + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + for entry in "$dir"/*.json; do + [ -f "$entry" ] && [ ! -L "$entry" ] || continue + names="$names$(basename "$entry") +" + done + [ -n "$names" ] || return 1 + printf '%s' "$names" | LC_ALL=C sort | fm_pf_sha256 +} diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh new file mode 100755 index 0000000000..d10808a694 --- /dev/null +++ b/bin/fm-public-followup.sh @@ -0,0 +1,905 @@ +#!/usr/bin/env bash +# fm-public-followup.sh - the deterministic consumer and delivery owner for +# public commitments made through the myfirstmate relay (X and Discord). +# +# THE PROBLEM THIS SOLVES: firstmate promises a public final reply, routes the +# work out, and then the conversation compacts or the session restarts. Nothing +# in memory survives, so the promise is only kept if reconciling it is a disk +# operation. Every command here reads durable state and nothing else. +# +# OWNERSHIP BOUNDARIES (do not re-implement any of these here): +# tasks-axi public-followup the typed obligation and its state machine. +# state/x-context/ the private full request context (fm-x-lib.sh). +# bin/fm-x-reply.sh posting to the relay, thread splitting, dry run. +# bin/fm-public-followup-lib.sh the activation gate and private transport. +# This script composes them; it never restates their contracts or schemas. +# +# ZERO OVERHEAD FOR HOMES THAT DO NOT USE THE RELAY: every subcommand gates +# first on the authoritative activation contract (a non-empty FMX_PAIRING_TOKEN +# in $FM_HOME/.env). Read-side and cleanup paths then use an O(1) presence check +# for registrations this home actually created. A relay-disabled home therefore +# runs one [ -f ] test before any backlog work: no tasks-axi call, no backlog scan, +# and no file created. Silent read-side commands return without output; commands +# that require an active relay report their configuration error after the same +# gate. A relay-enabled home with no live commitments stops at the second gate +# for the same cost. +# +# Usage: +# fm-public-followup.sh active +# Silent gate probe. Exit 0 when this home has live public-followup work +# worth looking at, 1 otherwise. Safe to call unconditionally. +# +# fm-public-followup.sh register --relation +# --work-home > --work-id --generation +# [--platform ] [--request ] +# Record the binding the relay path just created with `tasks-axi +# public-followup add` + `bind-work`. This is the event-driven +# registration: it creates this home's private public-followup directories +# (0700) and the bounded public-safe registration record, which is what +# later makes the presence checks O(1) and lets bound work report a typed +# terminal result. Refuses when the relay is not active for this home. +# +# fm-public-followup.sh brief +# Print the exact fm-public-followup-emit.sh command line the bound worker +# must run when its work reaches the promised terminal outcome, so the +# binding is copied into a brief instead of hand-assembled. +# +# fm-public-followup.sh consume +# Drain every pending typed terminal event: validate its derived identity, +# skip anything already accepted, apply `tasks-axi public-followup +# work-event`, and quarantine what tasks-axi refuses. Prints one +# "ready " line per obligation that +# became delivery-ready, and one "rejected : " line per +# refusal. Silent when there is nothing to do. Duplicate events and restart +# replay are no-ops. +# +# fm-public-followup.sh pending +# One bounded public-safe line per unresolved commitment, for the session +# start digest. Prunes registrations whose obligation is already closed. +# Silent when nothing is unresolved. +# +# fm-public-followup.sh deliver [--text-file ] +# Post the final public reply into the ORIGINAL thread and close the +# obligation. Uses the stored platform and opaque context binding, so the +# destination is never guessed. Without --text-file the accepted terminal +# event's bounded public-safe outcome is reused exactly, which keeps the +# common path deterministic. The sequence is begin-delivery with the +# payload hash, post, then record the posted receipt or a typed error. +# A validated receipt also clears any bound legacy X link before the +# registration is removed. +# An already-posted obligation is an idempotent success without another +# post; an obligation left in delivery-posting by a crash is REFUSED +# rather than posted again. +# +# fm-public-followup.sh record-posted --attempt --chunks +# Close an obligation whose post is known to have landed on exactly +# attempt with exactly messages, without posting anything. This is +# the late-receipt path: use it when a post succeeded but its receipt was +# lost, never to paper over an unknown outcome. +# +# fm-public-followup.sh guard-work +# Exit 3 when this home has an unresolved public commitment bound to that +# exact work, printing one line per blocking obligation. Exit 0 otherwise. +# Cleanup paths call this so bound work is never treated as finished while +# its public promise is still open. +# +# fm-public-followup.sh retire [--force] +# Drop the registration once its obligation is closed. --force is the +# explicit discard-approved escape hatch for an unresolved or missing +# obligation. +# +# Requires jq and a compatible tasks-axi for registration, reconciliation, +# delivery, cleanup guards, and retirement; `active` and `brief` only inspect +# local state. +# FM_PF_RETRY_BACKOFF_SECS (default 900) sets the next-attempt time recorded with +# a retryable delivery error. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" + +RETRY_BACKOFF=${FM_PF_RETRY_BACKOFF_SECS:-900} +case "$RETRY_BACKOFF" in ''|*[!0-9]*) RETRY_BACKOFF=900 ;; esac + +usage() { + echo "usage: fm-public-followup.sh [args]" >&2 +} + +# The header comment IS the help text, so the two can never drift apart. +help() { sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +die() { printf 'fm-public-followup: %s\n' "$1" >&2; exit "${2:-2}"; } + +PF_TEMP_FILES=() +pf_cleanup_temp_files() { + [ "${#PF_TEMP_FILES[@]}" -eq 0 ] || rm -f -- "${PF_TEMP_FILES[@]}" +} +trap pf_cleanup_temp_files EXIT + +now_rfc3339() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# next_attempt_rfc3339: the retry time recorded with a retryable delivery error. +# BSD and GNU date disagree on the flag, so try both and print nothing when +# neither works - the error is still recorded, just without a retry time. +next_attempt_rfc3339() { + local at + at=$(( $(date +%s) + RETRY_BACKOFF )) + date -u -r "$at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -d "@$at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || true +} + +require_tools() { + command -v jq >/dev/null 2>&1 || die "jq is required" 1 + command -v tasks-axi >/dev/null 2>&1 || die "tasks-axi is required" 1 +} + +# Every tasks-axi call runs from the home whose backlog owns the obligation, the +# same convention bin/fm-decision-hold.sh uses for typed backlog state. +tx() { (cd "$FM_HOME" && tasks-axi "$@"); } + +# obligation_json : the complete typed obligation payload on stdout, empty +# when the backlog simply has no such public-followup item, and a non-zero exit +# ONLY when the backlog could not be read at all. Callers depend on that +# distinction to report the right thing, so jq runs without -e here. tasks-axi +# stays the single source of truth; the registration record is never consulted +# for state. +obligation_json() { + local id=$1 out + out=$(tx public-followup list --json 2>/dev/null) || return 1 + [ -n "$out" ] || return 1 + printf '%s' "$out" | jq -c --arg id "$id" \ + '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null \ + || return 1 +} + +pf_field() { printf '%s' "$1" | jq -r "$2 // empty" 2>/dev/null; } + +# --- gates ------------------------------------------------------------------ + +# gate_or_exit: the shared silent gate for every read-side subcommand. Exits 0 +# with no output when this home has no public-followup work, so callers can +# invoke unconditionally without a relay-disabled home paying anything. +gate_or_exit() { + fm_pf_relay_active "$FM_HOME" || exit 0 + fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE" || exit 0 +} + +# --- subcommand: active ----------------------------------------------------- + +cmd_active() { + fm_pf_relay_active "$FM_HOME" || exit 1 + fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE" || exit 1 + exit 0 +} + +# --- subcommand: register --------------------------------------------------- + +cmd_register() { + local id=${1:-} + local relation='' work_home='' work_id='' generation='' platform='' request='' + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --relation) shift; relation=${1:-} ;; + --work-home) shift; work_home=${1:-} ;; + --work-id) shift; work_id=${1:-} ;; + --generation) shift; generation=${1:-} ;; + --platform) shift; platform=${1:-} ;; + --request) shift; request=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot own a public commitment" 1 + require_tools + + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_slug_valid "$relation" || die "unsafe relation id: $relation" + fm_pf_slug_valid "$work_id" || die "unsafe work id: $work_id" + fm_pf_home_id_valid "$work_home" \ + || die "work home must be 'main' or 'secondmate:', got '$work_home'" + case "$generation" in + ''|*[!0-9]*) die "generation must be a positive integer, got '$generation'" ;; + esac + [ "$generation" -ge 1 ] || die "generation must be >= 1" + + local payload + payload=$(obligation_json "$id") \ + || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] \ + || die "no public-followup obligation '$id' in this home's backlog; create it with tasks-axi public-followup add before registering" 1 + + # The relation must already be bound, so a registration can never describe a + # binding tasks-axi does not have. + printf '%s' "$payload" | jq -e --arg r "$relation" --arg h "$work_home" --arg w "$work_id" \ + '(.public_followup.work_relations // []) + | map(select(.relation_id == $r and .work_ref.home_id == $h and .work_ref.task_id == $w)) + | length > 0' >/dev/null 2>&1 \ + || die "obligation '$id' has no bound relation '$relation' for $work_home/$work_id; run tasks-axi public-followup bind-work first" 1 + + [ -n "$platform" ] || platform=$(pf_field "$payload" '.public_followup.request.platform') + [ -n "$request" ] || request=$(pf_field "$payload" '.public_followup.request.request_id') + [ -z "$request" ] || fm_pf_slug_valid "$request" || die "unsafe request id: $request" + + local mkdir_target + for mkdir_target in "$(fm_pf_registry_dir "$STATE")" "$(fm_pf_events_dir "$STATE")" \ + "$(fm_pf_consumed_dir "$STATE")" "$(fm_pf_rejected_dir "$STATE")"; do + fmx_private_artifact_dir_prepare "$mkdir_target" >/dev/null \ + || die "could not prepare $mkdir_target" 1 + done + + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\n' \ + "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ + | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$id" 600 \ + || die "could not write the registration record" 1 + + printf 'registered %s %s/%s generation=%s platform=%s\n' \ + "$id" "$work_home" "$work_id" "$generation" "${platform:-unknown}" +} + +# --- subcommand: brief ------------------------------------------------------ + +cmd_brief() { + local id=${1:-} relation work_home work_id generation + [ -n "$id" ] || { usage; exit 2; } + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 + [ -f "$(fm_pf_registry_dir "$STATE")/$id" ] \ + || die "no registration for '$id' in this home" 1 + + relation=$(fm_pf_registry_get "$STATE" "$id" relation_id) + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + generation=$(fm_pf_registry_get "$STATE" "$id" generation) + + cat < \\ + --deliverable = \\ + --outcome-text '' + +Do not post anything publicly yourself and do not look for the public thread: +the home above owns the reply. +EOF +} + +# --- subcommand: consume ---------------------------------------------------- + +# reject_event : quarantine one refused event with an +# inspectable reason so it is never retried in a loop. +reject_event() { + local file=$1 event_id=$2 reason=$3 rejected event_payload + rejected=$(fm_pf_rejected_dir "$STATE") + fmx_private_artifact_dir_prepare "$rejected" >/dev/null \ + || { printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason"; return 1; } + if ! printf '%s\n' "$reason" \ + | fmx_private_artifact_publish_stdin "$rejected" "$event_id.reason" 600 2>/dev/null; then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! event_payload=$(cat "$file" 2>/dev/null); then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! printf '%s' "$event_payload" \ + | fmx_private_artifact_publish_stdin "$rejected" "$event_id.json" 600 2>/dev/null; then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! rm -f -- "$file" 2>/dev/null; then + printf 'rejected %s: %s (quarantine cleanup failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + printf 'rejected %s: %s\n' "$event_id" "$reason" +} + +cmd_consume() { + gate_or_exit + fm_pf_has_events "$STATE" || exit 0 + require_tools + + local events_dir consumed_dir stderr_file file event_id payload derived out rc reason consume_rc=0 + local obligation delivery request platform + events_dir=$(fm_pf_events_dir "$STATE") + consumed_dir=$(fm_pf_consumed_dir "$STATE") + fmx_private_artifact_dir_prepare "$consumed_dir" >/dev/null \ + || die "could not prepare the consumed-event ledger" 1 + stderr_file=$(mktemp "${TMPDIR:-/tmp}/fm-pf-consume.XXXXXX") \ + || die "could not stage the reconciliation log" 1 + PF_TEMP_FILES+=("$stderr_file") + + for file in "$events_dir"/*.json; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + event_id=$(basename "$file" .json) + + if ! fm_pf_slug_valid "$event_id"; then + printf 'rejected %s: unsafe event filename (event retained)\n' "$event_id" + consume_rc=1 + continue + fi + + # Already accepted on an earlier pass (duplicate emit, or a replay after + # restart): drop the copy without touching the state machine. + if [ -f "$consumed_dir/$event_id" ]; then + rm -f -- "$file" 2>/dev/null || true + continue + fi + + if [ "$(wc -c < "$file" 2>/dev/null || echo 0)" -gt "$FM_PF_EVENT_BYTES_MAX" ]; then + reject_event "$file" "$event_id" "event exceeds $FM_PF_EVENT_BYTES_MAX bytes" || consume_rc=1 + continue + fi + + if ! payload=$(jq -ce . "$file" 2>/dev/null) || [ -z "$payload" ]; then + reject_event "$file" "$event_id" "event is not valid JSON" || consume_rc=1 + continue + fi + + # The filename, the declared event_id, and the identity tuple must all agree. + # A mismatch means the file was hand-edited or built by something other than + # fm-public-followup-emit.sh, so it is refused before tasks-axi sees it. + if [ "$(pf_field "$payload" '.event_id')" != "$event_id" ]; then + reject_event "$file" "$event_id" "declared event_id does not match the filename" || consume_rc=1 + continue + fi + derived=$(fm_pf_event_id \ + "$(pf_field "$payload" '.obligation_id')" \ + "$(pf_field "$payload" '.relation_id')" \ + "$(pf_field "$payload" '.source_home_id')" \ + "$(pf_field "$payload" '.work_id')" \ + "$(pf_field "$payload" '.generation')" \ + "$(pf_field "$payload" '.outcome_type')" \ + "$(printf '%s' "$payload" | jq -Sc '.deliverables // {}' 2>/dev/null)") + if [ -z "$derived" ] || [ "$derived" != "$event_id" ]; then + reject_event "$file" "$event_id" "event id does not match its own identity fields" || consume_rc=1 + continue + fi + + obligation=$(pf_field "$payload" '.obligation_id') + if ! fm_pf_slug_valid "$obligation"; then + reject_event "$file" "$event_id" "unsafe obligation id in event" || consume_rc=1 + continue + fi + + # tasks-axi is the authority on source home, work id, generation, schema, + # outcome, and deliverables. Anything it refuses is quarantined verbatim. + # stderr is captured separately so a warning can never corrupt the JSON that + # the accepted path parses. + if out=$(tx public-followup work-event "$obligation" --event-file "$file" --json 2>"$stderr_file"); then + rc=0 + else + rc=$? + fi + if [ "$rc" -ne 0 ]; then + reason=$( { cat "$stderr_file" 2>/dev/null; printf '%s\n' "$out"; } \ + | grep -v '^[[:space:]]*$' | head -1 | fm_pf_clean_outcome_text | fm_pf_bound_bytes 400) + reject_event "$file" "$event_id" "${reason:-tasks-axi refused the event}" || consume_rc=1 + continue + fi + + if ! printf 'accepted %s\n' "$(now_rfc3339)" \ + | fmx_private_artifact_publish_stdin "$consumed_dir" "$event_id" 600 2>/dev/null; then + printf 'accepted %s: consumed ledger could not be recorded; event retained for reconciliation\n' "$event_id" + consume_rc=1 + continue + fi + if ! rm -f -- "$file" 2>/dev/null; then + printf 'accepted %s: consumed ledger recorded but event could not be removed; event retained for reconciliation\n' "$event_id" + consume_rc=1 + continue + fi + + delivery=$(printf '%s' "$out" | jq -r '.task.public_followup.delivery.state // empty' 2>/dev/null) + if [ "$delivery" = ready ]; then + request=$(printf '%s' "$out" | jq -r '.task.public_followup.request.request_id // empty' 2>/dev/null) + platform=$(printf '%s' "$out" | jq -r '.task.public_followup.request.platform // empty' 2>/dev/null) + printf 'ready %s %s %s\n' "$obligation" "${request:-unknown}" "${platform:-unknown}" + fi + done + + # A fresh event must be able to wake firstmate again, so drop the surfaced + # signature once the inbox has been worked. + rm -f -- "$(fm_pf_root "$STATE")/$FM_PF_SURFACED_BASENAME" 2>/dev/null || true + return "$consume_rc" +} + +# --- subcommand: pending ---------------------------------------------------- + +cmd_pending() { + gate_or_exit + + local listing id payload delivery task_state summary platform request printed=0 + # An unreadable backlog with registrations present is exactly the silence this + # whole path exists to prevent, so say so rather than printing nothing. + if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1 \ + || ! listing=$(tx public-followup list --json 2>/dev/null) || [ -z "$listing" ] \ + || ! printf '%s' "$listing" | jq -e ' + type == "object" + and (.public_followups | type == "array") + and all(.public_followups[]; + type == "object" + and (.id | type == "string") + and (.public_followup | type == "object") + and (.state | type == "string")) + ' >/dev/null 2>&1; then + if fm_pf_has_registrations "$STATE"; then + printf 'cannot read this home'\''s public commitments through tasks-axi; %s registration(s) are still recorded under state/%s/registry\n' \ + "$(fm_pf_registry_ids "$STATE" | grep -c . || true)" "$FM_PF_DIRNAME" + printed=1 + fi + if fm_pf_has_events "$STATE"; then + printf 'unconsumed terminal results are waiting; run %s/bin/fm-public-followup.sh consume\n' "$FM_ROOT" + printed=1 + fi + [ "$printed" -eq 1 ] || exit 0 + return 0 + fi + + while IFS= read -r id; do + [ -n "$id" ] || continue + payload=$(printf '%s' "$listing" | jq -ce --arg id "$id" \ + '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null) + if [ -z "$payload" ]; then + # The obligation is gone from the backlog (pruned after Done): the + # registration is stale bookkeeping, not evidence, so drop it. + if ! clear_public_followup_link "$id"; then + printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" + printed=1 + continue + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + continue + fi + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + if [ "$task_state" = 'done' ] || [ "$delivery" = 'posted' ] || [ "$delivery" = 'waived' ]; then + if ! clear_public_followup_link "$id"; then + printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" + printed=1 + continue + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + continue + fi + summary=$(pf_field "$payload" '.public_followup.request.public_safe_summary' | fm_pf_clean_outcome_text) + platform=$(pf_field "$payload" '.public_followup.request.platform') + request=$(pf_field "$payload" '.public_followup.request.request_id') + printf 'unresolved %s state=%s platform=%s request=%s summary=%s\n' \ + "$id" "${delivery:-unknown}" "${platform:-unknown}" "${request:-unknown}" "$summary" + printed=1 + done </dev/null \ + | sed -n 's/.*(home:[[:space:]]*\([^;)]*\);.*/\1/p' | sed 's/[[:space:]]*$//') + fi + [ -n "$home" ] || return 1 + case "$home" in /*) ;; *) return 1 ;; esac + home=$(CDPATH='' cd -- "$home" 2>/dev/null && pwd -P) || return 1 + [ -f "$home/.fm-secondmate-home" ] && [ ! -L "$home/.fm-secondmate-home" ] || return 1 + marker=$(sed -n '1p' "$home/.fm-secondmate-home" 2>/dev/null) + [ "$marker" = "$id" ] || return 1 + printf '%s\n' "$home" +} + +clear_public_followup_link() { + local id=$1 work_home work_id home state + public_followup_registration_valid "$id" || return 1 + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + [ -n "$work_home" ] && [ -n "$work_id" ] || return 1 + case "$work_home" in + main) + home=$FM_HOME + state=$STATE + ;; + secondmate:*) + home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 1 + state="$home/state" + ;; + *) return 1 ;; + esac + FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_ROOT_OVERRIDE="$FM_ROOT" \ + "$FM_ROOT/bin/fm-x-followup.sh" --clear "$work_id" >/dev/null +} + +public_followup_legacy_link_status() { + local payload=$1 relations work_home work_id home meta + if ! printf '%s' "$payload" | jq -e ' + (.public_followup.work_relations | type == "array") + and all(.public_followup.work_relations[]; + (.work_ref.home_id | type == "string") + and (.work_ref.task_id | type == "string") + ) + ' >/dev/null 2>&1; then + return 2 + fi + relations=$(printf '%s' "$payload" | jq -r ' + .public_followup.work_relations[] + | [.work_ref.home_id, .work_ref.task_id] + | @tsv + ' 2>/dev/null) || return 2 + [ -n "$relations" ] || return 2 + while IFS=$'\t' read -r work_home work_id; do + [ -n "$work_home" ] && [ -n "$work_id" ] || return 2 + case "$work_home" in + main) home=$FM_HOME ;; + secondmate:*) home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 2 ;; + *) return 2 ;; + esac + meta="$home/state/$work_id.meta" + [ -e "$meta" ] || continue + [ -f "$meta" ] && [ ! -L "$meta" ] || return 2 + [ -n "$(fmx_meta_get "$meta" x_request)" ] && return 0 + done < "$tmp" + else + jq -n --argjson a "$attempt" --arg s "$state" --arg c "$code" --arg o "$(now_rfc3339)" \ + '{state:$s, attempt_count:$a, error_code:$c, occurred_at:$o}' > "$tmp" + fi + tx public-followup record-error "$id" --error-file "$tmp" >/dev/null 2>&1 + rc=$? + rm -f -- "$tmp" + return "$rc" +} + +record_posted() { + local id=$1 attempt=$2 request=$3 platform=$4 chunks=$5 tmp rc + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pf-receipt.XXXXXX") || return 1 + jq -n --argjson a "$attempt" --arg r "$request" --arg p "$platform" \ + --argjson c "$chunks" --arg t "$(now_rfc3339)" \ + '{state:"posted", request_id:$r, platform:$p, attempt_count:$a, + total_chunks:$c, posted_chunks:$c, posted_at:$t}' > "$tmp" + tx public-followup record-delivery "$id" --receipt-file "$tmp" >/dev/null 2>&1 + rc=$? + rm -f -- "$tmp" + return "$rc" +} + +cmd_deliver() { + local id=${1:-} text_file= + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --text-file) shift; text_file=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot post a public reply" 1 + require_tools + + local payload delivery attempt request platform text tmp_text hash chunks rc receipt receipt_fields receipt_dry_run link_status + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1 + + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(pf_field "$payload" '.public_followup.request.platform') + attempt=$(pf_field "$payload" '.public_followup.delivery.attempt_count') + case "$attempt" in ''|*[!0-9]*) attempt=0 ;; esac + + case "$delivery" in + posted|waived) + if public_followup_registration_valid "$id"; then + if ! clear_public_followup_link "$id"; then + die "obligation '$id' is already $delivery, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + else + link_status=1 + public_followup_legacy_link_status "$payload" || link_status=$? + case "$link_status" in + 0) die "obligation '$id' is already $delivery, but its legacy X link cannot be cleared without a valid registration; reconcile it before any later terminal follow-up" 1 ;; + 1) ;; + *) die "obligation '$id' is already $delivery, but its registration is missing or invalid and the legacy X link cannot be verified; reconcile it before any later terminal follow-up" 1 ;; + esac + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'already delivered %s state=%s\n' "$id" "$delivery" + return 0 + ;; + ready|retry-due|context-blocked|unknown|partial) + public_followup_registration_valid "$id" \ + || die "public-followup registration for '$id' is missing or invalid; reconcile it before delivery so any legacy X link can be cleared" 1 + ;; + delivery-posting) + die "obligation '$id' is mid-delivery on attempt $attempt: a previous post was started and its outcome was never recorded. Confirm whether that post landed, then close it with 'record-posted $id --attempt $attempt --chunks ' or reopen it for retry. Posting again here could duplicate the public reply." 1 + ;; + pending-work) + die "obligation '$id' is still waiting on its bound work; nothing to deliver yet" 1 + ;; + *) + die "obligation '$id' is in delivery state '${delivery:-unknown}', which is not deliverable" 1 + ;; + esac + + [ -n "$request" ] || die "obligation '$id' has no relay request id; its thread binding is unusable" 1 + + if [ -n "$text_file" ]; then + [ -f "$text_file" ] || die "reply text file not found: $text_file" + text=$(cat "$text_file") + else + # Deterministic default: reuse the accepted terminal event's bounded + # public-safe outcome exactly rather than paraphrasing a landed result. + text=$(printf '%s' "$payload" | jq -r ' + [(.public_followup.work_relations // [])[] + | (.accepted_events // [])[] + | .public_safe_outcome // empty] | last // empty' 2>/dev/null) + [ -n "$text" ] \ + || die "obligation '$id' carries no accepted public-safe outcome to reuse; pass --text-file with the reply you composed" 1 + fi + [ -n "$text" ] || die "the reply text is empty" 2 + + tmp_text=$(mktemp "${TMPDIR:-/tmp}/fm-pf-text.XXXXXX") || die "could not stage the reply text" 1 + PF_TEMP_FILES+=("$tmp_text") + receipt=$(mktemp "${TMPDIR:-/tmp}/fm-pf-postreceipt.XXXXXX") || die "could not stage the post receipt" 1 + PF_TEMP_FILES+=("$receipt") + printf '%s' "$text" > "$tmp_text" + + hash=$(fm_pf_sha256 < "$tmp_text") || die "sha256 (shasum or sha256sum) is required" 1 + [ -n "$hash" ] || die "could not hash the reply payload" 1 + + # begin-delivery is what makes a retry safe: it pins the attempt and the exact + # payload before anything leaves the machine. The attempt is read back rather + # than assumed, because every later receipt or error must name it exactly. + local begun + begun=$(tx public-followup begin-delivery "$id" --payload-hash "$hash" --json 2>/dev/null) \ + || die "tasks-axi refused to begin delivery for '$id'" 1 + attempt=$(printf '%s' "$begun" | jq -r '.task.public_followup.delivery.attempt_count // empty' 2>/dev/null) + case "$attempt" in + ''|*[!0-9]*) die "could not read the delivery attempt for '$id' after beginning it; nothing was posted" 1 ;; + esac + + rc=0 + FMX_REPLY_PLATFORM="$platform" FM_HOME="$FM_HOME" \ + "$FM_ROOT/bin/fm-x-reply.sh" "$request" --followup --receipt-file "$receipt" \ + --text-file "$tmp_text" >/dev/null || rc=$? + + if [ "$rc" -eq 0 ]; then + receipt_fields=$(jq -er --arg request "$request" ' + if type != "object" or .request_id != $request or .endpoint != "followup" + or (.chunks | type) != "number" or (.chunks < 1) or (.chunks != (.chunks | floor)) + or (.dry_run | type) != "boolean" then error("invalid receipt") + else [(.chunks | tostring), (.dry_run | tostring)] | @tsv end + ' "$receipt" 2>/dev/null) \ + || die "the public reply for '$id' POSTED but its receipt is missing or invalid; inspect the relay and close it with 'record-posted $id --attempt $attempt --chunks ' before any retry" 1 + IFS=$'\t' read -r chunks receipt_dry_run </dev/null || true + printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks" + return 0 + fi + die "the public reply for '$id' POSTED but its receipt could not be recorded; close it with 'record-posted $id --attempt $attempt --chunks ' before any retry, or the thread will get a second reply" 1 + fi + + case "$rc" in + 8) if ! record_error "$id" "$attempt" context-blocked reply_context_unresolved ""; then + die "the public reply for '$id' was not posted, and its held state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "held '$id': the original thread's platform or size budget could not be resolved, so nothing was posted. Retry once the request context is recoverable." 1 ;; + 9) if ! record_error "$id" "$attempt" expired-action-required followup_binding_exhausted ""; then + die "the relay rejected '$id', and its expired state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "the relay no longer accepts a follow-up for '$id' (window or cap exhausted); nothing was posted and this needs a captain decision" 1 ;; + *) if ! record_error "$id" "$attempt" retry-due relay_post_failed "$(next_attempt_rfc3339)"; then + die "posting the public reply for '$id' failed, and its retryable state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "posting the public reply for '$id' failed (exit $rc); recorded as retryable, nothing was delivered" 1 ;; + esac +} + +# --- subcommand: record-posted --------------------------------------------- + +cmd_record_posted() { + local id=${1:-} attempt='' chunks='' + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --attempt) shift; attempt=${1:-} ;; + --chunks) shift; chunks=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + case "$attempt" in ''|*[!0-9]*) die "--attempt is required and must be an integer" ;; esac + case "$chunks" in ''|*[!0-9]*) die "--chunks is required and must be a positive integer" ;; esac + [ "$chunks" -ge 1 ] 2>/dev/null || die "--chunks is required and must be a positive integer" + fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 + public_followup_registration_valid "$id" \ + || die "public-followup registration for '$id' is missing or invalid; reconcile it before recording a receipt so any legacy X link can be cleared" 1 + require_tools + + local payload request platform + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1 + request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(pf_field "$payload" '.public_followup.request.platform') + + record_posted "$id" "$attempt" "$request" "$platform" "$chunks" \ + || die "tasks-axi refused the receipt for '$id' attempt $attempt; the recorded attempt must match exactly" 1 + if ! clear_public_followup_link "$id"; then + die "the receipt for '$id' was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request" +} + +# --- subcommand: guard-work ------------------------------------------------- + +cmd_guard_work() { + local work_home=${1:-} work_id=${2:-} bound id payload delivery task_state blocked=0 + [ -n "$work_home" ] && [ -n "$work_id" ] || { usage; exit 2; } + fm_pf_relay_active "$FM_HOME" || exit 0 + fm_pf_has_registrations "$STATE" || exit 0 + + # Reading the registration records needs no tools, so establish whether this + # work is bound to any commitment before deciding anything else. + bound=$(fm_pf_registry_ids_for_work "$STATE" "$work_home" "$work_id") + [ -n "$bound" ] || exit 0 + + # From here the work IS bound to a public promise, so an unreadable state is a + # blocking answer, not a pass: cleanup must never proceed on a guess. + if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1; then + printf 'cannot verify the public commitments bound to %s/%s: jq and tasks-axi are required\n' \ + "$work_home" "$work_id" + exit 3 + fi + + while IFS= read -r id; do + [ -n "$id" ] || continue + if ! payload=$(obligation_json "$id"); then + printf 'cannot read the state of public commitment %s for %s/%s\n' "$id" "$work_home" "$work_id" + blocked=1 + continue + fi + # Gone from the backlog entirely (pruned after Done): nothing left to owe. + [ -n "$payload" ] || continue + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + case "$task_state:$delivery" in + done:*|*:posted|*:waived) continue ;; + esac + printf 'public commitment %s is still %s for %s/%s\n' "$id" "${delivery:-unknown}" "$work_home" "$work_id" + blocked=1 + done </dev/null || true + printf 'retired %s\n' "$id" +} + +# --- dispatch --------------------------------------------------------------- + +CMD=${1:-} +case "$CMD" in + --help|-h|help) help; exit 0 ;; + '') usage; exit 2 ;; +esac +shift + +case "$CMD" in + active) cmd_active "$@" ;; + register) cmd_register "$@" ;; + brief) cmd_brief "$@" ;; + consume) cmd_consume "$@" ;; + pending) cmd_pending "$@" ;; + deliver) cmd_deliver "$@" ;; + record-posted) cmd_record_posted "$@" ;; + guard-work) cmd_guard_work "$@" ;; + retire) cmd_retire "$@" ;; + *) usage; exit 2 ;; +esac diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh new file mode 100644 index 0000000000..441c9ce2c9 --- /dev/null +++ b/bin/fm-quota-axi-lib.sh @@ -0,0 +1,52 @@ +# shellcheck shell=bash +# Shared quota-axi compatibility floor for the bootstrap diagnostic. +# Usage: . bin/fm-quota-axi-lib.sh +# +# 0.1.16 is the floor because it is the first build that reports each provider's +# credential sources independently and exposes Grok `state.authStatus`. Without +# those fields a dispatch candidate cannot be checked against the authentication +# surface it actually uses, which is how one harness's expired CLI token used to +# produce a captain-facing sign-out claim for a candidate that never read it. +# +# This file is the single owner of that version number. bin/fm-bootstrap.sh +# turns a failing check into the operator-facing MISSING diagnostic, which is +# what keeps an older build from reaching a dispatch intake at all. + +FM_QUOTA_AXI_MIN=0.1.16 + +fm_quota_axi_compatible() { + local timeout=${1:-} output parts major minor patch extra + local min_major min_minor min_patch min_extra + command -v quota-axi >/dev/null 2>&1 || return 1 + if [ -n "$timeout" ]; then + case "$timeout" in + ''|*[!0-9]*|0) return 1 ;; + esac + if command -v timeout >/dev/null 2>&1; then + output=$(timeout "$timeout" quota-axi --version 2>/dev/null /dev/null 2>&1; then + output=$(gtimeout "$timeout" quota-axi --version 2>/dev/null /dev/null 2>&1; then + output=$(perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$timeout" quota-axi --version 2>/dev/null /dev/null printf '%s' "${base%.meta}" } +fm_send_record_interrupt() { # + local key=$1 id gen + [ "$key" = Escape ] || return 0 + case "$TARGET_HARNESS" in claude*) : ;; *) return 0 ;; esac + [ -n "$TARGET_META" ] || return 0 + id=$(fm_send_id_from_meta "$TARGET_META") + [ -f "$STATE/$id.busy-gen" ] || return 0 + gen=$(fm_meta_get "$TARGET_META" busy_gen) + if [ -n "$gen" ]; then + "$FM_ROOT/bin/fm-busy-event.sh" apply "$STATE" "$id" idle \ + --gen "$gen" --source fm-interrupt --event interrupt + else + "$FM_ROOT/bin/fm-busy-event.sh" apply "$STATE" "$id" idle \ + --current-gen --source fm-interrupt --event interrupt + fi || { + echo "error: key '$key' reached $T, but the Claude interrupt state could not be recorded for $id" >&2 + return 1 + } +} + fm_send_meta_for_key_value() { # local state=$1 key=$2 value=$3 meta got for meta in "$state"/*.meta; do @@ -224,6 +244,7 @@ if [ "${1:-}" = "--key" ]; then echo "error: key '$2' not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 exit 1 fi + fm_send_record_interrupt "$2" || exit 1 else MESSAGE=$* if [ "$MARK_FROM_FIRSTMATE" = 1 ]; then diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 0e518c1c8d..8343a8efd9 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -31,7 +31,7 @@ fm_harness_ancestry_pid() { for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - bc=$(basename "$comm") + bc=$(basename -- "$comm") hit=0; is_claude=0 if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then hit=1 @@ -69,7 +69,7 @@ fm_harness_pid_alive() { local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then + if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then return 0 fi case "$comm" in diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 1abbace4bf..76d901e5af 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -103,6 +103,8 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-tasks-axi-lib.sh . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" STATUS_TAIL=${FM_SESSION_START_STATUS_TAIL:-5} case "$STATUS_TAIL" in ''|*[!0-9]*) STATUS_TAIL=5 ;; esac @@ -136,7 +138,19 @@ print_file_or_absent() { } print_backlog_pointer() { - printf 'Full task bodies remain available on demand: tasks-axi show --full when compatible tasks-axi is available, or data/backlog.md.\n' + local backend + backend=$(fm_backlog_backend_value "$CONFIG") + case "$backend" in + beads) + printf 'Full task bodies remain available on demand: task show (beads task store), or data/backlog.md.\n' + ;; + manual) + printf 'Full task bodies remain available on demand: inspect data/backlog.md directly, or data/backlog.md via tasks-axi when available.\n' + ;; + *) + printf 'Full task bodies remain available on demand: tasks-axi show --full when compatible tasks-axi is available, or data/backlog.md.\n' + ;; + esac } print_backlog_manual_compact() { @@ -192,10 +206,29 @@ print_backlog_tasks_axi_compact() { fi } +print_backlog_beads_compact() { + local path=$1 out rc + printf 'compact backlog listing (beads task store; max %s item(s))\n' "$BACKLOG_LIMIT" + out=$(task list --label "status:ready" --limit "$BACKLOG_LIMIT" 2>&1) + rc=$? + if [ "$rc" -eq 0 ]; then + printf '%s\n' "$out" + else + printf 'beads task listing failed; falling back to title-line rendering.\n' + printf '%s\n' "$out" + if [ -f "$path" ]; then + print_backlog_manual_compact "$path" "fallback" + fi + fi +} + print_backlog_compact() { local path=$1 label=$2 subsection "$label" - if [ -f "$path" ]; then + if fm_beads_backend_available "$CONFIG"; then + print_backlog_beads_compact "$path" + print_backlog_pointer + elif [ -f "$path" ]; then if [ -s "$path" ]; then if fm_tasks_axi_backend_available "$CONFIG"; then print_backlog_tasks_axi_compact "$path" @@ -394,6 +427,23 @@ else printf 'absent\n' fi +# Public commitments made through the myfirstmate relay. A promise to reply in a +# public thread must survive compaction and restart, so it is surfaced from disk +# here rather than from conversation memory. fm-public-followup-lib.sh owns both +# gates: a home that never opted into the relay runs one [ -f ] test, prints no +# subsection, and never reaches fm-public-followup.sh. +if fm_pf_relay_active "$FM_HOME" \ + && { fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE"; }; then + PUBLIC_FOLLOWUP=$("$SCRIPT_DIR/fm-public-followup.sh" pending 2>/dev/null) || PUBLIC_FOLLOWUP= + if [ -n "$PUBLIC_FOLLOWUP" ]; then + subsection "Public commitments awaiting delivery" + printf '%s\n' "$PUBLIC_FOLLOWUP" + printf '\nEach line is a public reply this home still owes. Reconcile terminal results with\n' + printf '%s/bin/fm-public-followup.sh consume, then deliver a ready one with\n' "$FM_ROOT" + printf '%s/bin/fm-public-followup.sh deliver . Load fmx-respond for the procedure.\n' "$FM_ROOT" + fi +fi + # --- 6. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 78f8d79846..597893bfc0 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -27,6 +27,15 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. +# A herdr crewmate or scout is placed in the exact workspace of the firstmate +# or secondmate process launching it, resolved from that process's own herdr +# pane rather than from a workspace label (herdr enforces no label uniqueness, +# so a label cannot tell two "firstmate" workspaces apart). A claimed parent +# identity that is unreadable, contradictory, stale, or from another herdr +# session stops the spawn before any worker endpoint exists. A launcher +# outside herdr has no workspace to inherit and uses this home's own labeled +# workspace, which must then match exactly one. --secondmate is the deliberate +# exception: it stands up that secondmate home's own workspace. # Herdr additionally supports a default-off presentation-only layout when the # local config/herdr-presentation-spaces flag exists. A clean fresh task first # writes state/.herdr-presentation atomically, then creates a disposable @@ -120,7 +129,10 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { - sed -n '2,78p' "$0" | sed 's/^# \{0,1\}//' + # The whole leading comment block, ending at the first line that is not a + # comment. Derived rather than a fixed line range, which silently truncated + # this help mid-sentence every time the header above grew. + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' } case "${1:-}" in @@ -129,6 +141,26 @@ esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +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_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1 +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +fi +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + FM_DATA_OVERRIDE=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +fi STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" @@ -144,6 +176,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn @@ -417,7 +451,7 @@ fi [ -z "$HARNESS_ARG" ] || ARG3=$HARNESS_ARG # The verified launch command per adapter. The knowledge half of each adapter -# (busy signature, exit command, dialogs, quirks) lives in the harness-adapters skill. +# (busy-state source, exit command, dialogs, quirks) lives in the harness-adapters skill. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -970,9 +1004,18 @@ case "$BACKEND" in # to PROJ_ABS for just these two calls (bash restores it automatically # after each prefixed simple-command call) so the secondmate's tab lands # in the secondmate's own workspace, not the primary's "firstmate" one. + # + # Placement, separately from labeling: a crewmate/scout belongs in the + # EXACT herdr workspace this launching process is itself running in, which + # only its own herdr pane identity can name (a same-labeled sibling + # workspace must never be adopted). A --secondmate launch is the exception - + # it stands up a DIFFERENT home's own workspace by design - so it asks for + # the per-home container instead of inheriting this launcher's. HERDR_LABEL_HOME=$FM_HOME + HERDR_LAUNCHER_RELATIONSHIP=launcher-home if [ "$KIND" = secondmate ]; then HERDR_LABEL_HOME=$PROJ_ABS + HERDR_LAUNCHER_RELATIONSHIP=other-home fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 @@ -1027,8 +1070,21 @@ case "$BACKEND" in if ! fm_backend_herdr_server_ensure "$HERDR_SES"; then echo "warning: herdr presentation could not ensure its session server; using the ordinary flat layout without projection" >&2 elif spawn_herdr_presentation_order_lock_acquire "$HERDR_SES"; then - HERDR_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_parent_workspace_exact \ - "$HERDR_SES" "$HERDR_PARENT_LABEL" 2>/dev/null || true) + # The projected child is placed and bound UNDER this launcher's exact + # parent workspace. Its own herdr pane identity names that workspace + # directly; the label lookup is only the fallback for a launcher with + # no herdr ancestry at all. A claimed-but-broken identity refuses here + # rather than projecting under a guessed parent. + set +e + fm_backend_herdr_launcher_identity "$HERDR_SES" + HERDR_LAUNCHER_STATUS=$? + set -e + case "$HERDR_LAUNCHER_STATUS" in + 0) HERDR_PARENT_WORKSPACE_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID ;; + 2) HERDR_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_parent_workspace_exact \ + "$HERDR_SES" "$HERDR_PARENT_LABEL" 2>/dev/null || true) ;; + *) spawn_herdr_presentation_order_lock_release; exit 1 ;; + esac if [ -z "$HERDR_PARENT_WORKSPACE_ID" ]; then echo "warning: herdr presentation parent is absent or ambiguous; using the ordinary flat layout without projection" >&2 spawn_herdr_presentation_order_lock_release @@ -1056,7 +1112,7 @@ case "$BACKEND" in HERDR_PROJECTION_ABORT_TASK_PANE=$HERDR_PANE_ID HERDR_PROJECTION_ABORT_SEEDED_PANE=$FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID fm_backend_herdr_projection_order_best_effort \ - "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" + "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" "$HERDR_PARENT_WORKSPACE_ID" HERDR_HOME_ID=$(fm_backend_herdr_projection_home_identity "$HERDR_LABEL_HOME" 2>/dev/null || true) if [ -n "$HERDR_HOME_ID" ] \ && fm_backend_herdr_projection_live_binding_matches \ @@ -1078,7 +1134,7 @@ case "$BACKEND" in fi fi if [ "$HERDR_PROJECTED" -ne 1 ]; then - HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS") || exit 1 + HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS" "$HERDR_LAUNCHER_RELATIONSHIP") || exit 1 # fm_backend_herdr_container_ensure echoes ":\t" # (the second field empty when this call ADOPTED a pre-existing workspace # rather than creating a fresh one). Split on the guaranteed single tab @@ -1316,42 +1372,158 @@ exclude_path() { grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL" } if [ "$KIND" != secondmate ]; then + # Arm the semantic busy-state contract (bin/fm-busy-lib.sh) for every + # adapter with a verified semantic source. The launch brief sent below IS a + # submitted turn, so the seed record is busy/fm-spawn. The minted gen is + # embedded into each adapter's wiring so an event from a superseded + # incarnation is rejected as stale. Grok stays on its isolated rendered-tail + # fallback and standalone Kimi stays unknown until fm_busy_kimi_verified + # opens, so neither is armed here. + BUSY_GEN= + case "$HARNESS" in + codex*) + if fm_busy_codex_semantic_source; then + echo "error: codex semantic busy-state wiring is not implemented; extend the probe only together with verified wiring" >&2 + exit 1 + fi + ;; + esac + case "$HARNESS" in + claude*|opencode*|pi|pi-signed) + BUSY_GEN=$("$FM_ROOT/bin/fm-busy-event.sh" arm "$STATE_REAL" "$ID") || { + echo "error: failed to arm the busy-state contract for $ID" >&2 + exit 1 + } + ;; + kimi*) + # Standalone Kimi stays unknown until fm_busy_kimi_verified opens on a + # live-verified installed version (bin/fm-busy-lib.sh owns the gate and + # the required evidence). Arming without wiring would seed a busy record + # nothing can ever clear, so the arm waits for the wiring. + if fm_busy_kimi_verified; then + echo "error: kimi semantic busy-state wiring is not implemented; open the gate only together with verified wiring" >&2 + exit 1 + fi + ;; + esac case "$HARNESS" in claude*) + # Semantic busy-state hooks (bin/fm-busy-lib.sh): UserPromptSubmit opens + # a turn; Stop (normal completion), StopFailure (API-error turn end), + # and SessionEnd (process shutdown) all close it, so an abnormal end can + # never leave a stale busy record. Claude fires no hook for a manual + # interrupt, so the firstmate-controlled interruption procedure + # (harness-adapters) records idle/fm-interrupt itself. Stop keeps the + # turn-ended NOTIFICATION touch for the watcher. Every hook command + # tolerates a refused event (|| true) so a stale-gen writer can never + # break Claude's own lifecycle. mkdir -p "$WT/.claude" + busy_cmd_prefix="$(shell_quote "$FM_ROOT/bin/fm-busy-event.sh") apply $(shell_quote "$STATE_REAL") $(shell_quote "$ID")" + busy_suffix="--gen $(shell_quote "$BUSY_GEN") --source claude-hook" + j_submit=$(json_escape "$busy_cmd_prefix busy $busy_suffix --event user-prompt-submit 2>/dev/null || true") + j_stop=$(json_escape "touch $(shell_quote "$TURNEND"); $busy_cmd_prefix idle $busy_suffix --event stop 2>/dev/null || true") + j_stopfail=$(json_escape "$busy_cmd_prefix idle $busy_suffix --event stop-failure 2>/dev/null || true") + j_sessionend=$(json_escape "$busy_cmd_prefix idle $busy_suffix --event session-end 2>/dev/null || true") cat > "$WT/.claude/settings.local.json" < "$WT/.opencode/plugins/fm-turn-end.js" < ({ - event: async ({ event }) => { - if (event.type === "session.idle") await \$\`touch $TURNEND\` - }, -}) + cat > "$WT/.opencode/plugins/fm-busy-state.js" < + new Promise((resolve) => { + execFile("$FM_ROOT/bin/fm-busy-event.sh", [ + "apply", "$STATE_REAL", "$ID", state, + "--gen", "$BUSY_GEN", "--source", "opencode-plugin", "--event", event, + ], () => resolve()); + }); +export const FmBusyState = async () => { + let activeSession = null; + return { + event: async ({ event }) => { + if (event.type === "session.status") { + const sessionID = event.properties.sessionID; + const statusType = event.properties.status && event.properties.status.type; + if (statusType === "busy" || statusType === "retry") { + if (activeSession === null) activeSession = sessionID; + if (sessionID === activeSession) await busyEvent("busy", "session-" + statusType); + return; + } + if (statusType === "idle" && sessionID === activeSession) { + activeSession = null; + await busyEvent("idle", "session-status-idle"); + } + return; + } + if (event.type === "session.idle") { + if (event.properties.sessionID === activeSession) { + activeSession = null; + await busyEvent("idle", "session-idle"); + } + await new Promise((resolve) => { + execFile("touch", ["$TURNEND"], () => resolve()); + }); + } + }, + }; +}; EOF - exclude_path '.opencode/plugins/fm-turn-end.js' + exclude_path '.opencode/plugins/fm-busy-state.js' ;; pi|pi-signed) # Written OUTSIDE the worktree: pi's project-trust gate fires on any extension # loaded from inside the project (verified live), but an explicit -e path # elsewhere loads without a dialog. Lives in state/, cleaned by teardown. cat > "$STATE/$ID.pi-ext.ts" < busy when a low-level agent run begins; +// "agent_settled" -> idle only when ctx.isIdle() confirms Pi will not +// continue automatically - auto-retries, auto-compaction retries, tool +// loops, and queued continuations all keep the run un-settled, and a settle +// that raced another extension's fresh run keeps state busy via isIdle(). +// "turn_end" fires at every inner turn boundary (one LLM response plus its +// tool calls) and stays a wake NOTIFICATION touch for the watcher, never +// current-state truth. import { execFile } from "node:child_process"; +const busyEvent = (state: string, event: string) => + new Promise((resolve) => { + execFile("$FM_ROOT/bin/fm-busy-event.sh", [ + "apply", "$STATE_REAL", "$ID", state, + "--gen", "$BUSY_GEN", "--source", "pi-ext", "--event", event, + ], () => resolve()); + }); export default function (pi: any) { + pi.on("agent_start", () => busyEvent("busy", "agent-start")); + pi.on("agent_settled", (_event: any, ctx: any) => { + if (ctx && typeof ctx.isIdle === "function" && !ctx.isIdle()) return; + return busyEvent("idle", "agent-settled"); + }); pi.on("turn_end", () => execFile("touch", ["$TURNEND"])); } EOF ;; codex*) - # codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__. + # Semantic busy-state source negotiation (bin/fm-busy-lib.sh owns the + # probes and the evidence). Neither Codex path is usable on the + # installed binary: a pane worker's turns are not observable through + # the app-server protocol, and its lifecycle hooks did not fire for a + # firstmate-launched worker. Codex therefore classifies unknown with + # an explicit reason rather than falling back to idle, and no busy + # wiring is installed. The turn-end NOTIFICATION marker still rides + # the launch command via -c notify=[...] and __TURNEND__. ;; grok*) # grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the @@ -1450,6 +1622,7 @@ META_WINDOW=$T echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" + [ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN" # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; # data/fm-backend-design-d7's P1 compatibility contract). @@ -1496,9 +1669,20 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} +# Crewmate panes are created by a long-lived tmux/herdr daemon that does not +# inherit firstmate's current environment, so a bare `claude` in the pane falls +# back to the default ~/.claude store even when firstmate itself runs under a +# different CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split). +# Forward firstmate's own resolved store onto the claude launch so the crewmate +# uses the same credential/config firstmate is authenticated with. Only when set; +# an unset value is the single-store default and needs no prefix. +if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then + LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" +fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") - LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" + sq_primary_home=$(shell_quote "$FM_HOME") + LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=$sq_primary_home FM_HOME=$sq_home $LAUNCH" fi # Export GOTMPDIR into the crewmate's pane shell so the agent and every child # process (go build, go test, ...) inherit it. Sent before the launch command so diff --git a/bin/fm-startup-memory-budget-lib.sh b/bin/fm-startup-memory-budget-lib.sh new file mode 100644 index 0000000000..f2c06014b8 --- /dev/null +++ b/bin/fm-startup-memory-budget-lib.sh @@ -0,0 +1,224 @@ +# shellcheck shell=bash +# Startup-memory budget primitives. +# Usage: . bin/fm-startup-memory-budget-lib.sh +# +# The local, primary-authoritative config/startup-memory-budget setting is one +# strictly formatted positive decimal value followed by one newline. The +# locked primary bootstrap owns first materialization. This library owns safe +# parsing, default publication, and the portable prompt-memory estimate used by +# bin/fm-startup-memory-budget.sh and the internal /stow skill. + +FM_STARTUP_MEMORY_BUDGET_FILE="startup-memory-budget" +FM_STARTUP_MEMORY_BUDGET_DEFAULT="7500" +FM_STARTUP_MEMORY_BUDGET_ERROR="" +FM_STARTUP_MEMORY_BUDGET_VALUE="" +FM_STARTUP_MEMORY_MEASURE_BYTES="" +FM_STARTUP_MEMORY_MEASURE_TOKENS="" +FM_STARTUP_MEMORY_MEASURE_PRESENCE="" + +fm_startup_memory_budget_fail() { + FM_STARTUP_MEMORY_BUDGET_ERROR=$1 + return 1 +} + +fm_startup_memory_budget_link_count() { + if [ "$(uname)" = Darwin ]; then + stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +fm_startup_memory_budget_config_dir_safe() { + local dir=$1 + if [ -L "$dir" ]; then + fm_startup_memory_budget_fail "config directory is symlinked" + return 1 + fi + if [ ! -d "$dir" ]; then + fm_startup_memory_budget_fail "config directory is not a directory" + return 1 + fi + return 0 +} + +# fm_startup_memory_budget_file_valid +# Sets FM_STARTUP_MEMORY_BUDGET_VALUE only for a regular, single-linked file +# containing exactly one positive decimal value and one terminating newline. +fm_startup_memory_budget_file_valid() { + local path=$1 links value + FM_STARTUP_MEMORY_BUDGET_VALUE="" + if [ -L "$path" ]; then + fm_startup_memory_budget_fail "file is symlinked" + return 1 + fi + if [ ! -e "$path" ]; then + fm_startup_memory_budget_fail "file is absent" + return 1 + fi + if [ ! -f "$path" ]; then + fm_startup_memory_budget_fail "file is not a regular file" + return 1 + fi + links=$(fm_startup_memory_budget_link_count "$path") || { + fm_startup_memory_budget_fail "could not inspect file link count" + return 1 + } + if [ "$links" != 1 ]; then + fm_startup_memory_budget_fail "file is hardlinked" + return 1 + fi + value=$(<"$path") || { + fm_startup_memory_budget_fail "could not read file" + return 1 + } + case "$value" in + ''|0|*[!0-9]*|0*) + fm_startup_memory_budget_fail "value must be one positive decimal integer" + return 1 + ;; + esac + if ! printf '%s\n' "$value" | cmp -s "$path" -; then + fm_startup_memory_budget_fail "file must contain exactly one value followed by one newline" + return 1 + fi + FM_STARTUP_MEMORY_BUDGET_VALUE=$value + return 0 +} + +# fm_startup_memory_budget_read +# Prints the validated decimal value. It never treats an absent or unsafe file +# as an implicit default because callers need a visible, auditable setting. +fm_startup_memory_budget_read() { + local config_dir=$1 path + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + path="$config_dir/$FM_STARTUP_MEMORY_BUDGET_FILE" + fm_startup_memory_budget_file_valid "$path" || return 1 + printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_VALUE" +} + +# fm_startup_memory_budget_materialize +# Atomically publishes the visible default only when the file is absent. A +# concurrent valid creator is accepted; every unsafe or malformed existing +# artifact is rejected without replacement. +fm_startup_memory_budget_materialize() { + local config_dir=$1 path tmp + if [ -e "$config_dir" ] || [ -L "$config_dir" ]; then + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + else + mkdir -p "$config_dir" 2>/dev/null || { + fm_startup_memory_budget_fail "could not create config directory" + return 1 + } + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + fi + + path="$config_dir/$FM_STARTUP_MEMORY_BUDGET_FILE" + if [ -e "$path" ] || [ -L "$path" ]; then + fm_startup_memory_budget_read "$config_dir" >/dev/null || return 1 + return 0 + fi + + tmp=$(umask 077; mktemp "$config_dir/.startup-memory-budget.XXXXXX" 2>/dev/null) || { + fm_startup_memory_budget_fail "could not create default temporary file" + return 1 + } + if ! printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_DEFAULT" > "$tmp" \ + || ! fm_startup_memory_budget_file_valid "$tmp"; then + rm -f "$tmp" + [ -n "$FM_STARTUP_MEMORY_BUDGET_ERROR" ] \ + || fm_startup_memory_budget_fail "could not write default value" + return 1 + fi + + # link(2) gives no-clobber publication in this directory. Removing the + # temporary name leaves the published file with exactly one link. + if ln "$tmp" "$path" 2>/dev/null; then + rm -f "$tmp" + fm_startup_memory_budget_read "$config_dir" >/dev/null || return 1 + return 0 + fi + rm -f "$tmp" + # Another actor may have created the file. Accept it only if it now meets + # the same safe, exact format - never replace or guess at it. + fm_startup_memory_budget_read "$config_dir" >/dev/null +} + +# fm_startup_memory_estimated_tokens_for_bytes +# The estimate is ceil(UTF-8 bytes / 3): stable, dependency-free, and +# deliberately conservative for ordinary prompt text without claiming provider +# exactness. +fm_startup_memory_estimated_tokens_for_bytes() { + local bytes=$1 tokens + case "$bytes" in + ''|*[!0-9]*) return 1 ;; + esac + tokens=$((bytes / 3)) + if [ $((bytes % 3)) -ne 0 ]; then + tokens=$((tokens + 1)) + fi + printf '%s\n' "$tokens" +} + +# fm_startup_memory_measure_file +# Prints " ". Memory files must be +# ordinary files when present so a measurement never follows a symlink or reads +# a special file. +fm_startup_memory_measure_file() { + local path=$1 bytes tokens + FM_STARTUP_MEMORY_MEASURE_BYTES="" + FM_STARTUP_MEMORY_MEASURE_TOKENS="" + FM_STARTUP_MEMORY_MEASURE_PRESENCE="" + if [ ! -e "$path" ] && [ ! -L "$path" ]; then + FM_STARTUP_MEMORY_MEASURE_BYTES=0 + FM_STARTUP_MEMORY_MEASURE_TOKENS=0 + FM_STARTUP_MEMORY_MEASURE_PRESENCE=absent + printf '0 0 absent\n' + return 0 + fi + if [ -L "$path" ] || [ ! -f "$path" ]; then + fm_startup_memory_budget_fail "memory file is not an ordinary regular file: $path" + return 1 + fi + bytes=$(LC_ALL=C wc -c < "$path" 2>/dev/null | tr -d '[:space:]') || { + fm_startup_memory_budget_fail "could not measure memory file: $path" + return 1 + } + case "$bytes" in + ''|*[!0-9]*) + fm_startup_memory_budget_fail "invalid byte count for memory file: $path" + return 1 + ;; + esac + tokens=$(fm_startup_memory_estimated_tokens_for_bytes "$bytes") || { + fm_startup_memory_budget_fail "could not estimate memory tokens for: $path" + return 1 + } + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_BYTES=$bytes + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_TOKENS=$tokens + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_PRESENCE=present + printf '%s %s present\n' "$bytes" "$tokens" +} + +# fm_startup_memory_decimal_le +# Decimal comparison without shell arithmetic overflow. Inputs are normalized +# non-negative decimal strings. +fm_startup_memory_decimal_le() { + local left=$1 right=$2 left_len right_len + case "$left:$right" in + *[!0-9:]*|:*|*:) return 1 ;; + esac + left_len=${#left} + right_len=${#right} + if [ "$left_len" -lt "$right_len" ]; then + return 0 + fi + if [ "$left_len" -gt "$right_len" ]; then + return 1 + fi + [ "$left" = "$right" ] && return 0 + [[ "$left" < "$right" ]] +} diff --git a/bin/fm-startup-memory-budget.sh b/bin/fm-startup-memory-budget.sh new file mode 100755 index 0000000000..715da54948 --- /dev/null +++ b/bin/fm-startup-memory-budget.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Read and account for the local startup-memory budget. +# Usage: +# fm-startup-memory-budget.sh read +# fm-startup-memory-budget.sh report +# +# `read` prints the one validated effective budget from +# config/startup-memory-budget. `report` prints the stable local estimate for +# data/captain.md, data/captain-shared.md, and data/learnings.md together. +# Bootstrap owns default materialization; this command never creates or repairs +# configuration, so an absent, malformed, symlinked, hardlinked, or otherwise +# unsafe value is a concrete error rather than an inferred default. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" + +usage() { + sed -n '2,11{s/^# \{0,1\}//;p;}' "$0" +} + +print_error() { + printf 'startup-memory-budget: %s\n' "$1" >&2 +} + +read_budget() { + if ! fm_startup_memory_budget_read "$CONFIG" >/dev/null; then + print_error "invalid config/$FM_STARTUP_MEMORY_BUDGET_FILE - $FM_STARTUP_MEMORY_BUDGET_ERROR" + return 1 + fi + printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_VALUE" +} + +report() { + local budget bytes tokens presence total=0 shared_tokens=0 role=primary + if ! budget=$(read_budget); then + return 2 + fi + + if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + role=secondmate + fi + + printf 'estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate\n' + printf 'role=%s\n' "$role" + printf 'effective_budget_tokens=%s\n' "$budget" + for file in captain.md captain-shared.md learnings.md; do + if ! fm_startup_memory_measure_file "$DATA/$file" >/dev/null; then + print_error "$FM_STARTUP_MEMORY_BUDGET_ERROR" + return 2 + fi + bytes=$FM_STARTUP_MEMORY_MEASURE_BYTES + tokens=$FM_STARTUP_MEMORY_MEASURE_TOKENS + presence=$FM_STARTUP_MEMORY_MEASURE_PRESENCE + total=$((total + tokens)) + [ "$file" != captain-shared.md ] || shared_tokens=$tokens + printf 'file=data/%s bytes=%s estimated_tokens=%s status=%s\n' \ + "$file" "$bytes" "$tokens" "$presence" + done + printf 'total_estimated_tokens=%s\n' "$total" + if fm_startup_memory_decimal_le "$total" "$budget"; then + printf 'budget_status=within-budget\n' + else + printf 'budget_status=over-budget\n' + fi + if [ "$role" = secondmate ] \ + && ! fm_startup_memory_decimal_le "$shared_tokens" "$budget"; then + printf 'exception=primary-owned-shared-file-alone-exceeds-budget\n' + fi +} + +case "${1:-}" in + read) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + read_budget + ;; + report) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + report + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/bin/fm-subagent-pretool-check.sh b/bin/fm-subagent-pretool-check.sh index 169e3079d2..8edb507218 100755 --- a/bin/fm-subagent-pretool-check.sh +++ b/bin/fm-subagent-pretool-check.sh @@ -65,6 +65,19 @@ DELEGATION_STEMS='agent subagent task workflow cron schedul worktree delegate sp # reason a runaway task cannot be stopped. OBSERVE_ONLY_TOOLS='taskoutput taskstop taskget tasklist cronlist bashoutput killshell' +# Exact lowercase tool names that match a stem above but create no RUNNABLE +# work. These write only the harness's session-local todo list, which has no +# executor: it spawns no agent, allocates no worktree, registers no schedule, +# and starts nothing that could outlive the session or escape a firstmate +# guard. Denying them stops the primary tracking its own plan while granting no +# delegation power, and the deny text would tell it to run bin/fm-brief.sh for a +# todo entry, so the stem match here is a false positive rather than a policy. +# This is a separate list from OBSERVE_ONLY_TOOLS on purpose: these tools WRITE, +# so folding them into a list documented as observe-or-stop would make that +# contract untrue. Both lists are exact-name, never substring, so neither can +# widen by accident. +PLAN_ONLY_TOOLS='taskcreate taskupdate' + TOOL="" TOOL_SET=0 CLAUDE_MODE=0 @@ -139,7 +152,7 @@ case "$TOOL" in mcp__*) exit 0 ;; esac -for allowed in $OBSERVE_ONLY_TOOLS; do +for allowed in $OBSERVE_ONLY_TOOLS $PLAN_ONLY_TOOLS; do [ "$NORMALIZED" != "$allowed" ] || exit 0 done diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 6ec14aed4b..400a8bf535 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -96,7 +96,8 @@ # (default 300) # FM_HOUSEKEEPING_TICK seconds between housekeeping passes while # the watcher is mid-cycle (default 15) -# FM_BUSY_REGEX optional global busy-signature override +# FM_BUSY_REGEX optional rendered busy-signature override +# for delivery guards and Grok's fallback # FM_COMPOSER_IDLE_RE empty-composer regex applied after dim-ghost # and structural border stripping (default: # bare prompt glyphs plus busy footers) @@ -174,6 +175,11 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-supervisor-target-lib.sh . "$FM_DAEMON_DIR/fm-supervisor-target-lib.sh" +# The single owner of semantic busy state for recorded tasks +# (fm_busy_classify). +# shellcheck source=bin/fm-busy-lib.sh +. "$FM_DAEMON_DIR/fm-busy-lib.sh" + # --- tunables --------------------------------------------------------------- # Supervisor backends this daemon knows how to inject into today. zellij, orca, # and cmux are real backends elsewhere in firstmate (bin/fm-backend.sh) but this @@ -198,8 +204,9 @@ WEDGE_ALARM_NOTIFIER_PID= # The captain-relevant verb set and the status classifiers (last_status_line, # status_is_captain_relevant, window_to_task, scan_captain_relevant_statuses) now # live in bin/fm-classify-lib.sh, shared with the always-on watcher. -# Composer-empty detection and harness-scoped busy-footer matching live in -# bin/fm-tmux-lib.sh; FM_BUSY_REGEX still overrides every fallback here. +# Composer-empty detection, submit acknowledgement, and the harness-scoped +# supervisor-pane busy guard live in bin/fm-tmux-lib.sh. +# FM_BUSY_REGEX also overrides Grok's isolated task-state fallback. INJECT_FAIL_SLEEP_DEFAULT=30 INJECT_CONFIRM_RETRIES_DEFAULT=3 INJECT_CONFIRM_SLEEP_DEFAULT=0.5 @@ -544,34 +551,45 @@ mark_escalated_seen() { # esac } -# Busy + composer-empty detection are the shared primitives in fm-tmux-lib.sh -# (one source of truth with fm-send.sh). These thin wrappers keep the daemon's -# call sites and the unit tests stable. +# Busy and composer-empty detection form the injection boundary. +# These thin wrappers keep the daemon's call sites and unit tests stable. # # pane_input_pending returns 0 unless the composer is positively proven empty. # This includes real unsubmitted text, ambiguous structure, unreadable state, # and future verdicts. The detector drops dim/faint ghost text and strips the # harness's composer box borders, so an aligned ghost-only or idle bordered # claude composer ("│ > … │") is correctly proven empty. -# pane_is_busy / pane_input_pending: BACKEND-AWARE now (previously tmux-only -# direct calls). defaults to tmux when omitted, so every existing -# caller/test that passes only is unaffected. Dispatch goes through -# bin/fm-backend.sh's generic per-backend primitives (fm_backend_busy_state, -# fm_backend_capture, fm_backend_composer_state) rather than hand-rolling a -# case statement here, mirroring the fallback order stale_window_is_busy uses -# for per-task panes: try the backend's native busy state first, then match -# captured output. The supervisor pane has no recorded task harness and uses -# the historical combined fallback; stale task panes select the recorded -# harness's verified signature. +# pane_is_busy / pane_input_pending: BACKEND-AWARE (dispatch goes through +# bin/fm-backend.sh's generic per-backend primitives rather than a hand-rolled +# case statement here). defaults to tmux when omitted, so every +# existing caller/test that passes only is unaffected. +# +# This rendered reader applies only to the supervisor pane during away-mode +# injection. It never classifies a recorded worker task. The detected primary +# harness selects exactly one signature, so output from another harness cannot +# make the primary read busy. +# +# Resolved lazily and memoized: harness detection walks process ancestry, which +# is too heavy to pay on every source of this library (the unit tests and the +# launcher source it purely for its pure functions). +fm_daemon_primary_harness() { + if [ -z "${FM_DAEMON_PRIMARY_HARNESS:-}" ]; then + FM_DAEMON_PRIMARY_HARNESS=$("$FM_DAEMON_DIR/fm-harness.sh" 2>/dev/null || printf 'unknown') + [ -n "$FM_DAEMON_PRIMARY_HARNESS" ] || FM_DAEMON_PRIMARY_HARNESS=unknown + fi + printf '%s' "$FM_DAEMON_PRIMARY_HARNESS" +} + pane_is_busy() { # [backend] - local target=$1 backend=${2:-tmux} bs tail40 - bs=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) - case "$bs" in + local target=$1 backend=${2:-tmux} native tail40 harness + harness=$(fm_daemon_primary_harness) + native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) + case "$native" in busy) return 0 ;; esac tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || return 1 printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match + | fm_busy_lines_match "$harness" } # pane_input_pending dispatches through fm_backend_composer_state and treats @@ -593,21 +611,23 @@ task_window_harness() { # local win=$1 state=$2 task meta task=$(window_to_task "$win" "$state") meta="$state/$task.meta" - grep '^harness=' "$meta" | cut -d= -f2- || true + grep '^harness=' "$meta" 2>/dev/null | cut -d= -f2- || true } +# stale_window_is_busy: 0 when the task is PROVABLY working through the +# semantic busy-state contract (bin/fm-busy-lib.sh), 1 when it is not, and 2 +# when the endpoint could not be read at all. Only an exact busy verdict is +# working: unknown semantic state never becomes busy and never becomes a +# silent idle, so a stale pane whose state cannot be proven surfaces. stale_window_is_busy() { # - local win=$1 state=$2 backend harness label tail40 bs + local win=$1 state=$2 backend harness label task tail40 verdict backend=$(task_window_backend "$win" "$state") harness=$(task_window_harness "$win" "$state") - label="fm-$(window_to_task "$win" "$state")" + task=$(window_to_task "$win" "$state") + label="fm-$task" tail40=$(fm_backend_capture "$backend" "$win" 40 "$label" 2>/dev/null) || return 2 - bs=$(fm_backend_busy_state "$backend" "$win" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - esac - printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match "$harness" + verdict=$(fm_busy_classify "$backend" "$win" "$harness" "$task" "$state" "$tail40") + [ "${verdict%% *}" = busy ] } escalate_add() { # @@ -1113,8 +1133,7 @@ inject_msg() { # [state] # discovery), matching this function's pre-existing default assumption. backend="${FM_SUPERVISOR_BACKEND:-tmux}" fm_backend_target_exists "$backend" "$target" || return 1 - # (3) Busy-guard: never inject into an in-use pane. - # a) pane_is_busy: the harness shows a busy footer (agent mid-turn). + # (3) Busy-guard: never inject into an in-use supervisor pane. if pane_is_busy "$target" "$backend"; then log "inject deferred: supervisor pane busy (agent mid-turn)" return 1 @@ -1181,7 +1200,7 @@ is_wake_reason() { # # --- dispatch one wake reason to self-handle or escalate -------------------- # Side effects: logging, marker records, escalation buffer appends. handle_wake() { # - local reason=$1 state=$2 decision action distilled task last + local reason=$1 state=$2 decision action distilled task last stale_detail local kind="" arg="" if should_force_self "$reason"; then log "wake force-self (FM_INJECT_SKIP): $reason" @@ -1190,8 +1209,13 @@ handle_wake() { # case "$reason" in signal:*) kind=signal; arg="${reason#signal: }" decision=$(classify_signal "$arg" "$state") ;; - stale:*) kind=stale; arg="${reason#stale: }" - decision=$(classify_stale "$arg" "$state") ;; + stale:*) kind=stale; arg="${reason#stale: }"; stale_detail="${arg#"$arg"}" + case "$arg" in *" ("*) stale_detail="${arg#*" ("}"; arg="${arg%% \(*}" ;; esac + decision=$(classify_stale "$arg" "$state") + case "$stale_detail" in + idle\ *s,\ possible\ wedge,\ escalation\ *) + decision="escalate|${reason#stale: }" ;; + esac ;; check:*) decision=$(classify_check "$reason") ;; heartbeat|heartbeat:*) decision=$(classify_heartbeat) ;; *) decision=$(classify_unknown "$reason") ;; diff --git a/bin/fm-tasks-axi-lib.sh b/bin/fm-tasks-axi-lib.sh index 54d02fcc9e..2ff9363dd5 100644 --- a/bin/fm-tasks-axi-lib.sh +++ b/bin/fm-tasks-axi-lib.sh @@ -1,15 +1,16 @@ # shellcheck shell=bash -# Shared tasks-axi backend selection and compatibility probe for bootstrap, -# teardown, and secondmate backlog handoff. +# Shared backlog backend selection (tasks-axi, beads, or manual) and tasks-axi +# compatibility probe for bootstrap, teardown, and secondmate backlog handoff. # Usage: . bin/fm-tasks-axi-lib.sh -# Compatible means tasks-axi --version reports 0.1.1 or newer, +# Compatible tasks-axi means --version reports 0.1.1 or newer, # `tasks-axi update --help` exposes --archive-body for recoverable note rewrites, # and `tasks-axi mv --help` exposes [...] for atomic multi-ID moves required # by secondmate handoffs (introduced in tasks-axi 0.2.2). # `config/backlog-backend=manual` opts out of tasks-axi for routine firstmate -# backlog mutations, but validated secondmate handoffs always use `tasks-axi mv`. -# Absent or any other value keeps the default tasks-axi backend path, falling -# back to manual mutation when the tool is not compatible. +# backlog mutations; `config/backlog-backend=beads` uses the federated task store +# instead. Validated secondmate handoffs always use `tasks-axi mv` when on the +# tasks-axi backend. Absent or invalid values keep the default tasks-axi backend +# path, falling back to manual mutation when the tool is not compatible. fm_tasks_axi_version_parts() { local output @@ -72,5 +73,13 @@ fm_backlog_backend_manual() { fm_tasks_axi_backend_available() { local config_dir=$1 fm_backlog_backend_manual "$config_dir" && return 1 + [ "$(fm_backlog_backend_value "$config_dir")" = beads ] && return 1 fm_tasks_axi_compatible } + +fm_beads_backend_available() { + local config_dir=$1 + [ "$(fm_backlog_backend_value "$config_dir")" = beads ] || return 1 + command -v task >/dev/null 2>&1 || return 1 + task list --limit 1 >/dev/null 2>&1 +} diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 3af88feeac..b237af842a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -110,6 +110,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -139,6 +141,10 @@ PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) # tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root # (/tmp/fm-/); absent for tasks spawned before that change, so tolerate empty. TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) +BUSY_GEN=$(fm_meta_get "$META" busy_gen) +if [ -z "$BUSY_GEN" ]; then + BUSY_GEN=$(cat "$STATE/$ID.busy-gen" 2>/dev/null || true) +fi ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 @@ -146,6 +152,77 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes +PUBLIC_FOLLOWUP_HOME=$FM_HOME +PUBLIC_FOLLOWUP_STATE=$STATE +PUBLIC_FOLLOWUP_WORK_HOME=main +PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 +PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=0 +PUBLIC_FOLLOWUP_RELAY_ACTIVE=0 +public_followup_resolve_primary_home() { + local parent=$1 child=$2 id=$3 parent_meta registry lines line_count meta_home registry_home + fm_pf_home_id_valid "secondmate:$id" || return 1 + case "$parent" in /*) ;; *) return 1 ;; esac + parent=$(CDPATH='' cd -- "$parent" 2>/dev/null && pwd -P) || return 1 + child=$(CDPATH='' cd -- "$child" 2>/dev/null && pwd -P) || return 1 + [ "$parent" != "$child" ] || return 1 + parent_meta="$parent/state/$id.meta" + [ -f "$parent_meta" ] && [ ! -L "$parent_meta" ] || return 1 + [ "$(fm_meta_get "$parent_meta" kind)" = secondmate ] || return 1 + meta_home=$(fm_meta_get "$parent_meta" home) + meta_home=$(CDPATH='' cd -- "$meta_home" 2>/dev/null && pwd -P) || return 1 + [ "$meta_home" = "$child" ] || return 1 + registry="$parent/data/secondmates.md" + [ -f "$registry" ] && [ ! -L "$registry" ] || return 1 + lines=$(awk -v wanted="$id" '$1 == "-" && $2 == wanted { print }' "$registry" 2>/dev/null || true) + line_count=$(printf '%s\n' "$lines" | grep -c . || true) + [ "$line_count" -eq 1 ] || return 1 + line=$(printf '%s\n' "$lines") + registry_home=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p') + registry_home=$(CDPATH='' cd -- "$registry_home" 2>/dev/null && pwd -P) || return 1 + [ "$registry_home" = "$child" ] || return 1 + printf '%s\n' "$parent" +} +if [ -f "$FM_HOME/$SUB_HOME_MARKER" ]; then + SECOND_MATE_ID=$(sed -n '1p' "$FM_HOME/$SUB_HOME_MARKER") + # A marked child only enters the primary-binding path when the authoritative + # parent relay is active. A child that has not opted into the relay must + # retain the old teardown path, even without a durable parent registry. + if [ -n "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" ]; then + if fm_pf_relay_active "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME"; then + PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + elif fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + if [ "$PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE" = 1 ]; then + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=1 + if fm_pf_home_id_valid "secondmate:$SECOND_MATE_ID"; then + PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$SECOND_MATE_ID" + if PUBLIC_FOLLOWUP_HOME=$(public_followup_resolve_primary_home \ + "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" "$FM_HOME" "$SECOND_MATE_ID"); then + PUBLIC_FOLLOWUP_STATE="$PUBLIC_FOLLOWUP_HOME/state" + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 + if [ "$FORCE" != "--force" ] \ + && fm_pf_relay_active "$PUBLIC_FOLLOWUP_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 + fi + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi + fi + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi +elif [ "$KIND" = secondmate ]; then + PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$ID" + if [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 + fi +elif [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 +fi default_branch() { local ref branch @@ -210,6 +287,15 @@ remove_kimi_turnend_auth() { rm -f "$hooks_dir/$token" } +retire_busy_state() { + local state_dir=$1 id=$2 gen=${3:-} + if [ -n "$gen" ]; then + "$SCRIPT_DIR/fm-busy-event.sh" retire "$state_dir" "$id" --gen "$gen" + elif [ -f "$state_dir/$id.busy-gen" ]; then + "$SCRIPT_DIR/fm-busy-event.sh" retire "$state_dir" "$id" --current-gen + fi +} + # Best-effort Parlay deregistration (bin/fm-spawn.sh's header owns enrollment). # The recorded background pid is killed unconditionally so a `parlay` that went # missing from PATH between spawn and teardown never orphans it; only the final @@ -464,6 +550,8 @@ backlog_refresh_reminder() { ;; esac printf '%s\n' "Backlog: $ID just finished. Run $done_cmd, then run tasks-axi ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + elif [ "$(fm_backlog_backend_value "$CONFIG")" = beads ]; then + printf '%s\n' "Backlog: $ID just finished. The beads store has been updated with the completion status. Scan for items with status:ready and dispatch only work whose blockers are gone and date is due." else printf '%s\n' "Backlog: $ID just finished. Update data/backlog.md - move $ID to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due." fi @@ -992,7 +1080,7 @@ validate_firstmate_home_children_removal() { } cleanup_firstmate_home_children() { - local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc + local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -1040,6 +1128,7 @@ cleanup_firstmate_home_children() { elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ + "$child_wt/.opencode/plugins/fm-busy-state.js" \ "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then @@ -1059,6 +1148,11 @@ cleanup_firstmate_home_children() { remove_kimi_turnend_auth "$sub_state" "$child_id" deregister_parlay_agent "$sub_state" "$child_id" remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 + child_busy_gen=$(meta_value "$child_meta" busy_gen) + if [ -z "$child_busy_gen" ]; then + child_busy_gen=$(cat "$sub_state/$child_id.busy-gen" 2>/dev/null || true) + fi + retire_busy_state "$sub_state" "$child_id" "$child_busy_gen" || return 1 rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" \ "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" @@ -1114,6 +1208,28 @@ if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then fi fi +# A public commitment is not kept until its final reply lands in the ORIGINAL +# thread, and this cleanup removes the task records that make the promise +# reconcilable. Refuse while this home still owes a public reply for exactly this +# work. Both gates live in bin/fm-public-followup-lib.sh, so a home that never +# opted into the myfirstmate relay runs one [ -f ] test and nothing else here. +if [ "$FORCE" != "--force" ] && [ "$PUBLIC_FOLLOWUP_PARENT_UNRESOLVED" = 1 ]; then + echo "REFUSED: cannot resolve the primary home for marked secondmate $SECOND_MATE_ID; refusing cleanup without its durable parent binding." >&2 + exit 1 +fi +if [ "$FORCE" != "--force" ] \ + && [ -n "$PUBLIC_FOLLOWUP_STATE" ] \ + && [ "$PUBLIC_FOLLOWUP_RELAY_ACTIVE" = 1 ] \ + && fm_pf_has_registrations "$PUBLIC_FOLLOWUP_STATE"; then + if ! PUBLIC_FOLLOWUP_BLOCKING=$(FM_HOME="$PUBLIC_FOLLOWUP_HOME" FM_STATE_OVERRIDE="$PUBLIC_FOLLOWUP_STATE" \ + "$SCRIPT_DIR/fm-public-followup.sh" guard-work "$PUBLIC_FOLLOWUP_WORK_HOME" "$ID" 2>/dev/null); then + echo "REFUSED: task $ID still owes a public reply through the myfirstmate relay." >&2 + printf '%s\n' "$PUBLIC_FOLLOWUP_BLOCKING" >&2 + echo "Deliver it with bin/fm-public-followup.sh deliver , waive it with tasks-axi public-followup waive, or use --force after explicit discard approval." >&2 + exit 1 + fi +fi + if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then if ! inspectable_git_worktree "$WT"; then echo "REFUSED: Orca ship task $ID has no inspectable git worktree at ${WT:-}." >&2 @@ -1152,6 +1268,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ + "$WT/.opencode/plugins/fm-busy-state.js" \ "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true @@ -1251,6 +1368,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 +retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index 0a14a48022..f84f8ed09e 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -152,7 +152,6 @@ list_parallel_candidates() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -161,10 +160,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -172,7 +168,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 599e58938e..5d958eab35 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -38,7 +38,7 @@ # silently pass as a gate skip. # --jobs N run the selected scripts with up to N concurrent workers. # Default is 1 (serial). N>1 is allowed only when every -# selected script is in the Phase 2 proven-isolated set +# selected script is in the proven-isolated set # (bin/fm-test-isolation-proof.sh --list). Cap is 8. Stateful # families never schedule under --jobs. # -h, --help print this header @@ -117,15 +117,15 @@ now_ms() { # unclassified so new tests are still runnable and visible in summaries. family_for_basename() { case "$1" in - fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|fm-brief.test.sh|\ - fm-calm-pi-extension.test.sh|fm-captain-translation-contract.test.sh|fm-cd-pretool-check.test.sh|\ + fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|\ + fm-brief.test.sh|fm-vendor-auth-probe.test.sh|\ + fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ - fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ + fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ - fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ + fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) @@ -139,6 +139,7 @@ family_for_basename() { ;; fm-afk-inject-herdr-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\ fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\ + fm-backend-herdr-launcher-workspace-e2e.test.sh|\ fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\ fm-herdr-session-cleanup-e2e.test.sh|\ fm-backend-herdr-smoke.test.sh|fm-backend-herdr-workspace-per-home-e2e.test.sh) @@ -146,6 +147,7 @@ family_for_basename() { ;; fm-backlog-handoff.test.sh|fm-secondmate-harness.test.sh|fm-secondmate-lifecycle-e2e.test.sh|\ fm-secondmate-liveness.test.sh|fm-secondmate-safety.test.sh|fm-secondmate-sync.test.sh|\ + fm-startup-memory-budget.test.sh|\ fm-send-secondmate-marker.test.sh|fm-shared-captain-inheritance.test.sh) printf '%s\n' secondmate ;; @@ -229,7 +231,7 @@ real-herdr-gated EOF } -# Exact Phase 2 proven-isolated candidate set (same paths as +# Exact proven-isolated candidate set (same paths as # bin/fm-test-isolation-proof.sh --list). Do not expand without a new concurrent # isolation proof archive. list_proven_isolated() { @@ -237,7 +239,6 @@ list_proven_isolated() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -246,10 +247,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -257,7 +255,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh @@ -266,47 +263,41 @@ tests/fm-x-mode.test.sh EOF } -# Portable parallel shard 1: LPT balance of the proven-isolated set using -# Phase 1 serial duration averages from CI timing artifacts on main after -# #825/#832/#834 (docs/fm-test-portable-shards.md). Execution order is longest -# first so wall-clock stays near the balanced sum. +# Portable parallel shard 1: LPT balance of the proven-isolated set using the +# current concurrent-proof durations in docs/fm-test-isolation-proof.json. +# Execution order is longest first so wall-clock stays near the balanced sum. list_portable_parallel_1() { cat <<'EOF' -tests/fm-arm-pretool-check.test.sh +tests/fm-x-mode.test.sh tests/fm-cd-pretool-check.test.sh -tests/fm-backend-herdr.test.sh -tests/fm-pr-merge.test.sh +tests/fm-decision-hold-lifecycle.test.sh tests/fm-test-run.test.sh -tests/fm-send-popup-settle.test.sh +tests/fm-composer-ghost.test.sh +tests/fm-grok-harness.test.sh +tests/fm-lint.test.sh +tests/fm-pi-primary-types.test.sh tests/fm-review-diff.test.sh tests/fm-brief.test.sh -tests/fm-ensure-agents-md.test.sh -tests/fm-instruction-owners.test.sh -tests/fm-pi-primary-types.test.sh tests/fm-transition-lib.test.sh -tests/fm-composer-lib.test.sh -tests/fm-stow-contract.test.sh EOF } # Portable parallel shard 2: the complementary LPT half of the proven set. list_portable_parallel_2() { cat <<'EOF' -tests/fm-decision-hold-lifecycle.test.sh -tests/fm-x-mode.test.sh -tests/fm-herdr-lab.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-arm-pretool-check.test.sh tests/fm-crew-state.test.sh -tests/fm-grok-harness.test.sh -tests/fm-spawn-batch.test.sh -tests/fm-send-strict.test.sh +tests/fm-herdr-lab.test.sh +tests/fm-pr-merge.test.sh +tests/fm-send-popup-settle.test.sh tests/fm-tmux-submit-busy.test.sh -tests/fm-composer-ghost.test.sh tests/fm-send-settle.test.sh +tests/fm-send-strict.test.sh +tests/fm-spawn-batch.test.sh tests/fm-supervision-instructions.test.sh -tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-captain-translation-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh +tests/fm-ensure-agents-md.test.sh +tests/fm-composer-lib.test.sh EOF } @@ -599,7 +590,7 @@ families_for_test_reference() { # Conservative path → family map. Over-selects rather than under-selects. # Never expands to the complete suite. families_for_changed_path() { - local path=$1 + local path=$1 fixture_ref case "$path" in tests/fm-test-run.test.sh) printf '%s\n' pure-contract-unit @@ -656,13 +647,17 @@ families_for_changed_path() { printf '%s\n' live-harness-optin printf '%s\n' afk ;; + bin/fm-startup-memory-budget.sh|bin/fm-startup-memory-budget-lib.sh) + printf '%s\n' secondmate + printf '%s\n' session-bootstrap + ;; bin/fm-secondmate*|bin/fm-home-seed.sh|bin/fm-backlog-handoff.sh|\ bin/fm-config-inherit-lib.sh|bin/fm-config-push.sh|bin/fm-shared*) printf '%s\n' secondmate ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ - bin/fm-gate-refuse*|bin/fm-lock*) + bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh) printf '%s\n' session-bootstrap ;; bin/fm-pr-*|bin/fm-merge-local.sh|bin/fm-teardown.sh|bin/fm-review-diff.sh|\ @@ -687,6 +682,7 @@ families_for_changed_path() { bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ + bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit @@ -710,9 +706,26 @@ families_for_changed_path() { families_for_test_reference "$(basename "$path")" \ || printf '%s\n' "__unmapped__:$path" ;; + tests/fixtures/*/*) + # A fixture belongs to whichever suite reads its directory, found by the + # same reference scan used for shared helpers. Keyed on the directory + # rather than the file so adding a fixture selects the same suite. + # A removed fixture directory has no consuming suite left to select. + fixture_ref=${path#tests/fixtures/} + fixture_ref=${fixture_ref%%/*} + if [ -d "tests/fixtures/$fixture_ref" ]; then + families_for_test_reference "fixtures/$fixture_ref" \ + || printf '%s\n' "__unmapped__:$path" + fi + ;; bin/*) - families_for_test_reference "$(basename "$path")" \ - || printf '%s\n' "__unmapped__:$path" + # A deleted script has no consuming suite left to select, the same rule + # the fixture case above applies. Refusing on its absent mapping would + # make every retirement branch unable to select its changed tests. + if [ -e "$path" ]; then + families_for_test_reference "$(basename "$path")" \ + || printf '%s\n' "__unmapped__:$path" + fi ;; tests/*) printf '%s\n' "__unmapped__:$path" diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index cf8c3f7fa5..e8284ba1e0 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -46,8 +46,14 @@ # tmux adapter does not paper over a herdr-specific shape. # # Overrides: FM_COMPOSER_IDLE_RE matches an empty composer after ghost and -# structural border stripping. FM_BUSY_REGEX globally overrides harness-scoped -# busy-footer matching (mirrors fm-watch.sh / the daemon). +# structural border stripping. FM_BUSY_REGEX overrides the rendered busy-footer +# matching used here. +# +# NOT a task-state source: task busy state is owned by bin/fm-busy-lib.sh's +# semantic contract. The matching below serves only delivery guards: the submit +# acknowledgement and the away-mode supervisor-pane busy guard. Both ask about +# the pane receiving input, not the state of a recorded worker task. Matching +# stays harness-scoped so one harness's output cannot make another read busy. # # All functions are `set -u` and `set -e` safe (guarded tmux calls, explicit # returns) so they can be sourced into either context. @@ -60,7 +66,7 @@ # shellcheck source=bin/fm-composer-lib.sh . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" -# Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to +# Delivery-only rendered busy footers per harness. claude/codex: "esc to # interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel". # Claude's current spinner has a rotating glyph and word, but every active-turn # line has an ellipsis followed by a parenthesized elapsed duration. Keep this diff --git a/bin/fm-vendor-auth-probe.sh b/bin/fm-vendor-auth-probe.sh new file mode 100755 index 0000000000..1593fe7ae4 --- /dev/null +++ b/bin/fm-vendor-auth-probe.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication +# probe of a named vendor CLI. +# +# This script collects a FACT and renders no verdict. It takes no harness, model, +# or provider, reads no quota, and never decides whether a dispatch candidate is +# eligible. The dispatching first mate owns that judgment from `quota-axi`'s data +# plus each harness's authoritative model catalog; the decision procedure is +# owned once by .agents/skills/quota-array-dispatch/SKILL.md. +# +# Why it exists rather than the agent running the vendor CLI itself: the +# captain's 2026-07-30 `firstmate-grok-auth-preflight` decision approved exactly +# one bounded, non-interactive probe, and that safety envelope must not depend on +# agent memory. It is enforced here deterministically: +# - the argv is fixed in this file and never composed from input, so no caller +# can turn the probe into a login, logout, or interactive TUI launch; +# - stdin is closed, so caller input can never reach the vendor CLI; +# - a hard positive timeout bounds every command, so a hung CLI cannot wedge an +# intake; +# - raw vendor output is classified here and never printed, logged, or passed +# in an argument. +# +# The probe registry is a fixed-argv safety allowlist, not a routing table. It +# carries no harness, model, provider, credential-store, or provider-family +# relationship, and asking for a probe is always the caller's own explicit +# decision. A probe is registered only after its non-destructive discovery +# command and its output discriminators are verified first-hand and recorded in +# docs/verification/dispatch-auth.md. +# +# Registered probes: +# grok `grok models` - the standalone Grok Build CLI. Verified on grok +# 0.2.117: the command exits 0 in BOTH the authenticated and the +# unauthenticated case, so only the literal first stdout line +# discriminates and the exit status is never a verdict. +# +# Output: exactly one sanitized `key=value` line on stdout. No token, refresh +# token, header, path, length, prefix, hash, or raw vendor output is ever +# printed, logged, or passed in an argument. +# +# probe= the requested probe name +# status= authenticated | unauthenticated | indeterminate | +# timeout | unavailable +# version= the probed CLI's version, or none +# versionVerified= yes | no | none - whether the running CLI matches the +# version whose discriminator strings were verified +# +# `status` is evidence, never eligibility. Only `authenticated` and +# `unauthenticated` are ground truth. `indeterminate`, `timeout`, and +# `unavailable` mean the probe established nothing and must never be read as +# either outcome; unrecognized output is `indeterminate`, never authenticated. +# +# Exit status: 0 whenever the line is printed, 2 on a usage error. The exit +# status deliberately does not encode the probe result, because this script +# renders no verdict for a caller to branch on. +# +# Usage: +# fm-vendor-auth-probe.sh +# +# Environment: +# FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds; must be a +# positive integer, otherwise the default 20 is +# used. Zero is rejected because `timeout 0` and +# `alarm 0` both mean "no deadline". +set -u + +VERIFIED_GROK_VERSION=0.2.117 + +usage() { + cat <<'EOF' +fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication probe +of a named vendor CLI. It collects a fact and renders no verdict: it takes no +harness, model, or provider, reads no quota, and never decides dispatch +eligibility. The dispatching first mate owns that judgment. + +Usage: + fm-vendor-auth-probe.sh + +Registered probes: + grok `grok models` on the standalone Grok Build CLI + +Prints one sanitized key=value line: probe, status, version, versionVerified. + +status is evidence, never eligibility: + authenticated the vendor CLI reports an authenticated session + unauthenticated the vendor CLI reports no authenticated session + indeterminate output the verified discriminators do not cover + timeout the hard bound was hit + unavailable the vendor CLI is not on PATH +Only authenticated and unauthenticated are ground truth; the other three +establish nothing and must never be read as either outcome. + +The argv is fixed in the script, stdin is closed, and raw vendor output is never +printed. Login, logout, and the interactive TUI are never invoked. + +Exit status: 0 whenever the line is printed, 2 on a usage error. + +Environment: + FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds (default 20); + a non-positive or non-numeric value is rejected + in favor of the default +EOF +} + +die_usage() { + printf 'fm-vendor-auth-probe: %s\n' "$1" >&2 + printf 'usage: fm-vendor-auth-probe.sh (registered probes: grok)\n' >&2 + exit 2 +} + +PROBE= +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -*) die_usage "unknown option: $1" ;; + *) + [ -z "$PROBE" ] || die_usage "only one probe may be requested at a time" + PROBE=$1 + shift + ;; + esac +done + +[ -n "$PROBE" ] || die_usage "a probe name is required" + +# A non-positive bound is not a bound: `timeout 0` and the Perl fallback's +# `alarm 0` both disable the deadline, so a hung vendor CLI would run unbounded. +TIMEOUT=${FM_VENDOR_AUTH_PROBE_TIMEOUT:-20} +case "$TIMEOUT" in + ''|*[!0-9]*|0*) TIMEOUT=20 ;; +esac + +# Bounded execution, mirroring bin/fm-fleet-snapshot.sh's run_timed selection so +# a macOS host without coreutils still gets a hard bound instead of an unbounded +# vendor CLI call. Exit 124 means the bound was hit. +run_timed() { # + local seconds=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$seconds" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$seconds" "$@" + elif command -v perl >/dev/null 2>&1; then + perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$seconds" "$@" + else + return 124 + fi +} + +STATUS=unavailable +VERSION=none +VERSION_VERIFIED=none + +emit() { + printf 'probe=%s status=%s version=%s versionVerified=%s\n' \ + "$PROBE" "$STATUS" "$VERSION" "$VERSION_VERIFIED" + exit 0 +} + +# The two argv forms below are literals in this file. Nothing the caller supplies +# reaches the vendor CLI's argv or stdin. +grok_version() { + local output + output=$(run_timed "$TIMEOUT" grok --version 2>/dev/null /dev/null /dev/null 2>&1 || emit + VERSION=$(grok_version) + if [ "$VERSION" = "$VERIFIED_GROK_VERSION" ]; then + VERSION_VERIFIED=yes + else + VERSION_VERIFIED=no + fi + STATUS=$(probe_grok) + emit + ;; + *) + die_usage "no probe is registered for '$PROBE'" + ;; +esac diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 929c7231a4..8cec58bec1 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -9,6 +9,10 @@ STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}" +# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s +# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on +# the platform (Git Bash/MSYS) that already pays the highest fork price. +_FM_UNAME=$(uname 2>/dev/null || echo unknown) mkdir -p "$STATE" fm_current_pid() { @@ -24,17 +28,19 @@ fm_pid_alive() { } fm_pid_identity() { - local pid=$1 out proc_root stat_line starttime cmdline_hex + local pid=$1 out proc_root stat_line starttime cmdline_hex identity_key local -a stat_fields case "$pid" in ''|*[!0-9]*) return 1 ;; esac proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} - # Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is + # Prefer a Linux-compatible /proc when present: stat field 22 (starttime, clock ticks since boot) is # immune to the wall-clock steps that re-render the ps lstart fallback's date # (observed as WSL2 btime drift) and would evict a live watcher; combining the # full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision. - if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then + # Git Bash/MSYS exposes these compatible files but its Cygwin ps rejects the + # portable fallback's -o fields, so capability detection must not key on uname. + if [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 # After the final comm delimiter, array index 19 is proc stat field 22. read -r -a stat_fields <<< "${stat_line##*)}" @@ -45,7 +51,9 @@ fm_pid_identity() { esac cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1 [ -n "$cmdline_hex" ] || return 1 - printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex" + identity_key=proc-starttime + [ "$_FM_UNAME" != Linux ] || identity_key=linux-starttime + printf '%s=%s cmdline-hex=%s\n' "$identity_key" "$starttime" "$cmdline_hex" return 0 fi # Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is @@ -57,7 +65,7 @@ fm_pid_identity() { } fm_path_mtime() { - if [ "$(uname)" = Darwin ]; then + if [ "$_FM_UNAME" = Darwin ]; then stat -f %m "$1" 2>/dev/null else stat -c %Y "$1" 2>/dev/null diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 0a783ce287..3c2df49c89 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -68,7 +68,13 @@ BEAT="$STATE/.last-watcher-beat" # "Fresh" reuses the guard's threshold so there is one definition of liveness. GRACE=${FM_GUARD_GRACE:-300} # How long to wait for a freshly forked watcher to acquire the lock and beat. -CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-10} +# Git Bash/MSYS pays a much higher fork cost while the watcher completes its +# required pre-lock migration, so its bounded default covers that cold start. +case "${OSTYPE:-}" in + msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;; + *) ARM_CONFIRM_DEFAULT=10 ;; +esac +CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-$ARM_CONFIRM_DEFAULT} # Poll interval while attached to an existing healthy watcher. ATTACH_POLL=${FM_ARM_ATTACH_POLL:-0.5} CYCLE_LOG="$STATE/.watch-cycle-exits.log" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 1a7b240265..32b4cf9343 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -30,7 +30,16 @@ # also carries a "demand-deep-inspection" marker so the # wake payload itself, not just repetition, forces a # closer look instead of another routine supervision -# resume. Unless afk is active. +# resume. Unless afk is active. A genuinely busy pane +# (window_is_busy true) is exempt from the above, but +# only up to BUSY_TURN_MAX_SECS with no completed turn +# (state/.turn-ended, or the spawn record before any +# turn completes); past that bound busy_turn_over_age +# routes it through the same wedge timer, so it surfaces +# with the identical "stale: ..." reason, escalation +# count, and demand-deep-inspection marker, for human +# inspection only - never an automatic interrupt, +# signal, or restart of the worker or its tool process. # check: