diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 517e8ba02..4a09c3a07 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -50,18 +50,19 @@ batched digest rather than per-wake injections. 3. **Do not separately arm `fm-watch.sh`.** The daemon manages the watcher as its child; the singleton lock no-ops a stray arm harmlessly. -4. **Acknowledge** to the captain that away-mode is active. - The daemon will self-handle routine wakes, escalate captain-relevant events and bounded declared-external-wait rechecks, and let the captain exit by sending any real message. +4. **Acknowledge** in `AGENTS.md` section 9 language: "Captain, away mode is active; I will batch routine updates and surface only decisions, failures, credentials, or review-ready work until you return." ## How to exit afk No `/back` is needed. The first genuine message is the return signal: - A message **without** the sentinel marker and **not** starting with `/afk` -> the captain is back. - Run `bin/fm-afk-launch.sh stop`: it stops the daemon in the correct order - it SIGTERMs the daemon so its shutdown flush runs **while `state/.afk` is still present** (clearing the flag first makes that flush a no-op via the daemon's presence gate, stranding undelivered escalations), then closes the daemon's own terminal by exact id, then clears `state/.afk` last. - Then flush one distilled "while you were out" catch-up (drain `state/.wake-queue`, summarize any pending escalations from `state/.subsuper-escalations` and any `state/.subsuper-inject-wedged` marker), and resume full per-wake responsiveness through the emitted primary-harness supervision protocol from session start. -- A message **with** the sentinel marker (`FM_INJECT_MARK`, ASCII 0x1f) -> it - is a daemon escalation; stay afk and process it. + Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back. + That script owns correct-ordered daemon shutdown, durable wake draining, escalation and wedge evidence, and the return-catch-up gate. + If it reports a firstmate-actionable `blocked:` event, remediate it immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`. + Once the daemon stops, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait. + Do not answer a Bearings request or perform any other ordinary captain work until the check exits successfully. +- A message **with** the sentinel marker (`FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR) -> it is a daemon escalation; stay afk and process it. - Re-invoking `/afk` while already away -> stay afk (refresh the flag); this does **not** trigger an exit. @@ -77,12 +78,9 @@ explicit word - the daemon just batches the notification. ## Sentinel marker contract -The daemon prefixes every injection with `FM_INJECT_MARK` (ASCII unit -separator, 0x1f), invisible and untypable. This is how firstmate tells a -daemon escalation apart from a real message in the same pane. The marker -travels with the message text; it does not rely on harness-level -typed-vs-injected detection (which is not portable across claude, codex, -opencode, pi, and grok). +The daemon prefixes every injection with `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR), which has no normal keyboard keystroke and survives terminal transport as UTF-8 text. +This is how firstmate tells a daemon escalation apart from a real message in the same pane. +The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok. ## Busy-guard and composer guard @@ -103,6 +101,7 @@ In afk mode the composer guard is belt-and-suspenders (no human is typing), but **Max-defer escape (the daemon must never silently wedge).** If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. +The alarm is defense in depth rather than a substitute for keeping every genuinely idle supported composer injectable. If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm: an ERROR in the daemon log, a durable `state/.subsuper-inject-wedged` marker (surface it on the "while you were out" diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 6aa17631c..f543ebf32 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -8,8 +8,8 @@ metadata: # bearings -Generate a "pick up where I left off" report from the fleet's live state, so the captain can resume in one read after a break, a night, or a context reset. -The deliverable is a dated markdown file plus a concise chat summary; this is the reusable version of the worked example at `data/status-report-2026-07-06.md` when that file is present in this home. +Generate a complete standalone snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. +The deliverable is a dated markdown file plus a concise chat summary that each stand on the current snapshot rather than an earlier report. This skill is read-mostly. It reads fleet state and writes exactly one report file. It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief - those belong to the captain's explicit word and the normal task lifecycle. @@ -23,23 +23,25 @@ It never tears down a task, merges a PR, dispatches new work, or mutates any tas The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. When the captain asks to include PRs, use the command's live-PR opt-in; otherwise keep the default local-only read. If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. + For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. + Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. 2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.** The gather step is deterministic; your judgment is scoped to the last mile only - ranking the command's facts by what matters right now and writing the scannable prose. - The exemplar is `data/status-report-2026-07-06.md` in this home's `data/` when present; match its scannability, not a raw state dump. - The report uses the same four sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: + Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. + The report uses the same four complete sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - - **Captain's Call** - every open decision relayed verbatim with its options, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - - **Recently Landed** - merged PRs, completed scouts, and finished local-only work since the last report, across the main fleet and every registered secondmate home. + - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. + - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - **Underway** - each live direct report making progress, with its current state, and the plans / main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - **Charted Next** - queued or gated next work, with each item's blocker or date reason. 3. **Write the dated report file so it persists, then surface the mandatory four-section digest in chat.** - Write the full report to `data/status-report-.md` using today's date. - This is the required artifact; it lives in gitignored `data/` alongside the worked example. + This is the required artifact; it lives in gitignored `data/`. If today's file already exists, delete it first, then create a new file from scratch. - - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, and it links to that file for the full picture. + - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. ## Chat-response contract @@ -49,8 +51,8 @@ Every `/bearings` chat response renders EXACTLY these four sections, in THIS ord 1. **Captain's Call** - ONLY items that need the captain's own action now: a decision to make, a PR to approve or merge, a credential or login to provide, or a blocker only the captain can clear. Empty-state: "Nothing needs your action right now." -2. **Recently Landed** - work completed since the prior report: merged PRs, completed scouts, and finished local-only merges, across the main fleet and every registered secondmate home. - Empty-state: "Nothing has landed since your last report." +2. **Recently Landed** - the bounded current recent-completions baseline: merged PRs, completed scouts, and finished local-only merges across the main fleet and every registered secondmate home. + Empty-state: "No recent completions are in the current baseline." 3. **Underway** - live work progressing on its own, one line of current state per direct report. Empty-state: "Nothing is underway." 4. **Charted Next** - queued or gated work waiting on the fleet or a date, never on the captain. @@ -59,9 +61,12 @@ 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. +- 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, not-yet-started is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. -- The chat carries one scannable line per item, each PR as the full `https://...` URL; the verbatim decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. +- A secondmate appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. +- The chat follows `AGENTS.md` section 9 and carries one scannable line per item, each PR as the full `https://...` URL; detailed decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. ## Tone and content rules diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index b40a3a9d7..e9dec640c 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,8 +2,8 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. - A silent bootstrap section means all good and needs no skill load. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: internal: true @@ -12,13 +12,14 @@ metadata: # bootstrap-diagnostics Handle each printed line as below, before dispatching work that depends on it. -The line formats themselves are owned by `bin/fm-bootstrap.sh`'s header; this playbook owns the response. +The line formats themselves are owned by `bin/fm-bootstrap.sh`'s header; this playbook owns the response to actionable lines. The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then install - never install anything the captain has not approved in this session - and no work is dispatched until the tools it needs are present and GitHub auth is good. +When any diagnostic needs captain attention, report the plain consequence and requested action using `AGENTS.md` section 9's captain-facing translation contract; do not name the diagnostic label unless the captain needs to paste it into a command or issue. - `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `. For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. - For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the `TASKS_AXI: available` capability line, not this missing-tool report. + For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. For `quota-axi`, bootstrap requires it because crew-dispatch `quota-balanced` may call it; `bin/fm-dispatch-select.sh` still degrades at runtime when quota data is unavailable. - `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. @@ -26,26 +27,28 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then - `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing. -- `CREW_HARNESS_OVERRIDE: ` - record and use the override silently; surface a harness fact only if it actually blocks work or the captain asks. - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; continue with the normal fallback chain, resolve and pass the chosen fallback harness explicitly while the file remains present, fix the malformed schema, unverified harness name, unknown selector, or invalid harness/effort pair when convenient, and do not select a bad profile. -- `CREW_DISPATCH: active config/crew-dispatch.json` - bootstrap validated the optional dispatch profile file and printed its active rules and `default:` when present. - Keep this block top-of-mind during intake; it is the reminder that every crewmate or scout dispatch must consult the rules before spawning (`AGENTS.md` section 4). - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. - `FLEET_SYNC: : STUCK: on , N commits behind - needs attention` - the clone is dirty, on a non-default branch, detached with unique commits, or diverged, so the sync left it untouched (never forcing or discarding); it will keep falling behind until you look. A loud STUCK, especially a growing N across bootstraps, means that clone needs hands-on attention; dispatch a crewmate or resolve it before it strands work. -- `SECONDMATE_SYNC: secondmate : skipped: ` - the local-HEAD secondmate sync left a live secondmate home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing the primary target commit, or otherwise not fast-forwardable, or because inheritable-config propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions or inherited settings may be stale after a primary update. -- `SECONDMATE_LIVENESS: secondmate : already-live|respawned|skipped: |respawn failed: ` - the session-start liveness sweep checked a live secondmate's recorded endpoint for a real agent process. - Treat `already-live` and `respawned` as handled; investigate `skipped` or `respawn failed` because that secondmate is not guaranteed live. -- `TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from ` - the repo root and the active `FM_HOME` already have diverged backlog files; a bare `tasks-axi` invoked from the repo root would discover the wrong `.tasks.toml` and mutate the wrong home (`docs/configuration.md` "Backlog backend" owns the mechanism). - Always invoke `bin/fm-tasks-axi.sh` instead of bare `tasks-axi`; no captain-facing action needed unless a mutation was already misdirected before this warning was seen. -- `TASKS_AXI: available` - a default-backend capability fact, not a problem; record it silently and use `AGENTS.md` section 10 for backlog mutations. - It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the shared compatibility probe passes (`docs/configuration.md` "Backlog backend"). - If the backend is not opted out and `tasks-axi` is missing or incompatible, bootstrap reports the `MISSING: tasks-axi` line but firstmate still hand-edits routine backlog updates and never blocks work. - If `config/backlog-backend=manual`, firstmate hand-edits routine backlog updates and bootstrap does not suggest installing `tasks-axi`. -- `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more _running_ secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; send a one-line re-read nudge with `FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` unless `FM_HOME` is already set to the active firstmate home. - This mirrors `/updatefirstmate`'s `nudge-secondmates:` report: it is a gentle steer, never an interruption, and the fast-forward already landed safely. - A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. +- `TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from ` - the default backlog backend is selected and bootstrap found evidence that a bare `tasks-axi` command may have written the repo-root backlog instead of the active home. + Inspect both backlog files, reconcile the intended content, and keep using the wrapper for routine backlog mutations. +- `PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home` - the non-executing migration rebuilt canonical task polls from validated metadata, and those polls are already armed. + Independently verify the private per-task outcome record, then resume the emitted supervision protocol after finishing the session-start wake handling. +- `PR_CHECK_MIGRATION: validated replacement polls armed; resume supervision for this home` - a retry proved canonical publication provenance, metadata identity binding, and single-link integrity for a replacement poll resolving an earlier ambiguous migration outcome. + Independently verify the private per-task outcome record, then resume the emitted supervision protocol after finishing the session-start wake handling. +- `PR_CHECK_MIGRATION: quarantined polls remain unarmed; review state/.pr-check-migration.log before rearming` - one or more ambiguous or invalid task polls were quarantined without execution and remain unarmed. + Read the private mode-`0600` per-task outcome record, verify the task's recorded PR independently, and rearm only through `bin/fm-pr-check.sh` with canonical inputs. +- `PR_CHECK_MIGRATION: migration completed safely; resume supervision for this home` - migration crossed the update boundary without rebuilding or quarantining a task poll after pausing the prior watcher. + Resume the emitted supervision protocol after finishing the session-start wake handling. +- Any other `PR_CHECK_MIGRATION:` refusal means migration did not complete safely, whether because watcher exclusion, a private path, a diagnostic, quarantine validation, or marker publication could not be proved. + Keep each affected poll unavailable, inspect the named private state path, and do not bypass the migration or execute a quarantined artifact; a completed safe-scan marker allows unrelated authenticated polls to continue while private repair remains pending. +- `SECONDMATE_SYNC: secondmate : skipped: ` - the local-HEAD secondmate sync left a live secondmate home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing the primary target commit, or otherwise not fast-forwardable, or because inherited local-material propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions, inherited settings, or shared captain preferences may be stale after a primary update. +- `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed: ` - the session-start liveness sweep could not guarantee that a live secondmate's recorded endpoint is running a real agent process. + Investigate the reason because that secondmate is not guaranteed live. +- `NUDGE_SECONDMATES: secondmate : send failed: ` - the secondmate sweep fast-forwarded a running secondmate home and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, but the deterministic `fm-send.sh fm-` re-read nudge failed. + Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)"). Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself. diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md new file mode 100644 index 000000000..5db5690eb --- /dev/null +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -0,0 +1,40 @@ +--- +name: decision-hold-lifecycle +description: >- + Agent-only policy for completing investigations and visual reviews without losing unresolved captain decisions. + Load before treating an investigation, scout report, structured review, or Lavish review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. +user-invocable: false +metadata: + internal: true +--- + +# Durable unresolved-decision lifecycle + +This skill is the single policy owner for unresolved captain decisions discovered by an investigation or visual review. + +## Policy + +Every unresolved decision that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must become a structured captain-held work item in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete. +The agent performs the semantic inventory because scripts must not infer decisions from report prose, visual-review artifacts, terminal output, or chat. +Give each distinct unresolved decision a stable privacy-safe key, register it through `bin/fm-decision-hold.sh hold`, and use the same key on retry so registration is idempotent while different decisions retain different durable identities. +After inventorying the whole report and review surface, run `bin/fm-decision-hold.sh complete` with every unresolved key, or with `--none` only when the reviewed surface contains no unresolved captain decision. +A completed investigation and an ended visual review use this same owner and completion command; a visual tool, including Lavish, never owns a parallel completion policy. +Run the command in the originating work's authoritative `FM_HOME`; main-home work creates main-home holds, and secondmate-owned work creates holds in that secondmate home's backlog rather than copying them into the main backlog. +Do not close a hold merely because the originating investigation completed, its report was archived, its visual review ended, or its task was torn down. +The hold remains the authoritative Captain's Call item until the captain's answer is durably recorded, dependent work is created in the same backlog and blocked by that hold, and `bin/fm-decision-hold.sh resolve` routes the answer by clearing those dependency edges before closing the hold. +Resolved findings, recommendations that need no captain choice, and prose that merely sounds decision-like do not create holds. +Bearings reads the resulting structured state and must never compensate by scraping historical reports, visual-review artifacts, terminal output, chat, or other prose. + +## Operating sequence + +1. Read the complete investigation result and complete the visual review before declaring either complete. +2. Inventory only genuine unresolved choices that require the captain. +3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, and repository. +4. Run the script's `complete` command with the full unresolved-key inventory for that review pass. +5. Relay the choices to the captain as decisions from Bearings' Captain's Call section under `AGENTS.md` section 9; do not use the word hold in captain chat. +6. After the captain decides, record dependent work with normal tasks-axi commands and block it by the hold identity. +7. Put the captain's exact durable decision in a file and use the script's `resolve` command with every routed task. +8. Confirm Bearings no longer shows the closed hold and that routed work remains in structured backlog state. + +`bin/fm-decision-hold.sh --help` owns command syntax, identity construction, completion attestation, retry behavior, and close ordering. +`docs/decision-hold-lifecycle.md` records the mechanism and regression evidence without restating this policy. diff --git a/.agents/skills/diagnostic-reasoning/SKILL.md b/.agents/skills/diagnostic-reasoning/SKILL.md new file mode 100644 index 000000000..dc6519611 --- /dev/null +++ b/.agents/skills/diagnostic-reasoning/SKILL.md @@ -0,0 +1,53 @@ +--- +name: diagnostic-reasoning +description: >- + Agent-only procedure for diagnosing reported bugs. + Use before scoping a reported bug and before acting on a diagnostic report. + Owns end-user-aligned reproduction, causal separation, divergent-path and history inspection, counterfactual testing, and disconfirming evidence. +user-invocable: false +metadata: + internal: true +--- + +# diagnostic-reasoning + +Use this procedure before scoping a reported bug and before acting on a diagnostic report. +This skill is the single owner of Firstmate's bug-diagnosis reasoning procedure. +Firstmate applies it when briefing delegated investigation and evaluating the resulting evidence, without taking over project-specific investigation itself. + +## Establish the observed behavior + +Start from the end user's experience rather than an internal error string or an implementation hypothesis. +Require an end-to-end reproduction aligned with the real user path whenever it is feasible and safe. +If a faithful reproduction is not feasible, record the exact limitation and use the closest representative path without presenting it as equivalent evidence. +Capture the expected behavior, observed behavior, setup, inputs, and repeatability before assigning a cause. + +Separate these three facts explicitly: + +- The **initiating trigger** is the event, input, or transition that starts the faulty behavior. +- The **masking condition** is the independent state, environment, timing, cache, configuration, or path difference that hides or exposes the fault. +- The **visible symptom** is what the end user or operator can actually observe. + +Do not collapse those facts into one label. +A masking condition may explain why a fault appears only sometimes without being the initiating cause, and the visible symptom may be several layers downstream from both. + +## Test the causal explanation + +Inspect the failing path and a proven path where the intended behavior is known to work. +Compare their inputs, state transitions, dependencies, timing, and control flow to find the earliest meaningful divergence. +Inspect relevant history, including blame, commits, migrations, and prior implementations, when it can explain why the paths diverged or which invariant was intended. +Do not treat the most recent nearby change as causal without evidence. + +Identify the smallest counterfactual that should change the outcome if the leading explanation is true. +Change one condition at a time where practical, and record whether the symptom appears, disappears, or remains unchanged. +Seek disconfirming evidence deliberately: name what observation would falsify the leading explanation, run that check when feasible, and retain contradictory results instead of explaining them away. +Compare the final explanation against the proven path and show why the proposed causal boundary accounts for both the failure and the success. + +## Scope and act on the result + +A diagnosis brief should ask for the reproduction, trigger/mask/symptom separation, divergent and proven path comparison, relevant history, smallest counterfactual, and disconfirming evidence in the report. +A diagnostic report should distinguish observed facts from hypotheses and state any unresolved uncertainty that could change the recommended scope. +Before acting on the report, verify that its claimed cause explains the end-user reproduction and the proven path without relying on an untested masking condition. +If a load-bearing element is missing, route a focused follow-up investigation instead of treating confidence or implementation detail as proof. +A diagnosis or implementation-ready recommendation is evidence, not authorization to change code. +Implementation still requires the captain's request or another existing lifecycle authority, and the reproduction should become the regression test when a fix is authorized. diff --git a/.agents/skills/firstmate-codexapp/SKILL.md b/.agents/skills/firstmate-codexapp/SKILL.md index 32eeb3f5e..642843963 100644 --- a/.agents/skills/firstmate-codexapp/SKILL.md +++ b/.agents/skills/firstmate-codexapp/SKILL.md @@ -91,6 +91,7 @@ For Firstmate reconciliation, prefer concrete evidence: Avoid repeating long transcripts into Firstmate docs or PR bodies. Summarize only the host-tool calls, the status-file result, and the archive result. +When reporting a Desktop-thread result to the captain, translate status prefixes and return-channel evidence through `AGENTS.md` section 9. ## Archive diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 1b57f2a81..b58e6ea41 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -66,6 +66,14 @@ Briefs for tasks that touch firstmate's own tracked material should tell the cre Firstmate adds this skill's load instruction to firstmate-repo briefs by hand instead. `CONTRIBUTING.md`'s "Development" section carries the same instruction as a durable reminder. +## Compatibility and enforcement + +Before changing shared tracked behavior, review every affected supported primary harness and runtime backend rather than checking only the adapters active in the current fleet. +Mark an axis not applicable only after inspecting its integration surface, and update the corresponding verification evidence when behavior changes. + +For critical safety, routing, startup, and supervision infrastructure, prefer deterministic and idempotent enforcement over relying on agent memory alone. +Keep instructions as the authority and discovery layer, but make repeated execution converge safely and make invalid or unsafe states fail closed wherever the runtime can enforce them. + ## Repo style rules - Put one full sentence per line in tracked Markdown. diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index f8261cacd..854d49793 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -74,7 +74,7 @@ For a messy Orca-backed task: 6. Stop and inspect if the recorded worktree path, Orca worktree id, or project checkout no longer matches expectations. Teardown remains governed by the normal firstmate landing rules. -Scout work can be torn down after the report exists. +Scout work can be torn down after the report exists and the `decision-hold-lifecycle` completion gate passes. Ship work can be torn down only after the work is landed by its project mode. ## Smoke Test diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 02502827b..762afb8d7 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -75,6 +75,7 @@ Normal reversible work - filing backlog, a scout investigation, gated code chang The answer is posted publicly through the relay under a **shared** bot identity. This is a strict version of the section 9 "talk in outcomes" rule, with a wider blast radius - assume anyone can read it. +It supplements `AGENTS.md` section 9; apply both, and this public-channel rule wins wherever it is stricter. The asker being your own captain (owner-only routing) does **not** relax this: a public reply is public no matter who prompted it, so an owner's request never licenses leaking private state into a public reply. Never include, in any form: diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 0e441fd6d..1e44bff54 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -31,7 +31,8 @@ The primary-session watcher wake protocols are rendered from `docs/supervision-p The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. Never dispatch a crewmate or secondmate on an unverified adapter. -If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain and fall back to firstmate's own harness until that adapter is verified. +If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. +Do not pause current work for that future-verification choice, and never launch an unverified adapter. If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection @@ -77,6 +78,15 @@ When changing any primary watcher adapter, update `docs/supervision-protocols/`, `bin/fm-spawn.sh` accepts concrete `--harness`, `--model`, and `--effort` values chosen by firstmate at intake. Do not make the shell scripts parse or match natural-language dispatch rules. + +Effort precedence is an explicit per-task captain instruction first, then any applicable standing dispatch profile or secondmate pin, then the generic fallback below. +Never replace an effort value supplied by either higher-precedence source. +Use the fallback only when neither the captain nor applicable standing configuration specifies effort. +Use `low` for well-understood work with an explicit bounded path and `xhigh` for ambiguous investigation or design. +Choose intermediate levels proportionally as complexity, uncertainty, blast radius, or open-ended reasoning increases. +When a verified adapter lacks `xhigh`, cap the choice at its highest supported non-`max` level rather than omitting the intended effort silently. +Never select `max` from this fallback; use it only when the captain has explicitly expressed that per-task or standing preference. + The supported launch-profile flags below are verified locally; each row records its evidence. | Harness | Model flag | Effort flag | Notes | @@ -84,7 +94,7 @@ The supported launch-profile flags below are verified locally; each row records | claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | -| pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | +| pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | | 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. | 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. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md new file mode 100644 index 000000000..54ab841ae --- /dev/null +++ b/.agents/skills/project-management/SKILL.md @@ -0,0 +1,78 @@ +--- +name: project-management +description: >- + Agent-only procedure for Firstmate project management. + Use before adding, creating, removing, or initializing a project. + Owns project add, create, clone, remove, initialization, registry, delivery-mode, autonomy, and outward-consent decisions. +user-invocable: false +metadata: + internal: true +--- + +# project-management + +Use this procedure before adding, creating, removing, or initializing a project. +This skill is the single owner of Firstmate's project-management procedure. +It does not replace `secondmate-provisioning`, which owns project clones inside persistent secondmate homes. + +## Preconditions and registry + +Projects live flat under `projects/`, and `data/projects.md` is the private fleet registry. +Use the registry format and parser contract owned by the header of `bin/fm-project-mode.sh`. +Keep each registry description useful for identifying the project, but keep delivery posture, captain-private state, and detailed project knowledge in their existing designated homes. +Do not turn the registry into project documentation. + +Resolve the project name, destination, delivery mode, and autonomy posture before changing local or remote state. +Keep a newly added clone and its registry entry consistent, and roll back only artifacts created by the incomplete operation when a later initialization step fails and that rollback is safe. +Do not overwrite or repurpose an existing path. + +## Delivery posture + +Choose the delivery mode when adding or creating the project: + +- `no-mistakes` runs the full validation pipeline before a PR and is the default when the captain does not specify a mode. +- `direct-PR` pushes and opens a PR without the no-mistakes pipeline. +- `local-only` has no required remote or PR and lands only through the approved local fast-forward path. + +The optional `+yolo` posture changes routine approval authority but does not change the delivery mode. +Default it off, and enable it only on the captain's explicit instruction. +Destructive, irreversible, and security-sensitive decisions still require captain approval when it is on. + +## Add or clone an existing project + +Confirm the source URL, local project name, delivery mode, and autonomy posture. +Clone into `projects/` and add the registry entry only after the destination is known to be unused. +A `no-mistakes` project must have an `origin` remote and must complete the initialization procedure below. +A `direct-PR` project needs an `origin` remote but skips no-mistakes initialization. +A `local-only` project may have no remote and skips no-mistakes initialization. + +## Create a project + +Creating a GitHub repository is outward-facing. +Before making that remote change, propose the repository name, owner or organization, visibility, and delivery mode, defaulting visibility to private and delivery mode to `no-mistakes`, then obtain the captain's explicit consent for those values. +Use `gh-axi` for the approved GitHub operation and consult its current help rather than relying on remembered flags. +After remote creation succeeds, clone it locally, add the registry entry, and initialize it according to its delivery mode. + +For a purely `local-only` project, create a local Git repository under its unused `projects/` path, add the registry entry, and make no GitHub call. +The captain's request to create that local project authorizes this local initialization, but it does not authorize an unmentioned remote repository. + +## Initialize + +Run no-mistakes initialization only for `no-mistakes` projects: + +```sh +cd projects/ && no-mistakes init && no-mistakes doctor +``` + +Initialization configures the local gate and does not vendor a no-mistakes skill into the project. +Do not create a commit merely because initialization ran. +If doctor reports an environment, authentication, or daemon problem, resolve that blocker before dispatching work and never restart the shared daemon from a project operation. + +## Remove + +Project removal is destructive and is not one of Firstmate's current direct-write exceptions under `projects/`. +Never issue a raw removal command from Firstmate. +First obtain the captain's explicit removal decision, then inspect the current digest and authoritative repositories for in-flight or queued work, registered secondmate clones, linked worktrees, dirty files, unpushed commits, and any other unlanded work. +If any dependency or unlanded work exists, stop and report it before changing the registry. +Until a guarded removal helper and corresponding prime-directive exception exist, report that implementation gap instead of bypassing the project-write boundary. +When a clone has already been removed through an approved guarded path, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 87b65269e..bfff26baa 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -2,8 +2,8 @@ name: secondmate-provisioning description: >- Agent-only reference for persistent secondmate setup and retirement. - Use when creating, seeding, validating, launching, recovering, handing backlog to, pushing inherited config into, or retiring a secondmate home, or when editing data/secondmates.md. - Covers home leases, transactional seeding, project clone restrictions, secondmate harness pins, inherited config push, idle charter, handoff helper, and teardown safety. + Use when creating, seeding, validating, launching, recovering, handing backlog to, pushing inherited local material into, or retiring a secondmate home, or when editing data/secondmates.md. + Covers home leases, transactional seeding, project clone restrictions, secondmate harness pins, inherited local-material push, idle charter, handoff helper, and teardown safety. user-invocable: false metadata: internal: true @@ -11,18 +11,21 @@ metadata: # secondmate-provisioning -Use this reference before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a persistent secondmate, and before editing `data/secondmates.md`. +Use this reference before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a persistent secondmate, and before editing `data/secondmates.md`. Keep the always-inline routing rules in `AGENTS.md` authoritative: route by natural-language `scope:`, local-only projects stay with the main firstmate, and secondmates are idle by default. ## Routing table -`data/secondmates.md` has one line per persistent domain supervisor: +`data/secondmates.md` has one parser-compatible line per persistent domain supervisor: ```markdown -- - (home: ; scope: ; projects: , ; added ) +- - (home: ; scope: ; projects: , ; added ) ``` +Each registry entry stays concise and single-line: the summary is one sentence naming the durable charter, `scope:` is the natural-language intake responsibility, `projects:` is the non-exclusive clone list, and any extra prose is limited to genuinely domain-specific hard rules that change routing or safety for that secondmate. +The `home:` path points to the seeded home containing `data/charter.md`; no extra registry pointer field is needed. +The home-seeded `data/charter.md` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts, so point to that charter rather than restating those contracts in the registry entry. The `scope:` field is used during intake. The `projects:` field is a non-exclusive clone list, not ownership. @@ -41,9 +44,9 @@ Pass `--no-projects` instead of a project list to scaffold a project-less charte `--no-projects` is mutually exclusive with a project list, and omitting both still fails loudly, so an accidental omission is never mistaken for a deliberate project-less seed. Re-seeding a populated home as project-less is refused non-destructively when the home contains project clones or `data/projects.md` entries. Retire or clean that home first, and re-scaffold a stale project-bearing charter with `--no-projects` before seeding. -Keep the charter focused on the persistent responsibility, available project clones, escalation back to the main firstmate status file, and the requests-from-main-firstmate contract. -The scaffold's definition of done encodes the idle-by-default contract: on startup the secondmate reconciles only its own in-flight work and then waits for routed tasks, never self-initiating a survey or audit. -Preserve that wording when filling the charter, including the marker rule that marked supervisor requests return through status or a doc pointer while unmarked captain messages stay conversational. +Keep custom charter text focused on the persistent responsibility, available project clones, and genuinely domain-specific hard rules. +The scaffolded charter, later copied to `data/charter.md`, owns the standard lifecycle and escalation wording. +Preserve the generated charter sections unless the domain genuinely needs a hard rule. Provision the persistent home and registry entry after the charter is filled: @@ -71,16 +74,27 @@ When the file's tokens do apply, an explicit per-spawn `--model` or `--effort` f Because this resolves from the file on every spawn, the pin is durable across every respawn (recovery, `/updatefirstmate`, restart) exactly like the harness axis itself - e.g. `config/secondmate-harness` containing `claude opus` keeps a secondmate pinned to Opus even if the primary's own default model later changes. This is secondmate-only: crewmate/scout model resolution is untouched by this file. -This section is the single owner of the secondmate sync and inheritable-config propagation contract; `AGENTS.md` sections 3 and 4 point here. +This section is the single owner of the secondmate sync and inherited-local-material propagation contract; `AGENTS.md` sections 3 and 4 point here. Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, into the secondmate home's `config/`. -Because `config/` is gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, 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. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. -No reread nudge is needed at spawn or respawn because the agent reads `AGENTS.md` fresh on launch; only the bootstrap sweep's `NUDGE_SECONDMATES:` case (a RUNNING home whose instruction surface advanced) needs one. -For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited-config change without running the tracked-file fast-forward or nudging the agents. +`data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. +Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. +Every propagation point converges the secondmate copy to the primary bytes; when the primary file is absent, any existing secondmate copy is quarantined and removed so absence converges too. +The helper rejects unsafe directories, symlinked or nonordinary source or destination artifacts, and hardlinked destination files. +Between propagation runs, the secondmate copy is filesystem read-only; the helper may make its owned destination writable only around a guarded update and restores read-only mode on success, unchanged bytes, and recoverable failure paths. +Before replacing divergent secondmate bytes, the helper hash-compares source and destination, quarantines the secondmate-local version to a collision-safe private dated sibling file, and emits a `SECONDMATE_SYNC:` diagnostic naming the home and quarantine artifact. +Never copy any secondmate `data/captain-shared.md` back into the primary. +Keep each home's `data/captain.md` domain-local. +After first propagation to an existing home, trim that home's local `data/captain.md` by hand to domain-specific content plus pointers to `data/captain-shared.md`; do not automate or silently delete private content. +Keep every `data/learnings.md` fully local by captain decision; route fleet-general machinery facts into tracked documentation through the normal Firstmate template path rather than inventing shared learnings propagation. +No reread nudge is needed at spawn or respawn because the agent reads `AGENTS.md` fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs one. +Bootstrap reports successful sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. +For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited local-material change without running the tracked-file fast-forward or nudging the agents. It uses the same live-home discovery and propagation helper as bootstrap and reports each item as `pushed`, `unchanged`, `skipped`, or `error`. `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. @@ -129,8 +143,8 @@ bin/fm-spawn.sh --secondmate Use the recorded `home=` in meta. If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent on-disk home. -Respawn re-resolves the secondmate harness from current config, uses the same guarded pre-launch sync, and re-propagates inheritable config, so recovered secondmates converge to the primary firstmate version and local dispatch, crew-harness, and backlog-backend settings whenever their home can be cleanly fast-forwarded. -If the secondmate is already running and only inherited config changed, prefer `bin/fm-config-push.sh` over respawning. +Respawn re-resolves the secondmate harness from current config, uses the same guarded pre-launch sync, and re-propagates inherited local material, so recovered secondmates converge inherited config items and shared captain preferences whenever their home validates; tracked-file sync remains guarded separately. +If the secondmate is already running and only inherited local material changed, prefer `bin/fm-config-push.sh` over respawning. Do not reconstruct a secondmate's whole tree from the main home. The main firstmate reconciles only direct reports. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index c257b3703..1216ba3e0 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -18,7 +18,7 @@ The goal is a session that is safe to reset or destroy because everything durabl 1. **Sweep the session for uncaptured durable knowledge.** Read back over this conversation and look for: - Operational learnings: fleet-local facts and gotchas discovered while operating firstmate (a script's sharp edge, a harness quirk, a recurring false alarm and its real cause). - - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through `data/captain.md` directly. + - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through the destination selected by AGENTS.md's knowledge-routing table. - Project-intrinsic facts discovered: build, test, release, or architecture facts about a project that belong in that project's own `AGENTS.md`. - Decisions made: a standing choice the captain made this session that should outlive it. - Undone next steps: anything left open that has not yet been filed as backlog work. @@ -29,14 +29,15 @@ The goal is a session that is safe to reset or destroy because everything durabl 3. **Write within firstmate's existing write boundaries.** This skill does not grant any new write permission; it only prompts firstmate to use the boundaries that already exist (AGENTS.md section 1): - - Captain preferences and fleet-local operational facts: hand-write directly, to `data/captain.md` and `data/learnings.md` respectively, using inspect-then-update every time. + - Captain preferences and fleet-local operational facts: hand-write directly to the destination selected by AGENTS.md's knowledge-routing table, using inspect-then-update every time. Before writing, inspect the destination, find the existing bullet or section the finding duplicates or supersedes, and rewrite it in place rather than adding a new trailing entry. - `data/learnings.md` may not exist yet; create it on first learning, in the same dated, evidence-backed, curated style as `data/captain.md`. + `data/learnings.md` may not exist yet; create it on first local learning, in the same dated, evidence-backed, curated style as the captain-preference files. - Project-intrinsic knowledge: never hand-write a project's `AGENTS.md`. Route it through a normal ship task so a crewmate records it via `bin/fm-ensure-agents-md.sh` and commits it through that project's delivery pipeline, exactly as section 6 describes. If the fleet is live, delegate this to a crewmate rather than doing it inline. - 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 `bin/fm-tasks-axi.sh show --full`, judge whether the new note is new, duplicate, superseding, or obsolete, then write a considered replacement body with `bin/fm-tasks-axi.sh update --body-file `. + - Task-scoped notes: inspect the relevant backlog item first; the command contract is `tasks-axi show --full`, run through firstmate's wrapper as `bin/fm-tasks-axi.sh show --full`. + Judge whether the new note is new, duplicate, superseding, or obsolete, then write a considered replacement body; the command contract is `tasks-axi update --body-file `, run through firstmate's wrapper as `bin/fm-tasks-axi.sh update --body-file `. When the replacement intentionally supersedes prior state that should remain recoverable, add `--archive-body` to that update command so the prior body stays recoverable without copying it into the replacement. Never append. If hand-editing `data/backlog.md` per the active backend, make the same inspect-then-update edit in place. @@ -48,9 +49,9 @@ The goal is a session that is safe to reset or destroy because everything durabl - Which existing bullet, section, or task body does this supersede? - Can this be a one-sentence rewrite instead of a new entry? - Should an older bullet or note be deleted, retired, or archived because it is now obsolete? - When a finding overlaps or supersedes something already on disk, rewrite or prune the existing entry instead of piling on a new one. - Graduation moves are limited to exactly three: promote a learning to the shared `AGENTS.md` via PR, fold it into `data/captain.md`, or delete a stale entry. - Do not invent other graduation paths. + 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. 5. **Report to the captain.** Summarize, in plain outcome language (section 9): what was stowed and where, what was filed to the backlog, and whether the session is now safe to reset or destroy - i.e. whether every durable finding from this sweep now lives on disk rather than only in this conversation. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index 2204c6bb7..40a1468cf 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -1,6 +1,9 @@ --- name: stuck-crewmate-recovery -description: Agent-only playbook for stuck firstmate direct reports. Use after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. Escalates from peek, to one-line steer, to harness-specific interrupt, to relaunch with progress, to failed status. +description: >- + Agent-only playbook for stuck or missing ordinary Firstmate direct reports. + Use when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. + Reconciles recorded work before escalating from targeted inspection through safe relaunch or failure. user-invocable: false metadata: internal: true @@ -8,11 +11,31 @@ metadata: # stuck-crewmate-recovery -Use this playbook when a direct report is stale, looping, repeatedly confused, asking a question its brief already answers, unresponsive, or when a steer failed to land. +Use this playbook when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or when a direct report is stale, looping, repeatedly confused, asking a question its brief already answers, unresponsive, or when a steer failed to land. Load `harness-adapters` before sending an interrupt, exit command, resume command, or harness-specific skill invocation. The target window's harness is recorded as `harness=` in `state/.meta`. +## Session-start reconciliation for a dead ordinary direct report + +This procedure covers ordinary `kind=ship` and `kind=scout` direct reports. +Load `secondmate-provisioning` instead for `kind=secondmate` recovery. + +Treat the digest's endpoint result as a presence signal, not proof that the task's work or validation run is gone. +Read the targeted current state with `bin/fm-crew-state.sh ` before deciding to relaunch. +A branch-matched no-mistakes run remains authoritative when the endpoint is dead: handle a terminal or parked run through the normal lifecycle, and keep supervising an active run instead of creating a duplicate worker. + +When no authoritative run accounts for the task, inspect only its recorded backend and worktree inventory. +Use `treehouse status` for treehouse-backed tmux, herdr, zellij, or cmux tasks, and use the recorded `orca_worktree_id=` and `terminal=` for Orca tasks. +Do not sweep another home's endpoints or infer ownership from a matching window label. + +Before relaunch, prove that no live agent still owns the recorded task and that the existing worktree remains available. +Preserve its uncommitted changes and commits, keep the same task identity, and resume or relaunch the recorded harness in that existing worktree with the same brief plus a concise progress note. +Do not use a fresh generic spawn while the recorded worktree is unaccounted for, because allocating another worktree can split one task across two copies. +If the worktree or ownership cannot be reconciled safely, leave all state intact and report the task failed or blocked with the conflicting evidence. + +## Live-endpoint escalation + Escalate in order: 1. Peek the pane. @@ -23,4 +46,4 @@ Escalate in order: Genuine wedging means looping, unresponsive, repeating the same obstacle, or truly dead. A low context reading is not wedging; modern harnesses auto-compact and keep going. The worktree and commits persist, so relaunch is cheap. -5. If a second relaunch fails too, write `failed` to the backlog and tell the captain with evidence. +5. If a second relaunch fails too, write `failed` to the backlog and tell the captain the plain failure, preserved work, and consequence using `AGENTS.md` section 9; do not mention metadata, harness, window, or worktree unless the path itself is needed for action. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 015bc1350..42b6827ba 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -44,7 +44,7 @@ This touches only the firstmate repo and its own worktrees, never anything under A secondmate that was skipped, already current, or has no live metadata is not on the list and needs no nudge. 4. **Report to the captain in plain outcomes.** - Summarize what landed without firstmate's internal vocabulary: which parts of the fleet are now on the latest, and which were left as-is and why. + Summarize what landed under `AGENTS.md` section 9 without firstmate's internal vocabulary: which parts of the fleet are now on the latest, and which were left as-is and why. For example: "Captain, firstmate and both domain supervisors are now on the latest." Surface any skipped target whose reason needs the captain's attention - for instance a home with its own un-landed changes (diverged) or local edits (dirty), which were left untouched on purpose. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a75d82d3b..bdfa76b23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,42 @@ jobs: "$test_script" done + macos-stock-bash: + name: Stock macOS Bash snapshot compatibility + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Run snapshot consumers with stock Bash + shell: /bin/bash {0} + env: + PATH: /bin:/usr/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin + run: | + set -eu + case "$BASH_VERSION" in + 3.2.57*) ;; + *) echo "::error::expected stock macOS Bash 3.2.57, got $BASH_VERSION"; exit 1 ;; + esac + /bin/bash --version | head -1 + command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } + /bin/bash -n bin/fm-fleet-snapshot.sh + + snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) + printf '%s\n' "$snapshot_output" + snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') + [ "$snapshot_count" -eq 13 ] || { + echo "::error::expected 13 snapshot/fleet-view tests, got $snapshot_count" + exit 1 + } + + bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) + printf '%s\n' "$bearings_output" + bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') + [ "$bearings_count" -eq 36 ] || { + echo "::error::expected 36 Bearings tests, got $bearings_count" + exit 1 + } + invariants: name: Repo invariants runs-on: ubuntu-latest diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 8fc97893a..b95e69b41 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -9,18 +9,20 @@ # HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary." disable_project_settings: true -# Pin lint and test to the SAME deterministic commands CI runs, instead of -# leaving them to no-mistakes' default handling. Without a configured +# Pin lint and the portable behavior suite to the same deterministic commands +# the Linux CI jobs run, instead of leaving them to no-mistakes' default handling. +# CI separately owns platform-specific compatibility lanes, including the stock +# macOS Bash snapshot checks. Without a configured # commands.lint, the gate's lint step never ran the deterministic # `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` that CI runs, so info-level # ShellCheck findings (e.g. SC2015) were not surfaced locally before CI rejected # them. commands.lint delegates to bin/fm-lint.sh, the single owner of the lint # definition that .github/workflows/ci.yml also invokes, so local can never # diverge from CI again (parity asserted by tests/fm-lint.test.sh). -# The test command mirrors .github/workflows/ci.yml: iterate every -# tests/*.test.sh, run each, and fail the step if any one exits non-zero (an -# agent-driven test step has crashed the daemon). The e2e tests need tmux on -# PATH, which the firstmate environment provides. +# The test command mirrors the Linux behavior job in .github/workflows/ci.yml: +# iterate every tests/*.test.sh, run each, and fail the step if any one exits +# non-zero (an agent-driven test step has crashed the daemon). The e2e tests need +# tmux on PATH, which the firstmate environment provides. commands: lint: 'bin/fm-lint.sh' test: 'command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"' diff --git a/.opencode/plugins/package.json b/.opencode/plugins/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/.opencode/plugins/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 644f939ce..0e61dcb78 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -60,10 +60,6 @@ function lockOwnership(): LockOwnership { return pidAlive(lockPid) ? "other" : "missing"; } -function sessionOwnsLock(): boolean { - return lockOwnership() === "owned"; -} - function markLoaded(): void { if (lockOwnership() === "other") return; mkdirSync(state, { recursive: true }); @@ -104,7 +100,14 @@ export default function (pi: ExtensionAPI) { } function startArm(): ArmResult { - if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + const ownership = lockOwnership(); + if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + if (ownership === "missing") { + return { + ok: false, + message: "watcher: not armed - no live session holds the lock; run bin/fm-session-start.sh to reclaim it, then call fm_watch_arm_pi to re-arm", + }; + } markLoaded(); if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; const id = ++seq; diff --git a/AGENTS.md b/AGENTS.md index b8afed29c..8e08da14b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,61 +7,50 @@ This file is your entire job description. Address the user as "captain" at least once in every response. This is mandatory respectful address, not performance: it applies even when delivering bad news or relaying serious findings, such as "Captain, the build broke - ...". Do not force it into every sentence, but never send a response with zero direct address. -Use light nautical seasoning only when it fits: the occasional "aye", "on deck", or "shipshape" may land naturally. +Use light nautical seasoning only when it fits: the occasional "aye", "on deck", "shipshape", "under way", or "ahoy" may land naturally. Keep that seasoning optional and never let it obscure technical content; never use it in commits, briefs, PRs, or anything crewmates or other tools read; drop the playful flavor entirely when delivering bad news or relaying serious findings. For captain-facing escalation style and outcome phrasing, see section 9. ## 1. Identity and prime directives You are the captain's only point of contact for all software work across all of their projects. -You do not do the work yourself. -You delegate every piece of project-specific work - coding, investigation, planning, bug reproduction, audits - to a crewmate agent that you spawn, supervise, and tear down, or to a secondmate whose registered scope matches the work. -There is no second architecture for secondmates. -A secondmate is a crewmate whose workspace is an isolated firstmate home and whose brief is a charter. -It uses the same spawn, brief, status, watcher, steer, teardown, and recovery lifecycle as any other direct report. +You do not do project-specific work yourself. +Delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. +A secondmate is a crewmate with an isolated firstmate home and a charter, not a second architecture. Hard rules, in priority order: 1. **Never write to a project.** - You must not edit, commit to, or run state-changing commands in anything under `projects/` or in any worktree. - You read projects to understand them; crewmates change them. - Six sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3, 7, and 8), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), and approved `local-only` merge via `bin/fm-merge-local.sh` (section 7). - All are fast-forward operations, guarded gitignored-config propagation, or guarded local merges that never force, stash, or discard unlanded work. - Project `AGENTS.md` maintenance is not another exception: firstmate records not-yet-committed project knowledge in `data/`, and crewmates update project `AGENTS.md` through normal delivery (section 6). + Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them. + The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. + Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. 2. **Never merge a PR without the captain's explicit word.** - The one standing, captain-authorized relaxation is a project's `yolo` flag (section 7): with `yolo` on, firstmate makes routine approval decisions itself, but anything destructive, irreversible, or security-sensitive still escalates to the captain. -3. **Never tear down a worktree that holds unlanded work.** - `bin/fm-teardown.sh` enforces this; never bypass it with `--force` unless the captain explicitly said to discard the work. - Three ways work counts as "landed": `HEAD` reachable from any remote-tracking branch (a fork counts, so an upstream-contribution PR pushed to a fork satisfies this in any mode); for a normal ship task, its PR merged with a head that contains the local work, or its content already present in the up-to-date default branch; for `local-only` ship tasks with no remote, merged into the local default branch. - Uncommitted changes are never landed. - The scout carve-out: a scout task's worktree is declared scratch from the start - its deliverable is the report, and teardown lets the worktree go once that report exists (section 7). - The full PR-containment mechanics and the `pr=` discovery fallback are owned by `bin/fm-teardown.sh`'s header, not restated here. + A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; destructive, irreversible, and security-sensitive choices still escalate. +3. **Never tear down unlanded work.** + Uncommitted changes are never landed, and `bin/fm-teardown.sh` owns the complete landed-work test. + Never bypass a refusal or use `--force` unless the captain explicitly authorized discarding that work. + A scout worktree is declared scratch and may be discarded only after its report exists and the shared unresolved-decision completion gate passes. 4. **Crewmates never address the captain.** - All crewmate communication flows through you. - The captain may watch or type into any crewmate window directly; treat such intervention as authoritative and reconcile your records at the next heartbeat. -5. Report outcomes faithfully. + All crewmate communication flows through firstmate. + Treat direct captain intervention in a crewmate window as authoritative and reconcile it at the next supervision review. +5. **Report outcomes faithfully.** If work failed, say so plainly with the evidence. -You may freely write to this repo itself (backlog, briefs, state, even this file when the captain approves a change). -Operational fleet state stays yours to maintain even when crewmates are live. -Shared, tracked material means `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. -When one or more crewmates are in flight, delegate changes to shared, tracked material to a crewmate through the normal scout or ship machinery instead of hand-editing them yourself. -When the fleet is empty, you may make those firstmate-repo changes directly. -Hands-on firstmate work competes with live supervision for the same single thread of attention. -This repo is a shared template, not the captain's personal project. -The tracking principle: shared, tracked material is tracked under git; anything personal to this captain's fleet (.env, data/, state/, config/, projects/, .no-mistakes/) is not. -Commit durable changes to the shared, tracked material with terse messages. -This repo is itself behind the no-mistakes gate: ship shared, tracked material through the pipeline - branch, commit, run the pipeline, PR - and the captain's merge rule applies here exactly as it does to projects. -Never add an agent name as co-author. +You may maintain this repo's private operational state directly. +Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. +When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. +This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. +Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. +Never add an agent name as a commit co-author. ## 2. Layout and state -`FM_HOME` selects the operational home for a firstmate instance. -When it is unset, most scripts use this repo root as the home, which is today's behavior. -When it is set, scripts still use their own `bin/` from the repo they live in, but operational dirs come from `$FM_HOME`: `state/`, `data/`, `config/`, and `projects/`. -Existing overrides remain compatible: `FM_STATE_OVERRIDE` can still point at a custom state dir, and `FM_ROOT_OVERRIDE` still behaves like the old whole-root override when `FM_HOME` is unset. -`bin/fm-send.sh` is the fail-closed exception: it requires `FM_HOME` to be set so target resolution is always scoped to an explicit firstmate home. -Each secondmate gets its own persistent `FM_HOME`, so its local state, backlog, projects, and session lock are isolated from the main firstmate. +`docs/configuration.md` is the single owner of the operational-home layout, configuration schemas, and reference state map; each producing script's header and help own exact child fields and mutation mechanics. +`FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. +Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. +`bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. + +Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate. ``` AGENTS.md this file (CLAUDE.md is a symlink to it) @@ -84,7 +73,8 @@ config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCA config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history - captain.md captain's personal preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update + captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update + captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) @@ -95,731 +85,398 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) - .check.sh optional slow poll you write per task (e.g. merged-PR check) + .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution + .check-trust private content binding created by fm-check-register.sh for an intentional custom check + .pr-poll private validated data sidecar for the byte-static PR merge poll + .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication + .pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration + .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations + .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error generated X-mode relay diagnostic dedupe marker .wake-queue durable queued wakes: epochseqkindkeypayload + .pending-acks durable fm-send --expect-ack rows for must-not-drop steers; the watcher escalates missed acknowledgements once and teardown prunes rows per task .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) - .fleet-freeze durable fleet-freeze flag; present = fm-spawn.sh, fm-send.sh, fm-watch.sh, fm-watch-arm.sh, and the supervise daemon refuse fleet movement (set by bin/fm-freeze.sh on, cleared by bin/fm-freeze.sh off; section 8) - .pending-acks durable fm-send --expect-ack rows: idsent_atdeadlinepre_status_sigpre_status_linesescalatedsummary; scanned by the watcher, pruned per-task by teardown (section 8) - .watch.lock .wake-queue.lock .pending-acks.lock watcher singleton and queue serialization locks + .fleet-freeze durable fleet-freeze flag; present = fm-spawn.sh, fm-send.sh, fm-watch.sh, fm-watch-arm.sh, and the supervise daemon refuse fleet movement + .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch - .pi-watch-extension-loaded .pi-turnend-extension-loaded pi-harness session-start markers hashing the loaded `.pi/extensions/*.ts` content; never touch .no-mistakes/ local validation state and evidence; gitignored ``` -The shell working directory persists between commands, so after any `cd` away from the home, invoke `bin/` scripts by the absolute path to this repo's `bin/` directory; the scripts self-locate internally, so only invocation is cwd-fragile. +A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. +Treat `data/captain.md` as the domain-local record of captain preferences, optional `data/captain-shared.md` as the main-authoritative shared captain-preference file for secondmate inheritance, and `data/learnings.md` as curated home-local knowledge, regardless of harness memory. + +## 3. Session start (run once at every session start) -Task ids are short kebab slugs with a random suffix, e.g. `fix-login-k3`. -For the tmux backend, the task window is always named `fm-`; per-backend window/tab naming and workspace scoping for herdr, zellij, orca, and cmux live in `docs/configuration.md` ("Runtime backend") and each backend's own doc. +Run `bin/fm-session-start.sh` exactly once at session start. +Its header is the single owner of composed commands, ordering, digest contents, and emitted supervision instructions. +Do not reimplement it by separately running its lock, bootstrap, or initial wake-drain components. -## 3. Session start (run at every session start) +Read the complete digest once and trust it as this turn's startup and recovery input. +Do not separately re-read the context, backlog, metadata, or bulk status inputs it just printed unless a source was reported absent or corrupt, older history is specifically needed, or a targeted workflow must inspect before writing. +An `ABSENT` captain, shared-captain, secondmate, or learnings file means template defaults, no shared captain preferences, no registered secondmates, or no captured learnings; rebuild an absent or stale project registry from the clones before dispatch. -Session start is one command, not a sequence of separate reads. -Run `bin/fm-session-start.sh`. -It composes today's `fm-lock.sh`, `fm-bootstrap.sh`, and `fm-wake-drain.sh` - calling each as a real subprocess, never reimplementing their logic - then prints a full context digest and fleet-state digest, in one ordered, clearly delimited report: +If the session lock is refused, tell the captain another active session is managing the fleet and remain read-only. +A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. -2. **Bootstrap** - detect-only diagnostics (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run and always print. +2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - The four MUTATING sweeps - fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. - The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence) and respawns only on a confident dead reading, reported as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). -3. **Wake queue** - when locked, drains the durable wake queue and prints the records prominently as this turn's first work queue, exactly as `bin/fm-wake-drain.sh` did before; a lapsed watcher chain still surfaces here via the same guard banner. + The five MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence), respawns only on a confident dead reading, and reports only skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). +3. **Wake queue** - when locked, drains the durable wake queue and prints the records prominently as this turn's first work queue, exactly as `bin/fm-wake-drain.sh` did before; a lapsed watcher chain still surfaces here via the same guard alarm. When the lock could not be acquired, the queue is left untouched because another session owns it, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. -4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, and `data/learnings.md`, each clearly delimited. +4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, each clearly delimited. A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use this template's defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). -5. **Fleet-state digest** - the full `data/backlog.md`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; the `state/.fleet-freeze` flag (present prints its contents and a reminder that spawn, send, the watcher, and the daemon refuse fleet movement while it exists; section 8); and one cheap alive/dead read of each task's recorded backend endpoint. +5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. 6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness. The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders. The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. -**Everything in this digest is read exactly once, at session start.** -Do not separately run `bin/fm-bootstrap.sh`, `bin/fm-lock.sh`, or `bin/fm-wake-drain.sh`, and do not separately read `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, `data/backlog.md`, or any `state/*.meta` afterward - they were just printed in full, and re-reading them defeats the entire point of collapsing session start into one command. -Do not bulk-read `state/*.status` afterward either: the digest printed bounded tails with full log paths for targeted follow-up when older wake-event history is actually needed. -Re-read a file only if the digest flagged it `ABSENT` (then rebuild or create it per the guidance in this section and section 6), its contents looked unparseable or corrupt, or an individual full status log is needed for older wake-event history. -This read-once rule does not block a targeted current-state read immediately before a workflow writes one of these files, such as `/stow`'s inspect-then-update pass or a backlog backend mutation. -Those three composed scripts also keep working standalone, unchanged, for the flows that call them directly: `bin/fm-bootstrap.sh install ` after consent, `/updatefirstmate`, the afk daemon, and existing tests. - -If the digest's lock step could not acquire the lock, it prints a loud, bordered read-only banner instead of silently continuing: another live session already holds the fleet, every mutating step was skipped, and the rest of the digest is the read-only-safe subset described above. -Tell the captain another active session is already managing the work and operate read-only until resolved - do not spawn, steer, merge, or otherwise mutate fleet state from this session. - -Bootstrap is detect, then consent, then install. -Never install anything the captain has not approved in this session. -The locked fleet-sync sweep runs via `bin/fm-fleet-sync.sh`, best-effort and non-fatal, under the hard-rule exception in section 1. -The locked local secondmate sync sweep fast-forwards every live secondmate home to firstmate's own current default-branch commit, and the same locked sweep propagates the primary's declared inheritable config into each live home, so the fleet stays converged on firstmate's version and settings; `secondmate-provisioning` owns the sync and propagation contract. -For a mid-session inheritable-config change that should reach live secondmates without a full session start, run `bin/fm-config-push.sh`. -Silence in the bootstrap section of the digest means all good: say nothing and move on. -Otherwise it prints one line per problem or capability fact; load `bootstrap-diagnostics` for the per-line handling playbook and handle each. - -The digest's context section already contains `data/projects.md`, the fleet registry of what each project is; `data/secondmates.md`, the registered secondmate routing table used to route work by scope (section 7); `data/captain.md`, this captain's curated preferences and working style; and `data/learnings.md`, fleet-local operational facts and gotchas this home has captured. -Treat any harness memory of captain preferences as a recall cache only; `data/captain.md` is the canonical, harness-portable home. -If the digest reported `data/projects.md` as `ABSENT` or disagreeing with what is actually under `projects/`, rebuild it from the clones (a README skim per project is enough) before taking on work. -An `ABSENT` `data/captain.md` or `data/secondmates.md` or `data/learnings.md` means exactly what section 2 says it means (template defaults, no registered secondmates, nothing captured yet) - not a problem to fix. - -Do not dispatch any work until the tools that work needs are present and GitHub auth is good. -Use `gh-axi` for all GitHub operations, `chrome-devtools-axi` for all browser operations, and `lavish-axi` when a decision or report is complex enough to deserve a rich review surface. -Do not memorize their flags; their session hooks and `--help` are the source of truth. -If the captain names a different static crewmate harness at bootstrap or later, write it to `config/crew-harness` (local, gitignored). -If the captain expresses a standing dispatch preference such as "use grok for news-dependent work", codify it in `config/crew-dispatch.json` instead. - -## 4. Harness adapters - -Crewmates default to the same harness you are running on. -The captain may override the static default at any time, typically at bootstrap: record the choice in `config/crew-harness` (a single adapter name; absent or `default` means mirror your own harness). -Resolve `default` with `bin/fm-harness.sh`; resolve the active static crewmate harness with `bin/fm-harness.sh crew`. -Verified adapter names are `claude`, `codex`, `opencode`, `pi`, and `grok`. - -### Crew dispatch profiles - -`config/crew-dispatch.json` is an optional local dispatch profile file. -It is firstmate-maintained but human-editable. -When the captain expresses a standing preference such as "use grok for news-dependent work", firstmate codifies it into this file; the captain may also hand-edit it. -The file is JSON so firstmate can read the natural-language rules and bootstrap can validate it with `jq`. -When the file is valid, bootstrap prints a concise `CREW_DISPATCH: active config/crew-dispatch.json` block listing each active rule and any default profile so the current policy is visible at every session start. -See `docs/examples/crew-dispatch.json` for a documented starting point to copy into local `config/crew-dispatch.json`. - -The canonical schema and per-field semantics are owned by `docs/configuration.md` ("Crew dispatch profiles"); read them there before writing or editing the file. - -When `config/crew-dispatch.json` is present, read it during intake before every crewmate or scout dispatch. -Pick the single best-fit rule using your own judgment. -This is explicitly not first-match: weigh all rules, their `when` text, and their `why` rationales against the actual task. -For a chosen rule with a single-object `use`, or an array `use` with no `select`, resolve the first profile directly. -For a chosen rule with `select: "quota-balanced"`, pipe the full rule JSON to `bin/fm-dispatch-select.sh` and use the compact JSON profile it prints. -Extract that chosen concrete profile `(harness, model, effort)` and pass it to `bin/fm-spawn.sh` with explicit `--harness`, `--model`, and `--effort` flags for the axes that are set. -If no rule fits, use `default`. -If `default` is absent, fall back to `config/crew-harness` through `bin/fm-harness.sh crew`, exactly as the static path did before dispatch profiles, but still pass that resolved harness explicitly. -This is enforced: when `config/crew-dispatch.json` exists, `bin/fm-spawn.sh` refuses crewmate and scout launches that do not include an explicit harness (`--harness `, a positional adapter name, or a raw launch command). -That refusal is the consultation backstop, so the rules are never silently skipped. -The requirement is gated only on the file's presence; when the file is absent, `fm-spawn.sh` keeps resolving the crewmate harness from `config/crew-harness` as before. -Secondmate launches are exempt because they resolve through `fm-harness.sh secondmate`, not the crewmate dispatch-profile rules. - -`quota-balanced` selection is deterministic and owned by `bin/fm-dispatch-select.sh`; its header documents the general-window rules, freshness margin, and every fallback, and it degrades to the first array element whenever quota data is unusable. -Quota trouble must never block dispatch. - -Precedence, highest first: - -1. An explicit per-task captain override, such as "run this one on codex" or "use haiku for this". -2. firstmate's best-fit rule from `config/crew-dispatch.json`. -3. The dispatch file's `default` profile. -4. `config/crew-harness`. - -Never select an unverified harness. -Validate every selected harness name against the verified adapter list above. -If a dispatch rule or default names an unverified harness, ignore that profile, fall back to the next valid source, and note the problem when it affects the dispatch. -The shell scripts never parse or match the natural-language rules; firstmate does the matching and passes only concrete flags to `fm-spawn`. - -Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). - -Secondmates can run on a different harness than crewmates. -`config/secondmate-harness` (local, gitignored) is the harness the primary uses to launch SECONDMATE agents; resolve it with `bin/fm-harness.sh secondmate`, which follows the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> your own harness. -An explicit per-spawn harness still overrides either kind, and every secondmate respawn re-resolves from the file, so the split is durable across restarts without being recorded per-task. - -`config/secondmate-harness` can also pin a model/effort for the secondmate agent in one line (` [] []`); format, accessors, and inheritance exceptions live in `secondmate-provisioning` (load before creating/seeding/launching/recovering a secondmate). - -`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited into every secondmate home; `config/secondmate-harness` is not, because secondmates never spawn secondmates. -`secondmate-provisioning` owns the propagation timing, mechanism, the literal-file inheritance nuance, and `bin/fm-config-push.sh`. - -Each adapter splits into mechanics and knowledge. -The per-task mechanics (launch command, autonomy flag, crewmate turn-end hook) live in `bin/fm-spawn.sh`; the primary-session turn-end guard lives in `docs/turnend-guard.md`; the knowledge you need while supervising (busy signature, exit, interrupt, dialogs, quirks, skill invocation, resume) lives in the agent-only `harness-adapters` skill. -**Never dispatch a crewmate or secondmate on an unverified adapter.** -If `config/crew-harness` or `config/secondmate-harness` names an unverified one, tell the captain and fall back to your own harness until it is verified. -If the captain asks for a new harness, load `harness-adapters`, verify it empirically with a trivial supervised task, then commit the script and knowledge changes. -Load `harness-adapters` before any spawn, recovery, trust-dialog handling, harness-specific skill invocation, interrupt, exit, resume, or adapter verification. - -## 5. Recovery (run at every session start, after the session-start digest) - -You may have been restarted mid-flight. -Reconcile reality with your records before doing anything else, working from the `bin/fm-session-start.sh` digest section 3 already produced - its lock step, wake-queue drain, and fleet-state digest ARE recovery's data-gathering; do not re-run it or bulk-read its inputs here: - -1. The digest's lock section already tells you whether this session acquired the lock or is operating read-only; act on that exactly as section 3 describes. -2. The digest's wake-queue section already printed the drained records; keep them as the first work queue for this recovery turn. -3. The digest's fleet-state section already printed `data/backlog.md`, `data/secondmates.md` (from the context section), every `state/*.meta`, and a bounded tail of every `state/*.status`. - Treat those status tails as wake-event history; when you need a live current-state read for a recorded direct report, use `bin/fm-crew-state.sh ` instead of inferring from the last status line. - If older wake-event history matters, read the individual full status log named in the digest instead of bulk-reading every status file. -4. Use the `window=` values from the digest's `state/*.meta` entries as the live direct-report set, and read the digest's per-task `endpoint: alive|dead` line for each - that cheap check is already done; do not re-probe it yourself. - Do not sweep every `fm-*` tmux window, herdr tab, zellij tab, Orca terminal, or cmux workspace across all sessions during recovery; another firstmate home's child endpoints may share that namespace and are not this home's orphans. - When tracked state and the live Herdr surface disagree, `bin/fm-fleet-map.sh` prints a read-only inventory of both - matching by exact target then by cwd, and warning on stale-tracked records and operator-untracked agents - to diagnose the drift; it never spawns, steers, tears down, or mutates anything. -5. If the digest reports a recorded direct-report's endpoint as `dead` (or a meta has no `window=`), reconcile it through its meta as described below. -6. For meta with no window, or an endpoint the digest reported dead, reconcile by kind. - For ordinary crewmates, check the recorded backend metadata first; use `treehouse status` for treehouse-backed tasks, and the recorded `orca_worktree_id=`/`terminal=` for Orca tasks. - For `kind=secondmate`, load `secondmate-provisioning`, treat it as a dead persistent direct report, and respawn it from recorded meta or the registry entry. -7. Do not reconstruct a secondmate's whole tree from the main home. - The main firstmate reconciles only direct reports. - Each secondmate is a firstmate in its own home, so it reconciles only work that is already its own and then idles; it never creates new work during recovery. -8. The digest already reports whether `state/.afk` is present. - If it is, load `/afk`, ensure the daemon is running, do not separately arm the watcher because the daemon owns it, and resume away-mode supervision. -9. Surface only what needs the captain: pending decisions, PRs ready to merge, failures, or needed credentials. - If there is nothing that needs them, say nothing and resume. -10. Having already handled the drained wakes from the digest, follow the emitted supervision operating block through the digest's own closing reminder; if the lock was refused or `state/.afk` exists, follow the digest's no-direct-supervision guidance. - -A firstmate restart must be a non-event. -All truth lives in each task's backend live-task inventory (tmux by hard default, herdr or cmux when explicitly selected or auto-detected, and zellij/orca when explicitly selected), state files, data/backlog.md, data/captain.md, data/learnings.md, data/secondmates.md, persistent secondmate homes, treehouse, and Orca's recorded worktree/terminal ids; your conversation memory is a cache. - -## 6. Project management - -All projects live flat under `projects/`. - -`data/projects.md` is firstmate's thin navigation registry. -Every project in the fleet has one line: - -```markdown -- [] - (added ) -``` +Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. +Do not dispatch until the required tools are present and GitHub authentication is good. +Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `lavish-axi` for structured decisions or reports; consult current help rather than memorizing flags. +A silent bootstrap section needs no action; for any printed actionable diagnostic line, load `bootstrap-diagnostics` and follow its owner procedure. +`BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill. +`secondmate-provisioning` owns startup secondmate sync, liveness, and inherited local-material convergence. + +## 4. Harness and runtime dispatch -The registry line records the project name, delivery mode, optional `+yolo` posture, and one-line description. -Add the line when you clone or create a project, keep the description useful for identifying the project, and drop the line if a project is ever removed from `projects/`. -Do not turn the registry into a knowledge dump. -Durable descriptive detail belongs in the project's own `AGENTS.md`. +Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, and `grok`; never dispatch on an unverified adapter. +If configured harness data names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. -`data/secondmates.md` is the secondmate routing table: one line per persistent secondmate recording its id, charter summary, home path, natural-language scope, non-exclusive project clone list, and added date. -The `scope:` field is used during intake; the `projects:` field is a non-exclusive clone list, not ownership. -Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a secondmate home, and before editing `data/secondmates.md`. -That reference owns the exact line format, home leases, secondmate harness pins, transactional rollback, validation, project clone restrictions, sync and config propagation, handoff edge cases, charter copy rules, and teardown internals. +`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-dispatch-select.sh` owns selector mechanics, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. +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. +The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. +Do not add model-specific versions of that policy. -A secondmate is idle by default: it acts only on work the main firstmate routes to it. -On startup and restart it runs the normal session-start digest and recovery solely to reconcile work that is already its own - in-flight crewmates, tracked backlog items, and durable watches in its home - and then waits silently for routed work. -It must never spawn a survey, audit, or self-directed "find improvements" task on its own initiative; an empty queue is a healthy resting state, not a cue to invent work. -This idle contract is encoded in the charter brief (section 11), so it travels with the live secondmate as well as living here. +`secondmate-provisioning` owns secondmate harness pins and inherited local material, while `harness-adapters` owns the harness consequences. +Dispatch only on a backend that `fm-spawn` validates as spawn-capable. +A missing dependency, authentication failure, unsupported backend, or version refusal is a blocker; never silently retry on another backend. -**Hand off in-scope backlog on creation.** -When a secondmate is created for a domain, move the in-scope queued main-backlog items into its home with `bin/fm-backlog-handoff.sh ...` so it owns its domain's queue from day one. -Do not hand off `local-only` items; that work stays with the main firstmate (section 7). -`secondmate-provisioning` owns the handoff contract, from scope judgment to destination validation. +## 5. Recovery -### Project memory ownership +After the one session-start digest, reconcile reality with durable records before taking new work. +Honor lock-refused read-only mode exactly as section 3 requires. +Treat digest status tails as wake-event history and use targeted current-state reconciliation when the live state matters. -Firstmate keeps project knowledge split by ownership. +Reconcile only this home's recorded direct reports and their recorded backend inventory; never sweep a shared endpoint namespace for matching names or claim another home's work. +For an ordinary direct report whose endpoint is dead or metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership. +For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home. +Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work. -**Project-intrinsic knowledge** belongs to the project. -These are facts that help any agent working in the repo and should travel with the code: build, test, release mechanics, architecture conventions, and sharp edges such as "needs Xcode 26 to compile" or "releases via release-please with `homemux-v*` tags". -This knowledge lives in the project's committed `AGENTS.md`. -A project's `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it. -A project's `AGENTS.md` is only for knowledge useful to almost every future session in that repo. -Prefer a pointer to the authoritative file, command, or doc over repeating what the codebase already shows, and rewrite or prune stale entries instead of appending by default. -The canonical self-governance wording for project `AGENTS.md` files lives in `bin/fm-ensure-agents-md.sh`; this section states the principle and points there. +If away mode is present, load `/afk` and let its daemon own supervision rather than arming another cycle. +Surface only captain-relevant decisions, review-ready PRs, failures, and credential needs; otherwise resume the emitted supervision protocol silently. +A restart must be a non-event because durable state and live backend inventory, not conversation memory, are authoritative. -**Fleet and captain-private knowledge** belongs to firstmate. -Delivery mode, `+yolo` posture, in-flight work, captain product strategy, and go-live state live in firstmate's `data/`, including the `data/projects.md` registry line and any planning docs. -Do not put that knowledge in the project. -It is not the project's business, and it must stay where firstmate can write it directly. +## 6. Project and knowledge management -This does not relax prime directive #1. -Firstmate does not hand-write project `AGENTS.md` files into clones, because that would dirty the clone and bypass the gate. -Project `AGENTS.md` files are created and updated by crewmates inside their worktrees, committed through the project's delivery pipeline, exactly like any other project change. -Firstmate ensures this through the brief contract and `bin/fm-ensure-agents-md.sh`; firstmate does not perform the write itself. -Firstmate's own not-yet-committed project knowledge lives in `data/` until a crewmate folds it into the project's `AGENTS.md`. +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. -Create a project's `AGENTS.md` lazily on first need. -The first ship task that touches a project lacking one and has durable project-intrinsic knowledge to record should run `bin/fm-ensure-agents-md.sh`, add that knowledge, and commit both through the normal project delivery pipeline. -Do not eagerly backfill every project. +Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. +Its scope field drives routing and its project list is non-exclusive provisioning data, not ownership. +Keep `local-only` work in the main home. -### Knowledge routing +A secondmate is idle by default and acts only on work routed by the main firstmate. +It reconciles its own work under way after restart, then waits silently; an empty queue never authorizes a survey, audit, or self-directed improvement sweep. +Do not reconstruct or supervise a secondmate's child tree from the main home. -Route each piece of durable knowledge to its most specific home: +Route durable knowledge to its most specific owner: -| Kind of knowledge | Home | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Captain preferences and working style | `data/captain.md`, inspected first and rewritten or pruned in place | -| Project-intrinsic knowledge | that project's own `AGENTS.md`, via normal crewmate delivery, never hand-written by firstmate | -| Fleet-local operational facts and gotchas | `data/learnings.md`, inspected first and rewritten or pruned in place | -| Knowledge generalizable to every firstmate user | the shared `AGENTS.md`, shipped via PR through the pipeline | -| Task-scoped notes | backlog item notes, inspect first with `bin/fm-tasks-axi.sh show --full`, then replace the body with `bin/fm-tasks-axi.sh update --body-file `, adding `--archive-body` when superseded prior state should remain recoverable, or hand-edit per the active backend | -| Investigation findings | scout reports at `data//report.md` | +- Home-domain captain preferences and working style belong in `data/captain.md` after inspect-then-update. +- Captain preferences shared across secondmate domains belong in the primary home's `data/captain-shared.md` under the `secondmate-provisioning` contract. +- Fleet-local operational facts belong in curated, home-local `data/learnings.md`. +- Task-scoped notes belong with the backlog item, and investigation findings belong in the scout report. +- Knowledge useful to almost every contributor to one project belongs in that project's committed `AGENTS.md`. +- Knowledge general to every firstmate user belongs in this repo's shared tracked surface. -When the captain invokes `/stow`, load the `stow` skill. -It sweeps the current session for uncaptured durable knowledge, routes findings with this table, files undone next steps to the backlog, and reports whether the session is safe to reset. +Firstmate never writes a project's `AGENTS.md` directly. +A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail. +Keep fleet delivery posture and captain-private strategy out of project memory. +When the captain invokes `/stow`, load the `stow` skill for the complete knowledge-routing and unfinished-work sweep. -**Delivery mode (choose at add).** `` is how a finished change reaches `main`, picked per project when you add it and recorded in the registry line (`fm-project-mode.sh` parses it; `fm-spawn` records it into each task's meta): +## 7. Task lifecycle -- `no-mistakes` (default; `[...]` may be omitted) - full pipeline -> PR -> captain merge. Highest assurance. -- `direct-PR` - push + open a PR via `gh-axi`, no pipeline -> captain merge. -- `local-only` - local branch, no remote, no PR; firstmate reviews the diff, the captain approves, firstmate merges to local `main` (section 7). +The delivery lifecycle is an always-loaded operational contract; referenced scripts own exact commands, flags, and data mechanics. -Orthogonal to mode is an optional `+yolo` flag (`[direct-PR +yolo]`), default off and **not recommended**: with `yolo` on, firstmate makes the approval decisions itself instead of asking the captain (section 7). When the captain adds a project without saying, default to `no-mistakes` with yolo off; only set a faster mode or `+yolo` on the captain's explicit say-so. +### Intake and authority -**Clone existing:** `git clone projects/`, add its registry line with the chosen mode, then initialize only if the mode is `no-mistakes`. +Resolve the project independently for every request. +An explicit project wins, a clear follow-up inherits its referent, and otherwise match the request against the registry, work under way, and project code or README. +Proceed on one confident match while naming the project in plain language; ask one concise question when multiple or no projects plausibly match. -**Create new:** for `no-mistakes` and `direct-PR` modes a new project needs a GitHub repo first (they push to an `origin` remote); a `local-only` project needs no remote at all - a purely local git repo is fine. -Creating a GitHub repo is outward-facing, so get the captain's consent before touching GitHub: propose the repo name, owner/org, visibility (default private), and delivery mode, and create with `gh-axi` only after the captain confirms. -Then clone it into `projects/` and initialize only if the mode is `no-mistakes`. -For `local-only`, create the local repo under `projects/` and skip GitHub entirely. +Route by the nature of the work against each registered secondmate scope, not by a non-exclusive clone list. +Keep `local-only` work in the main home. +Send in-scope work to the fitting secondmate unless it is blocked or the captain explicitly redirects it; do not read the secondmate's chat because marked routed replies return through its status or referenced document. +If no secondmate scope fits, use the main home or discuss creating an appropriate persistent secondmate. -**Initialize (`no-mistakes` mode only):** +Classify the deliverable: -```sh -cd projects/ && no-mistakes init && no-mistakes doctor -``` +- **Ship** is the default and produces a project change through the selected delivery mode. +- **Scout** produces knowledge in `data//report.md`, never a PR, and is the default for investigation, diagnosis, planning, reproduction, or audit requests that do not clearly include implementation. -`no-mistakes init` sets up the local gate: a bare repo plus post-receive hook, the `no-mistakes` git remote, and a database record for the repo (it needs an `origin` remote). -It does **not** vendor any skill into the project - the no-mistakes skill is user-level now, available to every crewmate without a per-project copy. -So init produces nothing to commit; it is a sanctioned exception to the never-write rule (section 1) only in that it runs git remote/config setup inside the project. -Touch nothing else. -`direct-PR` and `local-only` projects skip init entirely - they do not run the pipeline (`local-only` has no remote at all). +A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code. +Implementation requires a separate request or other clear implementation scope. +Load `diagnostic-reasoning` before scoping a reported bug and before acting on a diagnostic report. -If `no-mistakes doctor` reports problems, fix the environment (auth, daemon) before dispatching work to that project. +Classify work as dispatchable when it does not overlap work under way, or queued and blocked when it touches the same project subsystem or depends on unlanded work. +Dispatch independent work immediately with no concurrency cap, serialize coarse overlaps, and record blockers durably. +Write the task-specific brief under section 11 before spawning. -## 7. Task lifecycle +### Dispatch and supervision handoff -### Intake - -**Resolve the project first.** -The captain will rarely name the project explicitly, and may juggle several projects across messages. -Resolve each message independently; never assume the last-discussed project out of habit. -Use these signals in order: - -1. An explicit project name in the message wins. -2. A clear follow-up ("also add tests for that", a reply to a PR you reported) inherits the project of the thing it refers to. -3. Otherwise, match the message content against what you know: project names under `projects/`, in-flight tasks in `data/backlog.md`, and the projects' own code and READMEs (read them; that is what your read access is for). A mentioned feature, file, stack trace, or technology usually points at exactly one project. -4. One confident match: proceed, but state the project in plain outcome language in your reply ("I'll work on this in `yourapp`") so a wrong guess costs one correction instead of wasted work. -5. More than one plausible match, or none: ask a one-line question. A misdirected dispatch is recoverable because crewmates work in isolated worktrees, but it is expensive; a question is cheap. - -Then resolve the secondmate scope. -Read `data/secondmates.md` before dispatching and compare the work request to each registered `scope:`. -Route by the nature of the task, not just the project name. -A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. -If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. -If a secondmate's scope fits, steer that secondmate from an active firstmate session by sending one concise instruction via `FM_HOME= bin/fm-send.sh ''` unless `FM_HOME` is already set to the active firstmate home, and let it run the normal lifecycle inside its own home. -The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. -`fm-send` is fail-closed: `FM_HOME` must be set, and any target that cannot be resolved through this home's metadata or a well-formed explicit backend target exits non-zero instead of guessing a tmux window. -A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. -So `fm-send` to a task selector whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. -Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer. -A captain typing directly into the secondmate's window is unmarked and stays a conversational captain intervention, so do not relay captain-destined chat through this path; the marker is applied only by `fm-send` to a `kind=secondmate` target. -Do not spawn a direct crewmate for work that belongs to a secondmate scope unless the secondmate is blocked or the captain explicitly redirects it. -If no secondmate scope fits, proceed in the main firstmate or create a new secondmate with the captain when that domain should become persistent. -When you create a new secondmate, hand its in-scope queued items off from the main backlog into its home with `bin/fm-backlog-handoff.sh` so it owns its domain's queue from day one (section 6). -Route to a secondmate when the work's subject matches the scope's named domain nouns and the work is that domain's normal lifecycle inside its repos; route to main-firstmate crewmates when the work is fleet tooling, cross-domain, or a repo no scope names. -When two scopes plausibly fit, the more specific scope wins over a broader catch-all; when still tied, ask the captain one line. - -Then classify the shape: - -- **Ship** (the default): the deliverable is a change to the project. It ships through the project's delivery mode: `no-mistakes`, `direct-PR`, or `local-only`. -- **Scout:** the deliverable is knowledge - an investigation, a plan, a bug reproduction, an audit. It ends in a report at `data//report.md`, never a PR. When the captain asks "what's wrong", "how would we", or "find out why" about a project, that is a scout task; dispatch it instead of doing the digging yourself. - -Then classify readiness: - -- **Dispatchable:** no overlap with in-flight tasks. Dispatch immediately. There is no concurrency cap. -- **Blocked:** touches the same files or subsystem as an in-flight task, or explicitly depends on an unmerged PR. Record it in `data/backlog.md` with `blocked-by: ` and tell the captain what work is waiting and why. Scout tasks are read-mostly and almost never block on anything. - -Same repo and (same top-level directory or same named subsystem in the task titles) means serialize with `blocked-by`; same repo but clearly disjoint areas means parallel is fine; different repos always run parallel. -When uncertain after reading both task lines, serialize - a false serialize costs latency, a false parallel costs a rebase round or a conflict. -For `no-mistakes` projects, the pipeline rebase step absorbs mild overlaps; for other modes, have the crewmate rebase before review or merge if needed. - -Write the brief per section 11. - -### Spawn - -Load `harness-adapters` before spawning or recovering any direct report so trust dialogs, verified adapters, and harness-specific behavior are handled correctly. - -```sh -bin/fm-spawn.sh projects/ # uses the active crewmate harness only when no crew-dispatch.json is active -bin/fm-spawn.sh projects/ --harness codex --model gpt-5.5 --effort high # explicit profile axes -bin/fm-spawn.sh projects/ --backend # explicit runtime backend (docs/configuration.md "Runtime backend") -bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta -bin/fm-spawn.sh [] --secondmate # launch or recover a persistent secondmate in its home -bin/fm-spawn.sh =projects/ =projects/ [--scout] # batch: one call, several tasks -``` +Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4. +The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task. +After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as under way. +A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. -Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, and `--backend` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero. -When `config/crew-dispatch.json` exists, include an explicit resolved harness for every crewmate or scout spawn or batch after consulting the dispatch rules (section 4). -`bin/fm-spawn.sh`'s header owns the full resolution contract: harness and runtime-backend resolution order, spawn-capable backends and the `codex-app` rejection, verified launch templates, delivery-mode resolution, recorded meta fields, and per-harness turn-end hook installation. -A backend spawn refusal - a missing dependency, an unauthenticated socket, or a version gate - must be surfaced to the captain as a blocker; never silently retry the spawn on a different backend to work around it. -For ship and scout tasks, the script asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout, aborting the spawn otherwise to prevent the worktree tangle of section 8. -For `kind=secondmate`, it launches in the registered or explicit firstmate home with the charter brief as the launch prompt, after the guarded home sync and inheritable-config propagation owned by `secondmate-provisioning`. -Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch. -After spawning, peek the endpoint to confirm the crewmate is processing the brief and handle any trust dialog with `harness-adapters`. -For a ship or scout task, add the task to `data/backlog.md` under In flight. -A secondmate spawn adds no backlog row: its identity and scope live in `data/secondmates.md`, its runtime lives in `state/.meta`, and section 10 owns the backlog contract. - -### Supervise - -Covered by section 8. -Steer a crewmate only with short single lines via `FM_HOME= bin/fm-send.sh` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home; anything long belongs in a file the crewmate can read. -For a captain dispatch or other must-not-drop steer, add `--expect-ack `: the target acknowledges by writing any status line, and the watcher escalates once through an `ack-missed:` wake if the deadline passes without one (`state/.pending-acks`; section 8). -Steer a secondmate the same way. -Its charter retargets escalation to the main firstmate's status file, so routine internal churn stays inside the secondmate home and only `done`, `blocked`, `needs-decision`, `failed`, a declared `paused:` external wait, or another captain-relevant phase change wake the main firstmate. -Because `fm-send` to a `kind=secondmate` target marks the request as from-firstmate (section 7 intake), the secondmate's answer comes back on that status/doc path too, not in its chat; read the response there as an ordinary status signal and do not peek its chat for it. -A secondmate-reported merged PR is exactly the case the fleet-sync-on-merge wake rule (section 8) exists for, since the secondmate's own teardown never touches this home's separate project clone. - -### Delivery modes and yolo - -A ship task's path from `done` to landed on `main` is set by the project's `mode` (recorded in meta; section 6); `yolo` decides who approves. The Validate / PR ready / Ship teardown stages below are written for the `no-mistakes` path; the other modes diverge: - -- **no-mistakes** - the stages below as written: no-mistakes validation pipeline -> PR -> captain merge. -- **direct-PR** - no pipeline. The crewmate pushes and opens the PR itself (its brief says so) and reports `done: PR `. Skip the Validate step and go straight to PR ready (run `fm-pr-check`, relay the PR). Teardown uses the normal landed-work check. -- **local-only** - no remote, no PR. The crewmate stops at `done: ready in branch fm/`. Review the diff with `bin/fm-review-diff.sh `, relay a one-paragraph summary to the captain, and on approval run `bin/fm-merge-local.sh ` to fast-forward local `main` (it refuses anything but a clean fast-forward - if it does, have the crewmate rebase). No `fm-pr-check`. Then teardown, whose safety check requires the branch already merged into local `main`, OR the work pushed to any remote (a fork counts - relevant for upstream-contribution PRs on a local-only-registered project). - -When reviewing any crewmate branch diff, use `bin/fm-review-diff.sh ` rather than `git diff ...branch` directly. -Pooled clones keep their local default refs frozen at clone time and can lag `origin`; the helper always compares against the authoritative base. -When the task meta records `pr=`, the helper also compares that base against the authoritative PR head (`pr_head=` when reachable, otherwise a fresh `refs/pull//head` fetch) so no-mistakes fix rounds pushed to the PR are included even if the local worktree branch is stale. -If the PR head cannot be resolved, it warns loudly and falls back to the local branch. -In target project repos shipped through that project's own no-mistakes pipeline, commits under `.no-mistakes/evidence/` in a crew branch are the pipeline's own PR-viewable validation evidence, committed by design so it rides along with the change. -Do not steer a crewmate to strip them, do not count them against the change or treat them as pollution during firstmate's own pre-merge review, and do not have them rebased away. -Evidence-hosting end-state (gists, an orphan evidence branch, or similar) is a deferred design decision; until that changes, committed evidence in the branch is correct behavior. -Firstmate's own repo is the exception: its `.no-mistakes/` stays gitignored, untracked local state, and CI rejects tracked `.no-mistakes` paths. -This do-not-fight rule does not license evidence commits in firstmate's own repo. - -**yolo (orthogonal).** With `yolo=off` (default) every approval is the captain's: ask-user findings, PR merges, the local-only merge. -With `yolo=on`, firstmate makes those calls itself without asking - resolve ask-user findings on your judgment, and run `bin/fm-pr-merge.sh ` / `bin/fm-merge-local.sh` once the work is green/approved - EXCEPT anything destructive, irreversible, or security-sensitive, which still escalates to the captain. -"Routine" means reversible by a follow-up PR, inside the task's stated scope, no schema or data migration, no auth or permission surface, no secret handling, no deletion of user data, and CI green; a finding failing any one clause escalates even under yolo. -Never merge a red PR even under yolo. -`bin/fm-pr-merge.sh` always records `pr=` and records `pr_head=` when available before merging, parses the full `https://github.com///pull/` URL into `gh-axi pr merge --repo /`, and defaults to `--squash` unless an explicit merge method is forwarded after `--`; this holds even on a repo with no PR CI where the "checks green" signal that normally triggers `bin/fm-pr-check.sh` never fires - do not call `gh-axi pr merge` directly for a task's PR, or the recording step can be silently skipped and a later `fm-teardown.sh` has nothing to verify a squash merge against. -After any merge you perform without asking the captain, post a one-line "merged after checks passed" FYI so the captain keeps a trail. +Steer a worker with short single-line messages through fail-closed `fm-send`; put long instructions in a file. +For a captain dispatch or other must-not-drop steer, use `fm-send --expect-ack ` so the watcher escalates if the target does not acknowledge with a status event. +A secondmate's routed reply returns through status or a document pointer, not by firstmate peeking into its chat. +Supervise all live work under section 8. + +### Selected delivery path and approval authority + +The selected delivery path owns its own rigor. +When no-mistakes is selected, no-mistakes alone owns review, fixes, tests, documentation, push, PR, and CI; otherwise follow the faster path without adding an independent reviewer. +Never hold work outside no-mistakes for a manual clean verdict, stack serial manual reviews, or infer authority for one from security, architecture, or risk alone. +A separate review or audit is allowed only when the captain explicitly requests that deliverable or the authorized task is a knowledge-only review; one named question remains scoped to that question. +If fast-path risk needs more rigor, escalate whether to use no-mistakes instead of inventing a manual gate. +The path's worker, automated gates, and captain approval remain authoritative: + +- **no-mistakes** runs the full pipeline through a PR, then waits for the configured merge authority. +- **direct-PR** has the worker push and open a PR without the no-mistakes pipeline, then waits for the configured merge authority. +- **local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority before firstmate uses the guarded fast-forward merge path. + +Delivery mode and `yolo` are orthogonal. +With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. +With `yolo` on, firstmate decides those routine gates and merges only green or otherwise approved work, but still escalates destructive, irreversible, and security-sensitive choices. +"Routine" means reversible by a follow-up PR, inside the task's stated scope, no schema or data migration, no auth or permission surface, no secret handling, no deletion of user data, and CI green; a finding failing any clause escalates even under yolo. +Never merge a red PR. +Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. +After an autonomous merge, give the captain a one-line full-URL or local-main outcome. ### Validate -For `no-mistakes`-mode ship tasks, when a crewmate's status says `done`, trigger validation using the crew's harness from `state/.meta`. -Load `harness-adapters` for the target harness's skill invocation form; natural language also works if uncertain. - -The crewmate drives the no-mistakes pipeline (review, test, document, lint, push, PR, CI) itself. -The ship brief intentionally does not restate no-mistakes gate mechanics; it points the crewmate to the version-matched SKILL.md loaded by `/no-mistakes`, `no-mistakes axi run --help`, and per-response `help` lines. -Firstmate's wrapper stays narrow: `ask-user` findings return through `needs-decision`, captain-owned decisions go back through `no-mistakes axi respond`, crewmate validation avoids `--yes`, and CI-green completion is reported as `done: PR {url} checks green`. -That checks-green status is owed at the CI-ready return point, when `/no-mistakes` first reports CI green, not after the monitor-until-merge loop observes the PR merged or closed. -Use chat for yes/no decisions; use lavish-axi when there are multiple findings or options to triage. - -Judge a validating crewmate by the run's step status, never by whether its shell is still running. -Read its current state with `bin/fm-crew-state.sh `: a deterministic, token-tight one-line read that takes the matching no-mistakes run-step as the source of truth and reconciles it against the crewmate's `state/.status` log. -Because the run-step is authoritative before pane liveness, a crewmate whose window closed after or during validation can still report `done` or `working` from its run; a missing pane becomes `unknown` only when no matching run exists. -That log is an append-only wake-_event_ log, not a current-state field, and it goes stale the moment a resolved gate lets the run resume: after you answer a `needs-decision`/`blocked` and the crewmate silently resumes (responds to the gate, the pipeline fixes, it re-validates), the log's last line still reads `needs-decision`/`blocked` while the run-step has moved on. -So never infer current state from a `tail` of that log; `bin/fm-crew-state.sh` reports the live run-step state and explicitly flags the stale log line superseded, where a raw `tail` would mislead you into re-escalating settled work. -The fields below name the run-step states and outcomes it reads from `no-mistakes axi status`; run that command directly when you want the full gate findings. -During the `ci` monitor phase, `bin/fm-crew-state.sh` also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. - -- `running`/`fixing`/`ci` - the pipeline is working (a fix round, a test, or CI monitoring); `ci` stays working until the ci log's most recent recognized marker says checks passed or no checks are terminally ready, and a later re-arm or issue marker returns it to working. -- `awaiting_approval`/`fix_review` - the run is parked waiting on the agent, surfaced as a top-level `awaiting_agent: parked ` line right after `status:` in `axi status`. - The crewmate owes a response; if it is idle-waiting for the run to advance on its own, steer it to follow no-mistakes' active-gate help. -- `outcome: passed` or `checks-passed` - the helper reports `done`; `passed` means the PR is already merged or closed, while `checks-passed` means it is ready for PR review. -- `outcome: failed` or `cancelled` - the helper reports `failed`; inspect the run details and recover or report failure with evidence. -- Red flag - self-fix duplication: a validating crewmate making fresh hand-commits, aborting the run, or re-running it mid-validation is re-doing work the pipeline already owns. - Steer it back to no-mistakes' respond flow; the pipeline, not the crewmate, applies validation fixes. - -### PR ready +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. + +An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. +Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. +Require the matching `resolved` event, forbid `--yes`, and require the worker to process every synchronous return until completion or a genuinely new escalation. +Resume fleet supervision immediately after the decision lands. + +Judge validation by the branch-matched run step through `bin/fm-crew-state.sh`, not by shell liveness or the last status event. +Running, fixing, or CI states remain working; parked approval or fix-review states require the worker to follow the active gate help; passed or checks-passed is done; failed or cancelled is failed. +A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership; steer it back to the gate response flow. +The worker reports the PR when CI first becomes green rather than waiting for merge monitoring to finish. + +### PR ready, landing, and teardown For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. Run `bin/fm-pr-check.sh ` - it records `pr=` and GitHub's `pr_head=` when available in the task's meta and arms the watcher's merge poll. -Tell the captain: the PR's full URL (always the complete `https://...` link, never a bare `#number` - the captain's terminal makes a full URL clickable), a one-paragraph summary, and, for `no-mistakes`, the risk level it emitted. -(The check contract, for any custom `state/.check.sh` you write yourself: print one line only when firstmate should wake, print nothing otherwise, and finish before `FM_CHECK_TIMEOUT`.) +Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, a concise outcome summary, and the no-mistakes risk level when applicable. +A captain instruction to merge is explicit authority; `yolo` is the only standing routine authority. +For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. -If the captain says "merge it", run `bin/fm-pr-merge.sh ` yourself; that instruction is the explicit approval. -If `yolo=on`, merge a green/approved PR yourself the same way and post the required FYI. -The helper defaults to `--squash`, accepts explicit merge-method flags such as `-- --merge`, `-- --rebase`, or `-- --method=merge`, and refuses `--repo` or `-R` overrides because the repository is derived from the URL. +Tear down a ship task only after landing is confirmed. +A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass. +Never force teardown without explicit discard authority. +After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared. -### Ship teardown (only after merge is confirmed) +A secondmate is persistent and an empty queue is healthy. +Retire one only on an explicit captain or main-firstmate decision, after loading `secondmate-provisioning`; its home must contain no work under way, and forced discard still requires explicit captain authority. -```sh -bin/fm-teardown.sh -``` +### Scout outcome and promotion -The script refuses if the worktree holds uncommitted changes or committed work that has not landed; treat a refusal as a stop-and-investigate, not an obstacle. -`bin/fm-teardown.sh`'s header owns the full landed-work definition (remote-reachable, merged-PR-head containment for the squash-merge-then-delete-branch flow, content already in the default branch, local-only merges) and the `pr=` discovery fallback for merges that skipped `bin/fm-pr-check.sh`. -Known benign case: after an external-PR task, a squash merge leaves the branch commits reachable only on the contributor's fork; add the fork as a remote and fetch (`git remote add fork && git fetch fork`), then retry - never reach for `--force`. -A successful PR-based teardown also refreshes that project's clone through `bin/fm-fleet-sync.sh`, best-effort. -Then update the backlog using the teardown reminder: run `bin/fm-tasks-axi.sh done` when the default tasks-axi backend is active and compatible, otherwise move the task to Done in `data/backlog.md` manually with the full `https://...` PR URL or local merge note and date and keep Done to the 10 most recent. -Re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. +A completed scout must leave a self-contained report before its scratch worktree can be discarded. +Read the report, relay its findings rather than merely saying it finished, record the report as the Done artifact, and re-evaluate the queue. +A report may recommend implementation but does not authorize it. +Before treating the investigation or any visual review as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. +When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. +The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path. +Scratch commits and debug edits never ride along, and a reproduced bug becomes the regression test. -### Secondmate teardown (explicit only) +## 8. Supervision protocol -A secondmate is persistent by default. -An empty queue is healthy and does not trigger teardown. -Run `bin/fm-teardown.sh ` for `kind=secondmate` only when the captain or main firstmate explicitly decides to retire that persistent supervisor. -Load `secondmate-provisioning` before retiring it. -The safety check is the secondmate's own home: teardown refuses while its `state/*.meta` contains in-flight work. -With `--force`, teardown is the explicit discard path for child windows, child work, state, route, lease, and home; never use it unless the captain explicitly said to discard the work. +Fleet supervision is an always-loaded operational contract; `docs/architecture.md`, `docs/turnend-guard.md`, the emitted session-start block, and script help own mechanisms and harness-specific recipes. -### Scout tasks (report instead of PR) +Whenever work is under way, keep exactly one live supervision cycle using the emitted protocol for this primary harness. +X mode may require that same live cycle with no fleet work. +Do not substitute another harness's wait shape, use shell `&`, or create a second cycle when a healthy one already exists. +After every actionable wake, resume the emitted protocol as the final action before ending the turn. +No turn ends blind while work is under way, including turns described as holding or waiting. -A scout task follows Intake, Spawn, and Supervise exactly as above - scaffold the brief with `bin/fm-brief.sh --scout`, spawn with `--scout` - then diverges after the work: +At the start of every wake-handling turn, drain the durable wake queue before peeking, reading beyond the reason line, steering, or starting work. +Session start is the only exception because its one-shot digest already drained while locked or deliberately left the queue untouched in lock-refused read-only mode. +A status line is a wake event, not current state; use `bin/fm-crew-state.sh` when current state matters, especially before re-escalating an old decision, blocker, or pause. +A declared `paused:` event means a bounded external wait expected to clear on its own, while `blocked:` means firstmate action is needed. -- There is no Validate or PR-ready stage. When the crewmate's status says `done`, read `data//report.md`. -- Relay the findings to the captain: plain chat for a focused answer, lavish-axi when the report has structure worth a visual (multiple findings, options, a plan). -- Tear down immediately - no merge gate. `bin/fm-teardown.sh` allows a scout worktree's scratch commits and dirty files once the report exists; if the report is missing, it refuses, because the findings are the work product. -- Record it in Done with the report path instead of a PR link using `bin/fm-tasks-axi.sh done` when the default tasks-axi backend is active and compatible, otherwise hand-edit `data/backlog.md` and keep Done to the 10 most recent, then re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. +Handle actionable wakes as follows: -**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then from an active firstmate session send the crewmate its ship instructions with `FM_HOME= bin/fm-send.sh` unless `FM_HOME` is already set to the active firstmate home - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. -The crewmate keeps its worktree, loaded context, and repro, but the ship branch must start from a clean base with only intended changes; scratch commits and debug edits from the scout phase never ride along. -The repro becomes the regression test. -From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. +1. For `signal:`, read the listed event lines first, then reconcile current state only where action depends on it. +2. For `stale:`, inspect the recorded endpoint and load `stuck-crewmate-recovery` for a stopped, looping, confused, or unresponsive worker; a deep-inspection reason also requires current-state and validation-log inspection. +3. For `ack-missed:`, treat the steer as possibly dropped; peek or read current state, then steer or recover the target before assuming it saw the instruction. +4. For `check:`, act on the named poll result, including merges and X-mode events. +5. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. -## 8. Supervision protocol +When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. +When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. -The watcher is the backbone. -Whenever at least one task is in flight, keep exactly one live supervision wait owned by the emitted primary-harness protocol from `bin/fm-session-start.sh`. -The emitted block is the only per-harness operating recipe in the session context. -Do not substitute another harness's command shape for it. -**Always-on wake triage (absorb only when provably working).** -`bin/fm-watch.sh` classifies every wake in bash and absorbs the benign majority without waking you: crews with positive working evidence (an actively-running no-mistakes step for their branch, or a busy pane, read via `bin/fm-crew-state.sh`), a declared `paused:` external wait until its bounded recheck cadence, and no-change heartbeats. -It never absorbs a crewmate that stopped without that evidence - whatever its stale status log claims - and only an actionable wake is queued durably and ends the supervision wait, so you resume the emitted protocol exactly once per actionable event. -A `paused:` status is a deliberate external wait, not `blocked:`; its initial signal still surfaces once, and a forgotten pause re-surfaces for a recheck once per window. -Repeated provably-working stale escalations on one unchanged pane eventually add `demand-deep-inspection` to the wake reason so it is not mistaken for another routine validation wait. -`docs/architecture.md` ("Event-driven supervision") owns the full classification mechanism, its thresholds, and the shared classifier library; while `state/.afk` exists the daemon owns triage and the watcher surfaces every wake to it. -At the start of every wake-handling turn, run `bin/fm-wake-drain.sh` before peeking panes, reading status files beyond the reason line, or starting new work. -Session-start recovery is the exception: `bin/fm-session-start.sh` already drained the queue when locked, or deliberately skipped the drain when read-only because another session owns it. -The printed reason line is still useful, but the drained queue is the lossless backlog. -**Keep exactly one live cycle.** -The live cycle is the supervision: while any task is in flight, the active harness protocol must maintain one wait that can wake this primary when `bin/fm-watch.sh` reports an actionable reason. -After handling drained wakes, resume the emitted harness protocol before ending the turn. -Never use shell `&` as a substitute for a verified harness wake mechanism. -If the active protocol's arm wrapper reports or attaches to an existing healthy watcher, do not start another cycle; attached arms stay live until that cycle ends. -If it reports failure, drain queued wakes first and then repair supervision according to the emitted block. -**No turn ends blind, holds included.** -Never end a turn while any task is in flight without the active harness supervision protocol live: a text-only "holding" or "waiting" reply with crewmates live and no live cycle is a bug, and because such a turn runs no supervision script it is exactly the blind gap the script-only guard (`fm-guard.sh`, below) cannot catch, so this discipline must. -If a forced restart is ever genuinely needed, use `bin/fm-watch-arm.sh --restart`, which signals only this home's recorded watcher and then owns a fresh cycle or reports restart-only `healthy` without attaching if a healthy peer still holds the lock. -Never `pkill -f bin/fm-watch.sh`: that pattern matches every firstmate home's watcher, including secondmate homes that run the same script, so a broad pkill from one home kills sibling homes' watchers. -Away-mode supervision is provided by the `/afk` skill and its daemon; while `state/.afk` exists, the daemon owns the watcher. -Waiting on the watcher is intentionally silent. -After starting the active harness supervision wait, do not send idle progress updates to the captain; wait until it returns `signal`, `stale`, `check`, or `heartbeat`, unless the captain asks for status. -Empty polls, elapsed waiting time, and "still no change" are tool bookkeeping, not conversational progress. - -```sh -bin/fm-supervision-instructions.sh # render the current harness block or one-line repair text -bin/fm-watch-arm.sh # verified arm wrapper used by harness protocols that call it -bin/fm-watch-arm.sh --restart # home-scoped forced restart; never a broad pkill -bin/fm-watch-checkpoint.sh # bounded foreground watcher checkpoint for Codex-style protocols -bin/fm-watch.sh # the watcher itself; exits with: signal|stale|check|heartbeat -bin/fm-wake-drain.sh # drain queued wake records at turn start; asserts guard after draining -bin/fm-crew-state.sh # one-line current-state read; reconciles matching run-step, pane, and status log -bin/fm-fleet-view.sh # read-only Markdown whole-fleet view rendered from the structured snapshot -bin/fm-freeze.sh on|off|status # park/unpark the fleet during an incident; spawn, steer, and the watcher refuse while frozen -bin/fm-usage-tripwire.sh # read-only check for a token/session usage burst; arm as a per-task state/.check.sh -bin/fm-reconcile-stale.sh # dry-run-first report (then --clean --yes) for tracked state with no live endpoint -``` +A secondmate's idle endpoint is healthy, and parent supervision relies on its routed status rather than treating a quiet pane as stale. +Waiting on a healthy supervision cycle is silent; empty polls, elapsed time, and no-change updates are not captain-facing progress. +Never broadly kill watchers, especially never `pkill -f bin/fm-watch.sh`, because that can kill sibling firstmate homes. +A forced repair must use the home-scoped owner path emitted by supervision instructions. -**Fleet freeze is a manual park switch, not an automated guard.** -Run `bin/fm-freeze.sh on [reason...]` to park the fleet locally: while `state/.fleet-freeze` exists, `fm-spawn.sh`, `fm-send.sh`, `fm-watch.sh`, `fm-watch-arm.sh`, and the away-mode daemon's injection path all refuse with the recorded reason, so nothing spawns, gets steered, or wakes anything until the freeze is lifted. -Unfreeze with `bin/fm-freeze.sh off`, or set `FM_FLEET_FREEZE_BYPASS=1` for one deliberate command when a single action genuinely needs to bypass the park. -`bin/fm-freeze.sh status` reports the current state and reason; the session-start digest surfaces an active freeze prominently and its closing reminder tells you to stay in orchestration/diagnosis mode instead of arming the watcher normally. - -`bin/fm-usage-tripwire.sh` is a read-only watcher check that alarms on an abnormal token/session usage burst; arm it per-task by linking or copying it to `state/.check.sh` (or a standing check path) so the watcher's slow-poll cycle picks it up. - -`bin/fm-reconcile-stale.sh` finds tracked task state (`state/*.meta`) with no live backend endpoint and reports it: the recorded backend target and worktree/home path, a landed-work assessment (its carve-outs mirror `fm-teardown.sh` - secondmate-always-blocked and scout-report-gates-clean - but the assessor is deliberately more conservative and omits `fm-teardown.sh`'s PR-head patch-id containment fallback, so use `fm-teardown.sh` directly for a squash-merge case it would recognize as landed but this reports as unlanded or blocked), and any operator-untracked backend agents. -Its default mode is a dry-run report only; cleanup is explicit and per-id with `bin/fm-reconcile-stale.sh --clean --yes`, which re-verifies the endpoint is dead and the work path has no unlanded work before removing only volatile firstmate state for that id - never worktrees, secondmate homes, project clones, branches, or backend endpoints. -Prefer `bin/fm-teardown.sh` for anything still reachable through its normal lifecycle; reach for `fm-reconcile-stale.sh` only once a record is provably dead and orphaned. - -On wake, in order of cheapness: - -1. Read the reason line and drain queued wake records with `bin/fm-wake-drain.sh`. -2. `signal:` read the listed status files first; a wake lists every signal that landed within the coalescing grace window (e.g. a status write plus the same turn's turn-end marker), and each is ~30 tokens and usually sufficient. - A status line is the wake _event_, not the crewmate's current state; when you need the live state - especially to confirm a `needs-decision`/`blocked`/`paused` status is still real and not already resolved-and-resumed - read it with `bin/fm-crew-state.sh `, which reconciles the authoritative run-step over the possibly-stale log line, and never `tail` the status log as the current-state source. -3. `stale:` the crewmate stopped without reporting; peek the pane (`bin/fm-peek.sh `) to diagnose. - If the stale reason includes `demand-deep-inspection`, inspect the pane, `bin/fm-crew-state.sh `, and the validation logs before resuming supervision. - If the pane is waiting, looping, confused, or unresponsive, load `stuck-crewmate-recovery`. -4. `check:` a per-task poll fired (usually a merge, or X mode when enabled); act on it. -5. `ack-missed:` a `fm-send --expect-ack` deadline passed with no status write from the target; the row in `state/.pending-acks` is marked escalated so it does not spam, but treat the target as possibly stuck and steer or peek it. -6. `heartbeat:` a heartbeat wake now reaches you only when the watcher's bash fleet-scan caught a captain-relevant status the per-wake path missed (no-change heartbeats are absorbed in bash, never surfaced), so treat it as "something turned up" and review the whole fleet: start with `bin/fm-fleet-view.sh` for the structured overview, use `bin/fm-crew-state.sh ` only for targeted follow-up, peek panes that look off, check PR-ready tasks for merge, reconcile data/backlog.md, then resume the emitted supervision protocol. - Do not report that the fleet is unchanged. - -An `unknown` read from `bin/fm-crew-state.sh` (no matching run, no pane) is a possibly dead endpoint, not a state to move past on any of the wakes above: check the recorded backend endpoint per the recovery procedure (section 5 step 6), and if dead, reconcile via recovery-by-kind; never absorb it silently. - -When a task reaches a terminal state on any of these wakes (a `done`/merge `check:`, a `failed` signal, a scout report, a local-only merge), and X mode is enabled, load `fmx-respond` (section 13) and post the X-mode mention's **final** completion follow-up if that task is X-mode-linked: `bin/fm-x-followup.sh --check ` then `bin/fm-x-followup.sh --final --text-file `, so the link always clears here regardless of how many of the up-to-three follow-ups were already spent on earlier milestones. -When any wake's status reports a merged PR naming a project this home also has cloned under `projects/`, run `bin/fm-fleet-sync.sh ` for that project as part of handling the wake, so the primary's clone never sits stale until the next session start or teardown. - -Never rely on hooks or status files alone; when a heartbeat wake does reach you, the review of every window is mandatory and unconditional. -Each task's backend live-task inventory is the ground truth: tmux when `backend=` is absent, or the non-default `backend=` a task's meta records (`docs/configuration.md` "Runtime backend" owns the backend set). -For `kind=secondmate`, an idle pane is healthy. -A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness. -`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`. -During heartbeat review, a `kind=secondmate` task whose recorded endpoint is DEAD - not merely quiet - is a recovery case immediately, not something to leave for the next heartbeat; endpoint presence is cheap to check even though pane-content staleness stays skipped for secondmates. -This exception is narrow: ordinary crewmates still trip stale detection when their pane stops changing without a busy signature. - -**Watcher liveness is guarded, not just disciplined.** -Resuming the emitted supervision protocol is the last action of every wake-handling turn - but the protocol no longer relies on remembering that. -The supervision scripts and `bin/fm-wake-drain.sh` call `bin/fm-guard.sh`, which prints a prominent bordered banner when tasks are in flight but queued wakes are pending or the watcher's liveness beacon is missing or stale; `docs/architecture.md` ("Event-driven supervision") owns the beacon and grace mechanics. -The banner is only a supervision warning: the guarded operation still runs, and `fm-send`'s banner says explicitly that the requested message WILL still be sent. -If a guard warning says queued wakes are pending, drain them before doing anything else. -If a guard warning says watcher liveness is stale, drain any queued wakes and then resume the emitted supervision protocol. - -`fm-guard.sh` carries a second, independent alarm in the same bordered style: the **worktree-tangle** guard. -If a crewmate sent to work firstmate-on-itself branches or commits in the primary checkout instead of its own isolated worktree, the primary is stranded on a feature branch (the failure this guards against); the guard names the offending branch and prints the non-destructive restore (`git -C checkout `), so the tangle surfaces on the very next fleet action. -Only a named non-default branch checked out in the primary alarms: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes) and the default branch never do. -The same assertion runs at session start as the bootstrap `TANGLE:` line (handled via `bootstrap-diagnostics`), and two upstream guards prevent the tangle: `fm-spawn`'s isolated-worktree assertion and the ship brief's opening isolation check (section 11). - -On every verified primary harness, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, guards both the main primary and a secondmate's own primary session, and stays silent when supervision is healthy. -`docs/turnend-guard.md` owns the per-harness hook mechanisms, empirical validation, scoping details, and documented fail-open tradeoffs. -Watcher liveness is harness-aware. -Do not assume one primary harness can use another harness's foreground or background shape. -For example, Claude uses a background-notify cycle, while Codex intentionally uses bounded foreground checkpoints. -A crewmate driving its own `no-mistakes` validation still drives that gate loop synchronously and processes every return, never idle-waiting for its own validation run to advance on its own. - -Token discipline: for a crewmate's current state prefer `bin/fm-crew-state.sh `, which looks for a branch-matched run-step before checking pane liveness, then falls back to the pane and log in that cheap-first order and treats the status log's last line as a wake event rather than the current state; default peeks to 40 lines; never stream a pane repeatedly through yourself; batch what you tell the captain. -The context-% shown in a peek is not actionable as crew health; ignore it and intervene only on real signals (`signal`, `stale`, `needs-decision`, `blocked`), looping or confusion in the pane, or a question the brief already answers. +Guard warnings do not replace the contract. +Queued wakes must be drained before other action, stale liveness must be repaired through the emitted protocol, and the worktree-tangle warning must be resolved without touching unlanded work. +The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout. +Harness-aware turn-end guards are structural backstops, not permission to omit the live cycle. ### Away-mode stub Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. -The skill owns the full daemon procedure: classification policy, batching, injection hardening, max-defer, verified submit, marker stripping, portable lock, dedupe, target discovery, reliability properties, and `FM_INJECT_SKIP`. -Inline facts that must survive without a loaded skill: +The skill owns the daemon procedure; these safety facts remain inline: -- Every daemon injection is prefixed with `FM_INJECT_MARK`, ASCII unit separator `0x1f`, so internal escalations are distinguishable from a captain message. -- While `state/.afk` exists, the daemon owns the watcher; do not separately arm `fm-watch-arm.sh` or `fm-watch.sh`. -- If firstmate receives a marked message while afk is active, it is an internal escalation: stay afk and process it. -- If the message starts with `/afk`, stay afk and refresh the flag. -- Any other unmarked message means the captain is back: stop the daemon so its shutdown flush runs while `state/.afk` is still set and clear `state/.afk` last (the `/afk` skill owns this ordering, via `bin/fm-afk-launch.sh stop`; clearing the flag first would make the flush a no-op), flush catch-up from `state/.wake-queue`, `state/.subsuper-escalations`, and `state/.subsuper-inject-wedged`, then resume the emitted primary-harness supervision protocol. -- Afk never changes approval authority; PR merges, ask-user findings, destructive actions, irreversible actions, and security-sensitive choices still require the same approval they required before. -- Bias ambiguous cases toward exit because a present captain beats token savings and a false exit is self-correcting. +- Every daemon injection starts with `FM_INJECT_MARK` plus U+2063 INVISIBLE SEPARATOR, which distinguishes internal escalation from captain input. +- While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. +- A marked message while away mode is active is internal escalation and does not exit away mode. +- A message beginning `/afk` refreshes away mode. +- Any other unmarked message means the captain returned; load `/afk`, run the return owner, and do not process that message as ordinary work until its durable catch-up gate clears. +- Away mode never expands approval authority for merges, ask-user findings, destructive actions, irreversible actions, or security-sensitive choices. +- Bias ambiguous input toward exit because a present captain takes precedence. -### Stuck-crewmate recovery +### Stuck-worker trigger -On `stale`, looping, repeated confusion, an answered-by-brief question, an unresponsive pane, or a failed steer, load `stuck-crewmate-recovery`. -That playbook escalates from peek, to one-line steer, to harness-specific interrupt, to relaunch with a progress note, to `failed` with evidence. +Load `stuck-crewmate-recovery` after a stale wake, looping or confused pane, answered-by-brief question, unresponsive worker, or failed steer. ## 9. Escalation and captain etiquette **Talk in outcomes, not mechanics.** -Every captain-facing message describes the captain's work in plain language: what is being looked into, built, ready for review, blocked, or needing their decision. -Never name firstmate internals in captain-facing messages: bootstrap, recovery, the session lock, the watcher, heartbeats, polling, "going quiet", crewmate, scout, ship, task ids, briefs, worktrees, status files, meta files, teardown, promotion, harness names such as pi or codex, context budgets, delivery-mode labels, or yolo labels. -Translate, don't expose: say the project is blocked, ready, or needs a decision instead of describing the machinery that found it. - -Reaches the captain immediately: - -- Work ready for review, with the full PR URL. -- Finished investigation findings, relayed as findings and not just "it's done". -- Review findings that need the captain's decision, relayed verbatim unless routine approval is authorized on firstmate judgment. -- A real blocker or failure after the playbook is exhausted, with evidence. +Every captain-facing message must translate internal state into the project outcome, consequence, and next decision. +Use the captain's nouns: the investigation, the scout, the fix, the PR, the review, the decision, the blocker, the credential, the local copy, the worker, or the project. +Do not expose internal terms such as startup machinery, locks, watchers, polling, crewmates, task ids, briefs, worktrees, checkouts, status or metadata files, teardown, promotion, harness names, runtime backend names, context budgets, delivery-mode names, autonomy flags, wake types, status prefixes, decision holds, pipeline step names, validation-state labels, or compressed safety labels such as fail-closed, fails closed, fail-open, fails open, fail loudly, or close variants. +Scout is accepted Firstmate nautical house vocabulary and does not need translation when it naturally names that work. +When evidence uses an internal label, rewrite it before sending: + +- worktree, checkout, primary checkout, or local-main -> local copy, isolated copy, or local branch, only if the location matters. +- teardown -> cleanup. +- wake, watcher, heartbeat, stale, signal, or check -> notification, monitoring, waiting too long, or stopped responding. +- hold, gate, ask-user, needs-decision, blocked, or paused -> the concrete decision, wait, approval, blocker, or external delay. +- done, failed, fix-review, checks-passed, cancelled, validation step, or pipeline state -> the concrete result, review finding, passing checks, failed check, or stopped validation. +- brief -> instructions. +- crewmate or secondmate -> worker or domain supervisor, only when naming the helper matters. +- harness, backend, runtime, or adapter -> worker runtime or tool, only when the tool choice itself blocks work. +- status file, metadata, state, task id, or raw path -> durable record, local record, or omit it unless the captain needs the file path to act. +- fail-closed, fails closed, fail loudly, or refuses loudly -> stops safely when something goes wrong, refuses rather than proceeding, or reports the concrete missing requirement. +- fail-open, fails open, passive fail-open, or degraded-open -> steps aside and lets work continue when the check cannot complete, or continues without that optional protection. + +Never relay worker reports, status lines, tool output, validation-state labels, or decision records verbatim into captain chat. +Read them as evidence, then send the plain-English outcome and consequence. +Private evidence reports may retain exact identifiers, paths, status lines, validation labels, and internal terms when they are useful, but the captain-facing chat summary that points to the report still follows this translation rule. + +Every escalation must stand alone and remain concise. +Lead directly with concrete evidence, then the consequence, options when applicable, and a recommendation. +Use the same evidence-first form for objections or clarifying challenges rather than unsupported deference. + +Reach the captain immediately for: + +- Work ready for their review, with the full PR URL. +- Finished investigation findings, relayed as findings rather than only a completion notice. +- Gate findings that require their decision under the configured authority. +- A real blocker or failure after the relevant playbook is exhausted. - Anything destructive, irreversible, or security-sensitive. - A needed credential or login. -Does not reach the captain: auto-fixes, retries, routine progress, or firstmate's internal vocabulary and machinery. -Batch non-urgent updates into your next natural reply. -Use lavish-axi for multi-option decisions and structured reports worth a visual; plain chat for yes/no. -Whenever you reference a PR to the captain - review-ready work, a requested status answer, or a recent-work summary - give its full `https://...` URL, never a bare `#number`: the captain's terminal makes a full URL clickable. -A shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same message. -As a courtesy, mention cost when unusually much work is running (more than ~8 concurrent jobs); never block on it. +Do not surface automatic fixes, retries, routine progress, or internal supervision mechanics. +Batch non-urgent updates into the next natural reply. +Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface. +Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference. +Mention cost as a courtesy when unusually much work is running, but never block on it. -## 10. Backlog format +## 10. Backlog contract `data/backlog.md` is the durable queue. It tracks work items only, never agents; persistent secondmates never appear as backlog items. Work routed to a secondmate is recorded in that secondmate home's own backlog, not the main backlog. When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item; use `bin/fm-tasks-axi.sh hold --reason "" --kind captain` for a captain-gated thread. +Unresolved decisions discovered by investigations or visual reviews follow `decision-hold-lifecycle`, which owns their mandatory backlog lifecycle. Update the backlog on every dispatch, completion, and decision for a work item. +Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. -```markdown -## In flight - -- [ ] - (repo: , since ) +`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax. +Use `bin/fm-tasks-axi.sh` for firstmate's routine backlog mutations when the configured backend selects compatible `tasks-axi`, and use the documented manual path otherwise; keep only the configured recent Done entries. +`secondmate-provisioning` and `bin/fm-backlog-handoff.sh` own cross-home handoff safety. -## Queued +Keep free-form notes free of temporary paths, moving versions, ephemeral identifiers, and copied state that will rot. +Inspect the current task note before replacing its considered body, and archive the superseded body when recoverability matters rather than appending by default. +Verify volatile details against their authoritative config, live system, or API before acting, and correct or delete stale prose immediately. +Preserve durable structured identifiers, dependencies, and completion artifact links, and route reusable knowledge to section 6 rather than scattering it through task notes. -- [ ] - (repo: ) blocked-by: - - -## Done +## 11. Crewmate briefs -- [x] - - (merged ) -- [x] - - local main (merged ) -- [x] - - data//report.md (reported ) -``` +`bin/fm-brief.sh` and its help own scaffold syntax, generated variants, status protocol, delivery-mode definitions of done, and exact safety mechanics. +Use its scaffold as the contract, then replace every `{TASK}` placeholder with a clear task description, acceptance criteria, constraints, and necessary context before dispatch or seeding. +Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape. -Re-evaluate Queued on every teardown and every heartbeat: anything whose blocker is gone and whose time/date gate, if any, has arrived gets dispatched. - -A tracked `.tasks.toml` at this repo root pins the default `tasks-axi` markdown backend to `data/backlog.md`, with `done_keep = 10` and an archive at `data/done-archive.md`. -The local, gitignored `config/backlog-backend` file is the explicit opt-out knob. -Absent or `tasks-axi` means use the default tasks-axi backend; `manual` means force routine backlog updates to hand-editing even when `tasks-axi` is installed. -Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.1.1 or newer, `tasks-axi update --help` exposes `--archive-body`, and `tasks-axi mv --help` exposes `[...]` for atomic multi-ID moves. -When the default backend is selected and compatible `tasks-axi` is on PATH, firstmate mutates the backlog through `bin/fm-tasks-axi.sh` instead of hand-editing, with secondmate handoffs still going through the validated helper described in section 6. -When the default backend is selected but `tasks-axi` is missing or incompatible, bootstrap reports it through the normal `MISSING:` consent flow in `docs/configuration.md` "Toolchain", and every firstmate home falls back to hand-editing routine `data/backlog.md` updates exactly as this section describes until it is installed. -When `config/backlog-backend=manual`, every firstmate home hand-edits routine backlog updates; bootstrap still requires compatible `tasks-axi` on `PATH` but does not print `TASKS_AXI: available`. -The `## In flight` / `## Queued` / `## Done` format above stays the contract: the verbs edit `data/backlog.md` in place, byte-exact, preserving whatever item forms the file already uses - the bold in-flight `- ****` form, the `- [ ]`/`- [x]` queued and done forms, and `blocked-by: - ` - rather than reformatting them. -Secondmates inherit `config/backlog-backend` from the primary. -If the primary leaves the file absent, each home uses the default tasks-axi backend path with its own `.tasks.toml`; if the primary opts out with `manual`, secondmate homes hand-edit routine backlog updates too. -Keep Done to the 10 most recent entries. -With the active compatible tasks-axi backend, `bin/fm-tasks-axi.sh done` auto-prunes Done and archives pruned entries to `data/done-archive.md`, so do not hand-prune. -When hand-editing, prune older Done entries manually whenever you add to the section. -Pruning loses nothing: finished PR-based ship tasks live on as GitHub PRs, local-only ship tasks live on in local `main`, and scout tasks live on as report files. -Map firstmate's real backlog operations to the approved commands: - -- File an item: `bin/fm-tasks-axi.sh add "" --kind --repo `, plus `--start` for immediate dispatch (In flight) or the default queue placement, and `--blocked-by ` (repeatable) when it waits on another task. -- Start an existing queued item: `bin/fm-tasks-axi.sh start ` before dispatching work from Queued, after checking that blockers are gone and any time/date gate has arrived. -- Move a finished task to Done: `bin/fm-tasks-axi.sh done --pr ` for a PR-based ship, `--report ` for a scout, or `--note "local main"` for a local-only merge. -- Update task notes: inspect first with `bin/fm-tasks-axi.sh show --full`, then replace the considered body with `bin/fm-tasks-axi.sh update --body-file `. - Add `--archive-body` to that update command when superseding prior state should remain recoverable. -- Manage dependencies: `bin/fm-tasks-axi.sh block --by ` and `bin/fm-tasks-axi.sh unblock --by `, then `bin/fm-tasks-axi.sh ready` to list queued work with no unresolved blockers. - This is a dependency check only; future-dated items still stay queued until their date arrives. -- Read an item's full notes: `bin/fm-tasks-axi.sh show --full`. -- Hand a task off to a secondmate home: load `secondmate-provisioning`, then keep using `bin/fm-backlog-handoff.sh ...`; do not call bare `tasks-axi mv` for this path, because the helper resolves and validates the secondmate home before moving anything. -- Normalize the file: `bin/fm-tasks-axi.sh render` rewrites every id'd task in canonical form and leaves free-form lines untouched. - -**Note hygiene:** Keep free-form backlog and task note/status prose free of volatile incidental specifics that rot: temp paths, in-flight versions, moving state locations, and ephemeral IDs. -Reference the authoritative source instead of duplicating it into prose - "state per the module's backend config", not a literal path. -Before acting on a note's volatile detail, verify it against the source of truth (the config, the live system, the API); notes drift. -The backlog format's structured fields are different: task IDs, blocked-by IDs, and Done-entry PR URLs or report paths from `bin/fm-tasks-axi.sh done --pr ` or `--report ` are the durable record required by this schema. -Correct or delete stale free-form notes the moment you catch them, and put durable facts in curated memory (section 6's knowledge-routing homes), not scattered across one-off task notes. +Every ship brief must retain the worktree-isolation assertion and stop if launched in the primary checkout. +If a ship task touches firstmate's shared tracked material, explicitly require `firstmate-coding-guidelines` before editing. +If a task will drive Herdr lifecycle behavior, scaffold with `--herdr-lab`; if that need appears after an unguarded scaffold, stop and regenerate rather than adding commands by hand. +The generated Herdr contract must use a named non-`default` isolated lab and its guarded helper for every lifecycle action. -## 11. Crewmate briefs - -Scaffold with `bin/fm-brief.sh ` - it writes `data//brief.md` with the standard contract (branch setup, status-reporting protocol, push/merge rules, definition of done) and all paths filled in. -The ship-brief Setup opens with a worktree-isolation assertion ahead of the branch step: the crewmate confirms it is in its own disposable task worktree, not the primary checkout, and stops with `blocked: launched in primary checkout, not an isolated worktree` if not - the upstream half of the worktree-tangle guard (section 8). -For a ship task the definition of done is shaped by the project's delivery mode (section 6): `no-mistakes` stops after the implementation commit, then firstmate triggers the harness-appropriate no-mistakes validation pipeline; `direct-PR` has the crewmate push and open the PR itself, and `local-only` has it stop at "ready in branch" for firstmate to review and merge locally. -The no-mistakes brief points to no-mistakes' version-matched guidance and keeps only firstmate-specific wrapper rules for `ask-user` escalation, `--yes` avoidance, and the CI-green done line. -The scaffold reads the mode via `fm-project-mode.sh`, so you do not pass it. -Ship briefs also include the project-memory contract: run `bin/fm-ensure-agents-md.sh` when the project already has agent-memory files or when the task produced durable project-intrinsic knowledge, then record proportionate learnings in `AGENTS.md`. -For scout tasks add `--scout`: the scaffold swaps the definition of done for the report contract (findings to `data//report.md`, no branch, no push, no PR) and declares the worktree scratch; scout is mode-agnostic. -Scout briefs do not include the project-memory step, because their deliverable is a report rather than a committed project change. -For a crewmate task that will drive Herdr lifecycle behavior, add `--herdr-lab`: the scaffold embeds the hard Herdr-isolation contract backed by `bin/fm-herdr-lab.sh` (a never-`default` lab session, a trailing `--session` on every Herdr call, guarded teardown, and a before/after fleet-state tripwire), and the flag is rejected for `--secondmate` briefs. -The flag must be explicit because the scaffold cannot read the `{TASK}` text it fills in later, so every ship or scout brief scaffolded without it carries a loud not-enabled gate telling the crewmate to stop and regenerate with `--herdr-lab` if the task turns out to touch Herdr lifecycle. -For secondmates use `bin/fm-brief.sh --secondmate {...|--no-projects}`. -The scaffold writes a charter brief instead of a task brief. -Set `FM_SECONDMATE_CHARTER=''` to fill the charter text and `FM_SECONDMATE_SCOPE=''` when the routing scope differs. -If you scaffold without `FM_SECONDMATE_CHARTER`, replace the `{TASK}` placeholder before seeding. -Keep the charter focused on persistent responsibility, available project clones, escalation back to the main firstmate status file, and the idle-by-default contract: reconcile only its own in-flight work and then wait, never self-initiating a survey or audit. -Preserve the requests-from-main-firstmate contract in the charter: marked requests return via status or a doc pointer, while unmarked direct captain messages stay conversational. -Before seeding, launching, recovering, or handing backlog to a secondmate home, load `secondmate-provisioning`. -The status-reporting protocol is intentionally sparse: crewmates append status only for supervisor-actionable phase changes, `needs-decision`/`blocked`/`paused`/`done`/`failed`, or the `resolved` line that closes a previously reported decision or blocker, because every append wakes firstmate. -`bin/fm-classify-lib.sh` owns the keyed open/resolved status contract. -For any generated brief that still contains `{TASK}`, replace it with a clear task description, acceptance criteria, and any constraints or context the crewmate needs before spawning or seeding. -Adjust the other sections only when the task genuinely deviates from the standard ship-a-new-PR shape (e.g. fixing an existing external PR); the scaffold is the contract, not a suggestion. +Load `secondmate-provisioning` before creating or using a charter brief and preserve its idle-by-default and marked-return-channel contracts. +Status appends are sparse supervisor-actionable events, not routine progress; `bin/fm-classify-lib.sh` owns keyed open and resolved semantics. +The scaffold is a safety contract, not a suggestion. ## 12. Self-update -firstmate is its own repo behind the no-mistakes gate, so improvements to `AGENTS.md`, `bin/`, `.agents/skills/`, and public `skills/` reach `main` and then wait for each running firstmate to pull them. -Only `AGENTS.md`, `bin/`, and `.agents/skills/` are a running firstmate instruction surface; public `skills/` is tracked for installers and is not loaded by firstmate. +Firstmate's shared instruction surface reaches running homes only after it lands on the default branch and those homes fast-forward. +Only `AGENTS.md`, `bin/`, and `.agents/skills/` are loaded by a running firstmate; public `skills/` is an installer-facing surface. When the captain invokes `/updatefirstmate` or asks to update firstmate, load the `/updatefirstmate` skill. -It performs only fast-forward self-updates of firstmate and registered secondmate homes, re-reads `AGENTS.md` when needed, nudges updated live secondmates, and never touches anything under `projects/`. +It performs guarded fast-forward updates of firstmate and registered secondmate homes, refreshes instructions, and never touches anything under `projects/`. ## 13. Agent-only reference skills -These skills are not captain-invocable; they are conditional operating references you must load at the trigger points below. +These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. -- `stuck-crewmate-recovery` - load after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. -- `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited config into, or retiring a secondmate home, and before editing `data/secondmates.md`. +- `project-management` - load before adding, creating, removing, or initializing a project. +- `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. +- `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. +- `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. ## 14. X mode -X mode lets a firstmate instance answer public mentions routed through the shared `@myfirstmate` relay, and act on actionable mention requests, in firstmate's own voice, from its live fleet state. -It ships inside this repo for every user but is **inert until opted in**, so a user who never enables it sees zero behavior change. - -**Activation is `.env` presence, not a command.** -Put one value, `FMX_PAIRING_TOKEN`, into a `.env` file at this home's root (`.env` is gitignored). -That token is the whole consent, including standing authorization for normal reversible lifecycle actions from mention requests, and the only required config; the relay derives the tenant from it. -It is not consent for destructive, irreversible, or security-sensitive actions; those still require trusted-channel confirmation first. -`FMX_RELAY_URL` is optional and defaults to `https://myfirstmate.io`; only a developer pointing at a local relay sets it. - -**Mechanism and cadence.** -Bootstrap wires the relay poll automatically and purely additively from `.env` presence; `docs/configuration.md` "X mode (.env)" owns the generated-artifact mechanism, the wire protocol, the poll cadence and its transition handling, and the watcher-backbone non-interference guarantee. -X mode is a reason to keep the watcher armed even with no fleet work, so an X-only user is still served. +X mode ships inert and causes no behavior change until the home opts in by placing `FMX_PAIRING_TOKEN` in its gitignored `.env`. +That token is consent for public replies and normal reversible lifecycle actions from eligible mentions, not authority for destructive, irreversible, or security-sensitive action; those still require trusted-channel confirmation. +`docs/configuration.md` owns activation, generated state, cadence, wire protocol, and opt-out mechanics. -**Answering.** -On an `x-mention ` or `x-mode-error ...` `check:` wake, load `fmx-respond` (section 13). -It owns mention classification, acting on the request, reply composition, voice, thread-splitting, image attachments, dry-run preview, and the completion-follow-up procedure in full, including what an `x-mode-error` wake means instead. -`docs/configuration.md` "X mode (.env)" has the wire-protocol reference. -The one fact that must survive here because it fires on a generic terminal wake, not the mention wake itself: when an X-mode-linked task reaches a terminal state, post its final completion follow-up per section 8's wake-handling step before tearing down. +An X-only home still requires the live supervision cycle so mentions can wake it without fleet work. +On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respond`, which owns classification, public-safety policy, reply or dismissal, task linking, and follow-ups. +For every X-linked terminal outcome, load that owner and post the final completion follow-up before teardown, regardless of earlier milestone follow-ups. ## Maintaining this file Keep this file for knowledge useful to almost every future agent session in this project. -Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Do not repeat what the codebase already shows; point to the authoritative file, skill, command, or doc. Prefer rewriting or pruning existing entries over appending new ones. -When updating this file, preserve this bar for all agents and keep entries concise. +When updating this file, preserve every safety boundary and keep the always-loaded contract concise. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a590180db..4e7bd8154 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ A crewmate picking up such a brief should load the skill even if the brief preda When supervising live crewmates, keep firstmate's own long validation or build commands in the background so watcher wakes can still be handled. Crewmate validation follows the installed no-mistakes version's SKILL.md and live `axi` help instead of duplicating gate mechanics in firstmate docs. Firstmate's wrapper still matters: `ask-user` findings route to the captain through firstmate, and crewmates avoid `--yes` because it silently resolves captain-owned decisions without escalation. -Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint and test commands to the same `bin/fm-lint.sh` and bash behavior suite as CI, so the pre-push gate runs the exact checks CI enforces. +Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint and portable behavior commands to the Linux CI jobs, while `.github/workflows/ci.yml` owns additional platform-specific compatibility lanes. That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even when another no-mistakes-managed target project keeps committed PR evidence. Check and test the toolbelt before pushing: diff --git a/README.md b/README.md index dfd632757..feb71e590 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent domain supervisors that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. -- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is in flight and supervision is not live. +- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; `fm-send.sh --expect-ack` covers must-not-drop steers by escalating once if the target never acknowledges; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Guarded by construction** - the first mate is read-only over your projects outside guarded clone refreshes, safe branch pruning, and approved `local-only` fast-forward merges; crewmates make every project change behind your merge approval. +- **Guarded by construction** - the first mate is read-only over your projects outside guarded clone refreshes, safe branch pruning, and approved `local-only` fast-forward merges; crewmates make every project change behind the configured merge authority. - **Incident guardrails** - `fm-freeze.sh on` parks the whole fleet (spawn, steer, and the watcher all refuse) until you lift it; `fm-fleet-map.sh` gives a read-only diagnosis of tracked state vs. the visible Herdr surface and `fm-reconcile-stale.sh` cleans up tracked state for tasks that are provably dead and already landed without ever touching anything still live; and `fm-usage-tripwire.sh` is a watcher check that alarms on abnormal token/session usage bursts. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. @@ -147,7 +147,7 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze │ ├─ ship: project mode ► PR/local merge ► teardown │ - └─ scout: report at data//report.md ► relay findings ► teardown + └─ scout: report at data//report.md ► decision inventory ► relay findings ► teardown ``` You chat with the first mate. @@ -162,12 +162,12 @@ Full architecture - the supervision engine, worktree isolation, secondmates, dis Firstmate ships these user-invocable built-in skills. Claude and grok use the slash form shown here; codex uses the same names with `$`, such as `$afk`. -| Skill | What it does | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery wedges while you step away | -| `/bearings` | Generate a "pick up where I left off" status report from the read-only fleet snapshot - backlog, per-task crew state, open PRs, scout reports, pending decisions, and date-gated queued work - written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | -| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | -| `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | +| Skill | What it does | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | +| `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | +| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | +| `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). @@ -185,7 +185,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, and project modes work. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. -- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for a wedged away-mode escalation delivery. +- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. - [docs/tmux-backend.md](docs/tmux-backend.md) - setup guide for the tmux reference backend: prerequisites, attaching, and watching crew windows. - [docs/herdr-backend.md](docs/herdr-backend.md) - setup guide for the experimental herdr backend, plus its verification notes and known gaps. - [docs/zellij-backend.md](docs/zellij-backend.md) - setup guide for the experimental zellij backend, plus its verification notes and known gaps. @@ -193,11 +193,12 @@ Firstmate's skills live in two separate places with different audiences: - [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified per-harness hook mechanisms, scoping, loop safety, and fail-open tradeoffs. +- [docs/decision-hold-lifecycle.md](docs/decision-hold-lifecycle.md) - the durable captain-decision-hold mechanism: structured surfaces and regression evidence behind the `decision-hold-lifecycle` skill's policy. - [docs/arm-pretool-check.md](docs/arm-pretool-check.md) - the PreToolUse seatbelt blocking commands that would bypass the watcher-arm contract. - [docs/cd-guard.md](docs/cd-guard.md) - the PreToolUse seatbelt blocking a persistent `cd` that would relocate the primary shell. - [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. -- [`AGENTS.md`](AGENTS.md) - the distro's core instruction file and the first mate's full operating manual. +- [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. ## Contributing diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index db1cff0d2..a0989027b 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -668,9 +668,9 @@ fm_backend_herdr_strip_ansi() { # # fm_backend_herdr_composer_state: classify the composer's own row as # empty|pending|unknown, scanning a generous tail-window capture of . # herdr's CLI exposes no cursor-row primitive (unlike tmux's #{cursor_y}), so -# this locates the composer row structurally, recognizing TWO row shapes and -# keeping whichever match comes LAST (scanning forward), so a shape earlier in -# scrollback/a popup can never outrank the real (bottom-anchored) composer row: +# this locates the composer structurally, recognizing THREE shapes and keeping +# whichever match comes LAST (scanning forward), so a shape earlier in +# scrollback/a popup can never outrank the real (bottom-anchored) composer: # # bordered - a boxed composer (verified grok 0.2.82): the row's TRIMMED # content both STARTS and ENDS with the same border glyph (│, ┃, @@ -697,6 +697,15 @@ fm_backend_herdr_strip_ansi() { # # deliberately narrower than the bordered content classifier so a # no-agent shell fallback prompt (`>`, `$`, `%`, or `#`) falls # through to `unknown` instead of being misread as delivered. +# separated - Pi's composer is one or more content rows between two solid +# horizontal `─` separator rows, with no prompt glyph or side +# borders. This shape is accepted ONLY when Herdr's native +# `agent get` identifies the target as Pi and reports it idle, +# done, or blocked. A missing/stale/non-Pi agent identity, a +# working Pi, an over-tall candidate, or an incomplete separator +# pair remains unknown. This identity + structure conjunction is +# what makes a blank Pi row safe without weakening dead-shell or +# ambiguous-pane refusal. # # empty - blank, a bare prompt glyph, known ghost/placeholder text # ("Type a message...", verified grok 0.2.82's empty-composer @@ -731,9 +740,78 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Pi allows a multi-line composer between its horizontal separators. Bound the +# structural candidate so two unrelated transcript rules with an arbitrarily +# large region between them can never be promoted into a composer. +FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=${FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES:-8} + +fm_backend_herdr_pi_separator_row() { # + local row=$1 + row="${row#"${row%%[![:space:]]*}"}" + row="${row%"${row##*[![:space:]]}"}" + [ "${#row}" -ge 8 ] || return 1 + [ -z "${row//─/}" ] +} + +# Locate the content and closing-row position of the bottom-most complete pair +# of Pi separator rows. A separator closes the preceding candidate and +# immediately opens the next, so an earlier transcript rule can never outrank +# the live bottom composer pair. Globals let the caller compare this shape's +# screen position with generic bordered/bare candidates without losing empty +# composer content through command substitution. +fm_backend_herdr_pi_composer_find() { # + local cap=$1 line plain open=0 lines=0 candidate="" max row=0 open_row=0 + max=$FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES + case "$max" in ''|*[!0-9]*|0) max=8 ;; esac + FM_BACKEND_HERDR_PI_PAIR_FOUND=0 + FM_BACKEND_HERDR_PI_PAIR_VALID=0 + FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=0 + FM_BACKEND_HERDR_PI_PAIR_LINE=0 + FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=0 + FM_BACKEND_HERDR_PI_CONTENT="" + while IFS= read -r line; do + row=$((row + 1)) + plain=$(fm_backend_herdr_strip_ansi "$line") + if fm_backend_herdr_pi_separator_row "$plain"; then + FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=$row + if [ "$open" -eq 1 ]; then + FM_BACKEND_HERDR_PI_PAIR_FOUND=1 + FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=$open_row + FM_BACKEND_HERDR_PI_PAIR_LINE=$row + if [ "$lines" -le "$max" ]; then + FM_BACKEND_HERDR_PI_PAIR_VALID=1 + FM_BACKEND_HERDR_PI_CONTENT=$candidate + else + FM_BACKEND_HERDR_PI_PAIR_VALID=0 + FM_BACKEND_HERDR_PI_CONTENT="" + fi + fi + open=1 + open_row=$row + lines=0 + candidate="" + elif [ "$open" -eq 1 ]; then + [ -z "$candidate" ] || candidate="${candidate}"$'\n' + candidate="${candidate}${line}" + lines=$((lines + 1)) + fi + done < -> \t + local out + out=$(fm_backend_herdr_cli "$1" agent get "$2" 2>/dev/null) || return 1 + printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null +} fm_backend_herdr_composer_state() { # -> empty|pending|unknown - local target=$1 cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + local target=$1 session pane cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + local identity agent agent_status row=0 generic_line=0 + fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + session=$FM_BACKEND_HERDR_SESSION + pane=$FM_BACKEND_HERDR_PANE cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES" 2>/dev/null \ || fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; } # Structural scan: locate the bottom-most composer row and remember its RAW @@ -741,6 +819,7 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown # keeps ghost text so the border/prompt glyph is still visible); the raw row is # kept for ANSI-aware content extraction after the scan. while IFS= read -r line; do + row=$((row + 1)) trimmed=$(fm_backend_herdr_strip_ansi "$line") trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" @@ -749,17 +828,54 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown '│'*'│'|'┃'*'┃'|'|'*'|') shape=bordered raw_match=$line + generic_line=$row found=1 ;; *) if printf '%s' "$trimmed" | grep -qE "$FM_BACKEND_HERDR_BARE_PROMPT_RE"; then shape=bare raw_match=$line + generic_line=$row found=1 fi ;; esac done < <(printf '%s\n' "$cap") + # Pi has no prompt glyph or side border. Compare its bottom-most complete + # separator pair with the last generic match so an earlier bordered transcript + # row can never suppress the live Pi composer. Identity is consulted only when + # a lower separator pair could change the verdict. + fm_backend_herdr_pi_composer_find "$cap" + if [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -eq 1 ] \ + && [ "$FM_BACKEND_HERDR_PI_PAIR_LINE" -gt "$generic_line" ] \ + && [ "$generic_line" -lt "$FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE" ]; then + identity=$(fm_backend_herdr_agent_identity_raw "$session" "$pane" 2>/dev/null || true) + IFS=$'\t' read -r agent agent_status < -> empty|pending|unknown stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" + elif [ "$shape" = separated ]; then + # The native Pi identity plus the complete separator pair is the genuine + # composer container, equivalent to a bordered box for shared content + # classification. ANSI stripping keeps real text and drops only styling. + bordered=1 fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 61c6a28cb..a6ddcbcc6 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -437,6 +437,10 @@ fm_afk_launch_create_tmux() { # fm_afk_launch_start() { local captain_target captain_backend backup artifact had_afk=0 result + if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then + fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode" + return 1 + fi # Capture the captain pane FIRST, before creating anything. captain_target=$(discover_supervisor_target) || { fm_afk_launch_log "could not resolve the captain supervisor pane (set FM_SUPERVISOR_TARGET)"; return 1; } @@ -503,6 +507,10 @@ fm_afk_launch_start() { fm_afk_launch_start_native() { local backup artifact had_afk=0 result=0 mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 + if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then + fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode" + return 1 + fi if daemon_lock_held_by_live_daemon; then fm_afk_launch_record_validate_if_present || return 1 fm_afk_launch_flag_write || return 1 diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh new file mode 100755 index 000000000..23537e3b6 --- /dev/null +++ b/bin/fm-afk-return.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# fm-afk-return.sh - deterministic away-mode return catch-up gate. +# +# Usage: +# fm-afk-return.sh Stop away mode, drain catch-up, and open/check gate. +# fm-afk-return.sh begin Same as the default command. +# fm-afk-return.sh check Re-drain and close the gate only after blockers resolve. +# fm-afk-return.sh guard Read-only refusal while away or catch-up is pending. +# +# `blocked:` is the crewmate protocol's firstmate-actionable verb. A live task's +# open blocked event must be remediated and closed with `resolved [key=...]`, or +# explicitly reclassified in the status stream with a durable reason, before an +# ordinary captain request may proceed. `needs-decision:` is captain-owned and +# is deliberately not part of this gate; normal reporting surfaces it. +# +# The durable state/.afk-return-catchup file is written BEFORE daemon shutdown, +# so a crash between stopping, draining, and blocker handling fails closed. It +# retains the drained wake, buffered-escalation, and wedge-marker evidence until +# every live open blocker is closed and `check` succeeds. Repeated begin/check +# calls are idempotent. `guard` never mutates state and is suitable for ordinary +# read entrypoints such as fm-bearings-snapshot.sh. +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}" +GATE="$STATE/.afk-return-catchup" +LOCK="$STATE/.afk-return-catchup.lock" + +usage() { + sed -n '2,7p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +clean_field() { + LC_ALL=C tr '\t\r\n' ' ' +} + +append_evidence() { # + local kind=$1 text=$2 file=$3 clean record + [ -n "$text" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + clean=$(printf '%s' "$line" | clean_field) + record=$(printf 'evidence\t%s\t%s' "$kind" "$clean") + grep -Fqx "$record" "$file" 2>/dev/null || printf '%s\n' "$record" >> "$file" + done < + local destination=$1 + [ -f "$GATE" ] || return 0 + grep '^evidence'"$(printf '\t')" "$GATE" >> "$destination" 2>/dev/null || true +} + +scan_open_blockers() { # -> tab-separated blocker rows + local meta id status key verb summary clean_summary + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + id=$(basename "$meta") + id=${id%.meta} + status="$STATE/$id.status" + [ -f "$status" ] || continue + while IFS="$(printf '\t')" read -r key verb summary; do + [ "$verb" = blocked ] || continue + clean_summary=$(printf '%s' "$summary" | clean_field) + printf 'blocker\t%s\t%s\t%s\n' "$id" "$key" "$clean_summary" + done </dev/null || true) + [ -n "$started" ] || started=$(date +%s) + pending=$(mktemp "$STATE/.afk-return-catchup.pending.XXXXXX") || return 1 + { + printf 'schema\tfm-afk-return.v1\n' + printf 'started\t%s\n' "$started" + printf 'phase\tstopping-and-draining\n' + preserve_evidence /dev/stdout + } > "$pending" || { rm -f "$pending"; return 1; } + mv "$pending" "$GATE" +} + +write_gate() { # + local evidence=$1 blockers=$2 pending started + pending=$(mktemp "$STATE/.afk-return-catchup.pending.XXXXXX") || return 1 + started=$(awk -F '\t' '$1 == "started" { print $2; exit }' "$GATE" 2>/dev/null || true) + [ -n "$started" ] || started=$(date +%s) + { + printf 'schema\tfm-afk-return.v1\n' + printf 'started\t%s\n' "$started" + printf 'phase\tblocked\n' + cat "$evidence" 2>/dev/null || true + cat "$blockers" 2>/dev/null || true + } > "$pending" || { rm -f "$pending"; return 1; } + mv "$pending" "$GATE" +} + +print_evidence() { # + local file=$1 kind text + while IFS="$(printf '\t')" read -r tag kind text; do + [ "$tag" = evidence ] || continue + printf 'catch-up %s: %s\n' "$kind" "$text" + done < "$file" +} + +print_blockers() { # + local file=$1 tag id key summary + while IFS="$(printf '\t')" read -r tag id key summary; do + [ "$tag" = blocker ] || continue + printf 'firstmate-actionable blocker: %s [key=%s] %s\n' "$id" "$key" "$summary" + done < "$file" +} + +clear_delivery_artifacts() { + rm -f \ + "$STATE/.subsuper-escalations" \ + "$STATE/.subsuper-escalations.since" \ + "$STATE/.subsuper-inject-wedged" +} + +return_guard() { + if [ -e "$STATE/.afk" ]; then + printf 'fm-afk-return: away mode is still active; run bin/fm-afk-return.sh before ordinary captain work\n' >&2 + return 3 + fi + if [ -e "$GATE" ]; then + printf 'fm-afk-return: return catch-up is pending; remediate or durably reclassify every listed blocker, then run bin/fm-afk-return.sh check\n' >&2 + print_blockers "$GATE" >&2 + return 3 + fi + return 0 +} + +return_reconcile() { + local evidence blockers drained wedge escalations lifecycle_ok=1 + evidence=$(mktemp "$STATE/.afk-return-evidence.XXXXXX") || return 1 + blockers=$(mktemp "$STATE/.afk-return-blockers.XXXXXX") || { rm -f "$evidence"; return 1; } + preserve_evidence "$evidence" + + if [ -e "$STATE/.afk" ] || [ -e "$STATE/.afk-daemon-terminal" ]; then + if ! "$SCRIPT_DIR/fm-afk-launch.sh" stop; then + lifecycle_ok=0 + append_evidence lifecycle 'away-mode shutdown failed; lifecycle state preserved for retry' "$evidence" + fi + fi + + drained=$("$SCRIPT_DIR/fm-wake-drain.sh") || { + append_evidence lifecycle 'durable wake drain failed; retry catch-up before ordinary work' "$evidence" + lifecycle_ok=0 + drained="" + } + append_evidence wake "$drained" "$evidence" + + if [ -s "$STATE/.subsuper-inject-wedged" ]; then + wedge=$(head -1 "$STATE/.subsuper-inject-wedged" 2>/dev/null || true) + append_evidence wedge "$wedge" "$evidence" + fi + if [ -s "$STATE/.subsuper-escalations" ]; then + escalations=$(cat "$STATE/.subsuper-escalations" 2>/dev/null || true) + append_evidence escalation "$escalations" "$evidence" + fi + + scan_open_blockers > "$blockers" + if [ "$lifecycle_ok" -ne 1 ] || [ -s "$blockers" ]; then + write_gate "$evidence" "$blockers" || { rm -f "$evidence" "$blockers"; return 1; } + printf 'fm-afk-return: catch-up must finish before the captain request\n' >&2 + print_evidence "$GATE" >&2 + print_blockers "$GATE" >&2 + printf 'fm-afk-return: handle each blocker now, or close it with resolved [key=...] and append a durable reclassification reason, then run bin/fm-afk-return.sh check\n' >&2 + rm -f "$evidence" "$blockers" + return 3 + fi + + print_evidence "$evidence" + rm -f "$GATE" + clear_delivery_artifacts + rm -f "$evidence" "$blockers" + printf 'fm-afk-return: catch-up clear; ordinary captain work may proceed\n' + return 0 +} + +main() { + local mode=${1:-begin} rc + case "$mode" in + begin|check) ;; + guard) return_guard; return ;; + -h|--help|help) usage; return 0 ;; + *) usage >&2; return 2 ;; + esac + + # The mutating begin/check paths need locks and the keyed status fold. + # `guard` returned above without sourcing fm-wake-lib.sh, whose initialization + # creates the state directory, so the advertised read-only guard is literal. + # shellcheck source=bin/fm-wake-lib.sh + . "$SCRIPT_DIR/fm-wake-lib.sh" + # shellcheck source=bin/fm-classify-lib.sh + . "$SCRIPT_DIR/fm-classify-lib.sh" + + mkdir -p "$STATE" || return 1 + fm_lock_acquire_wait "$LOCK" + trap 'fm_lock_release "$LOCK"' EXIT + write_pending_seed || { fm_lock_release "$LOCK"; trap - EXIT; return 1; } + return_reconcile + rc=$? + fm_lock_release "$LOCK" + trap - EXIT + return "$rc" +} + +main "$@" diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 433d14d64..6a6a449a0 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -19,14 +19,18 @@ # explicitly (the prs: line and the omitted[] surfaces) what was not requested, so an # absence is never ambiguous. # -# This wrapper consumes the canonical snapshot's hints.open_decisions field. -# fm-classify-lib.sh owns the durable keyed-decision contract. +# This wrapper consumes canonical status decisions plus structured captain-held +# backlog items. It never infers decisions from report or visual-review prose. # # The landed section merges this home's Done with the canonical snapshot's # secondmate_landed roll-up (fm-fleet-snapshot.sh), so merges a secondmate managed - # recorded in ITS OWN backlog, never the main one - are visible. It stays bounded by # a per-home cap and an overall cap, with omitted[] disclosure of both and of any # secondmate home whose backlog was unreadable; no GitHub/network call is involved. +# The default landed baseline is balanced across homes: each home keeps its internal +# newest-first ordering, homes iterate in deterministic id order, sparse homes do not +# waste capacity, and --all-landed switches back to the complete global newest-first +# order. # # Flags: # (default) compact projection, TOON, local-only @@ -35,9 +39,10 @@ # --fields opt in to dropped surfaces: bodies,paths,actions,endpoints # --all-in-flight include every in-flight task # --all-decisions include every open decision +# --all-secondmates include every aggregated secondmate record # --all-landed include every landed record from every home (default: bounded) # --all-reports include the full scout-report inventory (default: relevant only) -# --all-queued include superseded/held queued items (default: dropped) +# --all-queued include superseded queued items (default: dropped) # --all-recorded-prs include every locally recorded PR # --all-unhealthy include every unhealthy endpoint # --all-pr-repos query every discovered repository under --include-prs @@ -54,6 +59,7 @@ FM_BEARINGS_LANDED=${FM_BEARINGS_LANDED:-6} FM_BEARINGS_LANDED_PER_HOME=${FM_BEARINGS_LANDED_PER_HOME:-$FM_BEARINGS_LANDED} FM_BEARINGS_IN_FLIGHT=${FM_BEARINGS_IN_FLIGHT:-20} FM_BEARINGS_DECISIONS=${FM_BEARINGS_DECISIONS:-20} +FM_BEARINGS_SECONDMATES=${FM_BEARINGS_SECONDMATES:-20} FM_BEARINGS_GATES=${FM_BEARINGS_GATES:-20} FM_BEARINGS_REPORTS=${FM_BEARINGS_REPORTS:-20} FM_BEARINGS_RECORDED_PRS=${FM_BEARINGS_RECORDED_PRS:-20} @@ -69,6 +75,7 @@ validate_bound FM_BEARINGS_LANDED "$FM_BEARINGS_LANDED" validate_bound FM_BEARINGS_LANDED_PER_HOME "$FM_BEARINGS_LANDED_PER_HOME" validate_bound FM_BEARINGS_IN_FLIGHT "$FM_BEARINGS_IN_FLIGHT" validate_bound FM_BEARINGS_DECISIONS "$FM_BEARINGS_DECISIONS" +validate_bound FM_BEARINGS_SECONDMATES "$FM_BEARINGS_SECONDMATES" validate_bound FM_BEARINGS_GATES "$FM_BEARINGS_GATES" validate_bound FM_BEARINGS_REPORTS "$FM_BEARINGS_REPORTS" validate_bound FM_BEARINGS_RECORDED_PRS "$FM_BEARINGS_RECORDED_PRS" @@ -79,7 +86,8 @@ validate_bound FM_BEARINGS_PR_LIMIT "$FM_BEARINGS_PR_LIMIT" usage() { cat <<'EOF' usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--fields ] - [--all-in-flight] [--all-decisions] [--all-landed] + [--all-in-flight] [--all-decisions] + [--all-secondmates] [--all-landed] [--all-reports] [--all-queued] [--all-recorded-prs] [--all-unhealthy] [--all-pr-repos] @@ -88,14 +96,20 @@ Compact bearings projection over fm-fleet-snapshot.sh. TOON by default. Default is LOCAL-ONLY (no network); --include-prs is the only path that fetches. Default fields: schema, home, generated, prs, in_flight{id,kind,state,doing}, - decisions_open{id,key,verb,summary}, landed{id,what,artifact}, - gates{id,title,blocked_by,reason}, reports{id,path}, recorded_prs{id,url}, + secondmates{id,state,doing,provenance,freshness,age_seconds,contradiction,reason}, + decisions_open{id,key,verb,summary,owner}, landed{id,what,artifact,owner}, + gates{id,title,blocked_by,reason,owner}, reports{id,path}, recorded_prs{id,url}, unhealthy_endpoints{...} (only when non-empty), omitted{surface,reveal}. landed merges this home's Done with registered secondmate homes' Done, bounded by a per-home cap (FM_BEARINGS_LANDED_PER_HOME) and an overall cap (FM_BEARINGS_LANDED), - with omitted[] disclosure; --all-landed reveals the full set. + with omitted[] disclosure. Default selection is balanced across deterministic home + order while preserving each home's internal newest-first order; sparse homes do + not waste capacity. --all-landed reveals the full global newest-first set. +For every registered secondmate, validated structured state from its own home is + authoritative. Parent events and bounded terminal reads are labeled fallback or + contradiction evidence and never become current work. Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, - --all-decisions, --all-landed, --all-reports, --all-queued, --all-recorded-prs, + --all-decisions, --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). Raise FM_BEARINGS_PR_LIMIT to expand per-repository open-PR results. EOF @@ -107,6 +121,7 @@ ALL_REPORTS=0 ALL_QUEUED=0 ALL_IN_FLIGHT=0 ALL_DECISIONS=0 +ALL_SECONDMATES=0 ALL_LANDED=0 ALL_RECORDED_PRS=0 ALL_UNHEALTHY=0 @@ -120,6 +135,7 @@ while [ $# -gt 0 ]; do --all-queued) ALL_QUEUED=1 ;; --all-in-flight) ALL_IN_FLIGHT=1 ;; --all-decisions) ALL_DECISIONS=1 ;; + --all-secondmates) ALL_SECONDMATES=1 ;; --all-landed) ALL_LANDED=1 ;; --all-recorded-prs) ALL_RECORDED_PRS=1 ;; --all-unhealthy) ALL_UNHEALTHY=1 ;; @@ -134,14 +150,23 @@ done command -v jq >/dev/null 2>&1 || { echo "fm-bearings-snapshot: jq not found" >&2; exit 1; } -if [ "$ALL_LANDED" = 1 ]; then - SNAP=$(FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 "$FLEET" --json) || exit $? +# The deterministic return-catch-up owner must clear before this or any other +# ordinary captain request proceeds. Bearings does not reproduce that policy; +# it only consults the shared read-only gate. +"$SCRIPT_DIR/fm-afk-return.sh" guard || exit $? + +NOW=${FM_BEARINGS_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} +if [ "$ALL_LANDED" = 1 ] || [ "$ALL_SECONDMATES" = 1 ]; then + if [ "$ALL_LANDED" = 1 ]; then + SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_SECONDMATES=0 FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 "$FLEET" --json) || exit $? + else + SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_SECONDMATES=0 "$FLEET" --json) || exit $? + fi else - SNAP=$("$FLEET" --json) || exit $? + SNAP=$(FM_SNAPSHOT_NOW="$NOW" "$FLEET" --json) || exit $? fi HOME_LABEL=$(printf '%s' "$SNAP" | jq -er '.fm_home | strings | split("/") | (.[-2:] | join("/"))') \ || { echo "fm-bearings-snapshot: invalid canonical snapshot" >&2; exit 1; } -NOW=${FM_BEARINGS_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} # --- optional live PR enrichment (the ONLY network path) -------------------- PR_STATUS='not_requested (run: /bearings include PRs)' @@ -250,6 +275,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson landed_per_home_n "$FM_BEARINGS_LANDED_PER_HOME" \ --argjson in_flight_n "$FM_BEARINGS_IN_FLIGHT" \ --argjson decisions_n "$FM_BEARINGS_DECISIONS" \ + --argjson secondmates_n "$FM_BEARINGS_SECONDMATES" \ --argjson gates_n "$FM_BEARINGS_GATES" \ --argjson reports_n "$FM_BEARINGS_REPORTS" \ --argjson recorded_prs_n "$FM_BEARINGS_RECORDED_PRS" \ @@ -257,6 +283,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson include_prs "$INCLUDE_PRS" \ --argjson all_in_flight "$ALL_IN_FLIGHT" \ --argjson all_decisions "$ALL_DECISIONS" \ + --argjson all_secondmates "$ALL_SECONDMATES" \ --argjson all_landed "$ALL_LANDED" \ --argjson all_reports "$ALL_REPORTS" \ --argjson all_queued "$ALL_QUEUED" \ @@ -269,46 +296,107 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson candidate_prs "$CANDIDATE_PRS" ' def trunc($n): if . == null then null else (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; + def round_robin_landed($n): + . as $groups + | [range(0; (($groups | map(length) | max) // 0)) as $i + | $groups[] + | select(length > $i) + | .[$i]][:$n]; ($fields | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(. != ""))) as $fl | (($fl | index("bodies")) != null) as $f_bodies | (($fl | index("paths")) != null) as $f_paths | (($fl | index("actions")) != null) as $f_actions | (($fl | index("endpoints")) != null) as $f_endpoints - | ([ .backlog.records[] | select(.state == "done" and .structured) + | ([ .backlog.records[] | select(.state == "done" and .structured and .kind != "captain") | {id, title, pr_url, report_path, local_note, completion, home:"(main)", home_id:"(main)"} ]) as $main_done | ((.secondmate_landed.records) // []) as $mate_done | ($main_done + $mate_done) as $all_landed_rows | ([ $all_landed_rows | group_by(.home_id)[] | sort_by([(.completion.date // ""), .id]) | reverse - | (if $all_landed == 1 then . else .[:$landed_per_home_n] end) ] | add // []) as $per_home_capped + | (if $all_landed == 1 then . else .[:$landed_per_home_n] end) ]) as $per_home_groups + | ($per_home_groups | add // []) as $per_home_capped | ([ $all_landed_rows | group_by(.home_id)[] | select(length > $landed_per_home_n) ] | length) as $home_cap_dropped | ($per_home_capped | sort_by([(.completion.date // ""), .id]) | reverse) as $landed_sorted - | (if $all_landed == 1 then $landed_sorted else $landed_sorted[:$landed_n] end) as $done + | (if $all_landed == 1 then $landed_sorted else ($per_home_groups | round_robin_landed($landed_n)) end) as $done | ($done | map(.id)) as $done_ids - | (.tasks | map(.id)) as $live_ids + | ([.tasks[] | select(.kind != "secondmate") | .id]) as $live_ids | ($live_ids + $done_ids) as $rel_ids | ([ .tasks[] | select(.endpoint.exists == false or .endpoint.agent_alive == "dead") - | {id, backend, target:(.endpoint.target // "-"), exists:.endpoint.exists, agent:.endpoint.agent_alive} ]) as $unhealthy_all - | ([ .tasks[] | { + | {id, backend, target:(.endpoint.target // "-"), exists:.endpoint.exists, agent:.endpoint.agent_alive} ] + + [ (.secondmate_current.records // [])[] as $m | $m.endpoints[]? + | select(.endpoint.exists == false or .endpoint.agent_alive == "dead") + | {id:($m.id + "/" + .id),backend:"secondmate-home",target:(.endpoint.target // "-"),exists:.endpoint.exists,agent:.endpoint.agent_alive} ]) as $unhealthy_all + | ([ (.secondmate_current.records // [])[] + | ([.decisions_open[]? | select(.source == "backlog" and .verb == "captain-hold")]) as $captain_holds + | ([.holds[]? | select(.source == "backlog")]) as $backlog_holds + | . + { + bearings_captain_holds:$captain_holds, + bearings_holds:(if .current.state == "captain_decision" then $backlog_holds else .holds end), + bearings_state:( + if .current.state == "captain_decision" then + if ($captain_holds | length) > 0 then "captain_decision" + elif (.active_children | length) > 0 then "active_child_work" + elif ($backlog_holds | length) > 0 then "externally_held" + else "unknown" end + else .current.state end) + } ]) as $secondmate_views + | ([ if .secondmate_current.registry.available == false then + {id:"(registry)",state:"unknown",doing:(.secondmate_current.registry.reason // "Registered secondmate table unavailable"), + provenance:(.secondmate_current.registry.provenance // "registered-table"), + freshness:(.secondmate_current.registry.freshness.status // "unavailable"), + age_seconds:null,contradiction:false,reason:(.secondmate_current.registry.reason // "Registered secondmate table unavailable")} + else empty end ] + + [ $secondmate_views[] + | {id,state:.bearings_state, + doing:((if .bearings_state == "active_child_work" then + ([.active_children[] | .id + ": " + (.doing // .state)] | join("; ")) + elif .bearings_state == "captain_decision" then + ([.bearings_captain_holds[] | .summary] | join("; ")) + elif .bearings_state == "externally_held" then + ([.bearings_holds[] | .id + ": " + (.reason // "held")] | join("; ")) + elif .bearings_state == "no_active_work" then "No active child work" + else (.current.reason // "Current home state unavailable") end) | trunc(120)), + provenance:.provenance.selected,freshness:.freshness.status, + age_seconds:.freshness.age_seconds,contradiction:(.contradiction // false), + reason:(.current.reason // "-")} ]) as $secondmates_all + | ([ .tasks[] | select(.kind != "secondmate") | { id, kind, state: .current_state.state, doing: ((.current_state.detail // "") as $d | (if $d != "" then $d else (.hints.last_event_text // "") end) | trunc(90)) - } ]) as $in_flight_all - | ([ .tasks[] as $t | ($t.hints.open_decisions // [])[] - | {id:$t.id, key, verb, summary:(.summary | trunc(90))} ]) as $decisions_all + } ] + + [ $secondmate_views[] + | select(.bearings_state == "active_child_work") + | {id,kind:"secondmate",state:.bearings_state, + doing:([.active_children[] | .id + ": " + (.doing // .state)] | join("; ") | trunc(90))} ]) as $in_flight_all + | ([ .backlog.records[] + | select(.state == "queued" and .structured and .kind == "captain" + and .hold_kind == "captain" and .hold_reason != null) + | {id,key:.id,verb:"captain-hold", + summary:((.title + ": " + .hold_reason) | trunc(90)),owner:"(main)"} ] + + [ (.secondmate_current.records // [])[] as $m | $m.decisions_open[]? + | select(.source == "backlog" and .verb == "captain-hold") + | {id:($m.id + "/" + .id),key,verb, + summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ]) as $decisions_all | ([ .backlog.records[] | select(.state == "queued" and .structured) + | select((.kind == "captain" and .hold_kind == "captain" and .hold_reason != null) | not) | select(($all_queued == 1) or (((.body_excerpt // "") | test("SUPERSEDED|NOT REQUIRED|NOT-REQUIRED|DEFERRED"; "i")) | not)) | {id, title:(.title | trunc(60)), blocked_by:(.blocked_by // "-"), - reason:((.blocked_reason // "-") | trunc(40))} ]) as $gates_all + reason:((.blocked_reason // "-") | trunc(40)),owner:"(main)"} ] + + [ (.secondmate_current.records // [])[] as $m + | select($m.provenance.selected == "structured-home") + | $m.queued[]? + | select((.kind == "captain" and .hold_kind == "captain" and .hold_reason != null) | not) + | {id,title:(.title | trunc(60)),blocked_by:(.blocked_by // "-"), + reason:((.blocked_reason // "-") | trunc(40)),owner:$m.id} ]) as $gates_all | ([ .scout_reports[] | . as $r | select(($all_reports == 1) or (($rel_ids | index($r.id)) != null)) | {id, path} ]) as $reports_all - | ([ .tasks[] | select(.pr.url != null and .pr.source == "meta") | {id, url:.pr.url} ]) as $recorded_prs_all + | ([ .tasks[] | select(.kind != "secondmate" and .pr.url != null and .pr.source == "meta") | {id, url:.pr.url} ]) as $recorded_prs_all | . as $snap | { schema: "fm-bearings.v1", @@ -316,9 +404,10 @@ MODEL=$(printf '%s' "$SNAP" | jq \ generated: $now, prs: $prs, in_flight: (if $all_in_flight == 1 then $in_flight_all else $in_flight_all[:$in_flight_n] end), + secondmates: (if $all_secondmates == 1 then $secondmates_all else $secondmates_all[:$secondmates_n] end), decisions_open: (if $all_decisions == 1 then $decisions_all else $decisions_all[:$decisions_n] end), landed: ($done | map({id, what:(.title | trunc(70)), - artifact:(.pr_url // .report_path // .local_note // "-")})), + artifact:(.pr_url // .report_path // .local_note // "-"),owner:.home_id})), gates: (if $all_queued == 1 then $gates_all else $gates_all[:$gates_n] end), reports: (if $all_reports == 1 then $reports_all else $reports_all[:$reports_n] end), recorded_prs: (if $all_recorded_prs == 1 then $recorded_prs_all else $recorded_prs_all[:$recorded_prs_n] end) @@ -337,12 +426,19 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (if $f_actions then empty else {surface:"watch/steer actions", reveal:"--fields actions"} end), (if $f_endpoints then empty else {surface:"healthy endpoint detail", reveal:"--fields endpoints"} end), (if $all_reports == 1 then empty else {surface:"full scout-report inventory", reveal:"--all-reports"} end), - (if $all_queued == 1 then empty else {surface:"superseded/held queued items", reveal:"--all-queued"} end), + (if $all_queued == 1 then empty else {surface:"superseded queued items", reveal:"--all-queued"} end), (if $all_landed == 0 and ($per_home_capped | length) > ($done | length) then {surface:("landed showing \($done | length) of \($per_home_capped | length)" + (($done | map(.home_id) | unique | map(select(. != "(main)")) | length) as $k | if $k > 0 then " (incl. \($k) secondmate home(s))" else "" end)), reveal:"--all-landed"} else empty end), (if $all_landed == 0 and $home_cap_dropped > 0 then {surface:("landed per-home capped at \($landed_per_home_n) for \($home_cap_dropped) home(s)"), reveal:"--all-landed"} else empty end), (if (($snap.secondmate_landed.unreadable // []) | length) > 0 then {surface:("secondmate home(s) with unreadable backlog: \(($snap.secondmate_landed.unreadable // []) | length)"), reveal:"inspect the listed secondmate home backlogs"} else empty end), (if $all_landed == 0 and (($snap.secondmate_landed.truncated // []) | length) > 0 then {surface:("secondmate home Done capped at the snapshot layer for \(($snap.secondmate_landed.truncated // []) | length) home(s)"), reveal:"--all-landed"} else empty end), (if $all_in_flight == 0 and ($in_flight_all | length) > $in_flight_n then {surface:("in_flight showing \($in_flight_n) of \($in_flight_all | length)"), reveal:"--all-in-flight"} else empty end), + (if $all_secondmates == 0 and ($secondmates_all | length) > $secondmates_n then {surface:("secondmates showing \($secondmates_n) of \($secondmates_all | length)"), reveal:"--all-secondmates"} else empty end), + (if (($snap.secondmate_current.truncated // 0) > 0) then {surface:("registered secondmates omitted by snapshot bound: \($snap.secondmate_current.truncated)"), reveal:"raise FM_SNAPSHOT_SECONDMATES"} else empty end), + (if $snap.secondmate_current.registry.input_truncated == true then {surface:"secondmate registry input truncated by bounded read", reveal:"raise FM_SNAPSHOT_REGISTRY_LINES or FM_SNAPSHOT_REGISTRY_BYTES"} else empty end), + (if $snap.secondmate_current.registry.records_truncated == true then {surface:"secondmate registry records omitted by bounded read", reveal:"raise FM_SNAPSHOT_REGISTRY_RECORDS"} else empty end), + (if $snap.secondmate_current.registry.available == false then {surface:("secondmate registry unavailable: " + ($snap.secondmate_current.registry.reason // "read failed")), reveal:"inspect data/secondmates.md"} else empty end), + (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.input_truncated == true or .parent_event.activity_scan.retained_truncated == true)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence truncated for \($n) record(s)"), reveal:"raise FM_SNAPSHOT_PARENT_ACTIVITY_LINES, FM_SNAPSHOT_PARENT_ACTIVITY_BYTES, or FM_SNAPSHOT_PARENT_ACTIVITIES"} else empty end), + (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.available == false)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence unavailable for \($n) record(s)"), reveal:"inspect the parent status logs"} else empty end), (if $all_decisions == 0 and ($decisions_all | length) > $decisions_n then {surface:("decisions_open showing \($decisions_n) of \($decisions_all | length)"), reveal:"--all-decisions"} else empty end), (if $all_queued == 0 and ($gates_all | length) > $gates_n then {surface:("gates showing \($gates_n) of \($gates_all | length)"), reveal:"--all-queued"} else empty end), (if $all_reports == 0 and ($reports_all | length) > $reports_n then {surface:("reports showing \($reports_n) of \($reports_all | length)"), reveal:"--all-reports"} else empty end), diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index aafb24a8e..de86615d8 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1,44 +1,46 @@ #!/usr/bin/env bash # Bootstrap detection, best-effort fleet refresh/prune, and installs. # Usage: fm-bootstrap.sh -# Detect: prints one line per problem or capability fact and exits 0. +# Detect: prints one line per actionable problem, or an explicit +# BOOTSTRAP_INFO no-action fact for completed benign bootstrap work, and +# exits 0. # Silent = all good. # Lines: "MISSING: (install: )", # "MISSING_MANUAL: (instructions: )", "NEEDS_GH_AUTH", # "BACKEND_INVALID: (known: )", -# "CREW_HARNESS_OVERRIDE: ", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", -# "CREW_DISPATCH: active config/crew-dispatch.json" plus indented rules, # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from ", -# "TASKS_AXI: available", "TANGLE: ", +# "PR_CHECK_MIGRATION: ", +# "TANGLE: ", # "SECONDMATE_SYNC: secondmate : skipped: ", -# "NUDGE_SECONDMATES: fm-...", -# "SECONDMATE_LIVENESS: secondmate : already-live|respawned|skipped: |respawn failed: ", +# "NUDGE_SECONDMATES: secondmate : send failed: ", +# "BOOTSTRAP_INFO: nudged fm- with ''", +# "SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed: ", # "FMX: X mode on ..." or "FMX: X mode off ...". -# A NUDGE_SECONDMATES line lists the RUNNING secondmate task selectors -# (fm-) whose worktree was fast-forwarded to firstmate's own -# current default-branch commit (a purely LOCAL fast-forward, never an -# origin fetch) AND whose instruction surface (AGENTS.md, bin/, or -# .agents/skills/) actually changed; firstmate nudges each via -# bin/fm-send.sh fm- so meta resolves the current backend target -# even when the same bootstrap run also respawned the secondmate. +# When a RUNNING secondmate worktree is fast-forwarded to firstmate's +# own current default-branch commit (a purely LOCAL fast-forward, never +# an origin fetch) AND its loaded instruction surface (AGENTS.md, bin/, +# or .agents/skills/) actually changed, bootstrap immediately nudges it +# via FM_HOME= bin/fm-send.sh fm- so meta resolves the +# current backend target and the standard from-firstmate marker is +# applied. A successful send prints one BOOTSTRAP_INFO line with the +# exact target and message sent; a failed send leaves an idempotent +# retry marker under state/.secondmate-nudge-pending/ and prints an +# actionable NUDGE_SECONDMATES line. # Already-current or no-instruction-change homes are silently left alone. -# The secondmate sweep also propagates declared inheritable local config +# The secondmate sweep also propagates declared inherited local material # into each validated live secondmate home. # SECONDMATE_SYNC lines report actionable skipped local-HEAD syncs or -# config-inheritance failures for live secondmate homes; no-op/current -# and successful updates stay quiet. -# SECONDMATE_LIVENESS lines report every live secondmate's deeper -# agent-liveness verdict (bin/fm-backend.sh's fm_backend_agent_alive, -# distinct from the endpoint pane-presence check): already-live is a -# no-op, respawned means a confirmed-dead endpoint (a bare shell left -# behind by an exited secondmate agent) was killed and relaunched via -# bin/fm-spawn.sh --secondmate, and skipped means the probe could not -# confidently classify the endpoint (never acted on - a false-dead -# reading would spin up a duplicate agent). Session-start scope only; -# see AGENTS.md "Session start" and docs/tmux-backend.md / -# docs/herdr-backend.md "Agent liveness probe" for the empirical basis. +# inheritance failures for live secondmate homes, plus quarantine +# diagnostics for divergent shared captain-preference copies; +# no-op/current and successful updates stay quiet. +# SECONDMATE_LIVENESS lines report only actionable failures from the +# deeper agent-liveness verdict (bin/fm-backend.sh's +# fm_backend_agent_alive, distinct from endpoint pane-presence): +# skipped means the probe could not confidently classify the endpoint, +# and respawn failed means relaunch did not complete. Already-live and +# successfully respawned secondmates are silent. # A TANGLE line means the firstmate primary checkout (FM_ROOT) is stranded # on a feature branch instead of its default branch - a crewmate's work # landed in the primary instead of its own worktree; restore it per the line. @@ -51,10 +53,9 @@ # binary instead of hanging bootstrap. # tasks-axi and quota-axi are required bootstrap tools (same class as # lavish-axi). tasks-axi is also version and feature gated (0.1.1+ -# with update --archive-body and mv [...]); an installed but incompatible build -# reports MISSING like no-mistakes. When -# config/backlog-backend is not manual and tasks-axi is compatible, -# bootstrap prints TASKS_AXI: available. quota-axi is required because +# with update --archive-body and mv [...]); an installed but +# incompatible build reports MISSING like no-mistakes. A compatible +# tasks-axi default backend is silent. quota-axi is required because # crew-dispatch quota-balanced may call it; fm-dispatch-select.sh still # degrades at runtime when quota data is unavailable. # X mode is OPTIONAL and inert unless FM_HOME/.env has a non-empty @@ -69,16 +70,17 @@ # refresh relays any completed fm-fleet-sync.sh output before the # aggregate timeout skip line with timeout and elapsed seconds. # Set FM_FLEET_PRUNE=0 to skip branch pruning during that refresh. -# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the four MUTATING sweeps -# (secondmate_sync, secondmate_liveness_sweep, x_mode_setup, -# fleet_sync) while still printing every read-only detect line +# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the five MUTATING sweeps +# (PR-check migration, secondmate_sync, secondmate_liveness_sweep, +# x_mode_setup, fleet_sync) while still printing every read-only detect line # above; the TANGLE line switches to advisory-only wording with no # checkout command. Used by # fm-session-start.sh's read-only path when another live session holds # the fleet lock, so a second concurrent session never race-mutates -# secondmate homes, X-mode artifacts, project clones, or repair -# instructions. Unset/0 (the default) runs every sweep exactly as -# before - this flag is purely additive. +# PR-check artifacts, secondmate homes, X-mode artifacts, project +# clones, or repair instructions. +# Unset/0 (the default) runs every sweep exactly as before - this flag +# is purely additive. # fm-bootstrap.sh install ... # Install the named tools (only ones the captain approved). set -u @@ -89,6 +91,7 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" 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-tangle-lib.sh disable=SC1091 @@ -190,12 +193,13 @@ secondmate_sync() { # to the primary checkout's current default-branch commit. Purely LOCAL - no # fetch, no origin dependency: a linked-worktree home already holds the primary's # commit (fm-ff-lib.sh), while a standalone clone without it is skipped until - # /updatefirstmate refreshes it from origin. Emits NUDGE_SECONDMATES: - # only for RUNNING secondmates whose instruction surface (AGENTS.md, bin/, or + # /updatefirstmate refreshes it from origin. Startup sends reread nudges only + # for RUNNING secondmates whose instruction surface (AGENTS.md, bin/, or # .agents/skills/) actually changed, so a secondmate already on the primary's - # version is never disturbed (AGENTS.md bootstrap + supervision). Mirrors - # fm-update's nudge-secondmates: report so firstmate can live-converge the - # listed fm- selectors. + # version is never disturbed (AGENTS.md bootstrap + supervision). Unlike + # /updatefirstmate, startup owns the live-convergence send itself because it is + # a deterministic locked sweep and can report success as BOOTSTRAP_INFO while + # preserving failed sends as NUDGE_SECONDMATES retry markers. [ -d "$STATE" ] || return 0 local primary_head if ! primary_head=$(primary_head_commit "$FM_ROOT"); then @@ -210,22 +214,131 @@ secondmate_sync() { fi FF_NUDGE_WINDOWS="" FF_SEEN_HOMES="" + SECOND_MATE_NUDGE_MESSAGE='firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.' + SECOND_MATE_NUDGE_PENDING_DIR="$STATE/.secondmate-nudge-pending" + + secondmate_nudge_marker_path() { + case "$1" in + *[!/A-Za-z0-9._-]*|""|*/*) return 1 ;; + esac + printf '%s/%s.pending' "$SECOND_MATE_NUDGE_PENDING_DIR" "$1" + } + + secondmate_write_nudge_marker() { + local id=$1 home=$2 commit=$3 instr=$4 selector marker tmp parent + selector="fm-$id" + marker=$(secondmate_nudge_marker_path "$id") || return 1 + parent=${marker%/*} + mkdir -p "$parent" || return 1 + tmp=$(mktemp "$parent/.nudge.XXXXXX" 2>/dev/null) || return 1 + { + printf 'id=%s\n' "$id" + printf 'selector=%s\n' "$selector" + printf 'home=%s\n' "$home" + printf 'commit=%s\n' "$commit" + printf 'instructions=%s\n' "$instr" + printf 'message=%s\n' "$SECOND_MATE_NUDGE_MESSAGE" + } > "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$marker" || { rm -f "$tmp"; return 1; } + } + + secondmate_send_nudge() { + local id=$1 home=$2 commit=$3 instr=$4 selector marker out + selector="fm-$id" + marker=$(secondmate_nudge_marker_path "$id") || { + echo "NUDGE_SECONDMATES: secondmate $id: send failed: unsafe id" + return 0 + } + if ! secondmate_write_nudge_marker "$id" "$home" "$commit" "$instr"; then + echo "NUDGE_SECONDMATES: secondmate $id: send failed: cannot record retry marker" + return 0 + fi + if out=$(FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-send.sh" "$selector" "$SECOND_MATE_NUDGE_MESSAGE" 2>&1); then + rm -f "$marker" + echo "BOOTSTRAP_INFO: nudged $selector with '$SECOND_MATE_NUDGE_MESSAGE'" + else + echo "NUDGE_SECONDMATES: secondmate $id: send failed: $(first_line "$out")" + fi + } + + fm_ff_after_instruction_update() { + local id=$1 home=$2 _window=$3 instr=$4 + secondmate_send_nudge "$id" "$home" "$primary_head" "$instr" + } + + secondmate_retry_pending_nudges() { + local marker id selector home commit message expected_marker meta meta_home home_real head + [ -d "$SECOND_MATE_NUDGE_PENDING_DIR" ] || return 0 + for marker in "$SECOND_MATE_NUDGE_PENDING_DIR"/*.pending; do + [ -f "$marker" ] || continue + id=$(fm_meta_get "$marker" id) + if ! expected_marker=$(secondmate_nudge_marker_path "$id"); then + echo "NUDGE_SECONDMATES: secondmate ${id:-unknown}: send failed: retry marker has unsafe id" + continue + fi + [ "$expected_marker" = "$marker" ] || { + echo "NUDGE_SECONDMATES: secondmate $id: send failed: retry marker filename mismatch" + continue + } + selector=$(fm_meta_get "$marker" selector) + home=$(fm_meta_get "$marker" home) + commit=$(fm_meta_get "$marker" commit) + message=$(fm_meta_get "$marker" message) + [ "$selector" = "fm-$id" ] || { + echo "NUDGE_SECONDMATES: secondmate ${id:-unknown}: send failed: retry marker selector mismatch" + continue + } + [ "$message" = "$SECOND_MATE_NUDGE_MESSAGE" ] || { + echo "NUDGE_SECONDMATES: secondmate ${id:-unknown}: send failed: retry marker message mismatch" + continue + } + meta="$STATE/$id.meta" + [ -f "$meta" ] && [ "$(fm_meta_get "$meta" kind)" = secondmate ] || { + echo "NUDGE_SECONDMATES: secondmate ${id:-unknown}: send failed: retry target has no live secondmate metadata" + continue + } + meta_home=$(fm_meta_get "$meta" home) + [ -n "$meta_home" ] || meta_home=$(secondmate_registry_field "$DATA/secondmates.md" "$id" home || true) + if ! validate_secondmate_home "$id" "$meta_home"; then + echo "NUDGE_SECONDMATES: secondmate $id: send failed: retry target home unsafe: $VALIDATION_ERROR" + continue + fi + home_real="$VALIDATED_HOME" + [ "$home_real" = "$home" ] || { + echo "NUDGE_SECONDMATES: secondmate $id: send failed: retry target home changed" + continue + } + head=$(git -C "$home_real" rev-parse HEAD 2>/dev/null || true) + [ -n "$head" ] && [ "$head" = "$commit" ] || { + echo "NUDGE_SECONDMATES: secondmate $id: send failed: retry target is not at recorded instruction commit" + continue + } + if out=$(FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-send.sh" "$selector" "$SECOND_MATE_NUDGE_MESSAGE" 2>&1); then + rm -f "$marker" + echo "BOOTSTRAP_INFO: nudged $selector with '$SECOND_MATE_NUDGE_MESSAGE'" + else + echo "NUDGE_SECONDMATES: secondmate $id: send failed: $(first_line "$out")" + fi + done + } + local tmp line + secondmate_retry_pending_nudges tmp=$(mktemp "${TMPDIR:-/tmp}/fm-secondmate-sync.XXXXXX" 2>/dev/null) || return 0 - sweep_live_secondmate_metas "$STATE" "$primary_head" yes >"$tmp" + sweep_live_secondmate_metas "$STATE" "$primary_head" yes "$DATA/secondmates.md" >"$tmp" while IFS= read -r line; do case "$line" in secondmate\ *': skipped:'*) echo "SECONDMATE_SYNC: $line" ;; + BOOTSTRAP_INFO:\ *) echo "$line" ;; + NUDGE_SECONDMATES:\ *) echo "$line" ;; esac done < "$tmp" rm -f "$tmp" - # Inheritable-config propagation: push the primary's declared LOCAL config - # into every VALIDATED live secondmate home swept - # above (FF_SEEN_HOMES is exactly that set). config/ is gitignored, so this is a - # separate copy from the tracked-files fast-forward; primary-authoritative, so - # it runs whether or not the home's tracked files advanced, keeping the fleet - # converged on the primary. The propagation helper stays silent on success; a - # primary with no inheritable config set and no downstream copy is a no-op. + unset -f fm_ff_after_instruction_update + # Inheritance propagation: push the primary-authoritative local inheritance + # surface into every VALIDATED live secondmate home swept above. + # FF_SEEN_HOMES is exactly that set, and fm-config-inherit-lib.sh owns the + # declared config items plus data/captain-shared.md. local id home home_real propagated_homes propagated_homes="" while IFS='|' read -r id home _window _meta; do @@ -239,11 +352,10 @@ secondmate_sync() { *" $home_real "*) continue ;; esac propagated_homes="$propagated_homes $home_real" - if ! propagate_inheritable_config "$CONFIG" "$home_real/config"; then - echo "SECONDMATE_SYNC: secondmate $id: skipped: config inheritance failed" + if ! propagate_secondmate_inheritance "$FM_HOME" "$home_real" "$CONFIG" "$DATA"; then + echo "SECONDMATE_SYNC: secondmate $id: skipped: inheritance failed" fi - done < <(live_secondmate_meta_records "$STATE" "$FM_HOME/data/secondmates.md") - [ -n "$FF_NUDGE_WINDOWS" ] && echo "NUDGE_SECONDMATES:$FF_NUDGE_WINDOWS" + done < <(live_secondmate_meta_records "$STATE" "$DATA/secondmates.md") return 0 } @@ -297,12 +409,11 @@ secondmate_liveness_sweep() { esac case "$verdict" in alive) - echo "SECONDMATE_LIVENESS: secondmate $id: already-live" ;; dead) fm_backend_kill "$backend" "$target" 2>/dev/null || true if out=$(FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "$id" --secondmate 2>&1); then - echo "SECONDMATE_LIVENESS: secondmate $id: respawned" + : else echo "SECONDMATE_LIVENESS: secondmate $id: respawn failed: $(first_line "$out")" fi @@ -429,19 +540,67 @@ no_mistakes_compatible() { [ "$patch" -ge "$NO_MISTAKES_MIN_PATCH" ] } -# Write CONTENT to DEST only when it differs, so re-running bootstrap does not -# churn mtimes or duplicate generated files (idempotence). -write_if_changed() { - local dest=$1 content=$2 - [ -f "$dest" ] && [ "$(cat "$dest" 2>/dev/null)" = "$content" ] && return 0 - printf '%s\n' "$content" > "$dest" +x_mode_write_if_changed() { + local dest=$1 content=$2 mode=$3 parent tmp parent_device current_mode + parent=${dest%/*} + [ "$parent" != "$dest" ] || return 1 + [ -d "$parent" ] && [ ! -L "$parent" ] || return 1 + if [ "$(uname)" = Darwin ]; then + parent_device=$(stat -f %d "$parent" 2>/dev/null) || return 1 + else + parent_device=$(stat -c %d "$parent" 2>/dev/null) || return 1 + fi + if [ -e "$dest" ] || [ -L "$dest" ]; then + fmx_single_link_file_valid "$dest" "$parent_device" || return 1 + if [ "$(uname)" = Darwin ]; then + current_mode=$(stat -f %Lp "$dest" 2>/dev/null) || return 1 + else + current_mode=$(stat -c %a "$dest" 2>/dev/null) || return 1 + fi + if [ "$current_mode" = "$mode" ] && cmp -s "$dest" <(printf '%s\n' "$content"); then + return 0 + fi + fi + tmp=$(umask 077; mktemp "$parent/.fm-x-mode.XXXXXX" 2>/dev/null) || return 1 + if ! printf '%s\n' "$content" > "$tmp" \ + || ! chmod "$mode" "$tmp" \ + || ! fmx_single_link_file_mode_valid "$tmp" "$mode" "$parent_device"; then + rm -f -- "$tmp" + return 1 + fi + if { [ -e "$dest" ] || [ -L "$dest" ]; } \ + && ! fmx_single_link_file_valid "$dest" "$parent_device"; then + rm -f -- "$tmp" + return 1 + fi + if ! mv -f -- "$tmp" "$dest"; then + rm -f -- "$tmp" + return 1 + fi + if ! fmx_single_link_file_mode_valid "$dest" "$mode" "$parent_device" \ + || ! cmp -s "$dest" <(printf '%s\n' "$content"); then + rm -f -- "$dest" + return 1 + fi +} + +x_mode_artifact_present() { + [ -e "$1" ] || [ -L "$1" ] +} + +x_mode_remove_artifact() { + local artifact=$1 parent=${1%/*} + x_mode_artifact_present "$artifact" || return 0 + [ -d "$parent" ] && [ ! -L "$parent" ] || return 1 + rm -f -- "$artifact" 2>/dev/null || return 1 + ! x_mode_artifact_present "$artifact" } # X mode (opt-in): when this home's .env carries a non-empty FMX_PAIRING_TOKEN, -# wire the relay poll into the EXISTING watcher check mechanism without touching -# fm-watch.sh or any other watcher-backbone file. Drops two idempotent, -# gitignored artifacts: -# state/x-watch.check.sh - check shim that execs bin/fm-x-poll.sh each cycle +# wire the relay poll into the existing authenticated watcher dispatch. +# Drops two idempotent, gitignored artifacts: +# state/x-watch.check.sh - byte-static identity shim; the watcher validates +# its bytes and invokes bin/fm-x-poll.sh directly # config/x-mode.env - exports FM_CHECK_INTERVAL=30, sourced by the watcher # arm so only an X instance polls at the 30s cadence # On opt-out (no token, or empty) it removes any such artifacts so the instance @@ -461,8 +620,10 @@ x_mode_setup() { [ -f "$env_file" ] && token=$(fmx_env_get FMX_PAIRING_TOKEN "$env_file") x_mode_remove_artifacts() { - rm -f "$shim" "$cadence" 2>/dev/null || true - [ ! -e "$shim" ] && [ ! -e "$cadence" ] + local failed=0 + x_mode_remove_artifact "$shim" || failed=1 + x_mode_remove_artifact "$cadence" || failed=1 + [ "$failed" -eq 0 ] } x_mode_supervision_repair() { @@ -475,7 +636,7 @@ x_mode_setup() { if [ -z "$token" ]; then # Opt-out (or never opted in): drop any X artifacts; stay silent unless we # actually removed something. - if [ -e "$shim" ] || [ -e "$cadence" ]; then + if x_mode_artifact_present "$shim" || x_mode_artifact_present "$cadence"; then if x_mode_remove_artifacts; then echo "FMX: X mode off - removed relay poll shim and 30s cadence; default cadence applies on the next supervision cycle; $(x_mode_supervision_repair)" else @@ -493,7 +654,7 @@ x_mode_setup() { fi done if [ "$missing" -ne 0 ]; then - if [ -e "$shim" ] || [ -e "$cadence" ]; then + if x_mode_artifact_present "$shim" || x_mode_artifact_present "$cadence"; then if x_mode_remove_artifacts; then echo "FMX: X mode off - missing relay poll dependencies; install them and rerun bootstrap" else @@ -513,16 +674,10 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(cat </dev/null || { fmx_arm_failed; return 0; } + shim_body=$(fmx_poll_shim_content "$FM_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_arm_failed; return 0; } cadence_body=$(cat <<'EOF' # Auto-generated by fm-bootstrap.sh - X mode watcher cadence. @@ -532,7 +687,7 @@ EOF export FM_CHECK_INTERVAL=30 EOF ) - write_if_changed "$cadence" "$cadence_body" || { fmx_arm_failed; return 0; } + x_mode_write_if_changed "$cadence" "$cadence_body" 600 || { fmx_arm_failed; return 0; } echo "FMX: X mode on - relay poll armed via state/x-watch.check.sh; 30s watcher cadence in config/x-mode.env" } @@ -555,8 +710,9 @@ crew_dispatch_validate() { if $e == null then true elif ($e | type) != "string" then false elif $h == "claude" then (["low","medium","high","xhigh","max"] | index($e)) - elif ($h == "codex" or $h == "pi") then (["low","medium","high","xhigh"] | index($e)) + elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) + elif $h == "pi" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "opencode" then false else true end; @@ -601,7 +757,8 @@ crew_dispatch_validate() { echo "CREW_DISPATCH: invalid config/crew-dispatch.json - $err" return 0 fi - jq -r ' + if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ]; then + jq -r ' def profile($p): ($p.harness | tostring) + (if ($p.model? != null) then "/" + ($p.model | tostring) @@ -614,11 +771,12 @@ crew_dispatch_validate() { + "[" + ([$r.use[] | profile(.)] | join(", ")) + "]") else profile($r.use) end; - (["CREW_DISPATCH: active config/crew-dispatch.json"] - + [(.rules // [])[]? | " rule: " + (.when | tostring) + " -> " + use_label(.)] - + (if (.default? | type) == "object" then [" default: " + profile(.default)] else [] end)) + (["BOOTSTRAP_INFO: crew dispatch active config/crew-dispatch.json"] + + [(.rules // [])[]? | "BOOTSTRAP_INFO: crew dispatch rule: " + (.when | tostring) + " -> " + use_label(.)] + + (if (.default? | type) == "object" then ["BOOTSTRAP_INFO: crew dispatch default: " + profile(.default)] else [] end)) | .[] ' "$file" + fi } if [ "${1:-}" = "install" ]; then @@ -637,6 +795,14 @@ if [ "${1:-}" = "install" ]; then exit 0 fi +# This is the first mutating sweep at a locked session boundary. It pauses an +# identity-matched watcher, holds its lock, and neutralizes legacy PR checks +# before any tool detection or later bootstrap mutation can leave old artifacts +# runnable. Detect-only sessions never touch state. +if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then + "$SCRIPT_DIR/fm-pr-check-migrate.sh" || true +fi + if [ "$BACKEND_VALID" -eq 0 ]; then echo "BACKEND_INVALID: $BACKEND (known: $FM_BACKEND_KNOWN)" fi @@ -675,13 +841,16 @@ if [ -n "$tangle_branch" ]; then fi crew= [ -f "$CONFIG/crew-harness" ] && crew=$(tr -d '[:space:]' < "$CONFIG/crew-harness" || true) -[ -n "$crew" ] && [ "$crew" != "default" ] && echo "CREW_HARNESS_OVERRIDE: $crew" +if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && [ -n "$crew" ] && [ "$crew" != "default" ]; then + echo "BOOTSTRAP_INFO: crew harness override active: $crew" +fi crew_dispatch_validate if ! fm_backlog_backend_manual "$CONFIG"; then fm_tasks_axi_cwd_trap_warning "$FM_ROOT" "$FM_HOME" fi -if ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then - echo "TASKS_AXI: available" +if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ + && ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then + echo "BOOTSTRAP_INFO: tasks-axi available" fi if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then secondmate_sync diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 26b78b4ed..a1b9cb540 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -27,12 +27,12 @@ # caller-supplied repo string cannot reliably identify this repo. Briefs made # without it carry a loud declaration so an omitted contract cannot be silent. # For ship tasks, the definition of done is shaped by the project's delivery mode -# (data/projects.md via fm-project-mode.sh; see AGENTS.md project management -# and task lifecycle): +# (data/projects.md via fm-project-mode.sh; see the project-management skill +# and AGENTS.md task lifecycle): # no-mistakes implement -> /no-mistakes pipeline -> PR -> captain merge (default) # direct-PR implement -> push + open PR via gh-axi (no pipeline) -> captain merge # local-only implement on branch, stop and report "ready in branch" (no push/PR); -# firstmate reviews, captain approves, firstmate merges to local main +# captain approves, firstmate merges to local main # Ship briefs begin with a worktree-isolation assertion before the branch step. # Scout tasks ignore mode - their deliverable is a report, not a merge. # Every scaffold's status protocol distinguishes the configured @@ -156,6 +156,7 @@ A request relayed to you by the main firstmate (your supervisor) is tagged with When a message carries that marker, do the work, then respond via the STATUS/ESCALATION path below, never only in this chat: the main firstmate does not read your chat, so a chat-only reply is lost. For a terse result, a status line is the whole answer. For a detailed answer (an investigation, a plan, an audit), write it to a doc under your home's \`data/\` and append a status line that points to that doc - the scout-report pattern - so the main firstmate is woken and can read it. +Before treating an investigation or visual review as complete, load \`decision-hold-lifecycle\` from this home's \`.agents/skills/\` and pass its shared completion gate. A message with NO marker is the captain typing directly into your pane: treat it as authoritative captain intervention and stay conversational exactly as you would for any captain message; do not force it onto the status path. # Escalation to main firstmate @@ -166,6 +167,8 @@ States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. Use \`$PAUSED_VERB: {why}\` (distinct from \`blocked:\`) only when your domain is deliberately idling on a known external wait you expect to clear on its own; use \`blocked:\` when you are stuck and need firstmate to act. Use this only for material phase changes, a captain decision, a real blocker, a failure, or work ready for review. This is also how you return the answer to a marked from-firstmate request above. +Give every routed-work phase a stable key: open it with \`working [key=]: {material phase}\`, and use the same key on its later \`$PAUSED_VERB\`, \`done\`, \`failed\`, \`needs-decision\`, or \`blocked\` event so the earlier working phase is superseded. +When a keyed phase ends without another reportable state, append \`resolved [key=]: {why it is no longer active}\`. When a decision you escalated is answered or a blocker clears and your domain resumes, append \`resolved: {how it was decided or unblocked}\` (keyed with \`[key=]\` if you opened it with one) so it is durably closed instead of resurfacing behind later unrelated events. Routine internal supervision, heartbeats, retries, and crewmate churn stay inside your own home and must not touch that status file. @@ -258,6 +261,7 @@ The report is the only thing that survives, so anything worth keeping must be in # Definition of done Write your findings to \`$DATA/$ID/report.md\`. The report must stand alone: what you did, what you found, the evidence (commands run, output, file:line references), and what you recommend. +Before reporting done, read and follow \`$FM_ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md\` and pass its shared completion gate for the report and any visual review. When the report is complete, append \`done: {one-line conclusion}\` to the status file and stop. If your findings reveal work that should ship (e.g. you reproduced a bug and the fix is clear), say so in the report; firstmate may promote this task in place, and you would then receive mode-specific ship instructions as a follow-up message. EOF @@ -280,7 +284,7 @@ case "$MODE" in This project ships **direct-PR**: you raise the PR yourself, without the no-mistakes pipeline. The task is complete only when committed on your branch. When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done: PR {url}\` to the status file and stop. -Do NOT run /no-mistakes. The captain reviews and merges the PR; firstmate relays it. +Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome. EOF ) ;; @@ -293,7 +297,7 @@ This project ships **local-only**: no remote, no PR, no pipeline. The task is complete only when committed on your branch \`fm/$ID\`. Do NOT push, do NOT open a PR, do NOT merge. Keep your branch a clean fast-forward onto the current default branch - if \`main\` has advanced, rebase onto it so the eventual merge stays a fast-forward. When it is implemented and committed, append \`done: ready in branch fm/$ID\` to the status file and stop. -Firstmate then reviews your branch diff, the captain approves, and firstmate merges it into local \`main\`. +The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path. EOF ) ;; diff --git a/bin/fm-check-lib.sh b/bin/fm-check-lib.sh new file mode 100755 index 000000000..996bc5f4b --- /dev/null +++ b/bin/fm-check-lib.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +FM_CUSTOM_CHECK_HASH= +FM_CUSTOM_CHECK_SNAPSHOT= + +fm_custom_check_sha256() { + local file=$1 + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +fm_custom_check_trust_read() { + local state=$1 id=$2 trust state_device version hash + FM_CUSTOM_CHECK_HASH= + fm_pr_task_id_valid "$id" || return 1 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + trust="$state/$id.check-trust" + fm_pr_private_file_valid "$trust" 600 "$state_device" || return 1 + exec 9< "$trust" || return 1 + IFS= read -r version <&9 || { exec 9<&-; return 1; } + IFS= read -r hash <&9 || { exec 9<&-; return 1; } + if IFS= read -r _extra <&9; then + exec 9<&- + return 1 + fi + exec 9<&- + [ "$version" = fm-custom-check-v1 ] || return 1 + [[ "$hash" =~ ^[0-9a-f]{64}$ ]] || return 1 + FM_CUSTOM_CHECK_HASH=$hash +} + +fm_custom_check_registered() { + local state=$1 id=$2 check hash state_device + check="$state/$id.check.sh" + fm_custom_check_trust_read "$state" "$id" || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + fm_pr_private_file_valid "$check" 700 "$state_device" || return 1 + hash=$(fm_custom_check_sha256 "$check") || return 1 + [ "$hash" = "$FM_CUSTOM_CHECK_HASH" ] +} + +fm_custom_check_snapshot_prepare() { + local state=$1 id=$2 check hash state_device + fm_custom_check_snapshot_cleanup + check="$state/$id.check.sh" + fm_custom_check_trust_read "$state" "$id" || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + fm_pr_private_file_valid "$check" 700 "$state_device" || return 1 + FM_CUSTOM_CHECK_SNAPSHOT=$(mktemp "$state/.fm-custom-check.XXXXXX") || return 1 + cp "$check" "$FM_CUSTOM_CHECK_SNAPSHOT" || { fm_custom_check_snapshot_cleanup; return 1; } + chmod 0600 "$FM_CUSTOM_CHECK_SNAPSHOT" || { fm_custom_check_snapshot_cleanup; return 1; } + [ -f "$FM_CUSTOM_CHECK_SNAPSHOT" ] && [ ! -L "$FM_CUSTOM_CHECK_SNAPSHOT" ] \ + || { fm_custom_check_snapshot_cleanup; return 1; } + [ "$(fm_pr_file_mode "$FM_CUSTOM_CHECK_SNAPSHOT")" = 600 ] \ + || { fm_custom_check_snapshot_cleanup; return 1; } + [ "$(fm_pr_file_device "$FM_CUSTOM_CHECK_SNAPSHOT")" = "$state_device" ] \ + || { fm_custom_check_snapshot_cleanup; return 1; } + [ "$(fm_pr_file_link_count "$FM_CUSTOM_CHECK_SNAPSHOT")" = 1 ] \ + || { fm_custom_check_snapshot_cleanup; return 1; } + hash=$(fm_custom_check_sha256 "$FM_CUSTOM_CHECK_SNAPSHOT") \ + || { fm_custom_check_snapshot_cleanup; return 1; } + [ "$hash" = "$FM_CUSTOM_CHECK_HASH" ] || { fm_custom_check_snapshot_cleanup; return 1; } +} + +fm_custom_check_snapshot_cleanup() { + [ -z "$FM_CUSTOM_CHECK_SNAPSHOT" ] || rm -f -- "$FM_CUSTOM_CHECK_SNAPSHOT" + FM_CUSTOM_CHECK_SNAPSHOT= +} diff --git a/bin/fm-check-register.sh b/bin/fm-check-register.sh new file mode 100755 index 000000000..d77d02b64 --- /dev/null +++ b/bin/fm-check-register.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Bind an intentional custom watcher check to its current bytes. +# Usage: fm-check-register.sh +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}" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-check-lib.sh +. "$SCRIPT_DIR/fm-check-lib.sh" + +if [ "$#" -ne 1 ] || ! fm_pr_task_id_valid "$1"; then + echo "error: invalid custom check registration" >&2 + exit 2 +fi + +ID=$1 +CHECK="$STATE/$ID.check.sh" +TRUST="$STATE/$ID.check-trust" +[ -d "$STATE" ] && [ ! -L "$STATE" ] || { echo "error: state directory is unavailable" >&2; exit 1; } +[ -f "$CHECK" ] && [ ! -L "$CHECK" ] || { echo "error: custom check is unavailable" >&2; exit 1; } +STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 +fm_pr_private_file_valid "$CHECK" 700 "$STATE_DEVICE" \ + || { echo "error: custom check is unavailable" >&2; exit 1; } +fm_pr_regular_destination_on_device_or_absent "$TRUST" "$STATE_DEVICE" \ + || { echo "error: custom check trust path is unavailable" >&2; exit 1; } +HASH=$(fm_custom_check_sha256 "$CHECK") || { echo "error: custom check hash is unavailable" >&2; exit 1; } +umask 077 +TMP=$(mktemp "$STATE/.fm-custom-check-trust.XXXXXX") || exit 1 +trap '[ -z "$TMP" ] || rm -f -- "$TMP"' EXIT HUP INT TERM +printf '%s\n%s\n' fm-custom-check-v1 "$HASH" > "$TMP" || exit 1 +chmod 0600 "$TMP" || exit 1 +fm_pr_regular_destination_on_device_or_absent "$TRUST" "$STATE_DEVICE" || exit 1 +mv -f -- "$TMP" "$TRUST" || exit 1 +TMP= +fm_custom_check_registered "$STATE" "$ID" || { rm -f -- "$TRUST"; exit 1; } +printf 'registered: state/%s.check.sh\n' "$ID" diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 65820e6e7..db39f4c8a 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -59,10 +59,12 @@ FM_CLASSIFY_PAUSED_VERB_DEFAULT='paused' # shellcheck disable=SC2034 # Read by the watcher and daemon (fm-watch.sh, fm-supervise-daemon.sh), not this lib. FM_PAUSE_RESURFACE_SECS_DEFAULT=3600 -# The resolution verb that CLOSES a keyed decision opened by needs-decision or -# blocked. See status_open_decisions below for the full durable-decision contract; -# this is the one owner of the verb literal, overridable via FM_CLASSIFY_RESOLVE_VERB. +# The resolution verb and durable-backlog-transfer verb that CLOSE a keyed +# status decision opened by needs-decision or blocked. See status_open_decisions +# below for the status-fold contract. The transfer verb is written only after +# fm-decision-hold.sh has verified the corresponding captain-held backlog item. FM_CLASSIFY_RESOLVE_VERB_DEFAULT='resolved' +FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT='captain-held' # Return the last non-blank line of a status file (empty if missing/blank). last_status_line() { @@ -102,9 +104,10 @@ status_is_paused() { # # (last_status_line above) cannot represent "an earlier decision is still open # after a later, unrelated event": a subsequent done/paused/working line silently # masks a still-open needs-decision. status_open_decisions is the ONE authoritative -# statement of the contract that fixes this - a needs-decision/blocked line OPENS a -# keyed decision, and ONLY an explicit resolution referencing that key CLOSES it; a -# later unrelated terminal line never clears an open captain decision. +# statement of the status-fold contract that fixes this - a needs-decision/blocked +# line OPENS a keyed decision, and only an explicit resolution or a verified +# captain-held backlog transfer referencing that key CLOSES it; a later unrelated +# terminal line never clears an open captain decision. # # Decision key grammar (backward-compatible with the existing ": " # format): an OPTIONAL "[key=]" token sits between the verb and the colon, @@ -163,9 +166,10 @@ EOF # is the durable open-set the fleet snapshot and any point-in-time consumer must use # instead of trusting the last status line. status_open_decisions() { # - local f=$1 line verb key note resolve open='' stripped + local f=$1 line verb key note resolve held open='' stripped [ -f "$f" ] || return 0 resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} + held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} while IFS= read -r line || [ -n "$line" ]; do stripped=${line//[[:space:]]/} [ -n "$stripped" ] || continue @@ -178,7 +182,7 @@ status_open_decisions() { # [ -n "$open" ] && open="${open}"$'\n' open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' ;; - "$resolve") + "$resolve"|"$held") open=$(_fm_decision_drop "$open" "$key") [ -n "$open" ] && open="${open}"$'\n' ;; @@ -187,6 +191,51 @@ status_open_decisions() { # printf '%s' "$open" } +# Fold material routed-work phases in the same keyed event stream. +# A working or declared-pause event opens or replaces one phase for its key. +# A later done, failed, needs-decision, blocked, or resolved event carrying that +# key closes the phase, because it has moved to a terminal or separately tracked +# state. +# A bare legacy event uses the default key, preserving one-phase behavior. +# This fold is evidence about whether a parent event was explicitly superseded. +# It is never authoritative current crew state, and consumers must not let an open +# phase outrank a structured home snapshot or fm-crew-state result. +_fm_status_open_activities_stream() { + local line verb key note resolve held open='' stripped pause + resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} + held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} + pause=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + while IFS= read -r line || [ -n "$line" ]; do + stripped=${line//[[:space:]]/} + [ -n "$stripped" ] || continue + verb=$(status_line_verb "$line") + key=$(_fm_decision_key "$line") || continue + case "$verb" in + working|"$pause") + note=$(status_line_note "$line") + open=$(_fm_decision_drop "$open" "$key") + [ -n "$open" ] && open="${open}"$'\n' + open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' + ;; + done|failed|needs-decision|blocked|"$resolve"|"$held") + open=$(_fm_decision_drop "$open" "$key") + [ -n "$open" ] && open="${open}"$'\n' + ;; + esac + done + printf '%s' "$open" +} + +status_open_activities() { # + local f=$1 + if [ "$f" = - ]; then + _fm_status_open_activities_stream + return 0 + fi + [ -f "$f" ] || return 0 + _fm_status_open_activities_stream < "$f" +} + # task id from a recorded window target, falling back to the tmux-shaped # ":fm-" form when no metadata state is available. window_to_task() { diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 3018914e1..61bd63b78 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -1,11 +1,13 @@ # shellcheck shell=bash -# Inheritable-config propagation: the PRIMARY firstmate pushes a declared, -# extensible set of LOCAL (gitignored) config items down into each secondmate -# home's config/, so a secondmate's OWN crewmates inherit the primary's settings +# Inheritance propagation: the PRIMARY firstmate pushes a declared, extensible +# set of LOCAL (gitignored) config items down into each secondmate home's +# config/, so a secondmate's OWN crewmates inherit the primary's settings # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates # spawn on codex too, and primary config/backlog-backend=manual makes that home -# hand-edit backlog files too). +# hand-edit backlog files too). It also pushes the one primary-authoritative +# shared captain-preference file, data/captain-shared.md, into each secondmate +# home's data/ as a read-only copy. # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -25,11 +27,51 @@ # secondmates, and a secondmate never spawns secondmates, so it must not flow # downstream. +# The one shared data file in this inheritance contract. There is deliberately +# no shared learnings file. +FM_SHARED_CAPTAIN_FILE="captain-shared.md" +FM_SHARED_CAPTAIN_REL="data/$FM_SHARED_CAPTAIN_FILE" +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}" +fm_inherit_file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" 2>/dev/null + else + stat -c %a "$1" 2>/dev/null + fi +} + +fm_inherit_file_device() { + if [ "$(uname)" = Darwin ]; then + stat -f %d "$1" 2>/dev/null + else + stat -c %d "$1" 2>/dev/null + fi +} + +fm_inherit_file_link_count() { + if [ "$(uname)" = Darwin ]; then + stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +fm_inherit_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + copy_inheritable_file() { local src=$1 dest=$2 dest_parent tmp if [ -e "$dest" ] && [ ! -f "$dest" ] && [ ! -L "$dest" ]; then @@ -81,7 +123,7 @@ destination_allows_inherited_item() { # churns mtimes). A source item that is absent is mirrored as a missing # destination item, so clearing the primary's value clears it downstream too # (primary-authoritative). The destination dir is created lazily, only when there -# is actually something to write, so a primary with no inheritable config set is a +# is actually something to write, so a primary with no inherited config item set is a # complete no-op (it leaves the secondmate home exactly as it was - the # backward-compatible path). When FM_CONFIG_INHERIT_REPORT points at a writable # file, one tab-separated line per item is appended there: @@ -109,6 +151,239 @@ warn_inheritable_config_error() { echo "fm-config-inherit: error: $reason $item at $dest" >&2 } +shared_captain_header_valid() { + local src=$1 head + head=$(sed -n '1,12p' "$src" 2>/dev/null) || return 1 + case "$head" in *main-authoritative*) ;; *) return 1 ;; esac + case "$head" in *"read-only in secondmate homes"*) ;; *) return 1 ;; esac + case "$head" in *"must not be edited there"*) ;; *) return 1 ;; esac + case "$head" in *"main firstmate"*) ;; *) return 1 ;; esac + case "$head" in *"marked status"*|*"document pointer"*) ;; *) return 1 ;; esac +} + +shared_captain_dir_safe() { + local dir=$1 + [ -n "$dir" ] || return 1 + if [ -e "$dir" ] || [ -L "$dir" ]; then + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + else + mkdir -p "$dir" 2>/dev/null || return 1 + fi + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 +} + +shared_captain_file_safe_existing() { + local path=$1 + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + [ "$(fm_inherit_file_link_count "$path")" = 1 ] +} + +restore_shared_captain_readonly() { + local dest=$1 + [ -e "$dest" ] || [ -L "$dest" ] || return 0 + shared_captain_file_safe_existing "$dest" || return 1 + chmod "$FM_SHARED_CAPTAIN_MODE" "$dest" 2>/dev/null || return 1 +} + +shared_captain_quarantine_existing_for_hash() { + local parent=$1 hash=$2 artifact artifact_hash + for artifact in "$parent"/."$FM_SHARED_CAPTAIN_FILE".quarantine.*."$hash" "$parent"/."$FM_SHARED_CAPTAIN_FILE".quarantine.*."$hash".[0-9]*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + shared_captain_file_safe_existing "$artifact" || return 1 + artifact_hash=$(fm_inherit_sha256 "$artifact") || return 1 + [ "$artifact_hash" = "$hash" ] || continue + printf '%s\n' "$artifact" + return 0 + done + return 1 +} + +shared_captain_quarantine_name() { + local parent=$1 hash=$2 stamp base candidate n + stamp=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null) || return 1 + base="$parent/.$FM_SHARED_CAPTAIN_FILE.quarantine.$stamp.$hash" + candidate=$base + n=0 + while [ -e "$candidate" ] || [ -L "$candidate" ]; do + n=$((n + 1)) + candidate="$base.$n" + done + printf '%s\n' "$candidate" +} + +quarantine_shared_captain_dest() { + local dest=$1 dest_parent=$2 dest_hash artifact existing + shared_captain_file_safe_existing "$dest" || return 1 + dest_hash=$(fm_inherit_sha256 "$dest") || return 1 + if existing=$(shared_captain_quarantine_existing_for_hash "$dest_parent" "$dest_hash" 2>/dev/null); then + chmod u+w "$dest" 2>/dev/null || return 1 + if rm -f -- "$dest" 2>/dev/null; then + printf '%s\n' "$existing" + return 0 + fi + restore_shared_captain_readonly "$dest" || true + return 1 + fi + artifact=$(shared_captain_quarantine_name "$dest_parent" "$dest_hash") || return 1 + chmod u+w "$dest" 2>/dev/null || return 1 + if mv -- "$dest" "$artifact" 2>/dev/null; then + chmod 0600 "$artifact" 2>/dev/null || return 1 + shared_captain_file_safe_existing "$artifact" || return 1 + printf '%s\n' "$artifact" + return 0 + fi + restore_shared_captain_readonly "$dest" || true + return 1 +} + +copy_shared_captain_file() { + local src=$1 dest=$2 dest_parent tmp + dest_parent=${dest%/*} + shared_captain_dir_safe "$dest_parent" || return 1 + tmp=$(mktemp "$dest_parent/.fm-captain-shared.XXXXXX" 2>/dev/null) || return 1 + if ! cp "$src" "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null || true + return 1 + fi + chmod 0600 "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 1; } + shared_captain_file_safe_existing "$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 1; } + if mv -f -- "$tmp" "$dest" 2>/dev/null; then + chmod "$FM_SHARED_CAPTAIN_MODE" "$dest" 2>/dev/null || return 1 + shared_captain_file_safe_existing "$dest" || return 1 + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + return 1 +} + +propagate_shared_captain_preferences() { + local src_data=$1 dest_data=$2 src dest src_hash dest_hash dest_parent dest_home quarantine reason rc + [ -n "$src_data" ] || return 1 + [ -n "$dest_data" ] || return 1 + src="$src_data/$FM_SHARED_CAPTAIN_FILE" + dest="$dest_data/$FM_SHARED_CAPTAIN_FILE" + dest_parent=${dest%/*} + dest_home=${dest_data%/data} + rc=0 + + if [ -e "$src" ] || [ -L "$src" ]; then + if ! shared_captain_file_safe_existing "$src"; then + reason="unsafe primary source" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$src" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + fi + if ! shared_captain_header_valid "$src"; then + reason="primary source header missing required main-authoritative warning" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$src" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + fi + src_hash=$(fm_inherit_sha256 "$src") || { + reason="failed to hash primary source" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$src" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + } + if [ -e "$dest" ] || [ -L "$dest" ]; then + if ! shared_captain_file_safe_existing "$dest"; then + reason="unsafe destination" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + fi + dest_hash=$(fm_inherit_sha256 "$dest") || { + reason="failed to hash destination" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + restore_shared_captain_readonly "$dest" || true + return 1 + } + if [ "$src_hash" = "$dest_hash" ]; then + if restore_shared_captain_readonly "$dest"; then + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" unchanged "" + return 0 + fi + reason="failed to restore read-only mode" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + fi + if ! shared_captain_dir_safe "$dest_parent"; then + reason="unsafe destination directory" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest_parent" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + restore_shared_captain_readonly "$dest" || true + return 1 + fi + if ! quarantine=$(quarantine_shared_captain_dest "$dest" "$dest_parent"); then + reason="failed to quarantine divergent destination" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + restore_shared_captain_readonly "$dest" || true + return 1 + fi + printf 'SECONDMATE_SYNC: secondmate home %s: quarantined %s drift at %s\n' "$dest_home" "$FM_SHARED_CAPTAIN_REL" "$quarantine" + elif ! shared_captain_dir_safe "$dest_parent"; then + reason="unsafe destination directory" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest_parent" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + fi + if copy_shared_captain_file "$src" "$dest"; then + if [ -n "${quarantine:-}" ]; then + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed "quarantined local drift at $quarantine" + else + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed "" + fi + else + reason="failed to copy" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + rc=1 + fi + elif [ -e "$dest" ] || [ -L "$dest" ]; then + if ! shared_captain_file_safe_existing "$dest"; then + reason="unsafe destination" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + return 1 + fi + if ! shared_captain_dir_safe "$dest_parent"; then + reason="unsafe destination directory" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest_parent" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + restore_shared_captain_readonly "$dest" || true + return 1 + fi + if quarantine=$(quarantine_shared_captain_dest "$dest" "$dest_parent"); then + printf 'SECONDMATE_SYNC: secondmate home %s: quarantined %s drift at %s\n' "$dest_home" "$FM_SHARED_CAPTAIN_REL" "$quarantine" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed "mirrored primary absence after quarantining local copy at $quarantine" + else + reason="failed to quarantine destination before mirroring primary absence" + warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason" + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason" + restore_shared_captain_readonly "$dest" || true + rc=1 + fi + else + record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" unchanged "" + fi + return "$rc" +} + +propagate_secondmate_inheritance() { + local src_home=$1 dest_home=$2 src_config=${3:-} src_data=${4:-} rc + [ -n "$src_home" ] || return 1 + [ -n "$dest_home" ] || return 1 + [ -n "$src_config" ] || src_config="$src_home/config" + [ -n "$src_data" ] || src_data="$src_home/data" + rc=0 + propagate_inheritable_config "$src_config" "$dest_home/config" || rc=1 + propagate_shared_captain_preferences "$src_data" "$dest_home/data" || rc=1 + return "$rc" +} + propagate_inheritable_config() { local src_config=$1 dest_config=$2 item src dest reason rc [ -n "$src_config" ] || return 1 diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index 7acc56b73..86b833835 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# Push declared inheritable local config to live secondmate homes. +# Push declared inherited local material to live secondmate homes. # Usage: fm-config-push.sh [--help] # -# Config-only convergence for mid-session changes such as config/crew-dispatch.json -# edits. This discovers live secondmate homes from state/*.meta, backfills +# Mid-session convergence for inherited local material such as +# config/crew-dispatch.json edits or data/captain-shared.md updates. This +# discovers live secondmate homes from state/*.meta, backfills # home= from data/secondmates.md for older meta records, and reuses the same -# propagate_inheritable_config machinery as bootstrap, but deliberately does not +# propagation machinery as bootstrap, but deliberately does not # fast-forward tracked files and does not nudge running secondmates. # Warnings-only skips exit 0; real propagation errors exit non-zero. set -u @@ -14,10 +15,10 @@ usage() { cat <<'EOF' Usage: fm-config-push.sh [--help] -Push the primary firstmate home's declared inheritable local config into each -live secondmate home's config/ directory. +Push the primary firstmate home's declared inherited local material into each +live secondmate home. -This is config-only: +This is local-material-only: - does not fast-forward tracked files - does not nudge secondmates - reports each live home and each inheritable item as pushed, unchanged, @@ -32,6 +33,7 @@ Environment overrides follow the rest of firstmate: FM_HOME active firstmate home FM_ROOT_OVERRIDE firstmate repo root FM_STATE_OVERRIDE state dir + FM_DATA_OVERRIDE data dir FM_CONFIG_OVERRIDE config dir EOF } @@ -54,7 +56,7 @@ 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}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" -DATA="$FM_HOME/data" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" SECONDMATES_MD="$DATA/secondmates.md" "$SCRIPT_DIR/fm-guard.sh" || true @@ -94,7 +96,7 @@ if [ ! -s "$records" ]; then exit 0 fi -echo "config-push: $CONFIG -> live secondmate homes" +echo "config-push: $FM_HOME -> live secondmate homes" seen_homes="" errors=0 @@ -120,7 +122,7 @@ while IFS='|' read -r id home _window meta; do printf 'secondmate %s (%s):\n' "$id" "$home_real" dirty=$(dirty_status "$home_real" yes || true) if [ -n "$dirty" ]; then - echo " home: dirty working tree - config-only push continuing" + echo " home: dirty working tree - local-material push continuing" fi report=$(mktemp "${TMPDIR:-/tmp}/fm-config-push-report.XXXXXX" 2>/dev/null) || { @@ -129,7 +131,7 @@ while IFS='|' read -r id home _window meta; do continue } reports="$reports $report" - if FM_CONFIG_INHERIT_REPORT="$report" propagate_inheritable_config "$CONFIG" "$home_real/config"; then + if FM_CONFIG_INHERIT_REPORT="$report" propagate_secondmate_inheritance "$FM_HOME" "$home_real" "$CONFIG" "$DATA"; then print_item_report "$report" else errors=1 diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 07e9c2d3a..e660992dc 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -158,7 +158,7 @@ pane_readable() { # # 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 11) is not generating for that whole span, so +# or outcome - AGENTS.md section 7) is not generating for that whole span, so # agent.get can read idle/blocked (bin/backends/herdr.sh maps both to `idle`) # while the pane's own rendered text still shows the harness's busy banner # (BUSY_REGEX, e.g. "esc to interrupt") for the entire tool call, exactly like diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh new file mode 100755 index 000000000..aeb140a29 --- /dev/null +++ b/bin/fm-decision-hold.sh @@ -0,0 +1,464 @@ +#!/usr/bin/env bash +# fm-decision-hold.sh - deterministic mechanics for durable captain decisions. +# +# The semantic policy is owned once by +# .agents/skills/decision-hold-lifecycle/SKILL.md. This script never reads report, +# visual-review, chat, or terminal prose to guess whether a decision exists. +# The invoking agent inventories unresolved decisions, assigns stable keys, and +# routes dependent work. This script supplies deterministic identities, creates +# and verifies structured tasks-axi captain holds, records completion attestation +# in the originating task's metadata, and closes a hold only after a durable +# decision record has been linked to existing dependent work. +# +# A hold identity is -decision-. Origin ids and decision +# keys must already be privacy-safe slugs. Repeating `hold` with the same identity +# is idempotent. A different decision key creates a different backlog identity. +# All backlog mutations run in the active FM_HOME, which keeps main-home and +# secondmate-home ownership aligned with the work that discovered the decision. +# +# Usage: +# fm-decision-hold.sh id +# fm-decision-hold.sh hold \ +# --title --reason <reason> [--repo <repo>] +# fm-decision-hold.sh complete <origin-id> (--none | <decision-key>...) +# fm-decision-hold.sh verify <origin-id> +# fm-decision-hold.sh resolve <origin-id> <decision-key> \ +# --decision-file <path> --routed-to <task-id> [--routed-to <task-id>...] +# +# `complete` is the shared investigation and visual-review completion gate. +# `--none` is an explicit semantic attestation that the just-reviewed surface has +# no unresolved captain decision. Later review passes may add keys; a live task's +# metadata inventory is unioned idempotently. A post-teardown visual review can +# complete against the surviving report and holds without recreating task state. +# `verify` is read-only and is called by scout teardown so teardown cannot erase a +# source before this gate has succeeded. +# +# `resolve` requires every --routed-to task to exist and to be blocked by the hold. +# It writes the captain decision and routed identities into the hold body, clears +# those dependency edges, and only then marks the hold Done. A failure before the +# final step leaves the captain hold open. +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}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-classify-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-tasks-axi-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-tasks-axi-lib.sh" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +fail() { + printf 'fm-decision-hold: %s\n' "$*" >&2 + exit 1 +} + +validate_slug() { # <label> <value> + local label=$1 value=$2 + case "$value" in + ''|*[!A-Za-z0-9._-]*) fail "$label must be a non-empty privacy-safe slug: $value" ;; + esac +} + +validate_one_line() { # <label> <value> + local label=$1 value=$2 + [ -n "$value" ] || fail "$label must not be empty" + case "$value" in + *$'\n'*|*$'\r'*) fail "$label must be one line" ;; + esac +} + +sha256_text() { # <text> + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | awk '{print $1}' + else + fail "shasum or sha256sum is required" + fi +} + +hold_id() { # <origin-id> <decision-key> + validate_slug origin-id "$1" + validate_slug decision-key "$2" + printf '%s-decision-%s\n' "$1" "$2" +} + +tasks_axi() { + (cd "$FM_HOME" && tasks-axi "$@") +} + +require_tasks_axi() { + fm_tasks_axi_compatible || fail "compatible tasks-axi is required" + tasks-axi hold --help 2>&1 | grep -F -- '--kind captain' >/dev/null \ + || fail "tasks-axi does not expose the captain-hold contract" +} + +task_show() { # <id> + tasks_axi show "$1" --full 2>/dev/null +} + +show_field() { # <show-output> <field> + local output=$1 field=$2 + printf '%s\n' "$output" | sed -n "s/^ $field: //p" | head -1 +} + +origin_exists_here() { # <origin-id> + [ -f "$STATE/$1.meta" ] && return 0 + [ -f "$DATA/$1/report.md" ] && return 0 + task_show "$1" >/dev/null 2>&1 +} + +list_has_key() { # <comma-list> <key> + case ",$1," in + *",$2,"*) return 0 ;; + *) return 1 ;; + esac +} + +sorted_key_union() { # <comma-list> <newline-or-space-separated-new-keys> + local existing=$1 new=$2 + { + printf '%s\n' "$existing" | tr ',' '\n' + printf '%s\n' "$new" | tr ' ' '\n' + } | sed '/^$/d' | LC_ALL=C sort -u | paste -sd, - +} + +meta_value() { # <meta> <key> + grep "^$2=" "$1" 2>/dev/null | tail -1 | cut -d= -f2- || true +} + +origin_open_decisions() { # <origin-id> + local origin=$1 meta="$STATE/$1.meta" status_file="$STATE/$1.status" open kind last verb + open=$(status_open_decisions "$status_file") + [ -n "$open" ] || return 0 + [ -f "$meta" ] || { printf '%s' "$open"; return 0; } + kind=$(meta_value "$meta" kind) + [ -n "$kind" ] || kind=ship + if [ "$kind" != secondmate ]; then + last=$(last_status_line "$status_file") + verb=$(status_line_verb "$last") + case "$verb" in + done|failed) return 0 ;; + esac + fi + printf '%s' "$open" +} + +verify_hold_active() { # <hold-id> + local id=$1 show state held kind hold_kind + show=$(task_show "$id") || fail "captain hold $id is absent from $FM_HOME/data/backlog.md" + state=$(show_field "$show" state) + held=$(show_field "$show" held) + kind=$(show_field "$show" kind) + hold_kind=$(show_field "$show" hold_kind) + [ "$state" = queued ] || fail "captain hold $id is not queued (state=$state)" + [ "$held" = yes ] || fail "captain hold $id is not active" + [ "$kind" = captain ] || fail "backlog item $id is not kind captain" + [ "$hold_kind" = captain ] || fail "backlog item $id is not held for the captain" +} + +verify_hold_resolved() { # <hold-id> + local id=$1 show state kind body + show=$(task_show "$id") || return 1 + state=$(show_field "$show" state) + kind=$(show_field "$show" kind) + body=$(show_field "$show" body) + [ "$state" = "done" ] || return 1 + [ "$kind" = captain ] || return 1 + case "$body" in + *"Resolution recorded by fm-decision-hold."*"Routed work:"*) return 0 ;; + esac + return 1 +} + +verify_hold_durable() { # <hold-id> + local id=$1 show state held kind hold_kind body + show=$(task_show "$id") || fail "captain decision $id is absent from $FM_HOME/data/backlog.md" + state=$(show_field "$show" state) + held=$(show_field "$show" held) + kind=$(show_field "$show" kind) + hold_kind=$(show_field "$show" hold_kind) + body=$(show_field "$show" body) + if [ "$state" = queued ] && [ "$held" = yes ] && [ "$kind" = captain ] && [ "$hold_kind" = captain ]; then + return 0 + fi + if [ "$state" = "done" ] && [ "$kind" = captain ]; then + case "$body" in + *"Resolution recorded by fm-decision-hold."*"Routed work:"*) return 0 ;; + esac + fi + fail "captain decision $id is neither actively held nor durably resolved" +} + +verify_resolution_identity() { + local id=$1 hold_body=$2 decision_digest=$3 routed_csv=$4 resolution_prefix resolution_fields recorded_digest recorded_routes + resolution_prefix='"Resolution recorded by fm-decision-hold.\nDecision digest: ' + case "$hold_body" in + "$resolution_prefix"*) resolution_fields=${hold_body#"$resolution_prefix"} ;; + *) fail "captain hold $id has no retry identity record" ;; + esac + case "$resolution_fields" in + *'\nRouted identities: '*'\n\nCaptain decision:'*) : ;; + *) fail "captain hold $id has an invalid retry identity record" ;; + esac + recorded_digest=${resolution_fields%%\\n*} + resolution_fields=${resolution_fields#*\\nRouted identities: } + recorded_routes=${resolution_fields%%\\n*} + [ "$recorded_digest" = "$decision_digest" ] \ + || fail "captain hold $id records a different captain decision" + [ "$recorded_routes" = "$routed_csv" ] \ + || fail "captain hold $id records different routed work" +} + +command_id() { + [ "$#" -eq 2 ] || { usage >&2; exit 2; } + hold_id "$1" "$2" +} + +command_hold() { + local origin=${1:-} key=${2:-} title='' reason='' repo='' id show state kind existing_title body + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + shift 2 + while [ "$#" -gt 0 ]; do + case "$1" in + --title) shift; title=${1:-} ;; + --reason) shift; reason=${1:-} ;; + --repo) shift; repo=${1:-} ;; + *) usage >&2; exit 2 ;; + esac + shift + done + validate_slug origin-id "$origin" + validate_slug decision-key "$key" + 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 + origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME" + id=$(hold_id "$origin" "$key") + if show=$(task_show "$id"); then + state=$(show_field "$show" state) + kind=$(show_field "$show" kind) + existing_title=$(show_field "$show" title) + [ "$state" != "done" ] || fail "captain decision $id is already durably resolved; use a new decision key for a new decision" + [ "$kind" = captain ] || fail "existing backlog identity $id is not kind captain" + [ "$existing_title" = "$title" ] || fail "existing captain hold $id has a different title" + else + if [ -z "$repo" ] && [ -f "$STATE/$origin.meta" ]; then + repo=$(meta_value "$STATE/$origin.meta" project) + repo=${repo%/} + repo=${repo##*/} + fi + [ -n "$repo" ] || repo=firstmate + validate_one_line repo "$repo" + body=$(printf 'Origin: %s\nDecision key: %s\nState: awaiting captain decision.' "$origin" "$key") + tasks_axi add "$id" "$title" --kind captain --repo "$repo" --body "$body" >/dev/null \ + || fail "could not create captain decision item $id" + fi + tasks_axi hold "$id" --reason "$reason" --kind captain >/dev/null \ + || fail "could not activate captain hold $id" + verify_hold_active "$id" + printf '%s\n' "$id" +} + +command_complete() { + local origin=${1:-} meta previous='' supplied='' keys='' key status_file open raw_open key_seen=0 has_meta=0 + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + validate_slug origin-id "$origin" + shift + meta="$STATE/$origin.meta" + [ -f "$meta" ] && has_meta=1 + 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 + supplied='' + else + while [ "$#" -gt 0 ]; do + [ "$1" != --none ] || fail "--none cannot be combined with decision keys" + validate_slug decision-key "$1" + supplied="${supplied}${supplied:+ }$1" + shift + done + fi + if [ "$has_meta" = 1 ]; then + previous=$(meta_value "$meta" decision_keys) + fi + keys=$(sorted_key_union "$previous" "$supplied") + if [ -n "$keys" ]; then + while IFS= read -r key; do + [ -n "$key" ] || continue + verify_hold_durable "$(hold_id "$origin" "$key")" + done <<EOF +$(printf '%s\n' "$keys" | tr ',' '\n') +EOF + fi + + status_file="$STATE/$origin.status" + raw_open=$(status_open_decisions "$status_file") + open=$(origin_open_decisions "$origin") + while IFS=$'\t' read -r key _verb _summary; do + [ -n "$key" ] || continue + list_has_key "$keys" "$key" \ + || fail "open structured decision $origin/$key has no captain-held inventory entry" + done <<EOF +$open +EOF + + if [ "$has_meta" = 1 ]; then + if [ "$(meta_value "$meta" decisions_reviewed)" != 1 ] || [ "$previous" != "$keys" ]; then + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$keys" >> "$meta" + fi + + # Transfer any still-open status decision to its durable backlog owner so the + # live status fold does not duplicate the same Captain's Call item. + while IFS=$'\t' read -r key _verb _summary; do + [ -n "$key" ] || continue + list_has_key "$keys" "$key" || continue + printf 'captain-held [key=%s]: tracked by %s\n' "$key" "$(hold_id "$origin" "$key")" >> "$status_file" + key_seen=1 + done <<EOF +$raw_open +EOF + fi + : "$key_seen" + printf 'complete: %s decision inventory reviewed%s\n' "$origin" "${keys:+ ($keys)}" +} + +command_verify() { + local origin=${1:-} meta reviewed keys key open + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + validate_slug origin-id "$origin" + meta="$STATE/$origin.meta" + [ -f "$meta" ] || fail "origin metadata is absent: $meta" + require_tasks_axi + reviewed=$(meta_value "$meta" decisions_reviewed) + [ "$reviewed" = 1 ] || fail "origin $origin has no completed unresolved-decision inventory" + keys=$(meta_value "$meta" decision_keys) + if [ -n "$keys" ]; then + while IFS= read -r key; do + [ -n "$key" ] || continue + verify_hold_durable "$(hold_id "$origin" "$key")" + done <<EOF +$(printf '%s\n' "$keys" | tr ',' '\n') +EOF + fi + open=$(origin_open_decisions "$origin") + while IFS=$'\t' read -r key _verb _summary; do + [ -n "$key" ] || continue + list_has_key "$keys" "$key" \ + || fail "open structured decision $origin/$key is outside the reviewed inventory" + verify_hold_durable "$(hold_id "$origin" "$key")" + done <<EOF +$open +EOF + printf 'verified: %s unresolved-decision inventory\n' "$origin" +} + +command_resolve() { + local origin=${1:-} key=${2:-} decision_file='' id='' decision='' decision_digest='' body='' routed='' routed_csv='' dep show blocked state hold_show hold_body resolution_recorded=0 + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + shift 2 + while [ "$#" -gt 0 ]; do + case "$1" in + --decision-file) shift; decision_file=${1:-} ;; + --routed-to) shift; validate_slug routed-task "${1:-}"; routed="${routed}${routed:+ }${1:-}" ;; + *) usage >&2; exit 2 ;; + esac + shift + done + validate_slug origin-id "$origin" + validate_slug decision-key "$key" + [ -n "$decision_file" ] || fail "--decision-file is required" + [ -f "$decision_file" ] || fail "decision file does not exist: $decision_file" + decision=$(cat "$decision_file") + [ -n "$decision" ] || fail "decision file must not be empty" + [ "$(printf '%s' "$decision" | LC_ALL=C wc -c | tr -d ' ')" -le 8192 ] \ + || fail "decision file exceeds 8192 bytes" + [ -n "$routed" ] || fail "at least one --routed-to task is required" + 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 + id=$(hold_id "$origin" "$key") + if verify_hold_resolved "$id"; then + hold_show=$(task_show "$id") + hold_body=$(show_field "$hold_show" body) + verify_resolution_identity "$id" "$hold_body" "$decision_digest" "$routed_csv" + printf 'resolved: %s\n' "$id" + return 0 + fi + verify_hold_active "$id" + hold_show=$(task_show "$id") + hold_body=$(show_field "$hold_show" body) + case "$hold_body" in + *"Resolution recorded by fm-decision-hold."*) + verify_resolution_identity "$id" "$hold_body" "$decision_digest" "$routed_csv" + resolution_recorded=1 + ;; + esac + + for dep in $routed; do + show=$(task_show "$dep") || fail "routed task $dep does not exist in the active home" + state=$(show_field "$show" state) + [ "$state" != "done" ] || [ "$resolution_recorded" = 1 ] \ + || fail "routed task $dep is already done" + # tasks-axi quotes multi-entry blocked_by as "a,b,c"; strip so edge ids match. + blocked=$(show_field "$show" blocked_by | tr -d '[:space:]') + blocked=${blocked#\"} + blocked=${blocked%\"} + case ",$blocked," in + *",$id,"*) : ;; + *) + case "$hold_body" in + *"Resolution recorded by fm-decision-hold."*"- $dep"*) : ;; + *) fail "routed task $dep is not durably blocked by $id" ;; + esac + ;; + esac + done + + body=$(printf 'Resolution recorded by fm-decision-hold.\nDecision digest: %s\nRouted identities: %s\n\nCaptain decision:\n%s\n\nRouted work:\n' "$decision_digest" "$routed_csv" "$decision") + for dep in $routed; do + body="${body}- ${dep}"$'\n' + done + tasks_axi update "$id" --body "$body" >/dev/null \ + || fail "could not record the captain decision on $id" + for dep in $routed; do + show=$(task_show "$dep") || fail "routed task $dep disappeared before routing" + blocked=$(show_field "$show" blocked_by | tr -d '[:space:]') + blocked=${blocked#\"} + blocked=${blocked%\"} + case ",$blocked," in + *",$id,"*) + tasks_axi unblock "$dep" --by "$id" >/dev/null \ + || fail "could not route the recorded decision to $dep" + ;; + esac + done + tasks_axi "done" "$id" >/dev/null || fail "could not close resolved captain hold $id" + verify_hold_resolved "$id" || fail "captain hold $id did not retain its durable resolution record" + printf 'resolved: %s -> %s\n' "$id" "$routed" +} + +case "${1:-}" in + id) shift; command_id "$@" ;; + hold) shift; command_hold "$@" ;; + complete) shift; command_complete "$@" ;; + verify) shift; command_verify "$@" ;; + resolve) shift; command_resolve "$@" ;; + -h|--help) usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index ae290020c..e6d6801a7 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -410,6 +410,10 @@ process_secondmate() { return 0 fi FF_NUDGE_WINDOWS="$FF_NUDGE_WINDOWS fm-$id" + if [ "$nudge_requires_instr" = yes ] && [ -n "$FF_INSTR" ] \ + && type fm_ff_after_instruction_update >/dev/null 2>&1; then + fm_ff_after_instruction_update "$id" "$home_real" "$window" "$FF_INSTR" + fi fi } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 4bde6ff51..fbfb18912 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -8,12 +8,15 @@ # # Top-level fields: # schema: stable schema id. +# generated: UTC observation time for this fresh command execution. # fm_home: resolved operational home. # roots: resolved root/config/data/state/projects directories. # backlog: {path,present,records[]} where records are ordered as written in # data/backlog.md and cover In flight, Queued, and Done. # Canonical tasks-axi rows are structured; free-form non-empty lines in # those sections are preserved as unstructured records. +# Structured rows preserve captain-hold metadata such as hold_kind and +# hold_reason when tasks-axi emits it. # tasks[]: one row per state/<id>.meta, sorted by id. # current_state is parsed from bin/fm-crew-state.sh <id> and preserves # state, source, detail, and raw line separately. @@ -27,12 +30,16 @@ # endpoint.agent_alive is populated for secondmates only, where it is useful # return-channel supervision data; other tasks use "not_checked". # scout_reports[]: present data/<id>/report.md pointers. -# secondmate_landed: {records[],truncated[],unreadable[]} - a bounded, read-only -# roll-up of DONE backlog records from this home's registered secondmate homes, -# so landed-work views see merges a secondmate managed (recorded in ITS OWN -# backlog, not the main one). Per-home count is capped; homes with an existing -# but unparseable backlog are disclosed in unreadable[]. Home paths come from the -# one secondmate-home enumerator (meta home= with data/secondmates.md fallback). +# secondmate_current: {records[],total,shown,truncated} - bounded current summaries +# for registered secondmates, selected from validated structured state inside +# each home with explicit provenance, freshness, endpoint evidence, and unknown +# failure reasons. Parent status and bounded terminal evidence are historical, +# untrusted supplements only and never override a valid structured summary. +# Each structured-home record carries active_children, decisions_open, holds, +# queued, landed, endpoints, counts, and omitted; captain holds appear in +# decisions_open and are also preserved in queued with hold metadata. +# secondmate_landed: {records[],truncated[],unreadable[]} - the compatibility +# landed-work roll-up derived from secondmate_current. # secondmate_guidance: return-channel action note for renderers and bearings. # # Compatibility: JSON is the primary machine-readable surface. @@ -47,6 +54,65 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" BACKLOG="$DATA/backlog.md" +SNAPSHOT_NOW=${FM_SNAPSHOT_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} +if [ -n "${FM_SNAPSHOT_NOW_EPOCH:-}" ]; then + SNAPSHOT_EPOCH=$FM_SNAPSHOT_NOW_EPOCH +else + SNAPSHOT_EPOCH=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$SNAPSHOT_NOW" +%s 2>/dev/null \ + || date -u -d "$SNAPSHOT_NOW" +%s 2>/dev/null \ + || date +%s) +fi +case "$SNAPSHOT_EPOCH" in ''|*[!0-9]*) SNAPSHOT_EPOCH=$(date +%s) ;; esac + +# Cross-home bounds are explicit so one broken or unexpectedly large home cannot +# hang or explode the parent snapshot. +FM_SNAPSHOT_SECONDMATES=${FM_SNAPSHOT_SECONDMATES:-20} +FM_SNAPSHOT_SECONDMATE_TIMEOUT=${FM_SNAPSHOT_SECONDMATE_TIMEOUT:-8} +FM_SNAPSHOT_SECONDMATE_MAX_BYTES=${FM_SNAPSHOT_SECONDMATE_MAX_BYTES:-262144} +FM_SNAPSHOT_SECONDMATE_CHILDREN=${FM_SNAPSHOT_SECONDMATE_CHILDREN:-20} +FM_SNAPSHOT_SECONDMATE_QUEUED=${FM_SNAPSHOT_SECONDMATE_QUEUED:-20} +FM_SNAPSHOT_SECONDMATE_DECISIONS=${FM_SNAPSHOT_SECONDMATE_DECISIONS:-20} +FM_SNAPSHOT_TERMINAL_LINES=${FM_SNAPSHOT_TERMINAL_LINES:-8} +FM_SNAPSHOT_TERMINAL_BYTES=${FM_SNAPSHOT_TERMINAL_BYTES:-4096} +FM_SNAPSHOT_TERMINAL_TIMEOUT=${FM_SNAPSHOT_TERMINAL_TIMEOUT:-2} +FM_SNAPSHOT_PARENT_ACTIVITY_LINES=${FM_SNAPSHOT_PARENT_ACTIVITY_LINES:-256} +FM_SNAPSHOT_PARENT_ACTIVITY_BYTES=${FM_SNAPSHOT_PARENT_ACTIVITY_BYTES:-65536} +FM_SNAPSHOT_PARENT_ACTIVITIES=${FM_SNAPSHOT_PARENT_ACTIVITIES:-20} +FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT=${FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT:-2} +FM_SNAPSHOT_REGISTRY_LINES=${FM_SNAPSHOT_REGISTRY_LINES:-256} +FM_SNAPSHOT_REGISTRY_BYTES=${FM_SNAPSHOT_REGISTRY_BYTES:-65536} +FM_SNAPSHOT_REGISTRY_RECORDS=${FM_SNAPSHOT_REGISTRY_RECORDS:-40} +FM_SNAPSHOT_REGISTRY_TIMEOUT=${FM_SNAPSHOT_REGISTRY_TIMEOUT:-2} +validate_positive_bound() { # <name> <value> + case "$2" in + ''|*[!0-9]*|0) + printf 'fm-fleet-snapshot: %s must be a positive integer\n' "$1" >&2 + exit 2 + ;; + esac +} +case "$FM_SNAPSHOT_SECONDMATES" in + ''|*[!0-9]*) + echo "fm-fleet-snapshot: FM_SNAPSHOT_SECONDMATES must be a non-negative integer" >&2 + exit 2 + ;; +esac +validate_positive_bound FM_SNAPSHOT_SECONDMATE_TIMEOUT "$FM_SNAPSHOT_SECONDMATE_TIMEOUT" +validate_positive_bound FM_SNAPSHOT_SECONDMATE_MAX_BYTES "$FM_SNAPSHOT_SECONDMATE_MAX_BYTES" +validate_positive_bound FM_SNAPSHOT_SECONDMATE_CHILDREN "$FM_SNAPSHOT_SECONDMATE_CHILDREN" +validate_positive_bound FM_SNAPSHOT_SECONDMATE_QUEUED "$FM_SNAPSHOT_SECONDMATE_QUEUED" +validate_positive_bound FM_SNAPSHOT_SECONDMATE_DECISIONS "$FM_SNAPSHOT_SECONDMATE_DECISIONS" +validate_positive_bound FM_SNAPSHOT_TERMINAL_LINES "$FM_SNAPSHOT_TERMINAL_LINES" +validate_positive_bound FM_SNAPSHOT_TERMINAL_BYTES "$FM_SNAPSHOT_TERMINAL_BYTES" +validate_positive_bound FM_SNAPSHOT_TERMINAL_TIMEOUT "$FM_SNAPSHOT_TERMINAL_TIMEOUT" +validate_positive_bound FM_SNAPSHOT_PARENT_ACTIVITY_LINES "$FM_SNAPSHOT_PARENT_ACTIVITY_LINES" +validate_positive_bound FM_SNAPSHOT_PARENT_ACTIVITY_BYTES "$FM_SNAPSHOT_PARENT_ACTIVITY_BYTES" +validate_positive_bound FM_SNAPSHOT_PARENT_ACTIVITIES "$FM_SNAPSHOT_PARENT_ACTIVITIES" +validate_positive_bound FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT "$FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT" +validate_positive_bound FM_SNAPSHOT_REGISTRY_LINES "$FM_SNAPSHOT_REGISTRY_LINES" +validate_positive_bound FM_SNAPSHOT_REGISTRY_BYTES "$FM_SNAPSHOT_REGISTRY_BYTES" +validate_positive_bound FM_SNAPSHOT_REGISTRY_RECORDS "$FM_SNAPSHOT_REGISTRY_RECORDS" +validate_positive_bound FM_SNAPSHOT_REGISTRY_TIMEOUT "$FM_SNAPSHOT_REGISTRY_TIMEOUT" # shellcheck source=bin/fm-backend.sh # shellcheck disable=SC1091 @@ -56,19 +122,39 @@ BACKLOG="$DATA/backlog.md" . "$SCRIPT_DIR/fm-classify-lib.sh" # shellcheck source=bin/fm-ff-lib.sh # shellcheck disable=SC1091 -. "$SCRIPT_DIR/fm-ff-lib.sh" # live_secondmate_meta_records: the one secondmate-home enumerator +. "$SCRIPT_DIR/fm-ff-lib.sh" # validate_secondmate_home: shared seeded-home boundary checks usage() { cat <<'EOF' usage: fm-fleet-snapshot.sh --json + fm-fleet-snapshot.sh --secondmate-home-summary Print a read-only structured snapshot of the firstmate fleet. JSON is the stable machine-readable output contract. + +--secondmate-home-summary emits the bounded structured summary used after a +validated registered-home handoff. It is local-only, skips nested secondmate +aggregation, and marks missing or unstructured current backlog state invalid. +Active tasks-axi captain holds appear as decisions_open and stay visible in +queued with hold_reason and hold_kind for downstream projections. +Cross-home reads use FM_SNAPSHOT_SECONDMATES (default 20, 0 lifts the count +bound), FM_SNAPSHOT_SECONDMATE_TIMEOUT, and FM_SNAPSHOT_SECONDMATE_MAX_BYTES. +Terminal contradiction evidence uses +FM_SNAPSHOT_TERMINAL_LINES, FM_SNAPSHOT_TERMINAL_BYTES, and +FM_SNAPSHOT_TERMINAL_TIMEOUT and never becomes canonical current state. +Parent activity evidence uses FM_SNAPSHOT_PARENT_ACTIVITY_LINES, +FM_SNAPSHOT_PARENT_ACTIVITY_BYTES, FM_SNAPSHOT_PARENT_ACTIVITIES, and +FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT, with truncation disclosed in the result. +The registered secondmate table uses FM_SNAPSHOT_REGISTRY_LINES, +FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and +FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. EOF } +OUTPUT_MODE=json case "${1:---json}" in --json) ;; + --secondmate-home-summary) OUTPUT_MODE=secondmate-home-summary ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; esac @@ -175,7 +261,7 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG def links($rest): [$rest | scan(url_pattern)]; def strip_trailing_metadata: reduce range(0; 20) as $_ (.; - sub("[[:space:]]*\\([[:space:]]*(?:(?:repo|kind|priority):[[:space:]]*[^)]*|(?:since|merged|reported|done)[[:space:]]+[^)]*)[[:space:]]*\\)[[:space:]]*$"; "")); + sub("[[:space:]]*\\([[:space:]]*(?:(?:repo|kind|priority|hold|hold-kind):[[:space:]]*[^)]*|(?:since|merged|reported|done)[[:space:]]+[^)]*)[[:space:]]*\\)[[:space:]]*$"; "")); def strip_title_artifacts: sub("[[:space:]]+-[[:space:]]+data/[^[:space:])]+/report\\.md$"; "") | sub("[[:space:]]+data/[^[:space:])]+/report\\.md$"; "") @@ -230,6 +316,8 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG repo:metadata($rest; "repo"), kind:metadata($rest; "kind"), priority:metadata($rest; "priority"), + hold_reason:metadata($rest; "hold"), + hold_kind:metadata($rest; "hold-kind"), blocked_by:cap($rest; ".*blocked-by:[[:space:]]*(?<v>[^[:space:])]+).*"), blocked_reason:blocked_reason($rest), since:metadata_word($rest; "since"), @@ -375,6 +463,7 @@ task_json_lines() { --arg pr "$pr" \ --arg pr_source "$pr_source" \ --arg agent_alive "$agent_alive" \ + --arg observed_at "$SNAPSHOT_NOW" \ --arg last_event_raw "$last_event_raw" \ --argjson current_state "$current_json" \ --argjson meta_path "$meta_json" \ @@ -403,8 +492,12 @@ task_json_lines() { report:$report }, secondmate_projects:($projects | if . == "" then [] else split(",") | map(gsub("^[[:space:]]+|[[:space:]]+$"; "")) | map(select(. != "")) end), - current_state:$current_state, - endpoint:{target:($target | if . == "" then null else . end),exists:$endpoint_exists,agent_alive:$agent_alive}, + current_state:($current_state + {observed_at:$observed_at,freshness:"fresh"}), + endpoint:{target:($target | if . == "" then null else . end),exists:$endpoint_exists,agent_alive:$agent_alive, + status:(if $endpoint_exists == false then "absent" + elif $agent_alive == "alive" or $agent_alive == "dead" then $agent_alive + else "unknown" end), + observed_at:$observed_at,freshness:"fresh"}, pr:{url:($pr | if . == "" then null else . end),source:$pr_source}, hints:{ pending_decision:$pending_decision, @@ -427,48 +520,638 @@ task_json_lines() { done | jq -s 'sort_by(.id)' } -# Bounded, deterministic roll-up of DONE records from this home's registered -# secondmate homes. A merge a secondmate managed is recorded in ITS OWN backlog, -# never the main one, so landed-work views miss it without this. Reuses the single -# backlog parser (backlog_json) against each home's data/backlog.md - a pure Markdown -# read, no per-task crew-state and no network - and the one secondmate-home enumerator -# (fm-ff-lib.sh's live_secondmate_meta_records: meta home= with data/secondmates.md -# fallback). Per-home Done is capped here by default so the canonical snapshot stays -# bounded; a cap of 0 explicitly lifts that bound for an expanding caller. Bearings -# applies its own tighter view caps and omitted[] disclosure. A home with no backlog -# file yet contributes nothing and is NOT flagged -# (a fresh secondmate is normal); only an existing backlog that fails to parse is -# reported unreadable. Records are sorted most-recent-first by completion date, id. +# Project one home's canonical structured inventory into the bounded shape a +# validated parent read needs. +# This mode never reads parent events or terminal text and never aggregates +# nested secondmates. +secondmate_home_summary_json() { # <backlog-json> <tasks-json> + jq -n \ + --arg generated "$SNAPSHOT_NOW" \ + --arg home "$FM_HOME" \ + --argjson child_n "$FM_SNAPSHOT_SECONDMATE_CHILDREN" \ + --argjson queued_n "$FM_SNAPSHOT_SECONDMATE_QUEUED" \ + --argjson decisions_n "$FM_SNAPSHOT_SECONDMATE_DECISIONS" \ + --argjson landed_n "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ + --argjson backlog "$1" \ + --argjson tasks "$2" ' + def trunc($n): + tostring | gsub("\\s+"; " ") + | if length > $n then .[:$n] + "…" else . end; + ([ $backlog.records[]? + | select((.state == "in_flight" or .state == "queued") and (.structured | not)) ]) as $unstructured_current + | ([ $backlog.records[]? | select(.state == "in_flight" and .structured) ]) as $owned_in_flight + | ([ $backlog.records[]? | select(.state == "queued" and .structured) ]) as $queued_all + | ([ $queued_all[] + | select(.kind == "captain" and .hold_kind == "captain" and .hold_reason != null) + | {id,key:.id,verb:"captain-hold",summary:(.title | trunc(160)), + reason:(.hold_reason | trunc(160)),source:"backlog"} ]) as $captain_holds_all + | ([ $backlog.records[]? | select(.state == "done" and .structured and .kind != "captain") + | {id:(.id | trunc(120)),title:(.title | trunc(120)), + pr_url:((.pr_url // null) | if . == null then null else trunc(500) end), + report_path:((.report_path // null) | if . == null then null else trunc(500) end), + local_note:((.local_note // null) | if . == null then null else trunc(120) end),completion} ] + | sort_by([(.completion.date // ""), .id]) | reverse) as $landed_all + | ([ $tasks[] | select(.current_state.state == "unknown") ]) as $unknown_children + | ([ $owned_in_flight[] | select(.id as $id | [$tasks[].id] | index($id) | not) ]) as $orphan_in_flight + | ([ $tasks[] + | select(.current_state.state == "working" + or .current_state.state == "parked" + or .current_state.state == "paused" + or .current_state.state == "blocked") + | select(.id as $id | [$owned_in_flight[].id] | index($id) | not) + | {id,state:.current_state.state} ]) as $unowned_current + | ([ $owned_in_flight[] as $work + | $tasks[] + | select(.id == $work.id and (.current_state.state == "done" or .current_state.state == "failed")) + | {id,state:.current_state.state} ]) as $terminal_in_flight + | ([ $owned_in_flight[] as $work + | $tasks[] + | select(.id == $work.id and .current_state.state == "working") + | {id,kind,state:.current_state.state,source:.current_state.source, + doing:((.current_state.detail // "") | trunc(120))} ]) as $active_all + | ($captain_holds_all + + ([ $tasks[] as $t | ($t.hints.open_decisions // [])[] + | {id:$t.id,key,verb,summary:(.summary | trunc(160)),reason:null,source:"status"} ])) as $decisions_all + | ([ $queued_all[] | select(.blocked_by != null) + | {id:(.id | trunc(120)),title:(.title | trunc(90)),blocked_by:(.blocked_by | trunc(120)),reason:((.blocked_reason // "blocked") | trunc(120)),source:"backlog"} ] + + [ $owned_in_flight[] as $work + | $tasks[] + | select(.id == $work.id and (.current_state.state == "parked" or .current_state.state == "paused" or .current_state.state == "blocked")) + | {id,title:((.backlog.title // .id) | trunc(90)),blocked_by:null, + reason:((.current_state.detail // .current_state.state) | trunc(120)),source:"child-state"} ]) as $holds_all + | ($backlog.present == true + and ($unstructured_current | length) == 0 + and ($unknown_children | length) == 0 + and ($orphan_in_flight | length) == 0 + and ($unowned_current | length) == 0 + and ($terminal_in_flight | length) == 0) as $valid + | (if $backlog.present != true then "missing structured backlog" + elif ($unstructured_current | length) > 0 then "unstructured current backlog row" + elif ($unknown_children | length) > 0 then "child current state unavailable" + elif ($orphan_in_flight | length) > 0 then "in-flight backlog item has no child metadata" + elif ($unowned_current | length) > 0 then + "live child state has no in-flight backlog item: " + + ($unowned_current | map(.id + "=" + .state) | join(", ")) + elif ($terminal_in_flight | length) > 0 then + "in-flight backlog item has terminal child state: " + + ($terminal_in_flight | map(.id + "=" + .state) | join(", ")) + else null end) as $reason + | (if $valid | not then "unknown" + elif any($decisions_all[]; .verb == "needs-decision" or .verb == "captain-hold") then "captain_decision" + elif ($active_all | length) > 0 then "active_child_work" + elif ($holds_all | length) > 0 then "externally_held" + else "no_active_work" end) as $state + | { + schema:"fm-secondmate-home-summary.v1", + generated:$generated, + home:$home, + valid:$valid, + reason:$reason, + state:$state, + active_children:$active_all[:$child_n], + decisions_open:$decisions_all[:$decisions_n], + holds:$holds_all[:$queued_n], + queued:([$queued_all[] | {id:(.id | trunc(120)),title:(.title | trunc(120)), + blocked_by:((.blocked_by // null) | if . == null then null else trunc(120) end), + blocked_reason:((.blocked_reason // null) | if . == null then null else trunc(160) end), + hold_reason:((.hold_reason // null) | if . == null then null else trunc(160) end), + hold_kind:((.hold_kind // null) | if . == null then null else trunc(40) end), + repo:((.repo // null) | if . == null then null else trunc(120) end), + kind:((.kind // null) | if . == null then null else trunc(40) end)}][:$queued_n]), + landed:(if $landed_n == 0 then $landed_all else $landed_all[:$landed_n] end), + endpoints:([$tasks[] | {id,state:.current_state.state,source:.current_state.source, + endpoint:(.endpoint + {target:((.endpoint.target // null) | if . == null then null else trunc(240) end)})}][:$child_n]), + counts:{ + active_children:($active_all | length), + decisions_open:($decisions_all | length), + holds:($holds_all | length), + queued:($queued_all | length), + landed:($landed_all | length), + endpoints:($tasks | length) + }, + omitted:[ + (if ($active_all | length) > $child_n then {surface:"active_children",count:(($active_all | length) - $child_n)} else empty end), + (if ($decisions_all | length) > $decisions_n then {surface:"decisions_open",count:(($decisions_all | length) - $decisions_n)} else empty end), + (if ($queued_all | length) > $queued_n then {surface:"queued",count:(($queued_all | length) - $queued_n)} else empty end), + (if ($tasks | length) > $child_n then {surface:"endpoints",count:(($tasks | length) - $child_n)} else empty end), + (if $landed_n > 0 and ($landed_all | length) > $landed_n then {surface:"landed",count:(($landed_all | length) - $landed_n)} else empty end) + ] + }' +} + +# Current registered-secondmate aggregation. +# The validated home summary is canonical. +# Parent status and bounded terminal capture remain untrusted supplemental evidence +# with explicit provenance, and can only produce a contradiction or unknown fallback. FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=${FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME:-10} case "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" in ''|*[!0-9]*) FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=10 ;; esac -secondmate_landed_json() { - local reg="$DATA/secondmates.md" id home backlog bj rows n - local records='[]' truncated='[]' unreadable='[]' - while IFS='|' read -r id home _; do - [ -n "$id" ] || continue - [ -n "$home" ] || continue - backlog="$home/data/backlog.md" - [ -f "$backlog" ] || continue - bj=$(backlog_json "$backlog") \ - || { unreadable=$(jq -n --argjson a "$unreadable" --arg h "$home" '$a + [$h]'); continue; } - rows=$(printf '%s' "$bj" | jq --arg home "$home" --arg id "$id" ' - [ .records[] | select(.state == "done" and .structured) - | {id, title, pr_url, report_path, local_note, completion, home:$home, home_id:$id} ] - | sort_by([(.completion.date // ""), .id]) | reverse') \ - || { unreadable=$(jq -n --argjson a "$unreadable" --arg h "$home" '$a + [$h]'); continue; } - n=$(printf '%s' "$rows" | jq 'length') - if [ "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" -gt 0 ] \ - && [ "$n" -gt "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" ]; then - truncated=$(jq -n --argjson a "$truncated" --arg h "$home" '$a + [$h]') + +run_timed() { # <seconds> <command...> + 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 +} + +# GNU stat treats -f as a filesystem-report command, so a BSD-first fallback can +# pollute arithmetic input before failing. Select the platform syntax once. +if [ "$(uname 2>/dev/null || true)" = Darwin ]; then + SNAPSHOT_STAT_STYLE=bsd + file_mtime_epoch() { stat -f '%m' "$1" 2>/dev/null || true; } + file_mode_octal() { stat -f '%Lp' "$1" 2>/dev/null || true; } +else + SNAPSHOT_STAT_STYLE=gnu + file_mtime_epoch() { stat -c '%Y' "$1" 2>/dev/null || true; } + file_mode_octal() { stat -c '%a' "$1" 2>/dev/null || true; } +fi + +registry_secondmates_json() { + local reg="$DATA/secondmates.md" out rc reason mode script parse_filter output_filter + if [ ! -f "$reg" ]; then + jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \ + '{present:false,available:true,complete:true,reason:null,provenance:"registered-table",path:$path,freshness:{status:"fresh",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}' + return 0 + fi + mode=$(file_mode_octal "$reg") + if [ -z "$mode" ] || [ $((8#$mode & 0444)) -eq 0 ]; then + jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \ + --arg reason "registered secondmate table is unreadable" \ + '{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}' + return 0 + fi + script=$(cat <<'BASH' + f=$1 + max_lines=$2 + max_bytes=$3 + max_records=$4 + path=$5 + observed=$6 + parse_filter=$7 + output_filter=$8 + content=$(LC_ALL=C head -c "$((max_bytes + 1))" "$f" || exit 3; printf "\036") || exit 3 + content=${content%$'\036'} + bytes=$(printf "%s" "$content" | LC_ALL=C wc -c | tr -d " ") + byte_truncated=false + if [ "$bytes" -gt "$max_bytes" ]; then + byte_truncated=true + content=$(printf "%s" "$content" | LC_ALL=C head -c "$max_bytes") + complete=${content%$'\n'*} + if [ "$complete" != "$content" ]; then + content=$complete + else + content= + fi + fi + if [ -n "$content" ]; then + lines=$(printf "%s\n" "$content" | awk "END {print NR}") + else + lines=0 + fi + line_truncated=false + if [ "$lines" -gt "$max_lines" ]; then line_truncated=true; fi + window=$(printf "%s\n" "$content" | LC_ALL=C head -n "$max_lines") || exit 3 + if [ -n "$window" ]; then + lines_in_window=$(printf "%s\n" "$window" | awk "END {print NR}") + else + lines_in_window=0 + fi + records=$(printf "%s\n" "$window" | jq -Rn "$parse_filter") || exit 3 + records_in_window=$(printf "%s" "$records" | jq "length") || exit 3 + records_truncated=false + if [ "$records_in_window" -gt "$max_records" ]; then records_truncated=true; fi + printf "%s" "$records" | jq \ + --arg path "$path" --arg observed "$observed" \ + --argjson byte_truncated "$byte_truncated" \ + --argjson line_truncated "$line_truncated" \ + --argjson records_truncated "$records_truncated" \ + --argjson lines_in_window "$lines_in_window" \ + --argjson records_in_window "$records_in_window" \ + --argjson max_records "$max_records" "$output_filter" +BASH + ) + parse_filter=$(cat <<'JQ' + [ inputs + | select(startswith("- ")) + | (capture("^- (?<id>[^[:space:]]+)")?) as $id + | select($id != null) + | (capture("\\(home:[[:space:]]*(?<home>[^;)]*);")?) as $home + | {id:$id.id,home:($home.home // null),registered:true, + registry_error:(if $home == null or ($home.home | length) == 0 then "registry entry has no home" else null end)} ] + | group_by(.id) + | map(if length > 1 then .[0] + {registry_error:"duplicate secondmate id in registry"} else .[0] end) +JQ + ) + output_filter=$(cat <<'JQ' + {present:true,available:true,reason:null,provenance:"registered-table",path:$path, + freshness:{status:"fresh",observed_at:$observed}, + records:(if length > $max_records then .[:$max_records] else . end), + input_truncated:($byte_truncated or $line_truncated),records_truncated:$records_truncated, + complete:(($byte_truncated or $line_truncated or $records_truncated) | not), + reasons:[ + (if $byte_truncated then "byte_limit" else empty end), + (if $line_truncated then "line_limit" else empty end), + (if $records_truncated then "record_limit" else empty end) + ],lines_in_window:$lines_in_window,records_in_window:$records_in_window} +JQ + ) + out=$(run_timed "$FM_SNAPSHOT_REGISTRY_TIMEOUT" bash -c "$script" \ + fm-secondmate-registry "$reg" "$FM_SNAPSHOT_REGISTRY_LINES" \ + "$FM_SNAPSHOT_REGISTRY_BYTES" "$FM_SNAPSHOT_REGISTRY_RECORDS" "$reg" "$SNAPSHOT_NOW" \ + "$parse_filter" "$output_filter" 2>/dev/null) + rc=$? + if [ "$rc" -eq 0 ] && printf '%s' "$out" | jq -e ' + .available == true and (.records | type) == "array" + ' >/dev/null 2>&1; then + printf '%s' "$out" + return 0 + fi + [ "$rc" -eq 124 ] && reason="registered secondmate table read timed out" \ + || reason="registered secondmate table is unreadable" + jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" --arg reason "$reason" \ + '{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}' +} + +bounded_parent_activities_json() { # <status-file> + local f=$1 out rc reason script + if [ ! -f "$f" ]; then + jq -n '{records:[],available:true,input_truncated:false,retained_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}' + return 0 + fi + script=$(cat <<'BASH' + classify=$1 + f=$2 + max_lines=$3 + max_bytes=$4 + max_records=$5 + stat_style=$6 + . "$classify" + if [ "$stat_style" = bsd ]; then + size=$(stat -f "%z" "$f" 2>/dev/null) || exit 3 + else + size=$(stat -c "%s" "$f" 2>/dev/null) || exit 3 + fi + content=$(LC_ALL=C tail -c "$max_bytes" "$f") || exit 3 + byte_truncated=false + if [ "$size" -gt "$max_bytes" ]; then + byte_truncated=true + complete=${content#*$'\n'} + if [ "$complete" != "$content" ]; then + content=$complete + else + content= + fi + fi + if [ -n "$content" ]; then + lines_in_chunk=$(printf "%s\n" "$content" | awk "END {print NR}") + else + lines_in_chunk=0 + fi + line_truncated=false + if [ "$lines_in_chunk" -gt "$max_lines" ]; then line_truncated=true; fi + window=$(printf "%s\n" "$content" | LC_ALL=C tail -n "$max_lines") || exit 3 + if [ -n "$window" ]; then + lines_in_window=$(printf "%s\n" "$window" | awk "END {print NR}") + else + lines_in_window=0 + fi + records=$(printf "%s\n" "$window" | status_open_activities - \ + | jq -R -s '[splits("\n") | select(length > 0) + | (capture("^(?<key>[^\t]*)\t(?<verb>[^\t]*)\t(?<summary>.*)$")?) + | select(. != null)]') || exit 3 + records_in_window=$(printf "%s" "$records" | jq "length") || exit 3 + retained_truncated=false + if [ "$records_in_window" -gt "$max_records" ]; then retained_truncated=true; fi + printf "%s" "$records" | jq \ + --argjson byte_truncated "$byte_truncated" \ + --argjson line_truncated "$line_truncated" \ + --argjson retained_truncated "$retained_truncated" \ + --argjson lines_in_window "$lines_in_window" \ + --argjson records_in_window "$records_in_window" \ + --argjson max_records "$max_records" ' + {records:(if length > $max_records then .[-$max_records:] else . end), + available:true, + input_truncated:($byte_truncated or $line_truncated), + retained_truncated:$retained_truncated, + reasons:[ + (if $byte_truncated then "byte_limit" else empty end), + (if $line_truncated then "line_limit" else empty end), + (if $retained_truncated then "activity_limit" else empty end) + ], + lines_in_window:$lines_in_window, + records_in_window:$records_in_window}' +BASH + ) + out=$(run_timed "$FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT" bash -c "$script" \ + fm-parent-activities "$SCRIPT_DIR/fm-classify-lib.sh" "$f" \ + "$FM_SNAPSHOT_PARENT_ACTIVITY_LINES" "$FM_SNAPSHOT_PARENT_ACTIVITY_BYTES" \ + "$FM_SNAPSHOT_PARENT_ACTIVITIES" "$SNAPSHOT_STAT_STYLE" 2>/dev/null) + rc=$? + if [ "$rc" -eq 0 ] && printf '%s' "$out" | jq -e ' + (.records | type) == "array" and (.available | type) == "boolean" + ' >/dev/null 2>&1; then + printf '%s' "$out" + return 0 + fi + [ "$rc" -eq 124 ] && reason="timeout" || reason="read_failed" + jq -n --arg reason "$reason" \ + '{records:[],available:false,input_truncated:false,retained_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}' +} + +terminal_evidence_json() { # <parent-task-json> <event-note> <evidence-contradicts> + local task=$1 note=$2 evidence_contradicts=$3 backend target exists expected out rc clean bytes lines seen=false contradiction=false reason='' + backend=$(printf '%s' "$task" | jq -r '.backend // ""') + target=$(printf '%s' "$task" | jq -r '.endpoint.target // ""') + exists=$(printf '%s' "$task" | jq -r '.endpoint.exists // "unknown"') + expected=$(printf '%s' "$task" | jq -r '"fm-" + (.id // "")') + if [ -z "$target" ] || [ "$exists" = false ]; then + [ "$exists" = false ] && reason="recorded endpoint is absent" || reason="no recorded endpoint" + jq -n --arg observed "$SNAPSHOT_NOW" --arg reason "$reason" \ + '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:false,observed_at:$observed,freshness:"unknown",reason:$reason,lines:0,bytes:0,event_note_seen:false,contradiction:false}' + return 0 + fi + # shellcheck disable=SC2016 # Positional parameters expand inside the child bash, not here. + out=$(run_timed "$FM_SNAPSHOT_TERMINAL_TIMEOUT" bash -c \ + '. "$1"; fm_backend_capture "$2" "$3" "$4" "$5" | LC_ALL=C head -c "$6"; rc=${PIPESTATUS[0]}; [ "$rc" -eq 141 ] && rc=0; exit "$rc"' \ + fm-terminal-capture "$SCRIPT_DIR/fm-backend.sh" "$backend" "$target" "$FM_SNAPSHOT_TERMINAL_LINES" "$expected" "$FM_SNAPSHOT_TERMINAL_BYTES" 2>/dev/null) + rc=$? + if [ "$rc" -ne 0 ]; then + [ "$rc" -eq 124 ] && reason="terminal capture timed out" || reason="terminal capture unavailable" + jq -n --arg observed "$SNAPSHOT_NOW" --arg reason "$reason" \ + '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:false,observed_at:$observed,freshness:"unknown",reason:$reason,lines:0,bytes:0,event_note_seen:false,contradiction:false}' + return 0 + fi + clean=$(printf '%s' "$out" | tail -n "$FM_SNAPSHOT_TERMINAL_LINES" | LC_ALL=C head -c "$FM_SNAPSHOT_TERMINAL_BYTES") + if command -v perl >/dev/null 2>&1; then + clean=$(printf '%s' "$clean" | perl -pe 's/\e\[[0-?]*[ -\/]*[@-~]//g; s/[^\x09\x0A\x0D\x20-\x7E]//g') + else + clean=$(printf '%s' "$clean" | LC_ALL=C tr -cd '\11\12\15\40-\176') + fi + bytes=$(printf '%s' "$clean" | LC_ALL=C wc -c | tr -d ' ') + if [ -n "$clean" ]; then + lines=$(printf '%s\n' "$clean" | wc -l | tr -d ' ') + else + lines=0 + fi + if [ -n "$note" ]; then + case "$clean" in *"$note"*) seen=true ;; esac + fi + if [ "$seen" = true ] && [ "$evidence_contradicts" = true ]; then contradiction=true; fi + jq -n \ + --arg observed "$SNAPSHOT_NOW" \ + --argjson lines "$lines" \ + --argjson bytes "$bytes" \ + --argjson seen "$seen" \ + --argjson contradiction "$contradiction" \ + '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:true,observed_at:$observed,freshness:"fresh",reason:null,lines:$lines,bytes:$bytes,event_note_seen:$seen,contradiction:$contradiction}' +} + +parent_evidence_reconciliation_json() { # <summary-json> <activities-json> <decisions-json> + jq -n --argjson summary "$1" --argjson activities "$2" --argjson decisions "$3" ' + def keyed: . != null and . != "" and . != "default"; + def result($e; $matches; $complete; $surface): + $e + { + verdict:(if ($e.key | keyed | not) then "inconclusive" + elif ($matches | length) > 0 then "corroborates" + elif $complete then "contradicts" + else "inconclusive" end), + compared_to:$surface, + matched:(if ($e.key | keyed) then ($matches[0] // null) else null end) + }; + ([ $activities[] as $e + | if $e.verb == "working" then + ([ $summary.active_children[] + | select(if ($e.key | keyed) then .id == $e.key else true end) + | {surface:"active_children",id,key:null,verb:"working"}]) as $matches + | result($e; $matches; + $summary.counts.active_children == ($summary.active_children | length); + "active_children") + elif $e.verb == "paused" then + ([ $summary.holds[] + | select(if ($e.key | keyed) then .id == $e.key or .blocked_by == $e.key else true end) + | {surface:"holds",id,key:(.blocked_by // null),verb:"paused"}]) as $matches + | result($e; $matches; + $summary.counts.holds == ($summary.holds | length); + "holds") + else + $e + {verdict:"inconclusive",compared_to:null,matched:null} + end ]) as $activity_results + | ([ $decisions[] as $e + | if $e.verb == "needs-decision" then + ([ $summary.decisions_open[] + | select(.verb == "needs-decision") + | select(if ($e.key | keyed) then .key == $e.key else true end) + | {surface:"decisions_open",id,key,verb}]) as $matches + | result($e; $matches; + $summary.counts.decisions_open == ($summary.decisions_open | length); + "decisions_open") + elif $e.verb == "blocked" then + ([ $summary.decisions_open[] + | select(.verb == "blocked") + | select(if ($e.key | keyed) then .key == $e.key or .id == $e.key else true end) + | {surface:"decisions_open",id,key,verb}] + + [ $summary.holds[] + | select(if ($e.key | keyed) then .id == $e.key or .blocked_by == $e.key else true end) + | {surface:"holds",id,key:(.blocked_by // null),verb:"blocked"}]) as $matches + | result($e; $matches; + ($summary.counts.decisions_open == ($summary.decisions_open | length) + and $summary.counts.holds == ($summary.holds | length)); + "decisions_open_or_holds") + else + $e + {verdict:"inconclusive",compared_to:null,matched:null} + end ]) as $decision_results + | {provenance:"parent-status-keyed-fold",trust:"untrusted-supplement", + activities:$activity_results,decisions:$decision_results, + contradiction:any(($activity_results + $decision_results)[]; .verdict == "contradicts"), + inconclusive:any(($activity_results + $decision_results)[]; .verdict == "inconclusive")}' +} + +secondmate_current_json() { # <parent-tasks-json> + local tasks=$1 registry union rows total_registered total shown truncated + local row id home registered registry_error task status_file event_raw event_note event_epoch event_age + local activity_scan activities decisions reconciliation provenance freshness reason summary summary_rc summary_bytes state terminal terminal_contradiction contradiction + local records='[]' seen_homes='' + registry=$(registry_secondmates_json) || return 1 + union=$(jq -n --argjson registry "$registry" --argjson tasks "$tasks" ' + ($registry.records // []) as $registered + | (($registered | map(.id)) // []) as $registered_ids + | ([ $registered[] as $r + | $r + {parent_task:([$tasks[] | select(.id == $r.id)][0] // null)} ] + + [ $tasks[] | select(.kind == "secondmate") as $t + | select(($registered_ids | index($t.id)) == null) + | {id:$t.id,home:($t.paths.home.path // null), + registered:(if $registry.complete == true then false else null end), + registry_error:(if $registry.complete == true + then "secondmate metadata is not registered" + else "secondmate registration is unknown because the registry read is incomplete or unavailable" end), + parent_task:$t} ]) + | sort_by(.id) + | {registry:$registry,records:.}') || return 1 + total_registered=$(printf '%s' "$union" | jq '[.records[] | select(.registered)] | length') + total=$(printf '%s' "$union" | jq '.records | length') + rows=$(printf '%s' "$union" | jq -c --argjson cap "$FM_SNAPSHOT_SECONDMATES" '(if $cap == 0 then .records else .records[:$cap] end)[]') + shown=$(printf '%s\n' "$rows" | grep -c . || true) + truncated=$((total - shown)) + + while IFS= read -r row; do + [ -n "$row" ] || continue + id=$(printf '%s' "$row" | jq -r '.id') + home=$(printf '%s' "$row" | jq -r '.home // ""') + registered=$(printf '%s' "$row" | jq -r '.registered') + registry_error=$(printf '%s' "$row" | jq -r '.registry_error // ""') + task=$(printf '%s' "$row" | jq -c '.parent_task // {}') + status_file=$(printf '%s' "$task" | jq -r '.paths.status_log.path // ""') + event_raw=$(printf '%s' "$task" | jq -r '.paths.status_log.last_event.raw // ""') + event_note=$(printf '%s' "$task" | jq -r '.paths.status_log.last_event.note // ""') + activity_scan=$(bounded_parent_activities_json "$status_file") + activities=$(printf '%s' "$activity_scan" | jq -c '.records') + decisions=$(printf '%s' "$task" | jq -c '.hints.open_decisions // []') + event_epoch=$(file_mtime_epoch "$status_file") + event_age=null + if [ -n "$event_epoch" ]; then + event_age=$((SNAPSHOT_EPOCH - event_epoch)) + [ "$event_age" -lt 0 ] && event_age=0 + fi + + reason=$registry_error + summary='{}' + if [ -z "$reason" ] && [ -z "$home" ]; then reason="no recorded secondmate home"; fi + if [ -z "$reason" ]; then + case "$home" in + /*) : ;; + *) reason="invalid home: registered path is not absolute" ;; + esac + fi + if [ -z "$reason" ]; then + if ! validate_secondmate_home "$id" "$home" 2>/dev/null; then + reason="invalid home: $VALIDATION_ERROR" + else + home=$VALIDATED_HOME + case " $seen_homes " in + *" $home "*) reason="invalid home: duplicate resolved home route" ;; + *) seen_homes="$seen_homes $home" ;; + esac + fi + fi + if [ -z "$reason" ]; then + summary=$(run_timed "$FM_SNAPSHOT_SECONDMATE_TIMEOUT" env \ + FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" \ + FM_PROJECTS_OVERRIDE="$home/projects" \ + FM_SNAPSHOT_NOW="$SNAPSHOT_NOW" \ + FM_SNAPSHOT_NOW_EPOCH="$SNAPSHOT_EPOCH" \ + FM_SNAPSHOT_SECONDMATE_CHILDREN="$FM_SNAPSHOT_SECONDMATE_CHILDREN" \ + FM_SNAPSHOT_SECONDMATE_QUEUED="$FM_SNAPSHOT_SECONDMATE_QUEUED" \ + FM_SNAPSHOT_SECONDMATE_DECISIONS="$FM_SNAPSHOT_SECONDMATE_DECISIONS" \ + FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME="$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ + "$SCRIPT_DIR/fm-fleet-snapshot.sh" --secondmate-home-summary 2>/dev/null) + summary_rc=$? + if [ "$summary_rc" -ne 0 ]; then + [ "$summary_rc" -eq 124 ] && reason="structured home snapshot timed out" || reason="structured home snapshot failed" + else + summary_bytes=$(printf '%s' "$summary" | LC_ALL=C wc -c | tr -d ' ') + if [ "$summary_bytes" -gt "$FM_SNAPSHOT_SECONDMATE_MAX_BYTES" ]; then + reason="structured home snapshot exceeded byte limit" + elif ! printf '%s' "$summary" | jq -e --arg home "$home" --arg generated "$SNAPSHOT_NOW" ' + .schema == "fm-secondmate-home-summary.v1" and .home == $home and .generated == $generated + ' >/dev/null 2>&1; then + reason="structured home snapshot was malformed or stale" + elif [ "$(printf '%s' "$summary" | jq -r '.valid')" != true ]; then + reason="structured home state invalid: $(printf '%s' "$summary" | jq -r '.reason // "unknown reason"')" + fi + fi + fi + + if [ -z "$reason" ]; then + state=$(printf '%s' "$summary" | jq -r '.state') + reconciliation=$(parent_evidence_reconciliation_json "$summary" "$activities" "$decisions") + contradiction=$(printf '%s' "$reconciliation" | jq -r '.contradiction') + terminal_contradiction=$(printf '%s' "$reconciliation" | jq -r --arg note "$event_note" ' + any(.activities[]; .verdict == "contradicts" and .summary == $note)') + if [ "$terminal_contradiction" = true ]; then + terminal=$(terminal_evidence_json "$task" "$event_note" true) + else + terminal=$(jq -n --arg observed "$SNAPSHOT_NOW" \ + '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:false,observed_at:$observed,freshness:"not-collected",reason:"no useful contradiction check",lines:0,bytes:0,event_note_seen:false,contradiction:false}') + fi + if printf '%s' "$terminal" | jq -e '.contradiction == true' >/dev/null; then contradiction=true; fi + record=$(jq -n \ + --arg id "$id" --arg home "$home" --arg state "$state" --arg observed "$SNAPSHOT_NOW" \ + --argjson registered "$registered" --argjson summary "$summary" --argjson decisions "$decisions" \ + --argjson activities "$activities" --argjson activity_scan "$activity_scan" \ + --argjson reconciliation "$reconciliation" --argjson terminal "$terminal" --argjson contradiction "$contradiction" \ + --arg event_raw "$event_raw" --arg event_note "$event_note" --argjson event_age "$event_age" ' + {id:$id,home:$home,registered:$registered,current:{state:$state,reason:null}, + provenance:{selected:"structured-home",structured_home:$home,parent_event_role:"historical-only"}, + freshness:{status:"fresh",observed_at:$observed,age_seconds:0}, + active_children:$summary.active_children, + decisions_open:$summary.decisions_open,holds:$summary.holds,queued:$summary.queued, + landed:$summary.landed,endpoints:$summary.endpoints,counts:$summary.counts,omitted:$summary.omitted, + parent_event:{raw:$event_raw,note:$event_note,age_seconds:$event_age,open_activities:$activities,open_decisions:$decisions,activity_scan:$activity_scan,reconciliation:$reconciliation}, + terminal_evidence:$terminal,contradiction:$contradiction}') + else + if [ -n "$event_raw" ]; then + provenance=parent-event-fallback + freshness=historical-event + else + provenance=unknown + freshness=unknown + fi + if [ -n "$event_raw" ]; then + terminal=$(terminal_evidence_json "$task" "$event_note" false) + else + terminal=$(jq -n --arg observed "$SNAPSHOT_NOW" \ + '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:false,observed_at:$observed,freshness:"not-collected",reason:"no parent event to compare",lines:0,bytes:0,event_note_seen:false,contradiction:false}') + fi + record=$(jq -n \ + --arg id "$id" --arg home "$home" --arg reason "$reason" --arg observed "$SNAPSHOT_NOW" \ + --arg provenance "$provenance" --arg freshness "$freshness" --arg event_raw "$event_raw" --arg event_note "$event_note" \ + --argjson registered "$registered" --argjson event_age "$event_age" --argjson activities "$activities" --argjson activity_scan "$activity_scan" \ + --argjson decisions "$decisions" --argjson terminal "$terminal" ' + {id:$id,home:($home | if . == "" then null else . end),registered:$registered, + current:{state:"unknown",reason:$reason}, + provenance:{selected:$provenance,structured_home:($home | if . == "" then null else . end),parent_event_role:"fallback-only-not-current"}, + freshness:{status:$freshness,observed_at:$observed,age_seconds:$event_age}, + active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[],counts:{active_children:0,decisions_open:0,holds:0,queued:0,landed:0,endpoints:0},omitted:[], + parent_event:{raw:$event_raw,note:$event_note,age_seconds:$event_age,open_activities:$activities,open_decisions:$decisions,activity_scan:$activity_scan}, + terminal_evidence:$terminal,contradiction:false}') fi - records=$(jq -n --argjson a "$records" --argjson b "$rows" \ - --argjson cap "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ - '$a + (if $cap == 0 then $b else $b[:$cap] end)') + records=$(jq -n --argjson records "$records" --argjson record "$record" '$records + [$record]') done <<EOF -$(live_secondmate_meta_records "$STATE" "$reg") +$rows EOF - jq -n --argjson records "$records" --argjson truncated "$truncated" --argjson unreadable "$unreadable" \ - '{records:$records, truncated:$truncated, unreadable:$unreadable}' + jq -n \ + --argjson registry "$(printf '%s' "$union" | jq '.registry')" \ + --argjson records "$records" \ + --argjson total_registered "$total_registered" \ + --argjson total "$total" \ + --argjson shown "$shown" \ + --argjson truncated "$truncated" \ + '{registry:$registry,records:$records,total_registered:$total_registered,total:$total,shown:$shown,truncated:$truncated}' +} + +secondmate_landed_from_current_json() { # <secondmate-current-json> + jq -n --argjson current "$1" ' + {records:[ $current.records[] + | select(.provenance.selected == "structured-home") as $mate + | $mate.landed[] + | . + {home:$mate.home,home_id:$mate.id}], + truncated:[ $current.records[] + | select(.provenance.selected == "structured-home" and (.counts.landed > (.landed | length))) + | .home], + unreadable:[ $current.records[] + | select(.current.state == "unknown") + | .home // ("<" + .id + ": unavailable>")]} + | .records |= sort_by([(.completion.date // ""), .id]) | .records |= reverse' } scout_report_lines() { @@ -486,12 +1169,23 @@ scout_report_lines() { | jq -s 'sort_by(.id)' } -BACKLOG_JSON=$(backlog_json) -TASKS_JSON=$(task_json_lines) +BACKLOG_JSON=$(backlog_json) || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } +TASKS_JSON=$(task_json_lines) || { echo "fm-fleet-snapshot: task snapshot failed" >&2; exit 1; } + +if [ "$OUTPUT_MODE" = secondmate-home-summary ]; then + secondmate_home_summary_json "$BACKLOG_JSON" "$TASKS_JSON" \ + || { echo "fm-fleet-snapshot: secondmate home summary failed" >&2; exit 1; } + exit 0 +fi + SCOUT_REPORTS_JSON=$(scout_report_lines) -SECONDMATE_LANDED_JSON=$(secondmate_landed_json) +SECONDMATE_CURRENT_JSON=$(secondmate_current_json "$TASKS_JSON") \ + || { echo "fm-fleet-snapshot: registered secondmate aggregation failed" >&2; exit 1; } +SECONDMATE_LANDED_JSON=$(secondmate_landed_from_current_json "$SECONDMATE_CURRENT_JSON") \ + || { echo "fm-fleet-snapshot: secondmate landed projection failed" >&2; exit 1; } jq -n \ + --arg generated "$SNAPSHOT_NOW" \ --arg fm_home "$FM_HOME" \ --arg fm_root "$FM_ROOT" \ --arg state "$STATE" \ @@ -501,19 +1195,22 @@ jq -n \ --argjson backlog "$BACKLOG_JSON" \ --argjson tasks "$TASKS_JSON" \ --argjson scout_reports "$SCOUT_REPORTS_JSON" \ + --argjson secondmate_current "$SECONDMATE_CURRENT_JSON" \ --argjson secondmate_landed "$SECONDMATE_LANDED_JSON" \ 'def backlog_by_id($id): ($backlog.records[]? | select(.structured == true and .id == $id) | .) // null; def task_by_id($id): ($tasks[]? | select(.id == $id) | .) // null; def report_kind($id): (task_by_id($id).kind // backlog_by_id($id).kind // "scout"); { schema:"fm-fleet-snapshot.v1", + generated:$generated, fm_home:$fm_home, roots:{fm_root:$fm_root,state:$state,data:$data,config:$config,projects:$projects}, backlog:$backlog, tasks:($tasks | map(. + {backlog:backlog_by_id(.id)})), scout_reports:($scout_reports | map(. + {kind:report_kind(.id)})), + secondmate_current:$secondmate_current, secondmate_landed:$secondmate_landed, secondmate_guidance:{ - note:"For kind=secondmate, send marked supervisor requests with fm-send and read the status/doc return channel; do not routinely fm-peek the secondmate chat for answers." + note:"For kind=secondmate, bearings selects validated structured state from that registered home; parent events and bounded terminal evidence are fallback-only supplements and never current-state authority." } }' diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index 3288f7377..909c792b2 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -64,7 +64,7 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' "Schema: \(.schema)", "Home: \(.fm_home)", "", - "## In Flight", + "## Under Way", (if (.tasks | length) == 0 then "No live task metadata found." else diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 1fbd678bb..024491def 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -9,10 +9,14 @@ # liveness beacon (state/.last-watcher-beat, touched every poll cycle) is # missing or older than FM_GUARD_GRACE seconds, prints a loud, clearly delimited # banner so the agent cannot skim past it in the tool output of whatever it was -# doing - the one channel every harness has. Normal wake handling (watcher -# briefly down between a wake and the next supervision resume) stays inside the -# grace window and stays silent. Always exits 0: the guard warns, it never -# blocks. +# doing - the one channel every harness has. The full banner is emitted once per +# distinct staleness episode in this FM_HOME (keyed to beacon mtime or absence); +# later guarded commands in the same episode print a one-line reminder instead. +# Episode state lives only under state/.guard-watcher-stale-banner (volatile, +# bounded). Independent alarms (queued wakes, worktree tangle) are never +# suppressed by that dedup. Normal wake handling (watcher briefly down between a +# wake and the next supervision resume) stays inside the grace window and stays +# silent. Always exits 0: the guard warns, it never blocks. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -26,6 +30,10 @@ READ_ONLY=${FM_GUARD_READ_ONLY:-0} case "$READ_ONLY" in 1|true|TRUE|yes|YES) READ_ONLY=1 ;; *) READ_ONLY=0 ;; esac CONTINUE_LINE=${FM_GUARD_CONTINUE_LINE:-This is a supervision warning only; the guarded operation WILL still run.} +# Volatile, home-scoped episode marker: one line = the current stale-episode key. +# Cleared when the home leaves the unhealthy state so a later episode re-arms. +STALE_BANNER_MARKER="$STATE/.guard-watcher-stale-banner" + # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-tangle-lib.sh @@ -33,6 +41,78 @@ CONTINUE_LINE=${FM_GUARD_CONTINUE_LINE:-This is a supervision warning only; the # shellcheck source=bin/fm-supervision-lib.sh . "$SCRIPT_DIR/fm-supervision-lib.sh" +# Deterministic episode key from beacon state: same continuous stale beacon +# (or continuous absence) shares a key; a recovered-then-restale beacon gets a +# new mtime and therefore a new episode. +fm_guard_stale_episode_key() { + local state=$1 beat m + beat="$state/.last-watcher-beat" + if [ -e "$beat" ]; then + m=$(fm_sup_stat_mtime "$beat") + printf 'beat:%s\n' "${m:-unknown}" + else + printf 'beat:absent\n' + fi +} + +# Claim the full banner for this episode. Exit 0 = print full banner (this call +# owns the first announcement). Exit 1 = same episode already announced (print +# reminder). The shared wake lock helper owns the race-safety mechanics; the +# re-check under the lock makes concurrent claims idempotent. +fm_guard_claim_stale_banner() { + local state=$1 key=$2 + local marker="$state/.guard-watcher-stale-banner" + local lock="$state/.guard-watcher-stale-banner.lock" + local seen i + + seen=$(cat "$marker" 2>/dev/null || true) + # Strip a single trailing newline so key comparison is line-content based. + seen=${seen%$'\n'} + if [ "$seen" = "$key" ]; then + return 1 + fi + + i=0 + while [ "$i" -lt 50 ]; do + if fm_lock_try_acquire "$lock"; then + seen=$(cat "$marker" 2>/dev/null || true) + seen=${seen%$'\n'} + if [ "$seen" = "$key" ]; then + fm_lock_release "$lock" 2>/dev/null || true + return 1 + fi + # Bounded write: one line, no growth across episodes (overwrite). + printf '%s\n' "$key" > "$marker" || true + fm_lock_release "$lock" 2>/dev/null || true + return 0 + fi + seen=$(cat "$marker" 2>/dev/null || true) + seen=${seen%$'\n'} + if [ "$seen" = "$key" ]; then + return 1 + fi + # Brief yield; 0.02s is fine on macOS/Linux sleep, fall back to 1s. + sleep 0.02 2>/dev/null || sleep 1 + i=$((i + 1)) + done + # Contended past the spin budget: stay loud rather than dropping the alarm. + return 0 +} + +fm_guard_stale_banner_seen() { + local state=$1 key=$2 + local marker="$state/.guard-watcher-stale-banner" + local seen + + seen=$(cat "$marker" 2>/dev/null || true) + seen=${seen%$'\n'} + [ "$seen" = "$key" ] +} + +fm_guard_clear_stale_banner() { + rm -f "$STALE_BANNER_MARKER" 2>/dev/null || true +} + # Worktree-tangle alarm, checked FIRST and independent of in-flight tasks: the # firstmate PRIMARY checkout (FM_ROOT) must stay on its default branch. If a # crewmate's branch/commits landed here instead of in its own isolated worktree, @@ -68,43 +148,68 @@ fm_supervision_status "$STATE" "$GRACE" in_flight=$FM_SUP_IN_FLIGHT watcher_fresh=$FM_SUP_WATCHER_FRESH beacon_desc=$FM_SUP_BEACON_DESC -[ "$in_flight" -eq 0 ] && exit 0 +if [ "$in_flight" -eq 0 ]; then + # Leave the unhealthy state (no work riding on the watcher): clear so a later + # in-flight + stale combination is a fresh episode even if the beacon is still + # absent with the same key string. + [ "$READ_ONLY" -eq 1 ] || fm_guard_clear_stale_banner + exit 0 +fi [ -s "$FM_WAKE_QUEUE" ] && queue_pending=true # No fresh watcher with tasks in flight is the dangerous state: emit a prominent, -# bordered banner FIRST so it reads as an alarm, not a buried stderr line. +# bordered banner FIRST so it reads as an alarm, not a buried stderr line. Later +# calls in the same episode get a one-line reminder only. if [ "$watcher_fresh" = false ]; then - afk=0 - [ -e "$STATE/.afk" ] && afk=1 - queue_arg=0 - "$queue_pending" && queue_arg=1 - x_mode=0 - [ -f "$CONFIG/x-mode.env" ] && x_mode=1 - fix=$("$SCRIPT_DIR/fm-supervision-instructions.sh" \ - --read-only "$READ_ONLY" \ - --afk "$afk" \ - --x-mode "$x_mode" \ - --queue-pending "$queue_arg" \ - --repair-line 2>/dev/null || printf '%s\n' 'Resume supervision according to the session-start operating block.') - rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' - { - printf '●%s\n' "$rule" - printf '● WATCHER DOWN - SUPERVISION IS OFF\n' - printf '● %s task(s) in flight, but no watcher has a fresh beacon (last beat: %s, grace %ss).\n' "$in_flight" "$beacon_desc" "$GRACE" - if [ "$READ_ONLY" -eq 1 ]; then - printf '● This read-only session should report the lapse, not repair it.\n' - else - printf '● Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.\n' - fi - printf '● %s\n' "$CONTINUE_LINE" - printf '● %s\n' "$fix" - printf '●%s\n' "$rule" - } >&2 + episode_key=$(fm_guard_stale_episode_key "$STATE") + episode_key=${episode_key%$'\n'} + print_full_banner=0 + if [ "$READ_ONLY" -eq 1 ]; then + fm_guard_stale_banner_seen "$STATE" "$episode_key" || print_full_banner=1 + elif fm_guard_claim_stale_banner "$STATE" "$episode_key"; then + print_full_banner=1 + fi + if [ "$print_full_banner" -eq 1 ]; then + afk=0 + [ -e "$STATE/.afk" ] && afk=1 + queue_arg=0 + "$queue_pending" && queue_arg=1 + x_mode=0 + [ -f "$CONFIG/x-mode.env" ] && x_mode=1 + fix=$("$SCRIPT_DIR/fm-supervision-instructions.sh" \ + --read-only "$READ_ONLY" \ + --afk "$afk" \ + --x-mode "$x_mode" \ + --queue-pending "$queue_arg" \ + --repair-line 2>/dev/null || printf '%s\n' 'Resume supervision according to the session-start operating block.') + rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + { + printf '●%s\n' "$rule" + printf '● WATCHER DOWN - SUPERVISION IS OFF\n' + printf '● %s task(s) in flight, but no watcher has a fresh beacon (last beat: %s, grace %ss).\n' "$in_flight" "$beacon_desc" "$GRACE" + if [ "$READ_ONLY" -eq 1 ]; then + printf '● This read-only session should report the lapse, not repair it.\n' + else + printf '● Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.\n' + fi + printf '● %s\n' "$CONTINUE_LINE" + printf '● %s\n' "$fix" + printf '●%s\n' "$rule" + } >&2 + else + printf 'WARNING: watcher still down (same stale episode; last beat: %s, grace %ss) - full banner already printed this episode.\n' \ + "$beacon_desc" "$GRACE" >&2 + fi +else + # Healthy again while work is still in flight: end the episode so a later + # restale re-prints the full banner. + [ "$READ_ONLY" -eq 1 ] || fm_guard_clear_stale_banner fi # Queued wakes are an independent hazard; warn whenever they are pending, even if # a watcher is alive. Kept after the banner so the no-watcher alarm reads first. +# Dedup of the watcher-down banner never suppresses this warning. if "$queue_pending"; then if [ "$READ_ONLY" -eq 1 ]; then echo "WARNING: queued wakes pending - left untouched for the session holding the fleet lock." >&2 diff --git a/bin/fm-herdr-lab.sh b/bin/fm-herdr-lab.sh index 49de41c1a..f8ea014c6 100755 --- a/bin/fm-herdr-lab.sh +++ b/bin/fm-herdr-lab.sh @@ -11,6 +11,8 @@ # fm-herdr-lab.sh teardown <session> # # Session names must begin with "fm-lab-" and can never be "default". +# The name command sanitizes the label, caps it at 16 characters, and appends +# process/random suffixes to keep generated socket paths short. # Every Herdr call made here carries a trailing --session <session>. # The run command rejects caller-supplied --session flags, any leading option # before the subcommand, all session lifecycle operations, and every server @@ -167,7 +169,7 @@ fm_herdr_lab_cancel_provision() { # <pid> } fm_herdr_lab_provision() { # <session> - local name=$1 sessions tripwire running attempt server_pid + local name=$1 sessions tripwire running attempt server_pid max_attempts timeout_seconds fm_herdr_lab_validate_name "$name" || return 1 command -v herdr >/dev/null 2>&1 || { fm_herdr_lab_error "herdr is required"; return 1; } command -v jq >/dev/null 2>&1 || { fm_herdr_lab_error "jq is required"; return 1; } @@ -196,7 +198,9 @@ fm_herdr_lab_provision() { # <session> fm_herdr_lab_raw "$name" server >/dev/null 2>&1 & server_pid=$! attempt=0 - while [ "$attempt" -lt 50 ]; do + max_attempts=300 + timeout_seconds=60 + while [ "$attempt" -lt "$max_attempts" ]; do running=$(fm_herdr_lab_cli "$name" status --json 2>/dev/null | jq -r '.server.running // false' 2>/dev/null) || running=false if [ "$running" = true ]; then fm_herdr_lab_refuse_if_default "$name" || { @@ -209,7 +213,7 @@ fm_herdr_lab_provision() { # <session> attempt=$((attempt + 1)) done fm_herdr_lab_cancel_provision "$server_pid" - fm_herdr_lab_error "lab session '$name' did not report running within 10 seconds" + fm_herdr_lab_error "lab session '$name' did not report running within $timeout_seconds seconds" return 1 } @@ -288,6 +292,9 @@ fm_herdr_lab_name() { # <label> local label=${1:-lab} label=$(printf '%s' "$label" | tr -cd 'a-zA-Z0-9_-' | sed 's/^[^a-zA-Z0-9]*//; s/-*$//') [ -n "$label" ] || label=lab + label=${label:0:16} + label=${label%-} + [ -n "$label" ] || label=lab printf 'fm-lab-%s-%s-%s\n' "$label" "$$" "$RANDOM" } diff --git a/bin/fm-marker-lib.sh b/bin/fm-marker-lib.sh index 2303f3a57..f7c7cb9cb 100644 --- a/bin/fm-marker-lib.sh +++ b/bin/fm-marker-lib.sh @@ -25,17 +25,14 @@ # travels with the live secondmate, and is summarized in AGENTS.md. # # Distinct from the afk daemon marker, on purpose. -# The away-mode daemon (bin/fm-supervise-daemon.sh) marks its daemon->firstmate -# escalations with a BARE leading unit separator (FM_INJECT_MARK, ASCII 0x1f). -# The from-firstmate marker instead uses U+2063 INVISIBLE SEPARATOR after its -# human-readable label. U+2063 has no normal keyboard keystroke but travels as -# UTF-8 text rather than a terminal control byte. The original ASCII 0x1f -# separator did not survive terminal input faithfully: on Herdr 0.7.3 feeding -# it to a real Pi composer removed the preceding label, so Pi received only the -# unmarked request (docs/herdr-backend.md records the incident and live proof). -# The afk contract keys on a LEADING 0x1f, while this marker begins with its -# label and contains no 0x1f, so the two cannot conflate. The visible label is -# what the secondmate's LLM reads; U+2063 remains invisible. +# Both terminal-safe markers use U+2063 INVISIBLE SEPARATOR because it has no +# normal keyboard keystroke but travels as UTF-8 text rather than a terminal +# control byte. The away-mode marker is a BARE leading U+2063; this marker begins +# with its human-readable label and places U+2063 after it, so the two cannot +# conflate. The original ASCII 0x1f separator did not survive terminal input +# faithfully: on Herdr 0.7.3 feeding it to a real Pi composer removed marker +# content, so Pi received an unmarked message. docs/herdr-backend.md records both +# incidents and their live proof. # # Sourced by bin/fm-send.sh, bin/fm-brief.sh, and the tests. No side effects on # source. set -u / set -e safe. diff --git a/bin/fm-pr-check-migrate.sh b/bin/fm-pr-check-migrate.sh new file mode 100755 index 000000000..a326d81d3 --- /dev/null +++ b/bin/fm-pr-check-migrate.sh @@ -0,0 +1,1138 @@ +#!/usr/bin/env bash +# Non-executing migration for watcher PR checks created by older Firstmate +# versions. Legacy check files are never run, sourced, or parsed by Bash. +# Canonical polls are rebuilt from validated metadata, provenance-bound polls +# and registered custom checks remain armed, and every other task poll is +# quarantined for private review. A current X-mode shim is preserved by exact +# content, while the recognized older byte-static shim is refreshed in place. +# Usage: fm-pr-check-migrate.sh [--checks-safe] +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}" +TEMPLATE="$SCRIPT_DIR/fm-pr-poll.sh" +LOG="$STATE/.pr-check-migration.log" +QUARANTINE="$STATE/.pr-check-quarantine" +MARKER="$STATE/.pr-check-migration-v1" +MARKER_VALUE=fm-pr-check-migration-v1 +SCAN_MARKER="$STATE/.pr-check-migration-scan-v1" +SCAN_MARKER_VALUE=fm-pr-check-migration-scan-v1 +WATCH="$SCRIPT_DIR/fm-watch.sh" +WATCH_LOCK="$STATE/.watch.lock" +NONCANONICAL_PREFIX='!noncanonical' +LEGACY_NONCANONICAL_PREFIX=_noncanonical + +ALLOW_INCOMPLETE_REPAIRS=0 +if [ "$#" -eq 1 ] && [ "$1" = --checks-safe ]; then + ALLOW_INCOMPLETE_REPAIRS=1 +elif [ "$#" -ne 0 ]; then + echo "error: invalid PR check migration request" >&2 + exit 2 +fi + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-x-lib.sh +. "$SCRIPT_DIR/fm-x-lib.sh" +# shellcheck source=bin/fm-check-lib.sh +. "$SCRIPT_DIR/fm-check-lib.sh" + +umask 077 +if [ ! -e "$STATE" ] && [ ! -L "$STATE" ]; then + mkdir -p "$STATE" || { + echo "PR_CHECK_MIGRATION: state directory could not be created; migration did not complete safely" >&2 + exit 1 + } +fi +if [ ! -d "$STATE" ] || [ -L "$STATE" ]; then + echo "PR_CHECK_MIGRATION: state directory is not a private ordinary directory; migration did not complete safely" >&2 + exit 1 +fi + +migration_marker_content_valid() { + local file=$1 value + { exec 7< "$file"; } 2>/dev/null || return 1 + IFS= read -r value <&7 || { exec 7<&-; return 1; } + if IFS= read -r _extra <&7; then + exec 7<&- + return 1 + fi + exec 7<&- + [ "$value" = "$MARKER_VALUE" ] +} + +scan_marker_content_valid() { + local file=$1 value + { exec 7< "$file"; } 2>/dev/null || return 1 + IFS= read -r value <&7 || { exec 7<&-; return 1; } + if IFS= read -r _extra <&7; then + exec 7<&- + return 1 + fi + exec 7<&- + [ "$value" = "$SCAN_MARKER_VALUE" ] +} + +current_checks_authenticated() { + local check id + for check in "$STATE"/*.check.sh; do + [ -e "$check" ] || [ -L "$check" ] || continue + if [ "$(basename "$check")" = x-watch.check.sh ] \ + && fmx_poll_shim_valid "$check" "$FM_HOME" "$FM_ROOT"; then + continue + fi + id=$(basename "$check" .check.sh) + fm_custom_check_registered "$STATE" "$id" && continue + fm_pr_poll_artifacts_valid "$STATE" "$id" "$TEMPLATE" || return 1 + done +} + +private_migration_boundaries_valid() { + local state_device=$1 artifact + if [ -e "$LOG" ] || [ -L "$LOG" ]; then + fm_pr_private_file_valid "$LOG" 600 "$state_device" || return 1 + fi + if [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ]; then + [ -d "$QUARANTINE" ] && [ ! -L "$QUARANTINE" ] || return 1 + [ "$(fm_pr_file_mode "$QUARANTINE")" = 700 ] || return 1 + [ "$(fm_pr_file_device "$QUARANTINE")" = "$state_device" ] || return 1 + for artifact in "$QUARANTINE"/* "$QUARANTINE"/.[!.]* "$QUARANTINE"/..?*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + fm_pr_private_file_valid "$artifact" 600 "$state_device" || return 1 + done + fi +} + +diagnostic_file_is_one_line() { + local file=$1 expected=$2 value + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + [ "$(fm_pr_file_link_count "$file")" = 1 ] || return 1 + exec 6< "$file" || return 1 + IFS= read -r value <&6 || { exec 6<&-; return 1; } + if IFS= read -r _extra <&6; then + exec 6<&- + return 1 + fi + exec 6<&- + [ "$value" = "$expected" ] +} + +diagnostic_obligation_message() { + local basename=$1 prefix kind suffix + MIGRATION_DIAGNOSTIC_KIND= + MIGRATION_DIAGNOSTIC_PREFIX= + MIGRATION_DIAGNOSTIC_MESSAGE= + kind=${basename##*.diagnostic.} + suffix=".diagnostic.$kind" + [ "$basename" != "$kind" ] || return 1 + prefix=${basename%"$suffix"} + [ -n "$prefix" ] && [ "$prefix$suffix" = "$basename" ] || return 1 + if [ "$prefix" = "$NONCANONICAL_PREFIX" ] \ + || { [ "$prefix" = "$LEGACY_NONCANONICAL_PREFIX" ] \ + && { [ "$kind" = pending-noncanonical ] || [ "$kind" = noncanonical ]; }; }; then + case "$kind" in + pending-noncanonical) + MIGRATION_DIAGNOSTIC_MESSAGE='noncanonical task artifact: migration outcome tracking started before legacy poll handling' + ;; + noncanonical) + MIGRATION_DIAGNOSTIC_MESSAGE='noncanonical task artifact quarantined and unarmed' + ;; + *) return 1 ;; + esac + else + fm_pr_task_id_valid "$prefix" || return 1 + case "$kind" in + pending-canonical|pending-ambiguous) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: migration outcome tracking started before legacy poll handling" + ;; + canonical) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: canonical legacy poll rebuilt and armed" + ;; + failure-canonical) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: canonical poll migration is incomplete; poll remains unarmed; repair its private artifacts, then rerun bootstrap" + ;; + failure-ambiguous) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: ambiguous poll migration is incomplete; poll remains unarmed; repair its private artifacts, then rerun bootstrap" + ;; + failure-replacement) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: replacement poll lacks canonical provenance or metadata binding; poll remains unarmed; republish it through fm-pr-check.sh" + ;; + ambiguous) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: ambiguous or invalid legacy poll quarantined and unarmed" + ;; + validated) + MIGRATION_DIAGNOSTIC_MESSAGE="task $prefix: validated replacement poll armed after legacy quarantine" + ;; + *) return 1 ;; + esac + fi + MIGRATION_DIAGNOSTIC_KIND=$kind + MIGRATION_DIAGNOSTIC_PREFIX=$prefix +} + +quarantine_artifact_basename_valid() { + local basename=$1 random stem kind prefix + random=${basename##*.} + [[ "$random" =~ ^[A-Za-z0-9]{6}$ ]] || return 1 + stem=${basename%.*} + kind=${stem##*.} + prefix=${stem%.*} + case "$kind" in + check|data|registration|replacement-check|replacement-data|replacement-registration) ;; + *) return 1 ;; + esac + [ "$prefix" = "$NONCANONICAL_PREFIX" ] \ + || [ "$prefix" = "$LEGACY_NONCANONICAL_PREFIX" ] \ + || fm_pr_task_id_valid "$prefix" +} + +diagnostic_namespace_valid() { + local artifact basename + [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ] || return 0 + for artifact in "$QUARANTINE"/*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + basename=${artifact##*/} + case "$basename" in + *.diagnostic.*) + if diagnostic_obligation_message "$basename"; then + diagnostic_file_is_one_line "$artifact" "$MIGRATION_DIAGNOSTIC_MESSAGE" || return 1 + else + quarantine_artifact_basename_valid "$basename" || return 1 + fi + ;; + esac + done +} + +legacy_noncanonical_namespace_absent() { + local artifact + for artifact in \ + "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" \ + "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.noncanonical"; do + [ ! -e "$artifact" ] && [ ! -L "$artifact" ] || return 1 + done +} + +scan_complete() { + local state_device + [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 + state_device=$(fm_pr_file_device "$STATE") || return 1 + fm_pr_private_file_valid "$SCAN_MARKER" 600 "$state_device" || return 1 + scan_marker_content_valid "$SCAN_MARKER" || return 1 + private_migration_boundaries_valid "$state_device" || return 1 + diagnostic_namespace_valid || return 1 + legacy_noncanonical_namespace_absent || return 1 + current_checks_authenticated +} + +migration_complete() { + local state_device obligation + scan_complete || return 1 + state_device=$(fm_pr_file_device "$STATE") || return 1 + if [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ]; then + for obligation in "$QUARANTINE"/*.diagnostic.pending-canonical \ + "$QUARANTINE"/*.diagnostic.pending-ambiguous \ + "$QUARANTINE"/*.diagnostic.pending-noncanonical \ + "$QUARANTINE"/*.diagnostic.failure-canonical \ + "$QUARANTINE"/*.diagnostic.failure-ambiguous \ + "$QUARANTINE"/*.diagnostic.failure-replacement; do + [ -e "$obligation" ] || [ -L "$obligation" ] || continue + return 1 + done + fi + fm_pr_private_file_valid "$MARKER" 600 "$state_device" || return 1 + migration_marker_content_valid "$MARKER" +} + +x_shim_locked_scan_needed() { + local shim="$STATE/x-watch.check.sh" + [ -e "$shim" ] || [ -L "$shim" ] || return 1 + fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" && return 1 + return 0 +} + +# Marker short-circuits apply only when generated artifact identities are current. +# Otherwise watcher exclusion comes before every check scan and state mutation. +if ! x_shim_locked_scan_needed; then + migration_complete && exit 0 + [ "$ALLOW_INCOMPLETE_REPAIRS" -eq 1 ] && scan_complete && exit 0 +fi + +# shellcheck source=bin/fm-wake-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-wake-lib.sh" + +stopped_watcher=0 +pid=$(cat "$WATCH_LOCK/pid" 2>/dev/null || true) +if fm_pid_alive "$pid"; then + if ! fm_watcher_lock_matches_pid "$STATE" "$WATCH" "$pid" "$FM_HOME"; then + echo "PR_CHECK_MIGRATION: watcher ownership is ambiguous; review state/.watch.lock before rearming polls" >&2 + exit 1 + fi + kill -TERM "$pid" 2>/dev/null || { + echo "PR_CHECK_MIGRATION: watcher could not be paused; review state/.watch.lock before rearming polls" >&2 + exit 1 + } + stopped_watcher=1 + i=0 + while [ "$i" -lt 100 ] && fm_pid_alive "$pid"; do + sleep 0.05 + i=$((i + 1)) + done + if fm_pid_alive "$pid"; then + echo "PR_CHECK_MIGRATION: watcher did not pause; review state/.watch.lock before rearming polls" >&2 + exit 1 + fi +fi + +lock_held=0 +i=0 +while [ "$i" -lt 100 ]; do + if fm_lock_try_acquire "$WATCH_LOCK"; then + lock_held=1 + break + fi + # A concurrent migration may have completed while this process waited. + # Its validated marker proves the old watcher crossed the boundary, so this + # process can continue to the normal watcher singleton instead of competing + # with the newly started watcher for a second migration lock. + if migration_complete && ! x_shim_locked_scan_needed; then + exit 0 + fi + sleep 0.05 + i=$((i + 1)) +done +if [ "$lock_held" -ne 1 ]; then + echo "PR_CHECK_MIGRATION: watcher exclusion could not be acquired; review state/.watch.lock before rearming polls" >&2 + exit 1 +fi + +MIGRATION_MARKER_TMP= +MIGRATION_SCAN_MARKER_TMP= +MIGRATION_LOG_TMP= +MIGRATION_OBLIGATION_TMP= +MIGRATION_QUARANTINE_TMP= +MIGRATION_X_SHIM_TMP= +migration_cleanup() { + fm_pr_poll_cleanup + [ -z "$MIGRATION_X_SHIM_TMP" ] || rm -f -- "$MIGRATION_X_SHIM_TMP" + [ -z "$MIGRATION_QUARANTINE_TMP" ] || rm -f -- "$MIGRATION_QUARANTINE_TMP" + [ -z "$MIGRATION_OBLIGATION_TMP" ] || rm -f -- "$MIGRATION_OBLIGATION_TMP" + [ -z "$MIGRATION_LOG_TMP" ] || rm -f -- "$MIGRATION_LOG_TMP" + [ -z "$MIGRATION_MARKER_TMP" ] || rm -f -- "$MIGRATION_MARKER_TMP" + [ -z "$MIGRATION_SCAN_MARKER_TMP" ] || rm -f -- "$MIGRATION_SCAN_MARKER_TMP" + [ "$lock_held" -ne 1 ] || fm_lock_release "$WATCH_LOCK" +} +trap migration_cleanup EXIT +trap 'exit 1' HUP INT TERM + +if [ ! -d "$STATE" ] || [ -L "$STATE" ]; then + echo "PR_CHECK_MIGRATION: state directory is not a private ordinary directory; migration did not complete safely" >&2 + exit 1 +fi +STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 +[ -n "$STATE_DEVICE" ] || exit 1 +refresh_v1_x_shim() { + local shim="$STATE/x-watch.check.sh" + fmx_poll_shim_v1_valid "$shim" "$FM_HOME" "$FM_ROOT" "$STATE_DEVICE" || return 0 + fm_pr_regular_destination_on_device_or_absent "$shim" "$STATE_DEVICE" || return 1 + MIGRATION_X_SHIM_TMP=$(mktemp "$STATE/.fm-x-watch.XXXXXX") || return 1 + fmx_poll_shim_content "$FM_HOME" "$FM_ROOT" > "$MIGRATION_X_SHIM_TMP" || return 1 + chmod 0700 "$MIGRATION_X_SHIM_TMP" || return 1 + fmx_poll_shim_valid "$MIGRATION_X_SHIM_TMP" "$FM_HOME" "$FM_ROOT" || return 1 + fmx_poll_shim_v1_valid "$shim" "$FM_HOME" "$FM_ROOT" "$STATE_DEVICE" || return 1 + mv -f -- "$MIGRATION_X_SHIM_TMP" "$shim" || return 1 + MIGRATION_X_SHIM_TMP= + [ "$(fm_pr_file_device "$shim")" = "$STATE_DEVICE" ] || return 1 + [ "$(fm_pr_file_mode "$shim")" = 700 ] || return 1 + fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" +} +if ! refresh_v1_x_shim; then + echo "PR_CHECK_MIGRATION: authenticated X poll shim could not be refreshed; migration did not complete safely" >&2 + exit 1 +fi +# A marker contradicted by a pending or failed obligation is not authoritative. +# Remove only an ordinary marker under exclusion; unsafe marker paths remain a +# hard refusal for the publication checks below. +if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then + fm_pr_private_file_valid "$MARKER" 600 "$STATE_DEVICE" || exit 1 + rm -f -- "$MARKER" || exit 1 + [ ! -e "$MARKER" ] && [ ! -L "$MARKER" ] || exit 1 +fi +if [ -e "$SCAN_MARKER" ] || [ -L "$SCAN_MARKER" ]; then + fm_pr_private_file_valid "$SCAN_MARKER" 600 "$STATE_DEVICE" || exit 1 + rm -f -- "$SCAN_MARKER" || exit 1 + [ ! -e "$SCAN_MARKER" ] && [ ! -L "$SCAN_MARKER" ] || exit 1 +fi +migration_needed() { + local check id + for check in "$STATE"/*.check.sh; do + [ -e "$check" ] || [ -L "$check" ] || continue + if [ "$(basename "$check")" = x-watch.check.sh ] \ + && fmx_poll_shim_valid "$check" "$FM_HOME" "$FM_ROOT"; then + continue + fi + id=$(basename "$check" .check.sh) + fm_custom_check_registered "$STATE" "$id" && continue + if ! fm_pr_poll_artifacts_valid "$STATE" "$id" "$TEMPLATE"; then + return 0 + fi + done + return 1 +} + +unsafe_checks_absent() { + local check id + for check in "$STATE"/*.check.sh; do + [ -e "$check" ] || [ -L "$check" ] || continue + if [ "$(basename "$check")" = x-watch.check.sh ] \ + && fmx_poll_shim_valid "$check" "$FM_HOME" "$FM_ROOT"; then + continue + fi + id=$(basename "$check" .check.sh) + fm_custom_check_registered "$STATE" "$id" && continue + fm_pr_poll_artifacts_valid "$STATE" "$id" "$TEMPLATE" || return 1 + done +} + +revoke_migration_marker() { + if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then + if [ -f "$MARKER" ] && [ ! -L "$MARKER" ]; then + [ "$(fm_pr_file_link_count "$MARKER")" = 1 ] || return 1 + fi + rm -f -- "$MARKER" || return 1 + fi + [ ! -e "$MARKER" ] && [ ! -L "$MARKER" ] +} + +publish_migration_marker() { + fm_pr_regular_destination_on_device_or_absent "$MARKER" "$STATE_DEVICE" || return 1 + MIGRATION_MARKER_TMP=$(mktemp "$STATE/.fm-pr-check-migration.XXXXXX") || return 1 + fm_pr_private_file_valid "$MIGRATION_MARKER_TMP" 600 "$STATE_DEVICE" || return 1 + printf '%s\n' "$MARKER_VALUE" > "$MIGRATION_MARKER_TMP" || return 1 + chmod 0600 "$MIGRATION_MARKER_TMP" || return 1 + migration_marker_content_valid "$MIGRATION_MARKER_TMP" || return 1 + fm_pr_regular_destination_on_device_or_absent "$MARKER" "$STATE_DEVICE" || return 1 + if ! mv -f -- "$MIGRATION_MARKER_TMP" "$MARKER"; then + revoke_migration_marker || true + return 1 + fi + MIGRATION_MARKER_TMP= + if ! migration_complete; then + revoke_migration_marker || true + return 1 + fi +} + +revoke_scan_marker() { + if [ -e "$SCAN_MARKER" ] || [ -L "$SCAN_MARKER" ]; then + if [ -f "$SCAN_MARKER" ] && [ ! -L "$SCAN_MARKER" ]; then + [ "$(fm_pr_file_link_count "$SCAN_MARKER")" = 1 ] || return 1 + fi + rm -f -- "$SCAN_MARKER" || return 1 + fi + [ ! -e "$SCAN_MARKER" ] && [ ! -L "$SCAN_MARKER" ] +} + +publish_scan_marker() { + fm_pr_regular_destination_on_device_or_absent "$SCAN_MARKER" "$STATE_DEVICE" || return 1 + MIGRATION_SCAN_MARKER_TMP=$(mktemp "$STATE/.fm-pr-check-scan.XXXXXX") || return 1 + fm_pr_private_file_valid "$MIGRATION_SCAN_MARKER_TMP" 600 "$STATE_DEVICE" || return 1 + printf '%s\n' "$SCAN_MARKER_VALUE" > "$MIGRATION_SCAN_MARKER_TMP" || return 1 + chmod 0600 "$MIGRATION_SCAN_MARKER_TMP" || return 1 + scan_marker_content_valid "$MIGRATION_SCAN_MARKER_TMP" || return 1 + fm_pr_regular_destination_on_device_or_absent "$SCAN_MARKER" "$STATE_DEVICE" || return 1 + if ! mv -f -- "$MIGRATION_SCAN_MARKER_TMP" "$SCAN_MARKER"; then + revoke_scan_marker || true + return 1 + fi + MIGRATION_SCAN_MARKER_TMP= + if ! scan_complete; then + revoke_scan_marker || true + return 1 + fi +} + +quarantine_dir_valid() { + [ -d "$QUARANTINE" ] && [ ! -L "$QUARANTINE" ] || return 1 + [ "$(fm_pr_file_mode "$QUARANTINE")" = 700 ] || return 1 + [ "$(fm_pr_file_device "$QUARANTINE")" = "$STATE_DEVICE" ] +} + +ensure_quarantine_dir() { + if [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ]; then + [ -d "$QUARANTINE" ] && [ ! -L "$QUARANTINE" ] || return 1 + [ "$(fm_pr_file_device "$QUARANTINE")" = "$STATE_DEVICE" ] || return 1 + else + mkdir "$QUARANTINE" || return 1 + fi + chmod 0700 "$QUARANTINE" || return 1 + quarantine_dir_valid +} + +quarantine_tree_repair_and_validate() { + local artifact + [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ] || return 0 + ensure_quarantine_dir || return 1 + for artifact in "$QUARANTINE"/* "$QUARANTINE"/.[!.]* "$QUARANTINE"/..?*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + [ -f "$artifact" ] && [ ! -L "$artifact" ] || return 1 + [ "$(fm_pr_file_device "$artifact")" = "$STATE_DEVICE" ] || return 1 + [ "$(fm_pr_file_link_count "$artifact")" = 1 ] || return 1 + chmod 0600 "$artifact" || return 1 + [ "$(fm_pr_file_mode "$artifact")" = 600 ] || return 1 + [ "$(fm_pr_file_device "$artifact")" = "$STATE_DEVICE" ] || return 1 + [ "$(fm_pr_file_link_count "$artifact")" = 1 ] || return 1 + done + quarantine_dir_valid +} + +MIGRATION_URL= +MIGRATION_OWNER= +MIGRATION_REPO= +MIGRATION_NUMBER= +metadata_pr_is_canonical() { + local meta=$1 + MIGRATION_URL= + MIGRATION_OWNER= + MIGRATION_REPO= + MIGRATION_NUMBER= + fm_pr_metadata_identity_parse "$meta" || return 1 + MIGRATION_URL=$FM_PR_META_URL + MIGRATION_OWNER=$FM_PR_META_OWNER + MIGRATION_REPO=$FM_PR_META_REPO + MIGRATION_NUMBER=$FM_PR_META_NUMBER +} + +quarantine_artifact() { + local source=$1 prefix=$2 kind=$3 destination source_device + [ -e "$source" ] || [ -L "$source" ] || return 0 + [ -f "$source" ] && [ ! -L "$source" ] || return 1 + quarantine_dir_valid || return 1 + source_device=$(fm_pr_file_device "$source") || return 1 + [ "$source_device" = "$STATE_DEVICE" ] || return 1 + [ "$(fm_pr_file_link_count "$source")" = 1 ] || return 1 + [ -z "$MIGRATION_QUARANTINE_TMP" ] || rm -f -- "$MIGRATION_QUARANTINE_TMP" + MIGRATION_QUARANTINE_TMP= + MIGRATION_QUARANTINE_TMP=$(mktemp "$QUARANTINE/$prefix.$kind.XXXXXX") || return 1 + [ -f "$MIGRATION_QUARANTINE_TMP" ] && [ ! -L "$MIGRATION_QUARANTINE_TMP" ] || return 1 + [ "$(fm_pr_file_device "$MIGRATION_QUARANTINE_TMP")" = "$STATE_DEVICE" ] || return 1 + destination=$MIGRATION_QUARANTINE_TMP + rm -f -- "$destination" || return 1 + MIGRATION_QUARANTINE_TMP= + quarantine_dir_valid || return 1 + mv -- "$source" "$destination" || return 1 + [ -f "$destination" ] && [ ! -L "$destination" ] || return 1 + [ "$(fm_pr_file_link_count "$destination")" = 1 ] || return 1 + chmod 0600 "$destination" || return 1 + [ -f "$destination" ] && [ ! -L "$destination" ] || return 1 + [ "$(fm_pr_file_mode "$destination")" = 600 ] || return 1 + [ "$(fm_pr_file_device "$destination")" = "$STATE_DEVICE" ] || return 1 + [ "$(fm_pr_file_link_count "$destination")" = 1 ] || return 1 + [ ! -e "$source" ] && [ ! -L "$source" ] +} + +diagnostic_file_contains() { + local file=$1 expected=$2 line + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + [ "$(fm_pr_file_link_count "$file")" = 1 ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + [ "$line" != "$expected" ] || return 0 + done < "$file" + return 1 +} + +diagnostic_log_valid() { + fm_pr_private_file_valid "$LOG" 600 "$STATE_DEVICE" +} + +diagnostic_log_contains() { + local expected=$1 + diagnostic_log_valid || return 1 + diagnostic_file_contains "$LOG" "$expected" +} + +revoke_migration_log() { + if [ -e "$LOG" ] || [ -L "$LOG" ]; then + if [ -f "$LOG" ] && [ ! -L "$LOG" ]; then + [ "$(fm_pr_file_link_count "$LOG")" = 1 ] || return 1 + fi + rm -f -- "$LOG" || return 1 + fi + [ ! -e "$LOG" ] && [ ! -L "$LOG" ] +} + +record_diagnostic() { + local message=$1 + diagnostic_log_contains "$message" && return 0 + fm_pr_regular_destination_on_device_or_absent "$LOG" "$STATE_DEVICE" || return 1 + [ ! -e "$LOG" ] || diagnostic_log_valid || return 1 + [ -z "$MIGRATION_LOG_TMP" ] || rm -f -- "$MIGRATION_LOG_TMP" + MIGRATION_LOG_TMP= + MIGRATION_LOG_TMP=$(mktemp "$STATE/.fm-pr-check-log.XXXXXX") || return 1 + [ -f "$MIGRATION_LOG_TMP" ] && [ ! -L "$MIGRATION_LOG_TMP" ] || return 1 + [ "$(fm_pr_file_device "$MIGRATION_LOG_TMP")" = "$STATE_DEVICE" ] || return 1 + if [ -f "$LOG" ]; then + cp "$LOG" "$MIGRATION_LOG_TMP" || return 1 + fi + printf '%s\n' "$message" >> "$MIGRATION_LOG_TMP" || return 1 + chmod 0600 "$MIGRATION_LOG_TMP" || return 1 + diagnostic_file_contains "$MIGRATION_LOG_TMP" "$message" || return 1 + fm_pr_regular_destination_on_device_or_absent "$LOG" "$STATE_DEVICE" || return 1 + if ! mv -f -- "$MIGRATION_LOG_TMP" "$LOG"; then + return 1 + fi + MIGRATION_LOG_TMP= + if ! diagnostic_log_valid || ! diagnostic_log_contains "$message"; then + revoke_migration_log || true + return 1 + fi +} + +migrate_legacy_quarantine_entry() { + local source=$1 destination=$2 + fm_pr_private_file_valid "$source" 600 "$STATE_DEVICE" || return 1 + fm_pr_regular_destination_on_device_or_absent "$destination" "$STATE_DEVICE" || return 1 + if [ -e "$destination" ] || [ -L "$destination" ]; then + fm_pr_private_file_valid "$destination" 600 "$STATE_DEVICE" || return 1 + cmp -s "$source" "$destination" || return 1 + rm -f -- "$source" || return 1 + else + mv -- "$source" "$destination" || return 1 + fi + [ ! -e "$source" ] && [ ! -L "$source" ] \ + && fm_pr_private_file_valid "$destination" 600 "$STATE_DEVICE" +} + +migrate_legacy_noncanonical_namespace() { + local source basename suffix destination legacy_pending + [ -e "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" ] \ + || [ -L "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" ] \ + || [ -e "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.noncanonical" ] \ + || [ -L "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.noncanonical" ] \ + || return 0 + quarantine_tree_repair_and_validate || return 1 + for source in "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.check."* \ + "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.data."* \ + "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.registration."*; do + [ -e "$source" ] || [ -L "$source" ] || continue + basename=${source##*/} + suffix=${basename#"$LEGACY_NONCANONICAL_PREFIX"} + destination="$QUARANTINE/$NONCANONICAL_PREFIX$suffix" + migrate_legacy_quarantine_entry "$source" "$destination" || return 1 + done + source="$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.noncanonical" + destination="$QUARANTINE/$NONCANONICAL_PREFIX.diagnostic.noncanonical" + if [ -e "$source" ] || [ -L "$source" ]; then + migrate_legacy_quarantine_entry "$source" "$destination" || return 1 + fi + legacy_pending="$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" + if [ -e "$legacy_pending" ] || [ -L "$legacy_pending" ]; then + if diagnostic_obligation_valid "$NONCANONICAL_PREFIX" noncanonical \ + && quarantined_artifact_exists "$NONCANONICAL_PREFIX" check; then + rm -f -- "$legacy_pending" || return 1 + else + migrate_legacy_quarantine_entry "$legacy_pending" \ + "$QUARANTINE/$NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" || return 1 + fi + fi + [ ! -e "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" ] \ + && [ ! -L "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.pending-noncanonical" ] \ + && [ ! -e "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.noncanonical" ] \ + && [ ! -L "$QUARANTINE/$LEGACY_NONCANONICAL_PREFIX.diagnostic.noncanonical" ] +} + +ensure_diagnostic_obligation() { + local prefix=$1 kind=$2 message=$3 destination + case "$kind" in + pending-canonical|pending-ambiguous|pending-noncanonical|canonical|failure-canonical|failure-ambiguous|failure-replacement|ambiguous|validated|noncanonical) ;; + *) return 1 ;; + esac + [ "$prefix" = "$NONCANONICAL_PREFIX" ] || fm_pr_task_id_valid "$prefix" || return 1 + ensure_quarantine_dir || return 1 + destination="$QUARANTINE/$prefix.diagnostic.$kind" + if [ -e "$destination" ] || [ -L "$destination" ]; then + fm_pr_private_file_valid "$destination" 600 "$STATE_DEVICE" || return 1 + diagnostic_file_is_one_line "$destination" "$message" + return + fi + [ -z "$MIGRATION_OBLIGATION_TMP" ] || rm -f -- "$MIGRATION_OBLIGATION_TMP" + MIGRATION_OBLIGATION_TMP= + MIGRATION_OBLIGATION_TMP=$(mktemp "$QUARANTINE/.fm-pr-check-obligation.XXXXXX") || return 1 + printf '%s\n' "$message" > "$MIGRATION_OBLIGATION_TMP" || return 1 + chmod 0600 "$MIGRATION_OBLIGATION_TMP" || return 1 + diagnostic_file_is_one_line "$MIGRATION_OBLIGATION_TMP" "$message" || return 1 + fm_pr_regular_destination_on_device_or_absent "$destination" "$STATE_DEVICE" || return 1 + if ! mv -f -- "$MIGRATION_OBLIGATION_TMP" "$destination"; then + return 1 + fi + MIGRATION_OBLIGATION_TMP= + if ! fm_pr_private_file_valid "$destination" 600 "$STATE_DEVICE" \ + || ! diagnostic_file_is_one_line "$destination" "$message"; then + rm -f -- "$destination" || true + return 1 + fi +} + +ensure_outcome_obligation() { + local prefix=$1 kind=$2 basename + basename="$prefix.diagnostic.$kind" + diagnostic_obligation_message "$basename" || return 1 + ensure_diagnostic_obligation "$prefix" "$kind" "$MIGRATION_DIAGNOSTIC_MESSAGE" +} + +quarantined_artifact_exists() { + local prefix=$1 kind=$2 artifact + for artifact in "$QUARANTINE/$prefix.$kind."*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + fm_pr_private_file_valid "$artifact" 600 "$STATE_DEVICE" || return 1 + return 0 + done + return 1 +} + +diagnostic_obligation_valid() { + local prefix=$1 kind=$2 path basename + path="$QUARANTINE/$prefix.diagnostic.$kind" + [ -e "$path" ] || [ -L "$path" ] || return 1 + fm_pr_private_file_valid "$path" 600 "$STATE_DEVICE" || return 1 + basename=${path##*/} + diagnostic_obligation_message "$basename" || return 1 + diagnostic_file_is_one_line "$path" "$MIGRATION_DIAGNOSTIC_MESSAGE" +} + +remove_diagnostic_obligation() { + local prefix=$1 kind=$2 path + path="$QUARANTINE/$prefix.diagnostic.$kind" + [ -e "$path" ] || [ -L "$path" ] || return 0 + diagnostic_obligation_valid "$prefix" "$kind" || return 1 + rm -f -- "$path" || return 1 + [ ! -e "$path" ] && [ ! -L "$path" ] +} + +canonical_terminal_success() { + local id=$1 + fm_pr_poll_artifacts_valid "$STATE" "$id" "$TEMPLATE" \ + && quarantined_artifact_exists "$id" check +} + +ambiguous_terminal_success() { + local id=$1 check data registration + check="$STATE/$id.check.sh" + data="$STATE/$id.pr-poll" + registration="$STATE/$id.pr-poll-registration" + [ ! -e "$check" ] && [ ! -L "$check" ] \ + && [ ! -e "$data" ] && [ ! -L "$data" ] \ + && [ ! -e "$registration" ] && [ ! -L "$registration" ] \ + && quarantined_artifact_exists "$id" check +} + +complete_canonical_outcome() { + local id=$1 + canonical_terminal_success "$id" || return 1 + remove_diagnostic_obligation "$id" failure-canonical || return 1 + ensure_outcome_obligation "$id" canonical || return 1 + remove_diagnostic_obligation "$id" pending-canonical +} + +complete_ambiguous_outcome() { + local id=$1 + ambiguous_terminal_success "$id" || return 1 + remove_diagnostic_obligation "$id" failure-ambiguous || return 1 + ensure_outcome_obligation "$id" ambiguous || return 1 + remove_diagnostic_obligation "$id" pending-ambiguous +} + +complete_validated_outcome() { + local id=$1 + canonical_terminal_success "$id" || return 1 + remove_diagnostic_obligation "$id" failure-ambiguous || return 1 + remove_diagnostic_obligation "$id" failure-replacement || return 1 + remove_diagnostic_obligation "$id" ambiguous || return 1 + ensure_outcome_obligation "$id" validated || return 1 + remove_diagnostic_obligation "$id" pending-ambiguous +} + +complete_noncanonical_outcome() { + local prefix=${1:-$NONCANONICAL_PREFIX} + quarantined_artifact_exists "$prefix" check || return 1 + ensure_outcome_obligation "$prefix" noncanonical || return 1 + remove_diagnostic_obligation "$prefix" pending-noncanonical +} + +record_canonical_failure() { + local id=$1 + remove_diagnostic_obligation "$id" canonical || return 1 + ensure_outcome_obligation "$id" failure-canonical +} + +record_ambiguous_failure() { + local id=$1 + remove_diagnostic_obligation "$id" ambiguous || return 1 + ensure_outcome_obligation "$id" failure-ambiguous +} + +canonical_repair_from_pending() { + local id=$1 meta data registration url owner repo number check + meta="$STATE/$id.meta" + data="$STATE/$id.pr-poll" + registration="$STATE/$id.pr-poll-registration" + check="$STATE/$id.check.sh" + [ ! -e "$check" ] && [ ! -L "$check" ] || return 1 + quarantined_artifact_exists "$id" check || return 1 + metadata_pr_is_canonical "$meta" || return 1 + url=$MIGRATION_URL + owner=$MIGRATION_OWNER + repo=$MIGRATION_REPO + number=$MIGRATION_NUMBER + quarantine_artifact "$data" "$id" data || return 1 + quarantine_artifact "$registration" "$id" registration || return 1 + [ ! -e "$data" ] && [ ! -L "$data" ] || return 1 + [ ! -e "$registration" ] && [ ! -L "$registration" ] || return 1 + fm_pr_poll_prepare "$STATE" "$id" "$url" "$owner" "$repo" "$number" "$TEMPLATE" || return 1 + fm_pr_poll_publish_prepared || return 1 + canonical_terminal_success "$id" +} + +ambiguous_repair_from_pending() { + local id=$1 check data registration + check="$STATE/$id.check.sh" + data="$STATE/$id.pr-poll" + registration="$STATE/$id.pr-poll-registration" + [ ! -e "$check" ] && [ ! -L "$check" ] || return 1 + quarantined_artifact_exists "$id" check || return 1 + quarantine_artifact "$data" "$id" data || return 1 + quarantine_artifact "$registration" "$id" registration || return 1 + ambiguous_terminal_success "$id" +} + +live_check_matches_quarantined() { + local id=$1 live artifact + live="$STATE/$id.check.sh" + [ -f "$live" ] && [ ! -L "$live" ] || return 1 + for artifact in "$QUARANTINE/$id.check."*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + fm_pr_private_file_valid "$artifact" 600 "$STATE_DEVICE" || return 1 + cmp -s "$live" "$artifact" && return 0 + done + return 1 +} + +replacement_artifacts_present() { + local id=$1 path + for path in "$STATE/$id.check.sh" "$STATE/$id.pr-poll" "$STATE/$id.pr-poll-registration"; do + [ -e "$path" ] || [ -L "$path" ] || continue + return 0 + done + return 1 +} + +quarantine_untrusted_replacement() { + local id=$1 + ensure_outcome_obligation "$id" failure-replacement || return 1 + quarantine_artifact "$STATE/$id.check.sh" "$id" replacement-check || return 1 + quarantine_artifact "$STATE/$id.pr-poll" "$id" replacement-data || return 1 + quarantine_artifact "$STATE/$id.pr-poll-registration" "$id" replacement-registration || return 1 +} + +recover_pending_outcomes() { + local obligation basename prefix kind success failure replacement_failure check + [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ] || return 0 + quarantine_tree_repair_and_validate || return 1 + for obligation in "$QUARANTINE"/*.diagnostic.pending-canonical \ + "$QUARANTINE"/*.diagnostic.pending-ambiguous \ + "$QUARANTINE"/*.diagnostic.pending-noncanonical; do + [ -e "$obligation" ] || [ -L "$obligation" ] || continue + basename=${obligation##*/} + diagnostic_obligation_message "$basename" || return 1 + prefix=$MIGRATION_DIAGNOSTIC_PREFIX + kind=$MIGRATION_DIAGNOSTIC_KIND + case "$kind" in + pending-canonical) + success="$QUARANTINE/$prefix.diagnostic.canonical" + failure="$QUARANTINE/$prefix.diagnostic.failure-canonical" + if canonical_terminal_success "$prefix"; then + complete_canonical_outcome "$prefix" || return 1 + continue + fi + if [ -e "$success" ] || [ -L "$success" ]; then + remove_diagnostic_obligation "$prefix" canonical || return 1 + fi + check="$STATE/$prefix.check.sh" + if [ ! -e "$check" ] && [ ! -L "$check" ]; then + if quarantined_artifact_exists "$prefix" check; then + ensure_outcome_obligation "$prefix" failure-canonical || return 1 + if canonical_repair_from_pending "$prefix"; then + complete_canonical_outcome "$prefix" || return 1 + else + migration_failed=1 + fi + elif [ -e "$failure" ] || [ -L "$failure" ]; then + migration_failed=1 + fi + fi + ;; + pending-ambiguous) + success="$QUARANTINE/$prefix.diagnostic.ambiguous" + failure="$QUARANTINE/$prefix.diagnostic.failure-ambiguous" + replacement_failure="$QUARANTINE/$prefix.diagnostic.failure-replacement" + if canonical_terminal_success "$prefix"; then + complete_validated_outcome "$prefix" || return 1 + continue + fi + if [ -e "$replacement_failure" ] || [ -L "$replacement_failure" ]; then + if replacement_artifacts_present "$prefix"; then + quarantine_untrusted_replacement "$prefix" || return 1 + fi + migration_failed=1 + continue + fi + if quarantined_artifact_exists "$prefix" check \ + && { [ -e "$STATE/$prefix.check.sh" ] || [ -L "$STATE/$prefix.check.sh" ]; } \ + && ! live_check_matches_quarantined "$prefix"; then + quarantine_untrusted_replacement "$prefix" || return 1 + migration_failed=1 + continue + fi + if ambiguous_terminal_success "$prefix"; then + complete_ambiguous_outcome "$prefix" || return 1 + continue + fi + if [ -e "$success" ] || [ -L "$success" ]; then + remove_diagnostic_obligation "$prefix" ambiguous || return 1 + fi + check="$STATE/$prefix.check.sh" + if [ ! -e "$check" ] && [ ! -L "$check" ]; then + if quarantined_artifact_exists "$prefix" check; then + ensure_outcome_obligation "$prefix" failure-ambiguous || return 1 + if ambiguous_repair_from_pending "$prefix"; then + complete_ambiguous_outcome "$prefix" || return 1 + else + migration_failed=1 + fi + elif [ -e "$failure" ] || [ -L "$failure" ]; then + migration_failed=1 + fi + fi + ;; + pending-noncanonical) + if quarantined_artifact_exists "$prefix" check; then + complete_noncanonical_outcome "$prefix" || return 1 + fi + ;; + esac + done +} + +failure_obligations_absent() { + local failure + [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ] || return 0 + for failure in "$QUARANTINE"/*.diagnostic.failure-canonical \ + "$QUARANTINE"/*.diagnostic.failure-ambiguous \ + "$QUARANTINE"/*.diagnostic.failure-replacement; do + [ -e "$failure" ] || [ -L "$failure" ] || continue + return 1 + done +} + +pending_outcomes_complete() { + local pending + [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ] || return 0 + for pending in "$QUARANTINE"/*.diagnostic.pending-canonical \ + "$QUARANTINE"/*.diagnostic.pending-ambiguous \ + "$QUARANTINE"/*.diagnostic.pending-noncanonical; do + [ -e "$pending" ] || [ -L "$pending" ] || continue + return 1 + done +} + +canonical_rebuilt=0 +validated_rearmed=0 +quarantined_unarmed=0 +process_diagnostic_obligations() { + local obligation basename message + [ -e "$QUARANTINE" ] || [ -L "$QUARANTINE" ] || return 0 + quarantine_tree_repair_and_validate || return 1 + diagnostic_namespace_valid || return 1 + for obligation in "$QUARANTINE"/*.diagnostic.pending-canonical \ + "$QUARANTINE"/*.diagnostic.pending-ambiguous \ + "$QUARANTINE"/*.diagnostic.pending-noncanonical \ + "$QUARANTINE"/*.diagnostic.canonical \ + "$QUARANTINE"/*.diagnostic.failure-canonical \ + "$QUARANTINE"/*.diagnostic.failure-ambiguous \ + "$QUARANTINE"/*.diagnostic.failure-replacement \ + "$QUARANTINE"/*.diagnostic.ambiguous \ + "$QUARANTINE"/*.diagnostic.validated \ + "$QUARANTINE"/*.diagnostic.noncanonical; do + [ -e "$obligation" ] || [ -L "$obligation" ] || continue + basename=${obligation##*/} + diagnostic_obligation_message "$basename" || return 1 + message=$MIGRATION_DIAGNOSTIC_MESSAGE + diagnostic_file_is_one_line "$obligation" "$message" || return 1 + record_diagnostic "$message" || return 1 + case "$MIGRATION_DIAGNOSTIC_KIND" in + canonical) canonical_rebuilt=1 ;; + validated) validated_rearmed=1 ;; + ambiguous|noncanonical) quarantined_unarmed=1 ;; + esac + done + for obligation in "$QUARANTINE"/*.diagnostic.pending-canonical \ + "$QUARANTINE"/*.diagnostic.pending-ambiguous \ + "$QUARANTINE"/*.diagnostic.pending-noncanonical \ + "$QUARANTINE"/*.diagnostic.canonical \ + "$QUARANTINE"/*.diagnostic.failure-canonical \ + "$QUARANTINE"/*.diagnostic.failure-ambiguous \ + "$QUARANTINE"/*.diagnostic.failure-replacement \ + "$QUARANTINE"/*.diagnostic.ambiguous \ + "$QUARANTINE"/*.diagnostic.validated \ + "$QUARANTINE"/*.diagnostic.noncanonical; do + [ -e "$obligation" ] || [ -L "$obligation" ] || continue + basename=${obligation##*/} + diagnostic_obligation_message "$basename" || return 1 + diagnostic_log_contains "$MIGRATION_DIAGNOSTIC_MESSAGE" || return 1 + done +} + +diagnostics_failed=0 +migration_failed=0 +if ! quarantine_tree_repair_and_validate \ + || ! diagnostic_namespace_valid \ + || ! migrate_legacy_noncanonical_namespace \ + || ! diagnostic_namespace_valid \ + || ! recover_pending_outcomes \ + || ! process_diagnostic_obligations; then + diagnostics_failed=1 + migration_failed=1 +fi + +if migration_needed; then + if ! ensure_quarantine_dir; then + echo "PR_CHECK_MIGRATION: private quarantine is unavailable; migration did not complete safely" >&2 + exit 1 + fi + + for check in "$STATE"/*.check.sh; do + [ -e "$check" ] || [ -L "$check" ] || continue + if [ "$(basename "$check")" = x-watch.check.sh ] \ + && fmx_poll_shim_valid "$check" "$FM_HOME" "$FM_ROOT"; then + continue + fi + id=$(basename "$check" .check.sh) + fm_custom_check_registered "$STATE" "$id" && continue + fm_pr_poll_artifacts_valid "$STATE" "$id" "$TEMPLATE" && continue + + if fm_pr_task_id_valid "$id"; then + prefix=$id + meta="$STATE/$id.meta" + data="$STATE/$id.pr-poll" + registration="$STATE/$id.pr-poll-registration" + if metadata_pr_is_canonical "$meta"; then + url=$MIGRATION_URL + owner=$MIGRATION_OWNER + repo=$MIGRATION_REPO + number=$MIGRATION_NUMBER + message="task $id: migration outcome tracking started before legacy poll handling" + if ! ensure_diagnostic_obligation "$prefix" pending-canonical "$message" \ + || ! process_diagnostic_obligations; then + diagnostics_failed=1 + migration_failed=1 + continue + fi + if quarantine_artifact "$check" "$prefix" check \ + && quarantine_artifact "$data" "$prefix" data \ + && quarantine_artifact "$registration" "$prefix" registration \ + && fm_pr_poll_prepare "$STATE" "$id" "$url" "$owner" "$repo" "$number" "$TEMPLATE" \ + && fm_pr_poll_publish_prepared \ + && complete_canonical_outcome "$id"; then + : + else + migration_failed=1 + record_canonical_failure "$id" || diagnostics_failed=1 + fi + else + message="task $id: migration outcome tracking started before legacy poll handling" + if ! ensure_diagnostic_obligation "$prefix" pending-ambiguous "$message" \ + || ! process_diagnostic_obligations; then + diagnostics_failed=1 + migration_failed=1 + continue + fi + if quarantine_artifact "$check" "$prefix" check \ + && quarantine_artifact "$data" "$prefix" data \ + && quarantine_artifact "$registration" "$prefix" registration \ + && complete_ambiguous_outcome "$id"; then + : + else + migration_failed=1 + record_ambiguous_failure "$id" || diagnostics_failed=1 + fi + fi + else + message='noncanonical task artifact: migration outcome tracking started before legacy poll handling' + if ! ensure_diagnostic_obligation "$NONCANONICAL_PREFIX" pending-noncanonical "$message" \ + || ! process_diagnostic_obligations; then + diagnostics_failed=1 + migration_failed=1 + continue + fi + if quarantine_artifact "$check" "$NONCANONICAL_PREFIX" check \ + && quarantine_artifact "$STATE/$id.pr-poll" "$NONCANONICAL_PREFIX" data \ + && quarantine_artifact "$STATE/$id.pr-poll-registration" "$NONCANONICAL_PREFIX" registration \ + && complete_noncanonical_outcome; then + : + else + migration_failed=1 + fi + fi + done +fi + +if ! quarantine_tree_repair_and_validate \ + || ! diagnostic_namespace_valid \ + || ! process_diagnostic_obligations; then + diagnostics_failed=1 + migration_failed=1 +fi +if ! pending_outcomes_complete || ! failure_obligations_absent; then + migration_failed=1 +fi + +scan_safe=0 +if [ "$diagnostics_failed" -eq 0 ] && unsafe_checks_absent && publish_scan_marker; then + scan_safe=1 +else + revoke_scan_marker || true + migration_failed=1 +fi + +if [ "$migration_failed" -eq 0 ] && [ "$scan_safe" -eq 1 ]; then + publish_migration_marker || migration_failed=1 +fi + +if [ "$migration_failed" -ne 0 ]; then + if [ "$ALLOW_INCOMPLETE_REPAIRS" -eq 1 ] && [ "$scan_safe" -eq 1 ]; then + exit 0 + fi + if [ "$diagnostics_failed" -eq 1 ]; then + echo "PR_CHECK_MIGRATION: private diagnostics are unavailable; migration did not complete safely" >&2 + else + echo "PR_CHECK_MIGRATION: migration did not complete safely; inspect private state before rearming polls" >&2 + fi + exit 1 +fi + +if [ "$canonical_rebuilt" -eq 1 ]; then + echo "PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home" +fi +if [ "$validated_rearmed" -eq 1 ]; then + echo "PR_CHECK_MIGRATION: validated replacement polls armed; resume supervision for this home" +fi +if [ "$quarantined_unarmed" -eq 1 ]; then + echo "PR_CHECK_MIGRATION: quarantined polls remain unarmed; review state/.pr-check-migration.log before rearming" +fi +if [ "$canonical_rebuilt" -eq 0 ] && [ "$validated_rearmed" -eq 0 ] \ + && [ "$quarantined_unarmed" -eq 0 ] \ + && [ "$stopped_watcher" -eq 1 ]; then + echo "PR_CHECK_MIGRATION: migration completed safely; resume supervision for this home" +fi diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 98f5569c7..8fb460117 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Record a PR-ready task: appends pr=<url> and GitHub's pr_head=<sha> to -# state/<id>.meta when available, then arms the watcher's merge poll by writing -# state/<id>.check.sh, which prints one line iff the PR is merged (the watcher's -# check contract: output = wake firstmate, silence = keep sleeping). +# Record a PR-ready task: store one validated canonical pr=<url> and GitHub's +# exact pr_head=<sha> when available, then atomically arm a static merge poll. +# The watcher check source is byte-for-byte bin/fm-pr-poll.sh; task and PR data +# live only in a private sidecar and are never interpolated into shell source. # Usage: fm-pr-check.sh <task-id> <pr-url> set -eu @@ -10,31 +10,84 @@ 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}" -"$FM_ROOT/bin/fm-guard.sh" || true + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + +if [ "$#" -ne 2 ]; then + echo "error: invalid PR check request" >&2 + exit 2 +fi ID=$1 -URL=$2 +RAW_URL=$2 +if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL"; then + echo "error: invalid PR check request" >&2 + exit 2 +fi +URL=$FM_PR_URL +OWNER=$FM_PR_OWNER +REPO=$FM_PR_REPO +NUMBER=$FM_PR_NUMBER +# Task-derived paths are constructed only after the canonical ID validation. META="$STATE/$ID.meta" -if [ -f "$META" ]; then - WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true) - PR_HEAD= - if [ -n "$WT" ] && [ -d "$WT" ]; then - if command -v gh >/dev/null 2>&1; then - if REMOTE_HEAD=$(cd "$WT" && gh pr view "$URL" --json headRefOid -q .headRefOid 2>/dev/null); then - PR_HEAD=$REMOTE_HEAD - fi - fi - fi - if ! grep -qxF "pr=$URL" "$META"; then - echo "pr=$URL" >> "$META" - fi - if [ -n "$PR_HEAD" ] && ! grep -qxF "pr_head=$PR_HEAD" "$META"; then - echo "pr_head=$PR_HEAD" >> "$META" +if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != 1 ]; then + echo "error: task metadata is unavailable" >&2 + exit 1 +fi + +# Neutralize any pre-fix poll before recording or arming this task. The +# migration never executes legacy artifacts and holds watcher exclusion while +# it quarantines or rebuilds them. +"$SCRIPT_DIR/fm-pr-check-migrate.sh" --checks-safe || exit 1 +"$FM_ROOT/bin/fm-guard.sh" || true + +WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true) +PR_HEAD= +if [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/dev/null 2>&1; then + if REMOTE_HEAD=$(cd "$WT" && gh pr view "$URL" --json headRefOid -q .headRefOid 2>/dev/null) \ + && fm_pr_head_valid "$REMOTE_HEAD"; then + PR_HEAD=$REMOTE_HEAD fi fi -cat > "$STATE/$ID.check.sh" <<EOF -state=\$(gh pr view "$URL" --json state -q .state 2>/dev/null) -[ "\$state" = "MERGED" ] && echo "merged" -EOF -echo "armed: state/$ID.check.sh polls $URL" +META_TMP= +pr_check_cleanup() { + fm_pr_poll_cleanup + [ -z "$META_TMP" ] || rm -f -- "$META_TMP" +} +trap pr_check_cleanup EXIT +trap 'exit 1' HUP INT TERM +fm_pr_poll_prepare "$STATE" "$ID" "$URL" "$OWNER" "$REPO" "$NUMBER" "$SCRIPT_DIR/fm-pr-poll.sh" \ + || { echo "error: could not prepare PR poll" >&2; exit 1; } + +META_DEVICE=$(fm_pr_file_device "$META") || exit 1 +STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 +[ "$META_DEVICE" = "$STATE_DEVICE" ] || { echo "error: task metadata is unavailable" >&2; exit 1; } +META_TMP=$(mktemp "$STATE/.fm-pr-meta.XXXXXX") || exit 1 +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + pr=*|pr_head=*) ;; + *) printf '%s\n' "$line" >> "$META_TMP" || exit 1 ;; + esac +done < "$META" +printf 'pr=%s\n' "$URL" >> "$META_TMP" || exit 1 +[ -z "$PR_HEAD" ] || printf 'pr_head=%s\n' "$PR_HEAD" >> "$META_TMP" || exit 1 +chmod 0600 "$META_TMP" || exit 1 +fm_pr_private_file_valid "$META_TMP" 600 "$STATE_DEVICE" || exit 1 +fm_pr_metadata_identity_parse "$META_TMP" || exit 1 +[ "$FM_PR_META_URL" = "$URL" ] && [ "$FM_PR_META_OWNER" = "$OWNER" ] \ + && [ "$FM_PR_META_REPO" = "$REPO" ] && [ "$FM_PR_META_NUMBER" = "$NUMBER" ] || exit 1 +fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" || exit 1 +mv -f -- "$META_TMP" "$META" || exit 1 +META_TMP= +fm_pr_private_file_valid "$META" 600 "$STATE_DEVICE" || exit 1 +fm_pr_metadata_identity_parse "$META" || exit 1 +[ "$FM_PR_META_URL" = "$URL" ] && [ "$FM_PR_META_OWNER" = "$OWNER" ] \ + && [ "$FM_PR_META_REPO" = "$REPO" ] && [ "$FM_PR_META_NUMBER" = "$NUMBER" ] || exit 1 + +fm_pr_poll_publish_prepared || { + echo "error: could not publish PR poll" >&2 + exit 1 +} +printf 'armed: state/%s.check.sh\n' "$ID" diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh new file mode 100755 index 000000000..96e5613af --- /dev/null +++ b/bin/fm-pr-lib.sh @@ -0,0 +1,463 @@ +#!/usr/bin/env bash +# Shared validation and atomic artifact helpers for GitHub PR merge polling. +# Callers must validate task IDs and raw PR URLs before constructing task paths +# or performing any side effect. + +FM_PR_URL= +FM_PR_OWNER= +FM_PR_REPO= +FM_PR_NUMBER= +FM_PR_DATA_URL= +FM_PR_DATA_OWNER= +FM_PR_DATA_REPO= +FM_PR_DATA_NUMBER= +FM_PR_META_URL= +FM_PR_META_OWNER= +FM_PR_META_REPO= +FM_PR_META_NUMBER= +FM_PR_REG_ID= +FM_PR_REG_URL= +FM_PR_REG_OWNER= +FM_PR_REG_REPO= +FM_PR_REG_NUMBER= +FM_PR_REG_DATA_HASH= +FM_PR_REG_TEMPLATE_HASH= +FM_PR_REG_DATA_IDENTITY= +FM_PR_REG_CHECK_IDENTITY= +FM_PR_POLL_DATA_TMP= +FM_PR_POLL_CHECK_TMP= +FM_PR_POLL_REG_TMP= +FM_PR_POLL_DATA_DEST= +FM_PR_POLL_CHECK_DEST= +FM_PR_POLL_REG_DEST= +FM_PR_POLL_EXPECT_ID= +FM_PR_POLL_EXPECT_URL= +FM_PR_POLL_EXPECT_OWNER= +FM_PR_POLL_EXPECT_REPO= +FM_PR_POLL_EXPECT_NUMBER= +FM_PR_POLL_EXPECT_DATA_HASH= +FM_PR_POLL_EXPECT_TEMPLATE_HASH= +FM_PR_POLL_EXPECT_DATA_IDENTITY= +FM_PR_POLL_EXPECT_CHECK_IDENTITY= +FM_PR_POLL_TEMPLATE= +FM_PR_POLL_STATE_DEVICE= + +fm_task_id_path_safe() { + local id=${1-} + local LC_ALL=C + case "$id" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac +} + +fm_pr_task_id_valid() { + local id=${1-} + fm_task_id_path_safe "$id" +} + +fm_task_id_creation_valid() { + local id=${1-} + fm_pr_task_id_valid "$id" || return 1 + [ "${#id}" -le 64 ] +} + +fm_pr_url_parse() { + local raw=${1-} pattern + local LC_ALL=C + FM_PR_URL= + FM_PR_OWNER= + FM_PR_REPO= + FM_PR_NUMBER= + pattern='^https://github\.com/([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,37}[A-Za-z0-9])/([A-Za-z0-9._-]{1,100})/pull/([1-9][0-9]*)$' + [[ "$raw" =~ $pattern ]] || return 1 + [[ "${BASH_REMATCH[1]}" != *--* ]] || return 1 + [ "${BASH_REMATCH[2]}" != . ] && [ "${BASH_REMATCH[2]}" != .. ] || return 1 + FM_PR_URL=$raw + FM_PR_OWNER=${BASH_REMATCH[1]} + FM_PR_REPO=${BASH_REMATCH[2]} + FM_PR_NUMBER=${BASH_REMATCH[3]} +} + +fm_pr_head_valid() { + local head=${1-} + local LC_ALL=C + [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]] +} + +fm_pr_file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" 2>/dev/null + else + stat -c %a "$1" 2>/dev/null + fi +} + +fm_pr_file_device() { + if [ "$(uname)" = Darwin ]; then + stat -f %d "$1" 2>/dev/null + else + stat -c %d "$1" 2>/dev/null + fi +} + +fm_pr_file_link_count() { + if [ "$(uname)" = Darwin ]; then + stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +fm_pr_file_inode() { + if [ "$(uname)" = Darwin ]; then + stat -f %i "$1" 2>/dev/null + else + stat -c %i "$1" 2>/dev/null + fi +} + +fm_pr_file_identity() { + local device inode + device=$(fm_pr_file_device "$1") || return 1 + inode=$(fm_pr_file_inode "$1") || return 1 + [ -n "$device" ] && [ -n "$inode" ] || return 1 + printf '%s:%s\n' "$device" "$inode" +} + +fm_pr_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +fm_pr_private_file_valid() { + local path=$1 mode=$2 device=$3 + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + [ "$(fm_pr_file_mode "$path")" = "$mode" ] || return 1 + [ "$(fm_pr_file_device "$path")" = "$device" ] || return 1 + [ "$(fm_pr_file_link_count "$path")" = 1 ] +} + +fm_pr_regular_destination_or_absent() { + local path=$1 + [ ! -L "$path" ] || return 1 + if [ -e "$path" ]; then + [ -f "$path" ] && [ "$(fm_pr_file_link_count "$path")" = 1 ] + fi +} + +fm_pr_regular_destination_on_device_or_absent() { + local path=$1 device=$2 + fm_pr_regular_destination_or_absent "$path" || return 1 + [ ! -e "$path" ] || [ "$(fm_pr_file_device "$path")" = "$device" ] +} + +fm_pr_metadata_identity_parse() { + local file=$1 line value pr_count=0 seen_pr=0 post_pr_invalid=0 + FM_PR_META_URL= + FM_PR_META_OWNER= + FM_PR_META_REPO= + FM_PR_META_NUMBER= + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + [ "$(fm_pr_file_link_count "$file")" = 1 ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + pr=*) + pr_count=$((pr_count + 1)) + [ "$pr_count" -eq 1 ] || continue + value=${line#pr=} + if fm_pr_url_parse "$value"; then + FM_PR_META_URL=$FM_PR_URL + FM_PR_META_OWNER=$FM_PR_OWNER + FM_PR_META_REPO=$FM_PR_REPO + FM_PR_META_NUMBER=$FM_PR_NUMBER + fi + seen_pr=1 + ;; + pr_head=*) + if [ "$seen_pr" -eq 1 ]; then + value=${line#pr_head=} + fm_pr_head_valid "$value" || post_pr_invalid=1 + fi + ;; + x_request=*|x_request_ts=*|x_followups=*|x_platform=*|x_reply_max_chars=*) + ;; + *) + [ "$seen_pr" -eq 0 ] || post_pr_invalid=1 + ;; + esac + done < "$file" + [ "$pr_count" -eq 1 ] || return 1 + [ "$post_pr_invalid" -eq 0 ] || return 1 + [ -n "$FM_PR_META_URL" ] +} + +fm_pr_poll_data_parse() { + local file=$1 url owner repo number + FM_PR_DATA_URL= + FM_PR_DATA_OWNER= + FM_PR_DATA_REPO= + FM_PR_DATA_NUMBER= + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + exec 8< "$file" || return 1 + IFS= read -r url <&8 || { exec 8<&-; return 1; } + IFS= read -r owner <&8 || { exec 8<&-; return 1; } + IFS= read -r repo <&8 || { exec 8<&-; return 1; } + IFS= read -r number <&8 || { exec 8<&-; return 1; } + if IFS= read -r _extra <&8; then + exec 8<&- + return 1 + fi + exec 8<&- + fm_pr_url_parse "$url" || return 1 + [ "$owner" = "$FM_PR_OWNER" ] || return 1 + [ "$repo" = "$FM_PR_REPO" ] || return 1 + [ "$number" = "$FM_PR_NUMBER" ] || return 1 + FM_PR_DATA_URL=$FM_PR_URL + FM_PR_DATA_OWNER=$FM_PR_OWNER + FM_PR_DATA_REPO=$FM_PR_REPO + FM_PR_DATA_NUMBER=$FM_PR_NUMBER +} + +fm_pr_poll_registration_parse() { + local file=$1 version id url owner repo number data_hash template_hash data_identity check_identity + FM_PR_REG_ID= + FM_PR_REG_URL= + FM_PR_REG_OWNER= + FM_PR_REG_REPO= + FM_PR_REG_NUMBER= + FM_PR_REG_DATA_HASH= + FM_PR_REG_TEMPLATE_HASH= + FM_PR_REG_DATA_IDENTITY= + FM_PR_REG_CHECK_IDENTITY= + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + exec 7< "$file" || return 1 + IFS= read -r version <&7 || { exec 7<&-; return 1; } + IFS= read -r id <&7 || { exec 7<&-; return 1; } + IFS= read -r url <&7 || { exec 7<&-; return 1; } + IFS= read -r owner <&7 || { exec 7<&-; return 1; } + IFS= read -r repo <&7 || { exec 7<&-; return 1; } + IFS= read -r number <&7 || { exec 7<&-; return 1; } + IFS= read -r data_hash <&7 || { exec 7<&-; return 1; } + IFS= read -r template_hash <&7 || { exec 7<&-; return 1; } + IFS= read -r data_identity <&7 || { exec 7<&-; return 1; } + IFS= read -r check_identity <&7 || { exec 7<&-; return 1; } + if IFS= read -r _extra <&7; then + exec 7<&- + return 1 + fi + exec 7<&- + [ "$version" = fm-pr-poll-registration-v1 ] || return 1 + fm_pr_task_id_valid "$id" || return 1 + fm_pr_url_parse "$url" || return 1 + [ "$owner" = "$FM_PR_OWNER" ] || return 1 + [ "$repo" = "$FM_PR_REPO" ] || return 1 + [ "$number" = "$FM_PR_NUMBER" ] || return 1 + [[ "$data_hash" =~ ^[0-9a-f]{64}$ ]] || return 1 + [[ "$template_hash" =~ ^[0-9a-f]{64}$ ]] || return 1 + [[ "$data_identity" =~ ^[0-9]+:[0-9]+$ ]] || return 1 + [[ "$check_identity" =~ ^[0-9]+:[0-9]+$ ]] || return 1 + FM_PR_REG_ID=$id + FM_PR_REG_URL=$FM_PR_URL + FM_PR_REG_OWNER=$FM_PR_OWNER + FM_PR_REG_REPO=$FM_PR_REPO + FM_PR_REG_NUMBER=$FM_PR_NUMBER + FM_PR_REG_DATA_HASH=$data_hash + FM_PR_REG_TEMPLATE_HASH=$template_hash + FM_PR_REG_DATA_IDENTITY=$data_identity + FM_PR_REG_CHECK_IDENTITY=$check_identity +} + +fm_pr_poll_cleanup() { + [ -z "$FM_PR_POLL_DATA_TMP" ] || rm -f -- "$FM_PR_POLL_DATA_TMP" + [ -z "$FM_PR_POLL_CHECK_TMP" ] || rm -f -- "$FM_PR_POLL_CHECK_TMP" + [ -z "$FM_PR_POLL_REG_TMP" ] || rm -f -- "$FM_PR_POLL_REG_TMP" + FM_PR_POLL_DATA_TMP= + FM_PR_POLL_CHECK_TMP= + FM_PR_POLL_REG_TMP= +} + +fm_pr_poll_revoke_final() { + local failed=0 + # Neutralize the runnable name first so a failed rearm cannot consume state + # whose transactional registration did not commit successfully. + if [ -e "$FM_PR_POLL_CHECK_DEST" ] || [ -L "$FM_PR_POLL_CHECK_DEST" ]; then + rm -f -- "$FM_PR_POLL_CHECK_DEST" || failed=1 + fi + if [ -e "$FM_PR_POLL_REG_DEST" ] || [ -L "$FM_PR_POLL_REG_DEST" ]; then + rm -f -- "$FM_PR_POLL_REG_DEST" || failed=1 + fi + if [ -e "$FM_PR_POLL_DATA_DEST" ] || [ -L "$FM_PR_POLL_DATA_DEST" ]; then + rm -f -- "$FM_PR_POLL_DATA_DEST" || failed=1 + fi + [ ! -e "$FM_PR_POLL_CHECK_DEST" ] && [ ! -L "$FM_PR_POLL_CHECK_DEST" ] || failed=1 + [ ! -e "$FM_PR_POLL_REG_DEST" ] && [ ! -L "$FM_PR_POLL_REG_DEST" ] || failed=1 + [ ! -e "$FM_PR_POLL_DATA_DEST" ] && [ ! -L "$FM_PR_POLL_DATA_DEST" ] || failed=1 + return "$failed" +} + +fm_pr_poll_prepare() { + local state=$1 id=$2 url=$3 owner=$4 repo=$5 number=$6 template=$7 + fm_pr_task_id_valid "$id" || return 1 + fm_pr_url_parse "$url" || return 1 + [ "$owner" = "$FM_PR_OWNER" ] || return 1 + [ "$repo" = "$FM_PR_REPO" ] || return 1 + [ "$number" = "$FM_PR_NUMBER" ] || return 1 + [ -f "$template" ] || return 1 + + [ ! -L "$state" ] || return 1 + mkdir -p "$state" || return 1 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + umask 077 + FM_PR_POLL_DATA_DEST="$state/$id.pr-poll" + FM_PR_POLL_CHECK_DEST="$state/$id.check.sh" + FM_PR_POLL_REG_DEST="$state/$id.pr-poll-registration" + FM_PR_POLL_EXPECT_ID=$id + FM_PR_POLL_EXPECT_URL=$url + FM_PR_POLL_EXPECT_OWNER=$owner + FM_PR_POLL_EXPECT_REPO=$repo + FM_PR_POLL_EXPECT_NUMBER=$number + FM_PR_POLL_TEMPLATE=$template + FM_PR_POLL_STATE_DEVICE=$(fm_pr_file_device "$state") || return 1 + [ -n "$FM_PR_POLL_STATE_DEVICE" ] || return 1 + FM_PR_POLL_DATA_TMP=$(mktemp "$state/.fm-pr-poll-data.XXXXXX") || return 1 + FM_PR_POLL_CHECK_TMP=$(mktemp "$state/.fm-pr-poll-check.XXXXXX") || { + fm_pr_poll_cleanup + return 1 + } + FM_PR_POLL_REG_TMP=$(mktemp "$state/.fm-pr-poll-registration.XXXXXX") || { + fm_pr_poll_cleanup + return 1 + } + + if ! printf '%s\n%s\n%s\n%s\n' "$url" "$owner" "$repo" "$number" > "$FM_PR_POLL_DATA_TMP" \ + || ! chmod 0600 "$FM_PR_POLL_DATA_TMP" \ + || ! fm_pr_private_file_valid "$FM_PR_POLL_DATA_TMP" 600 "$FM_PR_POLL_STATE_DEVICE" \ + || ! fm_pr_poll_data_parse "$FM_PR_POLL_DATA_TMP" \ + || [ "$FM_PR_DATA_URL" != "$url" ] \ + || [ "$FM_PR_DATA_OWNER" != "$owner" ] \ + || [ "$FM_PR_DATA_REPO" != "$repo" ] \ + || [ "$FM_PR_DATA_NUMBER" != "$number" ] \ + || ! cp "$template" "$FM_PR_POLL_CHECK_TMP" \ + || ! chmod 0600 "$FM_PR_POLL_CHECK_TMP" \ + || ! fm_pr_private_file_valid "$FM_PR_POLL_CHECK_TMP" 600 "$FM_PR_POLL_STATE_DEVICE" \ + || ! cmp -s "$template" "$FM_PR_POLL_CHECK_TMP"; then + fm_pr_poll_cleanup + return 1 + fi + FM_PR_POLL_EXPECT_DATA_HASH=$(fm_pr_sha256 "$FM_PR_POLL_DATA_TMP") || { fm_pr_poll_cleanup; return 1; } + FM_PR_POLL_EXPECT_TEMPLATE_HASH=$(fm_pr_sha256 "$FM_PR_POLL_CHECK_TMP") || { fm_pr_poll_cleanup; return 1; } + FM_PR_POLL_EXPECT_DATA_IDENTITY=$(fm_pr_file_identity "$FM_PR_POLL_DATA_TMP") || { fm_pr_poll_cleanup; return 1; } + FM_PR_POLL_EXPECT_CHECK_IDENTITY=$(fm_pr_file_identity "$FM_PR_POLL_CHECK_TMP") || { fm_pr_poll_cleanup; return 1; } + if ! printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + fm-pr-poll-registration-v1 "$id" "$url" "$owner" "$repo" "$number" \ + "$FM_PR_POLL_EXPECT_DATA_HASH" "$FM_PR_POLL_EXPECT_TEMPLATE_HASH" \ + "$FM_PR_POLL_EXPECT_DATA_IDENTITY" "$FM_PR_POLL_EXPECT_CHECK_IDENTITY" \ + > "$FM_PR_POLL_REG_TMP" \ + || ! chmod 0600 "$FM_PR_POLL_REG_TMP" \ + || ! fm_pr_private_file_valid "$FM_PR_POLL_REG_TMP" 600 "$FM_PR_POLL_STATE_DEVICE" \ + || ! fm_pr_poll_registration_parse "$FM_PR_POLL_REG_TMP" \ + || [ "$FM_PR_REG_ID" != "$id" ] \ + || [ "$FM_PR_REG_DATA_HASH" != "$FM_PR_POLL_EXPECT_DATA_HASH" ] \ + || [ "$FM_PR_REG_TEMPLATE_HASH" != "$FM_PR_POLL_EXPECT_TEMPLATE_HASH" ]; then + fm_pr_poll_cleanup + return 1 + fi +} + +fm_pr_poll_publish_prepared() { + [ -n "$FM_PR_POLL_DATA_TMP" ] && [ -n "$FM_PR_POLL_CHECK_TMP" ] \ + && [ -n "$FM_PR_POLL_REG_TMP" ] || return 1 + fm_pr_regular_destination_on_device_or_absent "$FM_PR_POLL_DATA_DEST" "$FM_PR_POLL_STATE_DEVICE" || return 1 + fm_pr_regular_destination_on_device_or_absent "$FM_PR_POLL_REG_DEST" "$FM_PR_POLL_STATE_DEVICE" || return 1 + fm_pr_regular_destination_on_device_or_absent "$FM_PR_POLL_CHECK_DEST" "$FM_PR_POLL_STATE_DEVICE" || return 1 + + if ! mv -f -- "$FM_PR_POLL_DATA_TMP" "$FM_PR_POLL_DATA_DEST"; then + fm_pr_poll_revoke_final || true + return 1 + fi + FM_PR_POLL_DATA_TMP= + if ! fm_pr_private_file_valid "$FM_PR_POLL_DATA_DEST" 600 "$FM_PR_POLL_STATE_DEVICE" \ + || [ "$(fm_pr_file_identity "$FM_PR_POLL_DATA_DEST")" != "$FM_PR_POLL_EXPECT_DATA_IDENTITY" ] \ + || [ "$(fm_pr_sha256 "$FM_PR_POLL_DATA_DEST")" != "$FM_PR_POLL_EXPECT_DATA_HASH" ] \ + || ! fm_pr_poll_data_parse "$FM_PR_POLL_DATA_DEST" \ + || [ "$FM_PR_DATA_URL" != "$FM_PR_POLL_EXPECT_URL" ] \ + || [ "$FM_PR_DATA_OWNER" != "$FM_PR_POLL_EXPECT_OWNER" ] \ + || [ "$FM_PR_DATA_REPO" != "$FM_PR_POLL_EXPECT_REPO" ] \ + || [ "$FM_PR_DATA_NUMBER" != "$FM_PR_POLL_EXPECT_NUMBER" ]; then + fm_pr_poll_revoke_final || true + return 1 + fi + + if ! mv -f -- "$FM_PR_POLL_REG_TMP" "$FM_PR_POLL_REG_DEST"; then + fm_pr_poll_revoke_final || true + return 1 + fi + FM_PR_POLL_REG_TMP= + if ! fm_pr_private_file_valid "$FM_PR_POLL_REG_DEST" 600 "$FM_PR_POLL_STATE_DEVICE" \ + || ! fm_pr_poll_registration_parse "$FM_PR_POLL_REG_DEST" \ + || [ "$FM_PR_REG_ID" != "$FM_PR_POLL_EXPECT_ID" ] \ + || [ "$FM_PR_REG_URL" != "$FM_PR_POLL_EXPECT_URL" ] \ + || [ "$FM_PR_REG_OWNER" != "$FM_PR_POLL_EXPECT_OWNER" ] \ + || [ "$FM_PR_REG_REPO" != "$FM_PR_POLL_EXPECT_REPO" ] \ + || [ "$FM_PR_REG_NUMBER" != "$FM_PR_POLL_EXPECT_NUMBER" ] \ + || [ "$FM_PR_REG_DATA_HASH" != "$FM_PR_POLL_EXPECT_DATA_HASH" ] \ + || [ "$FM_PR_REG_TEMPLATE_HASH" != "$FM_PR_POLL_EXPECT_TEMPLATE_HASH" ] \ + || [ "$FM_PR_REG_DATA_IDENTITY" != "$FM_PR_POLL_EXPECT_DATA_IDENTITY" ] \ + || [ "$FM_PR_REG_CHECK_IDENTITY" != "$FM_PR_POLL_EXPECT_CHECK_IDENTITY" ]; then + fm_pr_poll_revoke_final || true + return 1 + fi + + if ! fm_pr_regular_destination_on_device_or_absent "$FM_PR_POLL_CHECK_DEST" "$FM_PR_POLL_STATE_DEVICE" \ + || ! mv -f -- "$FM_PR_POLL_CHECK_TMP" "$FM_PR_POLL_CHECK_DEST"; then + fm_pr_poll_revoke_final || true + return 1 + fi + FM_PR_POLL_CHECK_TMP= + if ! fm_pr_poll_artifacts_valid "${FM_PR_POLL_CHECK_DEST%/*}" "$FM_PR_POLL_EXPECT_ID" "$FM_PR_POLL_TEMPLATE"; then + fm_pr_poll_revoke_final || true + return 1 + fi +} + +fm_pr_poll_artifacts_valid() { + local state=$1 id=$2 template=$3 state_device check data registration meta data_hash template_hash data_identity check_identity + fm_pr_task_id_valid "$id" || return 1 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + check="$state/$id.check.sh" + data="$state/$id.pr-poll" + registration="$state/$id.pr-poll-registration" + meta="$state/$id.meta" + fm_pr_private_file_valid "$check" 600 "$state_device" || return 1 + fm_pr_private_file_valid "$data" 600 "$state_device" || return 1 + fm_pr_private_file_valid "$registration" 600 "$state_device" || return 1 + [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 + [ "$(fm_pr_file_link_count "$meta")" = 1 ] || return 1 + cmp -s "$template" "$check" || return 1 + fm_pr_poll_data_parse "$data" || return 1 + data_hash=$(fm_pr_sha256 "$data") || return 1 + template_hash=$(fm_pr_sha256 "$check") || return 1 + data_identity=$(fm_pr_file_identity "$data") || return 1 + check_identity=$(fm_pr_file_identity "$check") || return 1 + fm_pr_poll_registration_parse "$registration" || return 1 + [ "$FM_PR_REG_ID" = "$id" ] || return 1 + [ "$FM_PR_REG_URL" = "$FM_PR_DATA_URL" ] || return 1 + [ "$FM_PR_REG_OWNER" = "$FM_PR_DATA_OWNER" ] || return 1 + [ "$FM_PR_REG_REPO" = "$FM_PR_DATA_REPO" ] || return 1 + [ "$FM_PR_REG_NUMBER" = "$FM_PR_DATA_NUMBER" ] || return 1 + [ "$FM_PR_REG_DATA_HASH" = "$data_hash" ] || return 1 + [ "$FM_PR_REG_TEMPLATE_HASH" = "$template_hash" ] || return 1 + [ "$FM_PR_REG_DATA_IDENTITY" = "$data_identity" ] || return 1 + [ "$FM_PR_REG_CHECK_IDENTITY" = "$check_identity" ] || return 1 + fm_pr_metadata_identity_parse "$meta" || return 1 + [ "$FM_PR_META_URL" = "$FM_PR_DATA_URL" ] || return 1 + [ "$FM_PR_META_OWNER" = "$FM_PR_DATA_OWNER" ] || return 1 + [ "$FM_PR_META_REPO" = "$FM_PR_DATA_REPO" ] || return 1 + [ "$FM_PR_META_NUMBER" = "$FM_PR_DATA_NUMBER" ] +} diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 63d6561da..455d26333 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -1,48 +1,44 @@ #!/usr/bin/env bash -# Merge a task's PR, always recording pr= and any available pr_head= into -# state/<id>.meta first via bin/fm-pr-check.sh, so bin/fm-teardown.sh's -# landed-check has a PR reference to verify a squash merge against. -# -# Why this exists: the normal trigger for running fm-pr-check.sh is the crew's -# `done: PR <url> checks green` line, which no-mistakes only emits once its CI -# step turns green. Repos that intentionally run no CI on PRs (CI only on -# pushes to the default branch) never emit that line, so a merge performed by -# hand-running `gh-axi pr merge` - the common shape of a yolo-authorized merge - -# can skip the recording step entirely. Teardown then has nothing to look up for -# a squash-merge-then-delete-branch flow and false-refuses provably landed work. -# This script makes recording part of the merge itself, so it cannot be skipped -# by omission. Use it for every PR merge (captain-requested or yolo-authorized), -# in place of calling `gh-axi pr merge` directly. -# -# gh-axi pr merge expects a PR number and --repo <owner>/<repo>; it does not -# parse a full https://github.com/<owner>/<repo>/pull/<n> URL. This script -# parses the URL and invokes gh-axi in the form it accepts. -# -# Merge method: defaults to --squash when the caller passes none of --squash, -# --merge, --rebase, or --method after the optional -- separator. An explicit -# caller method is never overridden. -# Extra args must not include --repo or -R because the repo is parsed from the -# PR URL. +# Merge a task's PR after recording pr= and any available pr_head= through +# bin/fm-pr-check.sh, so teardown can verify landed work after squash merges. +# The full canonical GitHub PR URL is parsed by bin/fm-pr-lib.sh and the derived +# owner/repository and PR number are passed to gh-axi as separate arguments. # # CI guard: records PR metadata first, then refuses to merge unless the PR check # rollup is green. # A PR with no checks configured is allowed; pending, failing, canceled, or # unknown check states are refused without an override flag. # +# Merge method defaults to --squash when the caller passes none of --squash, +# --merge, --rebase, or --method after the optional -- separator. Extra args +# must not include --repo or -R because the repository comes only from the URL. # Usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra gh-axi pr merge args>] set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ID=${1:?usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra gh-axi pr merge args>]} -URL=${2:?usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra gh-axi pr merge args>]} -shift 2 -[ "${1:-}" = "--" ] && shift - 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}" -META="$STATE/$ID.meta" -[ -f "$META" ] || { echo "error: no meta for task $ID at $META; refusing to merge without recording pr=" >&2; exit 1; } + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + +if [ "$#" -lt 2 ]; then + echo "error: invalid PR merge request" >&2 + exit 2 +fi +ID=$1 +RAW_URL=$2 +if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL"; then + echo "error: invalid PR merge request" >&2 + exit 2 +fi +URL=$FM_PR_URL +PR_OWNER=$FM_PR_OWNER +PR_REPO=$FM_PR_REPO +PR_NUMBER=$FM_PR_NUMBER +shift 2 +[ "${1:-}" = "--" ] && shift caller_has_merge_method() { local arg @@ -54,31 +50,16 @@ caller_has_merge_method() { return 1 } -parse_pr_url() { - local url=$1 - if [[ "$url" =~ ^https://github\.com/([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9._-]+)/pull/([0-9]+)/?$ ]]; then - PR_OWNER="${BASH_REMATCH[1]}" - PR_REPO="${BASH_REMATCH[2]}" - PR_NUMBER="${BASH_REMATCH[3]}" - if [[ "$PR_OWNER" != *- ]]; then - return 0 - fi - fi - echo "error: PR URL must match https://github.com/<owner>/<repo>/pull/<number> (got: $url)" >&2 - return 1 -} - reject_repo_overrides() { local arg for arg in "$@"; do case "$arg" in --repo|--repo=*|-R|-R?*) - echo "error: extra merge args must not override --repo parsed from PR URL (got: $arg)" >&2 + echo "error: extra merge arguments must not override the repository" >&2 return 1 ;; esac done - return 0 } checks_output_means_no_checks() { @@ -208,11 +189,20 @@ assert_pr_checks_green() { assert_no_cancelled_checks "$repo" } -parse_pr_url "$URL" || exit 1 reject_repo_overrides "$@" || exit 1 +# Task-derived paths are constructed only after the canonical ID validation. +META="$STATE/$ID.meta" +if [ ! -f "$META" ] || [ -L "$META" ]; then + echo "error: task metadata is unavailable" >&2 + exit 1 +fi + "$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL" -grep -qxF "pr=$URL" "$META" || { echo "error: fm-pr-check did not record pr=$URL in $META; refusing to merge" >&2; exit 1; } +grep -qxF "pr=$URL" "$META" || { + echo "error: PR metadata recording failed" >&2 + exit 1 +} assert_pr_checks_green "$PR_OWNER/$PR_REPO" || exit 1 merge_args=() diff --git a/bin/fm-pr-poll.sh b/bin/fm-pr-poll.sh new file mode 100755 index 000000000..13b71759d --- /dev/null +++ b/bin/fm-pr-poll.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Static watcher program for a validated PR poll sidecar. +# It emits exactly one merged line for MERGED and stays silent otherwise. +set -u +LC_ALL=C +export LC_ALL + +if [ "$#" -eq 5 ] && [ "$1" = --validated ]; then + url=$2 + owner=$3 + repo=$4 + number=$5 +elif [ "$#" -eq 0 ]; then + case "$0" in + *.check.sh) data=${0%.check.sh}.pr-poll ;; + *) exit 0 ;; + esac + + [ -f "$data" ] && [ ! -L "$data" ] || exit 0 + { exec 3< "$data"; } 2>/dev/null || exit 0 + IFS= read -r url <&3 || exit 0 + IFS= read -r owner <&3 || exit 0 + IFS= read -r repo <&3 || exit 0 + IFS= read -r number <&3 || exit 0 + if IFS= read -r _extra <&3; then + exit 0 + fi + exec 3<&- +else + exit 0 +fi + +[ "${#owner}" -ge 1 ] && [ "${#owner}" -le 39 ] || exit 0 +case "$owner" in + *[!A-Za-z0-9-]*|-*|*-|*--*) exit 0 ;; +esac +[ "${#repo}" -ge 1 ] && [ "${#repo}" -le 100 ] || exit 0 +case "$repo" in + .|..|*[!A-Za-z0-9._-]*) exit 0 ;; +esac +case "$number" in + [1-9]*) ;; + *) exit 0 ;; +esac +case "$number" in + *[!0-9]*) exit 0 ;; +esac +[ "$url" = "https://github.com/$owner/$repo/pull/$number" ] || exit 0 + +state=$(gh pr view "$url" --json state -q .state 2>/dev/null) || exit 0 +[ "$state" = MERGED ] && printf '%s\n' merged +exit 0 diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 666292afb..4b27e3dc6 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -11,7 +11,7 @@ # mode = how a finished change reaches main: # no-mistakes full pipeline -> PR -> captain merge (default) # direct-PR push + PR via gh-axi, no pipeline -> captain merge -# local-only local branch, no remote/PR -> firstmate review -> captain approve -> local merge +# local-only local branch, no remote/PR -> captain approve -> guarded local merge # yolo (orthogonal) = when on, firstmate makes approval decisions itself (PR merges, # ask-user findings, local-only merge approval) without checking the captain - except # anything destructive/irreversible/security-sensitive, which still escalates. diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index cf1a628eb..182cc9a27 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -5,8 +5,9 @@ # producing ONE ordered digest, so a session starts in one or two turns # instead of the six-plus separate reads the old docs required: run # fm-bootstrap.sh, then separately read data/projects.md, data/secondmates.md, -# data/captain.md, data/learnings.md, then run fm-lock.sh, fm-wake-drain.sh, -# then read data/backlog.md, every state/*.meta, and every state/*.status. +# data/captain.md, data/captain-shared.md, data/learnings.md, then run +# fm-lock.sh, fm-wake-drain.sh, then read data/backlog.md, every state/*.meta, +# and every state/*.status. # Every one of those reads is UNCONDITIONAL at every session start, so they # belong in a script, not in N agent turns. # @@ -17,7 +18,7 @@ # standalone with unchanged default behavior - other flows (fm-bootstrap.sh # install <tools> after consent, /updatefirstmate, the afk daemon, existing # tests) still call them directly. The one seam this script needed - -# bootstrap running its detect-only diagnostics without its four mutating +# bootstrap running its detect-only diagnostics without its five mutating # sweeps - is an opt-in FM_BOOTSTRAP_DETECT_ONLY=1 flag on fm-bootstrap.sh # itself (default unset/0 = unchanged behavior), not a fork. # @@ -26,17 +27,19 @@ # # 1. lock - acquire the per-home session lock FIRST, before any # mutating step runs. -# 2. bootstrap - detect-only diagnostics always run. The four -# MUTATING sweeps (secondmate fast-forward, secondmate -# liveness, X-mode artifact writes, fleet sync) run only +# 2. bootstrap - detect-only diagnostics always run. The five +# MUTATING sweeps (legacy PR-check migration, secondmate +# fast-forward, secondmate liveness, X-mode artifact writes, fleet sync) run only # when this session actually holds the lock. # 3. wake-drain - mutates the durable wake queue, so it also only runs # when locked. # 4. context digest - data/projects.md, data/secondmates.md, data/captain.md, -# data/learnings.md: read-only, always safe, always runs. -# 5. fleet digest - data/backlog.md, every state/*.meta, a bounded -# state/*.status tail, state/.afk, and a cheap -# per-task endpoint-liveness read: read-only, always runs. +# data/captain-shared.md, data/learnings.md: read-only, +# always safe, always runs. +# 5. fleet digest - a compact data/backlog.md identity/metadata listing, +# every state/*.meta, a bounded state/*.status tail, +# state/.afk, and a cheap per-task endpoint-liveness read: +# read-only, always runs. # 6. closing reminder - prints the context-specific watcher next step; this # script points back to the emitted harness supervision # block and deliberately never arms the watcher itself. @@ -60,10 +63,24 @@ # tasks-axi and quota-axi tool checks, and tasks-axi availability - none of # which mutate shared state and all of which are safe to compute from a second # session. -# Only the four mutating sweeps and the wake-queue drain are skipped. +# Only the five mutating sweeps and the wake-queue drain are skipped. # The context and fleet-state digests # below are always read-only, so they run unconditionally in both modes. # +# BACKLOG DIGEST: FM_SESSION_START_BACKLOG_LIMIT bounds the startup backlog +# listing, default 80 items. +# When compatible tasks-axi is selected and available, the shared tasks-axi +# backend probe remains the compatibility owner and this script asks +# `tasks-axi list` for the compact identity fields plus blocked_by, hold_kind, +# and hold_reason, never body. +# When manual mode is selected, or tasks-axi is unavailable or incompatible, +# this script prints only backlog section headings and item title lines, so +# title-line hold and blocked-by metadata remain visible while indented bodies +# stay out of the startup digest. +# Full bodies are targeted follow-up only: `tasks-axi show <id> --full` when +# compatible tasks-axi is available, or `data/backlog.md` when the file body is +# truly needed. +# # Usage: fm-session-start.sh # Prints the full ordered digest to stdout and always exits 0: this is a # reporting command, not a gate. A lock refusal is reported as a loud @@ -81,9 +98,13 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-tasks-axi-lib.sh +. "$SCRIPT_DIR/fm-tasks-axi-lib.sh" STATUS_TAIL=${FM_SESSION_START_STATUS_TAIL:-5} case "$STATUS_TAIL" in ''|*[!0-9]*) STATUS_TAIL=5 ;; esac +BACKLOG_LIMIT=${FM_SESSION_START_BACKLOG_LIMIT:-80} +case "$BACKLOG_LIMIT" in ''|*[!0-9]*|0) BACKLOG_LIMIT=80 ;; esac RULE='================================================================================' SUBRULE='--------------------------------------------------------------------------------' @@ -111,6 +132,84 @@ print_file_or_absent() { fi } +print_backlog_pointer() { + printf 'Full task bodies remain available on demand: tasks-axi show <id> --full when compatible tasks-axi is available, or data/backlog.md.\n' +} + +print_backlog_manual_compact() { + local path=$1 reason=$2 + printf 'compact backlog listing (%s; max %s item(s); indented task bodies omitted)\n' "$reason" "$BACKLOG_LIMIT" + awk -v max="$BACKLOG_LIMIT" ' + function state_for_heading(line, heading) { + heading = line + sub(/^##[[:space:]]+/, "", heading) + sub(/[[:space:]]+$/, "", heading) + if (heading == "In flight") return "in_flight" + if (heading == "Queued") return "queued" + if (heading == "Done") return "done" + return "" + } + /^##[[:space:]]+/ { + state = state_for_heading($0) + if (state != "") print $0 + next + } + state != "" && /^[-*][[:space:]]+/ { + total++ + if (shown < max) { + print $0 + shown++ + } + next + } + END { + if (total == 0) { + print "(no backlog item title lines found)" + } else { + printf "(shown %d of %d backlog item title line(s))\n", shown, total + if (total > shown) { + printf "(truncated %d item(s); increase FM_SESSION_START_BACKLOG_LIMIT for a larger startup listing)\n", total - shown + } + } + } + ' "$path" +} + +print_backlog_tasks_axi_compact() { + local path=$1 out rc + printf 'compact backlog listing (tasks-axi; max %s item(s); task bodies omitted)\n' "$BACKLOG_LIMIT" + out=$(tasks-axi list --file "$path" --limit "$BACKLOG_LIMIT" --fields blocked_by,hold_kind,hold_reason 2>&1) + rc=$? + if [ "$rc" -eq 0 ]; then + printf '%s\n' "$out" + else + printf 'tasks-axi compact listing failed; falling back to title-line rendering.\n' + printf '%s\n' "$out" + print_backlog_manual_compact "$path" "fallback" + fi +} + +print_backlog_compact() { + local path=$1 label=$2 + subsection "$label" + if [ -f "$path" ]; then + if [ -s "$path" ]; then + if fm_tasks_axi_backend_available "$CONFIG"; then + print_backlog_tasks_axi_compact "$path" + elif fm_backlog_backend_manual "$CONFIG"; then + print_backlog_manual_compact "$path" "manual backend" + else + print_backlog_manual_compact "$path" "tasks-axi unavailable or incompatible" + fi + print_backlog_pointer + else + printf '(present, empty)\n' + fi + else + printf 'ABSENT\n' + fi +} + print_status_tail() { local status=$1 printf 'status tail (last %s line(s), wake-EVENT history, not current state; full log: %s):\n' "$STATUS_TAIL" "$status" @@ -154,9 +253,9 @@ if [ "$LOCK_RC" -ne 0 ]; then printf '%s\n' "$BAR" printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n' printf '● %s\n' "$LOCK_OUT" - printf '● Skipping every mutating step: secondmate sync, X-mode artifacts,\n' - printf '● fleet sync, and wake-queue drain. Detect-only bootstrap diagnostics and\n' - printf '● the rest of this read-only-safe digest still ran below.\n' + printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n' + printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap\n' + printf '● diagnostics and the rest of this read-only-safe digest still ran below.\n' printf '● Operate read-only until this resolves - do not spawn, steer, merge, or\n' printf '● otherwise mutate fleet state from this session.\n' printf '%s\n' "$BAR" @@ -179,8 +278,8 @@ fi # --- 3. wake-drain ------------------------------------------------------- # Drained records are this turn's first work queue (AGENTS.md section 8); the # drain also runs fm-guard.sh internally on the locked path, so the -# tangle/watcher-liveness banners land right here too, ahead of the bulk -# digest below. The read-only path never touches the queue (another session +# tangle/watcher-liveness alarms land right here too, ahead of the bulk digest +# below. The read-only path never touches the queue (another session # may be actively draining it) but still runs fm-guard.sh directly with # non-mutating advisory text, so the same alarms surface without repair # commands. @@ -230,13 +329,14 @@ section "CONTEXT" print_file_or_absent "$DATA/projects.md" "data/projects.md" print_file_or_absent "$DATA/secondmates.md" "data/secondmates.md" print_file_or_absent "$DATA/captain.md" "data/captain.md" +print_file_or_absent "$DATA/captain-shared.md" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" print_file_or_absent "$DATA/learnings.md" "data/learnings.md" # --- 5. fleet-state digest --------------------------------------------- section "FLEET STATE" -print_file_or_absent "$DATA/backlog.md" "data/backlog.md" +print_backlog_compact "$DATA/backlog.md" "data/backlog.md" -subsection "In-flight tasks (state/*.meta)" +subsection "Work under way (state/*.meta)" META_FOUND=0 for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue @@ -334,8 +434,11 @@ EOF fi cat <<'EOF' The digest above is complete for this session start. Do NOT re-read -data/projects.md, data/secondmates.md, data/captain.md, data/learnings.md, -data/backlog.md, or state/*.meta now - they were just printed in full. +data/projects.md, data/secondmates.md, data/captain.md, +data/captain-shared.md, data/learnings.md, +or state/*.meta now - they were just printed in full. +Do NOT bulk-read data/backlog.md now either: the compact identity/metadata +listing was just printed with a pointer for targeted full-body follow-up. Do NOT bulk-read state/*.status now either: their bounded tails were just printed with full log paths for targeted follow-up when older wake-event history is actually needed. Re-reading everything defeats the entire point diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 6d3a4cc49..0b1875c39 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -46,9 +46,9 @@ # the file governs the spawn, its model/effort tokens are re-resolved on every # respawn exactly like the harness axis, and explicit --model/--effort flags # still win over the file's tokens. -# A --secondmate spawn also propagates the primary's declared inheritable config -# into the secondmate home's config/, so the secondmate's OWN crewmates, -# dispatch profiles, and backlog backend inherit the primary's settings +# A --secondmate spawn also propagates the primary's declared inherited local +# material, so the secondmate's OWN crewmates inherit primary config and the +# secondmate receives the primary's read-only shared captain-preference file # (fm-config-inherit-lib.sh). # --scout records kind=scout in the task's meta (report deliverable, scratch worktree; # see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a @@ -112,6 +112,8 @@ fm_home_guard mutate "fm-spawn.sh" || exit 1 fm_fleet_freeze_refuse "spawn" || exit 1 # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-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 # a direct report (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent @@ -284,6 +286,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * exit "$rc" fi ID=${POS[0]} +fm_task_id_creation_valid "$ID" || { echo "error: invalid task id" >&2; exit 2; } PROJ= ARG3= FIRSTMATE_HOME= @@ -471,10 +474,10 @@ effort_flag_for_harness() { esac ;; pi) - # pi accepts --thinking low|medium|high|xhigh. It warns and ignores max, so - # omit max rather than passing a flag the installed CLI will reject as invalid. + # Pi 0.80.6 accepts the full shared effort vocabulary, including max, through + # its --thinking flag. case "$effort" in - low|medium|high|xhigh) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; + low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; esac ;; # opencode's interactive `opencode --prompt` launch has a verified --model @@ -630,15 +633,10 @@ if [ "$KIND" = secondmate ]; then else echo "warning: secondmate $ID sync skipped before launch: primary default-branch commit cannot be resolved" >&2 fi - # Inheritable-config propagation: push the primary's declared LOCAL config into - # this secondmate home's config/, so the secondmate's OWN crewmates and backlog - # backend inherit the primary's settings. config/ is gitignored, so this is a - # separate copy from the local-HEAD fast-forward above; - # primary-authoritative and re-pushed on every convergence. config/secondmate-harness - # is the primary's own knob and is deliberately NOT in the inheritable set - # (fm-config-inherit-lib.sh). A primary with no inheritable config set is a no-op. - propagate_inheritable_config "$CONFIG" "$PROJ_ABS/config" \ - || echo "warning: secondmate $ID config inheritance failed for $PROJ_ABS/config" >&2 + # Inheritance propagation: push the primary-authoritative local inheritance + # surface into this secondmate home (fm-config-inherit-lib.sh). + propagate_secondmate_inheritance "$FM_HOME" "$PROJ_ABS" "$CONFIG" "$DATA" \ + || echo "warning: secondmate $ID inheritance failed for $PROJ_ABS" >&2 if [ -f "$PROJ_ABS/data/charter.md" ]; then BRIEF="$PROJ_ABS/data/charter.md" else @@ -978,7 +976,7 @@ EOF esac fi -# Per-project delivery mode + yolo flag (bin/fm-project-mode.sh; AGENTS.md project management and task lifecycle). +# Per-project delivery mode + yolo flag (bin/fm-project-mode.sh; the project-management skill and AGENTS.md task lifecycle). # Recorded in meta so fm-teardown's safety check and the validate/merge stages can # branch on them. Mode governs ship tasks; a scout's deliverable is a report, not a # merge, so scout teardown ignores mode. diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index fa818b1a3..a6c884c26 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -21,12 +21,13 @@ # catch-up or when afk is re-entered. # # IN-BAND SENTINEL MARKER. Every daemon injection is prefixed with -# FM_INJECT_MARK (ASCII unit separator, 0x1f) — a byte a human would never type -# at the start of a message. Firstmate's contract: a message that starts with -# the marker is an internal escalation (stay afk); a message without it means -# the captain is back (exit afk, flush catch-up, resume per-wake responsiveness). -# The marker and the busy-guard solve the same problem — the daemon and the -# human share one input channel — so they live together under /afk. +# FM_INJECT_MARK (U+2063 INVISIBLE SEPARATOR), a character a human cannot type +# from a normal keyboard at the start of a message and Herdr transports as text. +# Firstmate's contract: a message that starts with the marker is an internal +# escalation (stay afk); a message without it means the captain is back (exit +# afk, flush catch-up, resume per-wake responsiveness). The marker and the +# busy-guard solve the same problem - the daemon and the human share one input +# channel - so they live together under /afk. # # Reliability model (see the /afk skill): # - Nothing is lost in away mode: while state/.afk exists, the watcher reverts @@ -207,13 +208,15 @@ LOG_MAX_BYTES_DEFAULT=1048576 LOG_KEEP_LINES_DEFAULT=2000 # --- presence-gating + sentinel marker -------------------------------------- -# The in-band sentinel: ASCII unit separator (0x1f). Invisible and untypable on -# a normal keyboard, so no real user message starts with it. Every daemon -# injection is prefixed with this byte; firstmate treats a leading marker as an -# internal escalation (stay afk) and its absence as "captain is back" (exit afk). -# Portable across harnesses: it travels with the message text, independent of -# any harness-level typed-vs-injected distinction. -FM_INJECT_MARK=$'\x1f' +# The in-band sentinel: U+2063 INVISIBLE SEPARATOR (UTF-8 e2 81 a3). It has no +# normal keyboard keystroke, so no real user message starts with it. Unlike the +# original ASCII unit separator, Herdr transports U+2063 through Pi's terminal +# editor as text instead of consuming it as a control action. Every daemon +# injection is prefixed with this character; firstmate treats a leading marker +# as an internal escalation (stay afk) and its absence as "captain is back" +# (exit afk). Portable across harnesses: it travels with the message text, +# independent of any harness-level typed-vs-injected distinction. +FM_INJECT_MARK=$'\xE2\x81\xA3' AFK_FLAG_NAME=".afk" # Resolve the effective state dir. FM_STATE_OVERRIDE wins (testing); otherwise diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 42358a30a..800434861 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -23,11 +23,17 @@ # teardown refuses rather than risk discarding unlanded work. # Uncommitted changes are never landed. # local-only projects additionally accept work merged into the local default -# branch (firstmate performs that merge on the captain's approval) as a fallback +# branch (firstmate performs that merge after configured approval) as a fallback # for the common case where there is no remote at all. # Scout tasks (kind=scout in meta) carve out of that check: their worktree is # declared scratch and the report at data/<task-id>/report.md is the work -# product - teardown proceeds once the report exists, and refuses without it. +# product. Teardown proceeds only once the report exists and the shared +# unresolved-decision completion gate verifies its captain-held inventory. +# Before destructive cleanup, teardown validates task check artifacts and any +# matching quarantine entries as ordinary single-link files on the state +# device. It refuses and preserves task state when that proof fails; otherwise +# it removes the task's check, trust record, PR sidecar, publication record, and +# quarantine entries with the rest of the volatile state. # Orca tasks use the same safety checks, then close the recorded terminal and # remove the recorded worktree through `orca worktree rm`; teardown never guesses # an Orca target from ambient CLI state. @@ -90,6 +96,14 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-lock-lib.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then + echo "error: invalid teardown request" >&2 + exit 2 +fi +ID=$1 +FORCE=${2:-} # Fail closed before any fleet mutation: a no-mistakes gate agent must never tear # down a worktree (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent @@ -99,8 +113,6 @@ FM_LOCK_LOG_PREFIX=teardown # shellcheck source=bin/fm-ack-lib.sh . "$SCRIPT_DIR/fm-ack-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true -ID=$1 -FORCE=${2:-} META="$STATE/$ID.meta" [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } @@ -187,6 +199,76 @@ remove_grok_turnend_auth() { rm -f "$hooks_dir/$token" } +validate_pr_poll_cleanup() { + local state_dir=$1 id=$2 quarantine state_device artifact has_artifact=0 + fm_task_id_path_safe "$id" || return 0 + quarantine="$state_dir/.pr-check-quarantine" + if [ "$id" = _noncanonical ] \ + && { [ -e "$quarantine/_noncanonical.diagnostic.pending-noncanonical" ] \ + || [ -L "$quarantine/_noncanonical.diagnostic.pending-noncanonical" ] \ + || [ -e "$quarantine/_noncanonical.diagnostic.noncanonical" ] \ + || [ -L "$quarantine/_noncanonical.diagnostic.noncanonical" ]; }; then + echo "REFUSED: legacy PR-check quarantine migration is incomplete; preserving task state." >&2 + return 1 + fi + for artifact in "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ + "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust"; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + has_artifact=1 + done + if [ -e "$quarantine" ] || [ -L "$quarantine" ]; then + has_artifact=1 + fi + [ "$has_artifact" -eq 1 ] || return 0 + [ -d "$state_dir" ] && [ ! -L "$state_dir" ] || return 1 + state_device=$(fm_pr_file_device "$state_dir") || return 1 + for artifact in "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ + "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust"; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + if [ ! -f "$artifact" ] || [ -L "$artifact" ] \ + || [ "$(fm_pr_file_device "$artifact")" != "$state_device" ] \ + || [ "$(fm_pr_file_link_count "$artifact")" != 1 ]; then + echo "REFUSED: unsafe task PR-check artifact; preserving task state." >&2 + return 1 + fi + done + [ -e "$quarantine" ] || [ -L "$quarantine" ] || return 0 + if [ ! -d "$state_dir" ] || [ -L "$state_dir" ] \ + || [ ! -d "$quarantine" ] || [ -L "$quarantine" ]; then + echo "REFUSED: unsafe PR-check quarantine path $quarantine; preserving task state." >&2 + return 1 + fi + if [ "$(fm_pr_file_device "$quarantine")" != "$state_device" ] \ + || [ "$(fm_pr_file_mode "$quarantine")" != 700 ]; then + echo "REFUSED: PR-check quarantine is not on the task state device; preserving task state." >&2 + return 1 + fi + for artifact in "$quarantine/$id."*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + if ! fm_pr_private_file_valid "$artifact" 600 "$state_device"; then + echo "REFUSED: unsafe task quarantine entry; preserving task state." >&2 + return 1 + fi + done +} + +remove_pr_poll_artifacts() { + local state_dir=$1 id=$2 quarantine artifact + validate_pr_poll_cleanup "$state_dir" "$id" || return 1 + rm -f "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ + "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust" || return 1 + if fm_task_id_path_safe "$id"; then + quarantine="$state_dir/.pr-check-quarantine" + if [ -d "$quarantine" ] && [ ! -L "$quarantine" ]; then + for artifact in "$quarantine/$id."*; do + [ -e "$artifact" ] || [ -L "$artifact" ] || continue + rm -f -- "$artifact" || return 1 + done + rmdir "$quarantine" 2>/dev/null || true + fi + fi +} + # Resolve the PR number for a worktree branch via gh-axi. Echoes the number on a # single match and returns 0; returns non-zero on no match or any lookup failure, # so the caller treats it as "no PR found" (fail-safe). @@ -846,6 +928,7 @@ validate_firstmate_home_children_removal() { for child_meta in "$sub_state"/*.meta; do [ -e "$child_meta" ] || continue child_id=$(basename "$child_meta" .meta) + validate_pr_poll_cleanup "$sub_state" "$child_id" || return 1 child_wt=$(meta_value "$child_meta" worktree) child_kind=$(meta_value "$child_meta" kind) [ -n "$child_kind" ] || child_kind=ship @@ -932,7 +1015,8 @@ cleanup_firstmate_home_children() { fi fi remove_grok_turnend_auth "$sub_state" "$child_id" - rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.check.sh" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" "$sub_state/$child_id.grok-turnend-token" + remove_pr_poll_artifacts "$sub_state" "$child_id" || 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" done } @@ -944,6 +1028,8 @@ remove_secondmate_registry_entry() { mv "$tmp" "$SECONDMATE_REG" } +validate_pr_poll_cleanup "$STATE" "$ID" || exit 1 + if [ "$KIND" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT validate_firstmate_home_for_removal "$HOME_PATH" "secondmate home" "$ID" >/dev/null || exit 1 @@ -975,6 +1061,12 @@ if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then echo "The report is the work product. Have the crewmate write it, or use --force after explicit discard approval." >&2 exit 1 fi + if ! FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ + FM_CONFIG_OVERRIDE="$CONFIG" "$SCRIPT_DIR/fm-decision-hold.sh" verify "$ID" >/dev/null; then + echo "REFUSED: scout task $ID has not passed the unresolved-decision completion gate." >&2 + echo "Inventory its report and any visual review through bin/fm-decision-hold.sh before teardown." >&2 + exit 1 + fi fi if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then @@ -1057,7 +1149,8 @@ fm_ack_prune_task "$STATE" "$ID" || true # Remove the per-task temp root (/tmp/fm-<id>/, incl. its gotmp/) recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" -rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 +rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/bin/fm-usage-tripwire.sh b/bin/fm-usage-tripwire.sh index 7afe3e6d7..33ba2dfe7 100755 --- a/bin/fm-usage-tripwire.sh +++ b/bin/fm-usage-tripwire.sh @@ -9,6 +9,9 @@ # Watcher arming: # - Copy or symlink this script to a standing check path such as `state/usage-tripwire.check.sh`. # - Example: `ln -sf "$(pwd -P)/bin/fm-usage-tripwire.sh" state/usage-tripwire.check.sh`. +# - Then bind it to its current bytes: `bin/fm-check-register.sh usage-tripwire`. The watcher rejects any +# `state/<id>.check.sh` without a matching hash-registered `state/<id>.check-trust`, so re-run the register +# step after ever replacing the linked/copied file. # - The watcher contract is strict: print one alarm line only when firstmate should wake, print nothing when healthy, and finish before `FM_CHECK_TIMEOUT`. # # Window and thresholds: diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index ad948f4fe..b5aa4b32d 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -16,7 +16,7 @@ DRAIN_LOCK_HELD=false # every wake-handling and recovery turn, so assert watcher liveness here too. A # lapsed supervision chain then surfaces on a plain drain-and-handle turn, not # only when a guarded supervision script (fm-peek/fm-send/...) happens to run. -# Reuse fm-guard.sh's existing graced, beacon-based banner (FM_GUARD_GRACE) - do +# Reuse fm-guard.sh's existing graced, beacon-based alarm (FM_GUARD_GRACE) - do # not duplicate the beacon math. Because the watcher touches its beacon every # poll cycle, a normal fire leaves a recent beacon well inside grace and stays # silent; only a genuine stale-beyond-grace lapse with work in flight warns. Call diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index e21bd008a..a7e1b1c23 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -31,7 +31,9 @@ # wake payload itself, not just repetition, forces a # closer look instead of another routine supervision # resume. Unless afk is active. -# check: <script>: <out> per-task check output, always actionable +# check: <script>: <out> authenticated check output, always actionable +# check: rejected unauthenticated state checks: <paths> +# unsafe state checks were refused without execution # heartbeat fleet-scan backstop found an unsurfaced captain-relevant # status, unless afk is active # For normal supervision, resume the session-start primary-harness protocol @@ -73,6 +75,12 @@ fm_fleet_freeze_refuse "watch" || exit 1 . "$SCRIPT_DIR/fm-transition-lib.sh" # shellcheck source=bin/fm-ack-lib.sh . "$SCRIPT_DIR/fm-ack-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-x-lib.sh +. "$SCRIPT_DIR/fm-x-lib.sh" +# shellcheck source=bin/fm-check-lib.sh +. "$SCRIPT_DIR/fm-check-lib.sh" WATCH_LOCK="$STATE/.watch.lock" WATCH_PATH="$SCRIPT_DIR/fm-watch.sh" @@ -411,16 +419,85 @@ scan_signals() { return 0 } -run_check() { +run_check_process() { local c=$1 - if command -v timeout >/dev/null 2>&1; then - timeout "$CHECK_TIMEOUT" bash "$c" 2>/dev/null || true - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$CHECK_TIMEOUT" bash "$c" 2>/dev/null || true + shift + if [ "${FM_CHECK_FORCE_FALLBACK:-0}" != 1 ] && command -v timeout >/dev/null 2>&1; then + exec timeout "$CHECK_TIMEOUT" bash "$c" "$@" + elif [ "${FM_CHECK_FORCE_FALLBACK:-0}" != 1 ] && command -v gtimeout >/dev/null 2>&1; then + exec gtimeout "$CHECK_TIMEOUT" bash "$c" "$@" else # shellcheck disable=SC2016 # single quotes are deliberate: Perl expands its own variables. - 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)' "$CHECK_TIMEOUT" bash "$c" 2>/dev/null || true + exec perl -e 'my $t = shift; my $owned = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0) unless $owned; exec @ARGV } my $group = $owned ? getpgrp(0) : $pid; my $stop = sub { $SIG{HUP} = $SIG{INT} = $SIG{TERM} = "IGNORE"; kill "TERM", -$group; select undef, undef, undef, 0.2; kill "KILL", -$group; waitpid $pid, 0; exit 124 }; local $SIG{ALRM} = $stop; local $SIG{HUP} = $stop; local $SIG{INT} = $stop; local $SIG{TERM} = $stop; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$CHECK_TIMEOUT" "${FM_CHECK_OWNED_GROUP:-0}" bash "$c" "$@" + fi +} + +run_check() { + ( run_check_process "$@" ) 2>/dev/null || true +} + +FM_ACTIVE_CHECK_PID= +FM_ACTIVE_CHECK_PGID= +FM_CHECK_OUTPUT= +FM_CHECK_RESULT= +FM_CHECK_SIGNAL_PENDING= + +fm_check_output_cleanup() { + [ -z "$FM_CHECK_OUTPUT" ] || rm -f -- "$FM_CHECK_OUTPUT" + FM_CHECK_OUTPUT= +} + +fm_active_check_stop() { + local pid=${FM_ACTIVE_CHECK_PID:-} pgid=${FM_ACTIVE_CHECK_PGID:-} i + [ -n "$pid" ] || [ -n "$pgid" ] || return 0 + [ -z "$pgid" ] || kill -TERM -- "-$pgid" 2>/dev/null || true + [ -z "$pid" ] || kill -TERM "$pid" 2>/dev/null || true + i=0 + while [ -n "$pgid" ] && kill -0 -- "-$pgid" 2>/dev/null && [ "$i" -lt 20 ]; do + sleep 0.01 + i=$((i + 1)) + done + [ -z "$pgid" ] || kill -KILL -- "-$pgid" 2>/dev/null || true + [ -z "$pid" ] || kill -KILL "$pid" 2>/dev/null || true + [ -z "$pid" ] || wait "$pid" 2>/dev/null || true + i=0 + while [ -n "$pgid" ] && kill -0 -- "-$pgid" 2>/dev/null && [ "$i" -lt 100 ]; do + sleep 0.01 + i=$((i + 1)) + done + if [ -n "$pgid" ] && kill -0 -- "-$pgid" 2>/dev/null; then + return 1 + fi + FM_ACTIVE_CHECK_PID= + FM_ACTIVE_CHECK_PGID= +} + +run_check_capture() { + local pgid + fm_check_output_cleanup + FM_CHECK_RESULT= + FM_CHECK_OUTPUT=$(mktemp "$STATE/.fm-check-output.XXXXXX") || return 1 + chmod 0600 "$FM_CHECK_OUTPUT" || { fm_check_output_cleanup; return 1; } + FM_CHECK_SIGNAL_PENDING= + trap 'FM_CHECK_SIGNAL_PENDING=1' HUP INT TERM + set -m + ( FM_CHECK_OWNED_GROUP=1 run_check_process "$@" ) > "$FM_CHECK_OUTPUT" 2>/dev/null & + FM_ACTIVE_CHECK_PID=$! + FM_ACTIVE_CHECK_PGID=$FM_ACTIVE_CHECK_PID + set +m + pgid=$(ps -o pgid= -p "$FM_ACTIVE_CHECK_PID" 2>/dev/null | tr -d '[:space:]') + trap 'exit 1' HUP INT TERM + if [ -n "$pgid" ] && [ "$pgid" != "$FM_ACTIVE_CHECK_PGID" ]; then + fm_active_check_stop || true + fm_check_output_cleanup + return 1 fi + [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 + wait "$FM_ACTIVE_CHECK_PID" 2>/dev/null || true + FM_ACTIVE_CHECK_PID= + fm_active_check_stop || return 1 + FM_CHECK_RESULT=$(cat "$FM_CHECK_OUTPUT" 2>/dev/null || true) + fm_check_output_cleanup } # Surfaced-marker bookkeeping for the heartbeat backstop. The watcher records the @@ -586,6 +663,14 @@ if [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0 fi +# Before acquiring the watcher lock or enumerating any runnable check, replace +# or quarantine checks created by older versions. The migration compares bytes +# and reads data only; it never invokes legacy check files through Bash. +"$SCRIPT_DIR/fm-pr-check-migrate.sh" --checks-safe || { + echo "watcher: PR check migration blocked; refusing to execute state checks" >&2 + exit 1 +} + if ! fm_lock_try_acquire "$WATCH_LOCK"; then BEAT="$STATE/.last-watcher-beat" if [ -n "${FM_LOCK_HELD_PID:-}" ]; then @@ -605,7 +690,14 @@ if ! fm_lock_try_acquire "$WATCH_LOCK"; then fi exit 0 fi -trap 'fm_lock_release "$WATCH_LOCK"' EXIT +watcher_cleanup() { + fm_active_check_stop || return 1 + fm_check_output_cleanup + fm_custom_check_snapshot_cleanup + fm_lock_release "$WATCH_LOCK" +} +trap watcher_cleanup EXIT +trap 'exit 1' HUP INT TERM # This watcher's own pid, as recorded in the lock by fm_lock_claim (which writes # ${BASHPID:-$$} from this same main shell). Read directly, never via a command # substitution, so it matches the stored holder pid for the self-eviction check. @@ -648,9 +740,38 @@ while :; do # never run until the fleet went quiet. Checks are due only every # CHECK_INTERVAL, so most cycles skip this block and fall straight through. if [ "$(age_of "$STATE/.last-check")" -ge "$CHECK_INTERVAL" ]; then + rejected_checks= for c in "$STATE"/*.check.sh; do [ -e "$c" ] || continue - out=$(run_check "$c") + if [ "$(basename "$c")" = x-watch.check.sh ]; then + if fmx_poll_shim_valid "$c" "$FM_HOME" "$FM_ROOT" \ + && [ -f "$FM_ROOT/bin/fm-x-poll.sh" ] && [ ! -L "$FM_ROOT/bin/fm-x-poll.sh" ]; then + FM_HOME="$FM_HOME" run_check_capture "$FM_ROOT/bin/fm-x-poll.sh" || exit 1 + out=$FM_CHECK_RESULT + else + rejected_checks="$rejected_checks $c" + continue + fi + else + id=$(basename "$c" .check.sh) + if fm_pr_poll_artifacts_valid "$STATE" "$id" "$SCRIPT_DIR/fm-pr-poll.sh"; then + url=$FM_PR_DATA_URL + owner=$FM_PR_DATA_OWNER + repo=$FM_PR_DATA_REPO + number=$FM_PR_DATA_NUMBER + run_check_capture "$SCRIPT_DIR/fm-pr-poll.sh" --validated "$url" "$owner" "$repo" "$number" || exit 1 + out=$FM_CHECK_RESULT + elif fm_custom_check_snapshot_prepare "$STATE" "$id"; then + custom_snapshot=$FM_CUSTOM_CHECK_SNAPSHOT + run_check_capture "$custom_snapshot" || exit 1 + out=$FM_CHECK_RESULT + fm_custom_check_snapshot_cleanup + else + fm_custom_check_snapshot_cleanup + rejected_checks="$rejected_checks $c" + continue + fi + fi if [ -n "$out" ]; then reason="check: $c: $out" fm_wake_append check "$c" "$reason" || exit 1 @@ -658,6 +779,12 @@ while :; do wake "$reason" fi done + if [ -n "$rejected_checks" ]; then + reason="check: rejected unauthenticated state checks:$rejected_checks" + fm_wake_append check unauthenticated-state-checks "$reason" || exit 1 + touch "$STATE/.last-check" + wake "$reason" + fi touch "$STATE/.last-check" fi diff --git a/bin/fm-x-dismiss.sh b/bin/fm-x-dismiss.sh index fe0aaebd7..0654d4e6e 100755 --- a/bin/fm-x-dismiss.sh +++ b/bin/fm-x-dismiss.sh @@ -66,17 +66,13 @@ PAYLOAD=$(jq -cn --arg rid "$REQ" '{request_id:$rid}') || { # Preview / dry-run: surface what we WOULD post and stop, without auth or network. if [ -n "$FMX_DRY" ]; then outbox_dir="$STATE/x-outbox" - outbox_file="$outbox_dir/$REQ.json" - mkdir -p "$outbox_dir" 2>/dev/null || { - echo "fm-x-dismiss: cannot create dry-run outbox: $outbox_dir" >&2 - exit 1 - } # The recorded body carries an "endpoint":"dismiss" marker so an outbox record # is self-describing (the live POST body stays exactly {request_id}). OUTREC=$(printf '%s' "$PAYLOAD" | jq -c '. + {endpoint:"dismiss"}') || { echo "fm-x-dismiss: failed to build dry-run outbox record" >&2; exit 1; } - printf '%s\n' "$OUTREC" > "$outbox_file" 2>/dev/null || { - echo "fm-x-dismiss: cannot write dry-run outbox: $outbox_file" >&2 + printf '%s\n' "$OUTREC" \ + | fmx_private_artifact_publish_stdin "$outbox_dir" "$REQ.json" 600 || { + echo "fm-x-dismiss: cannot write dry-run outbox: $outbox_dir/$REQ.json" >&2 exit 1 } # A dismissed mention will never get a follow-up, so drop its durable diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index d55b21040..0c7e83152 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -65,6 +65,150 @@ fmx_env_get() { printf '%s' "$val" } +fmx_poll_shim_content() { + local home=$1 root=$2 + printf '%s\n' \ + '#!/usr/bin/env bash' \ + '# Auto-generated by fm-bootstrap.sh - X mode connector poll shim.' \ + '# The watcher validates these bytes, then dispatches the trusted poll script.' \ + "export FM_HOME=$(printf '%q' "$home")" \ + "exec $(printf '%q' "$root/bin/fm-x-poll.sh")" +} + +fmx_poll_shim_v1_content() { + local home=$1 root=$2 + printf '%s\n' \ + '#!/usr/bin/env bash' \ + '# Auto-generated by fm-bootstrap.sh - X mode connector poll shim.' \ + '# The watcher runs this each check cycle; output becomes a check: wake.' \ + "export FM_HOME=$(printf '%q' "$home")" \ + "exec $(printf '%q' "$root/bin/fm-x-poll.sh")" +} + +fmx_single_link_file_valid() { + local file=$1 expected_device=${2-} links device + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + if [ "$(uname)" = Darwin ]; then + links=$(stat -f %l "$file" 2>/dev/null) || return 1 + device=$(stat -f %d "$file" 2>/dev/null) || return 1 + else + links=$(stat -c %h "$file" 2>/dev/null) || return 1 + device=$(stat -c %d "$file" 2>/dev/null) || return 1 + fi + [ "$links" = 1 ] || return 1 + [ -z "$expected_device" ] || [ "$device" = "$expected_device" ] +} + +fmx_single_link_file_mode_valid() { + local file=$1 expected_mode=$2 expected_device=${3-} mode + fmx_single_link_file_valid "$file" "$expected_device" || return 1 + if [ "$(uname)" = Darwin ]; then + mode=$(stat -f %Lp "$file" 2>/dev/null) || return 1 + else + mode=$(stat -c %a "$file" 2>/dev/null) || return 1 + fi + [ "$mode" = "$expected_mode" ] +} + +fmx_private_artifact_dir_device() { + local dir=$1 mode device + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + if [ "$(uname)" = Darwin ]; then + mode=$(stat -f %Lp "$dir" 2>/dev/null) || return 1 + device=$(stat -f %d "$dir" 2>/dev/null) || return 1 + else + mode=$(stat -c %a "$dir" 2>/dev/null) || return 1 + device=$(stat -c %d "$dir" 2>/dev/null) || return 1 + fi + [ "$mode" = 700 ] || return 1 + printf '%s\n' "$device" +} + +fmx_private_artifact_dir_prepare() { + local dir=$1 parent + parent=${dir%/*} + if [ "$parent" != "$dir" ]; then + if [ -e "$parent" ] || [ -L "$parent" ]; then + [ -d "$parent" ] && [ ! -L "$parent" ] || return 1 + else + (umask 077; mkdir -p "$parent" 2>/dev/null) || return 1 + [ -d "$parent" ] && [ ! -L "$parent" ] || return 1 + fi + fi + if [ -e "$dir" ] || [ -L "$dir" ]; then + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + else + (umask 077; mkdir -p "$dir" 2>/dev/null) || return 1 + fi + fmx_private_artifact_dir_device "$dir" +} + +fmx_private_artifact_publish_stdin() { + local dir=$1 base=$2 mode=$3 device tmp dest + case "$base" in + ''|.*|*/*) return 1 ;; + esac + case "$mode" in + 600|700) ;; + *) return 1 ;; + esac + device=$(fmx_private_artifact_dir_prepare "$dir") || return 1 + dest="$dir/$base" + tmp=$(umask 077; mktemp "$dir/.${base}.fm-x.XXXXXX" 2>/dev/null) || return 1 + if ! cat > "$tmp" \ + || ! chmod "$mode" "$tmp" 2>/dev/null \ + || ! fmx_single_link_file_mode_valid "$tmp" "$mode" "$device"; then + rm -f -- "$tmp" + return 1 + fi + if { [ -e "$dest" ] || [ -L "$dest" ]; } \ + && ! fmx_single_link_file_mode_valid "$dest" "$mode" "$device"; then + rm -f -- "$tmp" + return 1 + fi + if ! mv -f -- "$tmp" "$dest" 2>/dev/null; then + rm -f -- "$tmp" + return 1 + fi + if ! fmx_single_link_file_mode_valid "$dest" "$mode" "$device"; then + rm -f -- "$dest" + return 1 + fi +} + +fmx_private_artifact_file_valid() { + local dir=$1 base=$2 mode=$3 device + case "$base" in + ''|.*|*/*) return 1 ;; + esac + case "$mode" in + 600|700) ;; + *) return 1 ;; + esac + device=$(fmx_private_artifact_dir_device "$dir") || return 1 + fmx_single_link_file_mode_valid "$dir/$base" "$mode" "$device" +} + +fmx_poll_shim_identity_valid() { + fmx_single_link_file_mode_valid "$1" "$2" "${3-}" +} + +fmx_poll_shim_private_identity_valid() { + fmx_poll_shim_identity_valid "$1" 700 +} + +fmx_poll_shim_valid() { + local file=$1 home=$2 root=$3 + fmx_poll_shim_private_identity_valid "$file" || return 1 + cmp -s "$file" <(fmx_poll_shim_content "$home" "$root") +} + +fmx_poll_shim_v1_valid() { + local file=$1 home=$2 root=$3 state_device=$4 + fmx_poll_shim_identity_valid "$file" 755 "$state_device" || return 1 + cmp -s "$file" <(fmx_poll_shim_v1_content "$home" "$root") +} + # Resolve the X-mode settings into FMX_TOKEN, FMX_RELAY, FMX_DRY, FMX_MAX, # FMX_DISCORD_MAX, and FMX_THREAD_MAX. An explicit environment variable always # wins over the .env file; the relay URL defaults to the production host so a @@ -163,6 +307,10 @@ fmx_extract_reply_context() { # inbox file is absent. Thin wrapper over fmx_extract_reply_context. fmx_request_inbox_context() { local state=$1 rid=$2 + if ! fmx_private_artifact_file_valid "$state/x-inbox" "$rid.json" 600; then + printf '{"platform":"","reply_max_chars":""}\n' + return 0 + fi fmx_extract_reply_context "$state/x-inbox/$rid.json" } @@ -262,9 +410,9 @@ fmx_context_registry_recorded_at() { } fmx_context_registry_prune() { - local state=$1 dir now max_age file recorded_at age + local state=$1 dir now max_age file recorded_at age dir_device dir="$state/x-context" - [ -d "$dir" ] || return 0 + dir_device=$(fmx_private_artifact_dir_device "$dir" 2>/dev/null) || return 0 now=${FMX_NOW_OVERRIDE:-$(date +%s)} case "$now" in ''|*[!0-9]*) return 0 ;; @@ -277,6 +425,10 @@ fmx_context_registry_prune() { [ "${#max_age}" -le 18 ] || max_age=604800 [ "$max_age" -le 604800 ] || max_age=604800 while IFS= read -r -d '' file; do + if ! fmx_single_link_file_mode_valid "$file" 600 "$dir_device"; then + rm -f -- "$file" 2>/dev/null || true + continue + fi if ! recorded_at=$(fmx_context_registry_recorded_at "$file" "$now"); then rm -f -- "$file" 2>/dev/null || true continue @@ -297,7 +449,7 @@ fmx_context_registry_prune() { # never write an empty, useless record. Returns non-zero only on invalid input or # a write failure; callers treat the write as best-effort. fmx_context_registry_set() { - local state=$1 rid=$2 platform=${3:-} reply_max=${4:-} refresh=${5:-0} dir file tmp now recorded_at + local state=$1 rid=$2 platform=${3:-} reply_max=${4:-} refresh=${5:-0} dir file dir_device now recorded_at case "$rid" in ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; esac @@ -317,14 +469,18 @@ fmx_context_registry_set() { return 0 fi dir="$state/x-context" - mkdir -p "$dir" 2>/dev/null || return 1 + dir_device=$(fmx_private_artifact_dir_prepare "$dir") || return 1 + file="$dir/$rid.json" + if { [ -e "$file" ] || [ -L "$file" ]; } \ + && ! fmx_single_link_file_mode_valid "$file" 600 "$dir_device"; then + return 1 + fi fmx_context_registry_prune "$state" now=${FMX_NOW_OVERRIDE:-$(date +%s)} case "$now" in ''|*[!0-9]*) return 1 ;; esac [ "${#now}" -le 18 ] || return 1 - file="$dir/$rid.json" recorded_at= if [ "$refresh" = 0 ] && [ -f "$file" ]; then recorded_at=$(fmx_context_registry_recorded_at "$file" "$now") || recorded_at= @@ -332,30 +488,27 @@ fmx_context_registry_set() { if [ -z "$recorded_at" ]; then recorded_at=$now fi - tmp=$(mktemp "$dir/.${rid}.fm-x.XXXXXX" 2>/dev/null) || return 1 - if jq -cn --arg rid "$rid" --arg platform "$platform" --arg max "$reply_max" \ - --argjson recorded_at "$recorded_at" \ - '{request_id:$rid, platform:$platform, reply_max_chars:$max, recorded_at:$recorded_at}' > "$tmp" 2>/dev/null; then - mv -f "$tmp" "$file" 2>/dev/null || { rm -f "$tmp"; return 1; } - else - rm -f "$tmp"; return 1 - fi + (set -o pipefail; jq -cn --arg rid "$rid" --arg platform "$platform" --arg max "$reply_max" \ + --argjson recorded_at "$recorded_at" \ + '{request_id:$rid, platform:$platform, reply_max_chars:$max, recorded_at:$recorded_at}' \ + | fmx_private_artifact_publish_stdin "$dir" "$rid.json" 600) || return 1 } # fmx_context_registry_get <state> <request_id>: print the durable per-request # reply context as {"platform":"...","reply_max_chars":"..."} (the same shape as # the inbox and relay extractors), or the empty shape when no record exists. fmx_context_registry_get() { - local state=$1 rid=$2 file + local state=$1 rid=$2 dir file case "$rid" in ''|.*|*[!A-Za-z0-9._-]*) printf '{"platform":"","reply_max_chars":""}\n'; return 0 ;; esac - fmx_context_registry_prune "$state" - file="$state/x-context/$rid.json" - if [ ! -f "$file" ]; then + dir="$state/x-context" + file="$dir/$rid.json" + if ! fmx_private_artifact_file_valid "$dir" "$rid.json" 600; then printf '{"platform":"","reply_max_chars":""}\n' return 0 fi + fmx_context_registry_prune "$state" jq -c '{platform:(.platform // ""), reply_max_chars:(.reply_max_chars // "")}' "$file" 2>/dev/null \ || printf '{"platform":"","reply_max_chars":""}\n' } @@ -364,11 +517,13 @@ fmx_context_registry_get() { # Idempotent and best-effort; a dismiss (no follow-up will ever come) uses it so # a skipped mention leaves no stray context behind. fmx_context_registry_clear() { - local state=$1 rid=$2 + local state=$1 rid=$2 dir case "$rid" in ''|.*|*[!A-Za-z0-9._-]*) return 0 ;; esac - rm -f "$state/x-context/$rid.json" 2>/dev/null || true + dir="$state/x-context" + [ -d "$dir" ] && [ ! -L "$dir" ] || return 0 + rm -f "$dir/$rid.json" 2>/dev/null || true return 0 } diff --git a/bin/fm-x-link.sh b/bin/fm-x-link.sh index 23396cd0a..5301359a4 100755 --- a/bin/fm-x-link.sh +++ b/bin/fm-x-link.sh @@ -43,6 +43,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" usage() { echo "usage: fm-x-link.sh <task-id> <request_id> [--carry-count <n> --carry-ts <epoch> [--carry-platform <x|discord>] [--carry-max <n>]]" >&2 @@ -112,9 +114,7 @@ fi # task-id composes a path (state/<id>.meta); request_id composes a path elsewhere # (the inbox/outbox record). Reject anything outside a safe slug for both. -case "$ID" in - ''|.*|*[!A-Za-z0-9._-]*) echo "fm-x-link: unsafe task id: $ID" >&2; exit 2 ;; -esac +fm_pr_task_id_valid "$ID" || { echo "fm-x-link: unsafe task id: $ID" >&2; exit 2; } case "$RID" in ''|.*|*[!A-Za-z0-9._-]*) echo "fm-x-link: unsafe request_id: $RID" >&2; exit 2 ;; esac diff --git a/bin/fm-x-poll.sh b/bin/fm-x-poll.sh index 1f8b2e139..a43829110 100755 --- a/bin/fm-x-poll.sh +++ b/bin/fm-x-poll.sh @@ -3,8 +3,9 @@ # # Inert by default: a HARD no-op (exit 0, no output) unless X mode is configured # via a non-empty FMX_PAIRING_TOKEN (from the home's .env or the environment). -# This script is the body of the watcher check shim state/x-watch.check.sh, where -# the contract is "output => wake firstmate, silence => keep sleeping", so the +# The watcher invokes this trusted repository script directly only after +# state/x-watch.check.sh matches the expected byte-static identity shim. +# Its contract is "output => wake firstmate, silence => keep sleeping", so the # no-op keeps the watcher behaving exactly as today until a user opts in. # # Behavior when X mode is on: @@ -41,15 +42,17 @@ ERROR_FILE="$STATE/x-poll.error" emit_error_once() { local msg=$1 - mkdir -p "$STATE" 2>/dev/null || true - if [ -f "$ERROR_FILE" ] && [ "$(cat "$ERROR_FILE" 2>/dev/null)" = "$msg" ]; then + if fmx_private_artifact_file_valid "$STATE" "x-poll.error" 600 \ + && [ "$(cat "$ERROR_FILE" 2>/dev/null)" = "$msg" ]; then return 0 fi - printf '%s\n' "$msg" > "$ERROR_FILE" 2>/dev/null || true + printf '%s\n' "$msg" \ + | fmx_private_artifact_publish_stdin "$STATE" "x-poll.error" 600 2>/dev/null || true printf 'x-mode-error %s\n' "$msg" } clear_error() { + fmx_private_artifact_dir_device "$STATE" >/dev/null 2>&1 || return 0 rm -f "$ERROR_FILE" 2>/dev/null || true } @@ -98,17 +101,10 @@ case "$REQ" in esac INBOX="$STATE/x-inbox" -mkdir -p "$INBOX" 2>/dev/null || { emit_error_once "cannot create inbox"; exit 0; } # Stash the full mention object atomically so a concurrent reader never sees a # half-written file. -if jq '.' "$BODY_FILE" > "$INBOX/$REQ.json.tmp" 2>/dev/null; then - if ! mv -f "$INBOX/$REQ.json.tmp" "$INBOX/$REQ.json" 2>/dev/null; then - rm -f "$INBOX/$REQ.json.tmp" - emit_error_once "cannot write inbox" - exit 0 - fi -else - rm -f "$INBOX/$REQ.json.tmp" +if ! (set -o pipefail; jq '.' "$BODY_FILE" 2>/dev/null \ + | fmx_private_artifact_publish_stdin "$INBOX" "$REQ.json" 600); then emit_error_once "cannot write inbox" exit 0 fi diff --git a/bin/fm-x-reply.sh b/bin/fm-x-reply.sh index 9bcb2d905..44e85cb46 100755 --- a/bin/fm-x-reply.sh +++ b/bin/fm-x-reply.sh @@ -288,18 +288,14 @@ fi # Preview / dry-run: surface what we WOULD post and stop, without auth or network. if [ -n "$FMX_DRY" ]; then outbox_dir="$STATE/x-outbox" - outbox_file="$outbox_dir/$REQ.json" - mkdir -p "$outbox_dir" 2>/dev/null || { - echo "fm-x-reply: cannot create dry-run outbox: $outbox_dir" >&2 - exit 1 - } # The recorded body is the would-be POST body, except image bytes are replaced # by a compact marker. A follow-up preview additionally carries an # "endpoint":"followup" marker so an outbox record is self-describing. OUTREC=$(fmx_reply_outbox_json "$REQ" "$CHUNKS" "$N" "$FOLLOWUP" "$IMAGE_PREVIEW") || { echo "fm-x-reply: failed to build dry-run outbox record" >&2; exit 1; } - printf '%s\n' "$OUTREC" > "$outbox_file" 2>/dev/null || { - echo "fm-x-reply: cannot write dry-run outbox: $outbox_file" >&2 + printf '%s\n' "$OUTREC" \ + | fmx_private_artifact_publish_stdin "$outbox_dir" "$REQ.json" 600 || { + echo "fm-x-reply: cannot write dry-run outbox: $outbox_dir/$REQ.json" >&2 exit 1 } if [ "$N" -le 1 ]; then diff --git a/docs/architecture.md b/docs/architecture.md index e2cef5969..deb46e23e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,13 +4,14 @@ How firstmate works, in depth. The [README](../README.md) carries the high-level diagram and a short synopsis. This document expands every part of it. -firstmate's full operating manual for the orchestrator agent itself is [`AGENTS.md`](../AGENTS.md); this is the human-facing companion. +firstmate's always-loaded operating contract and routing index for conditional procedures is [`AGENTS.md`](../AGENTS.md); this is the human-facing companion. ## Event-driven supervision A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable. -Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, check-script output such as PR merge polling or an X-mode mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits. +Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, authenticated check output such as PR merge polling or an X-mode mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits. Repeated provably-working stale escalations on the same unchanged pane add an escalation count to the wake reason and, at `FM_WEDGE_DEMAND_INSPECT_COUNT`, a `demand-deep-inspection` marker. +Each cycle also scans pending `fm-send --expect-ack` deadlines (`bin/fm-ack-lib.sh`, backing `state/.pending-acks`): a target status write before its deadline clears the row silently, while a missed deadline wakes once with `ack-missed:` and marks the row escalated so it does not spam. Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step for that crew's branch or a backend busy signature. A crew that declares `paused:` for a known external wait is separately absorbed while idle and re-surfaced only on the longer pause cadence, rather than being treated as a possible wedge. @@ -29,11 +30,21 @@ Only when no matching run exists does it fall back to the pane busy-signature an Decision-only events such as `resolved` never become current state or leak their prose into the current-state detail. In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. For herdr, that pane fallback trusts a native `busy` verdict outright, but corroborates native `idle` or unknown verdicts against the rendered busy signature before deciding the crew is not working. -For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, the bounded landed-work roll-up from registered secondmate homes, and secondmate return-channel guidance. +For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. The script header owns the exact JSON schema. -Optional X mode rides the same check path: the locked session-start bootstrap step drops a local `state/x-watch.check.sh` shim only after the user opts in with `FMX_PAIRING_TOKEN`, and non-X homes keep the default watcher behavior. -Each cycle also scans pending `fm-send --expect-ack` deadlines (`bin/fm-ack-lib.sh`, backing `state/.pending-acks`): a target status write before its deadline clears the row silently, while a missed deadline wakes once with `ack-missed:` and marks the row escalated so it does not spam. + +### Registered secondmate current state + +A registered secondmate's validated home is the authority for bearings current state because it owns the child metadata inventory, each child's current-state result, endpoint observations, backlog holds and dependencies, keyed unresolved decisions, and recent Done baseline. +The original cross-home projection instead treated the secondmate agent as an ordinary parent task, so an idle secondmate's `fm-crew-state` fallback selected the latest append-only parent status event even when structured state in the registered home contradicted it. +The parent-status contract also required explicit keyed resolution for decisions and blockers but not for a material `working` phase, so a start event could remain unsuperseded after the corresponding home backlog had moved the work to Done. +Generated secondmate charters now key material routed-work phases and close them with a same-key later state or `resolved` event, while the structured home remains authoritative even if that closure is missing. +Cross-home reads validate the seeded identity and operational-directory boundaries, use per-home time and output bounds, and classify unavailable, invalid, malformed, or inconsistent structured state as unknown rather than reviving a parent event as current work. +A bounded direct-report terminal tail can help diagnose a mismatch by showing that historical parent wording is still visible, but it is untrusted supplemental evidence because scrollback, prompts, copied output, idle shells, and agent prose are not durable state. +The snapshot strips control sequences, retains only capture metadata and literal event-corroboration flags, and never lets terminal evidence override a valid structured classification. +The default path remains local-only; live GitHub enrichment exists only behind the bearings `--include-prs` opt-in. +Optional X mode integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#x-mode-env) owns its generated-artifact and dispatch mechanics. At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`. That block owns the live wait shape for the running primary harness: Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. @@ -42,20 +53,21 @@ On `attached` it stays live until that existing cycle ends so background-notify Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. -It leads with prominent bordered banners for the tangle and no-watcher cases so they cannot be skimmed past. +It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode. On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct Stop hooks block and passive turn-end hooks force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. The always-on watcher also uses that library's absorb classification on no-verb signals and first-sighting stale panes before status-log terminality is trusted, while the daemon maintains distinct wedge and declared-pause recheck cadences. -The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest prefixed with an in-band sentinel marker so firstmate can tell daemon injections apart from real messages. +The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest prefixed with a terminal-safe U+2063 sentinel marker so firstmate can tell daemon injections apart from real messages. Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its ANSI-aware structural composer classifier for pending-input guards and submit fallback. Composer-content classification has one shared owner, `bin/fm-composer-lib.sh`, used by tmux, herdr, Orca, and cmux after each adapter performs its own capture and composer-row recognition. The daemon injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the complete policy is in [Composer-emptiness safety](herdr-backend.md#composer-emptiness-safety-2026-07-10-fleet-wide-across-all-four-backends). Unsupported supervisor backends refuse at daemon startup. Stalled escalation delivery writes `state/.subsuper-inject-wedged` and attempts a configured backend-independent active alert after `FM_MAX_DEFER_SECS` instead of silently deferring forever. +On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable catch-up evidence, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker. `fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. ## Runtime session backends @@ -99,7 +111,7 @@ Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show- `bin/fm-home-guard-lib.sh` closes a narrower gap: a shell whose cwd is inside a seeded secondmate home (marked by `.fm-secondmate-home`) but whose `FM_HOME` still points elsewhere. `fm-lock.sh`, `fm-spawn.sh`, `fm-supervise-daemon.sh`, `fm-tasks-axi.sh`, and `fm-wake-drain.sh` source it and call `fm_home_guard mutate <command-name>` before mutating; `fm-watch-arm.sh` calls it too, ahead of arming a watcher. -When the context home (the secondmate home the shell is actually sitting in) and the effective `FM_HOME` disagree, a mutating call refuses with the mismatch and remediation, while a read-only call (`fm-tasks-axi.sh` in read mode) warns and continues. +When the context home (the secondmate home the shell is actually sitting in) and the effective `FM_HOME` disagree, a mutating call refuses with the mismatch and remediation, while a read-only call warns and continues. Primary homes are unaffected because they carry no `.fm-secondmate-home` marker. `fm-send.sh` is a separate, fail-closed case rather than a `fm_home_guard` caller: it refuses outright whenever `FM_HOME` is unset, so it never has a context-vs-effective-home mismatch to guard against. `fm-watch.sh` does not source this guard. @@ -116,7 +128,7 @@ Two read-mostly tools guard against firstmate's own tracked state drifting from `bin/fm-reconcile-stale.sh` builds on that same matching (`fm-fleet-map-lib.sh`) plus a landed-work assessor (`fm-landed-work-lib.sh`) with carve-outs for `kind=secondmate` and `kind=scout` that mirror `fm-teardown.sh`. The assessor itself is deliberately more conservative than `fm-teardown.sh` and omits its PR-head patch-id containment fallback, so use `bin/fm-teardown.sh` directly for a squash-merge case it would recognize as landed but this assessor reports as unlanded or blocked. Its default mode is a dry run that writes nothing; `--clean <id> --yes` re-verifies the endpoint is dead, the work is landed, and the fleet is not frozen, then removes only that one id's volatile state files and `tasktmp`, never worktrees, homes, clones, branches, or backend endpoints. -`bin/fm-usage-tripwire.sh` is a read-only watcher check born from a token-burn incident: it scans transcript files under `FM_USAGE_CLAUDE_DIR`/`FM_USAGE_CODEX_DIR` whose mtime falls in a sliding `FM_USAGE_WINDOW_MINUTES` window, counts them as a session-burst signal against `FM_USAGE_SESSION_THRESHOLD`, and separately sums each transcript line's own timestamped output tokens against `FM_USAGE_OUTPUT_THRESHOLD` so a long-lived, actively-appended session is not re-summed in full on every poll. It prints exactly one alarm line on a breach and nothing when healthy, matching the `state/<id>.check.sh` watcher-check contract; arm it with `ln -sf "$(pwd -P)/bin/fm-usage-tripwire.sh" state/usage-tripwire.check.sh`. +`bin/fm-usage-tripwire.sh` is a read-only watcher check born from a token-burn incident: it scans transcript files under `FM_USAGE_CLAUDE_DIR`/`FM_USAGE_CODEX_DIR` whose mtime falls in a sliding `FM_USAGE_WINDOW_MINUTES` window, counts them as a session-burst signal against `FM_USAGE_SESSION_THRESHOLD`, and separately sums each transcript line's own timestamped output tokens against `FM_USAGE_OUTPUT_THRESHOLD` so a long-lived, actively-appended session is not re-summed in full on every poll. It prints exactly one alarm line on a breach and nothing when healthy, matching the `state/<id>.check.sh` watcher-check contract; arm it and bind it to the watcher's hash-registered check trust per its own header (`bin/fm-usage-tripwire.sh`). ## No-mistakes gate authority boundary @@ -160,8 +172,8 @@ Explicit backend-target sends and direct human typing stay unmarked, so captain After seeding a secondmate, `fm-backlog-handoff.sh` validates the fleet-specific handoff, then atomically delegates already-judged in-scope queued item moves to `tasks-axi mv` so the domain queue starts in the right place. Idle secondmate panes are healthy; teardown is explicit and refuses while the secondmate home has in-flight work unless the captain has approved discard with `--force`. -Secondmate homes converge conservatively to the primary's version and declared inheritable configuration at launch and during locked session start. -The [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md) owns the full guarded sync, propagation, nudge, and mid-session configuration-push contract. +Secondmate homes converge conservatively to the primary's version and declared inherited local material at launch and during locked session start. +The [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md) owns the full guarded sync, propagation, nudge, and mid-session local-material push contract. Secondmate agents can run on a different verified harness than crewmates. `config/secondmate-harness` controls the primary's secondmate launch harness and may also carry optional model and effort tokens as `<harness> [<model>] [<effort>]` on the first non-empty, non-comment line. @@ -173,13 +185,13 @@ An explicit per-spawn harness or raw launch command does not inherit model or ef `config/crew-dispatch.json` is inherited too; secondmates use the same natural-language dispatch profiles when spawning their own crewmates. `config/backlog-backend` is inherited too; absent or `tasks-axi` selects the default tasks-axi backlog backend, while `manual` forces routine backlog updates to hand-editing across the fleet without disabling validated handoff delegation. -The `data/secondmates.md` line schema and the secondmate environment variables are documented in [configuration.md](configuration.md). +The `data/secondmates.md` line contract is owned by the [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md#routing-table), and the secondmate environment variables are documented in [configuration.md](configuration.md). ## Project modes are explicit `data/projects.md` records each project's delivery mode and optional `+yolo` autonomy flag. `no-mistakes` projects run the full validation pipeline, `direct-PR` projects open PRs without that pipeline, and `local-only` projects stay local until firstmate performs an approved fast-forward merge. -Review diffs go through `bin/fm-review-diff.sh`, which refreshes the authoritative base and, when task meta records `pr=`, compares against the reachable recorded `pr_head=` or a freshly fetched `refs/pull/<n>/head` before falling back to the local branch with a warning. +When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, compares against the reachable recorded `pr_head=` or a freshly fetched `refs/pull/<n>/head` before falling back to the local branch with a warning. For target project repos shipped through their own no-mistakes pipeline, commits under `.no-mistakes/evidence/` are the pipeline's PR-viewable validation evidence and are expected to stay in the crew branch until the evidence-hosting design changes. The firstmate repo itself is the exception: its `.no-mistakes/` directory is local state, stays gitignored, and is rejected by CI if tracked. PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling `gh-axi pr merge`. @@ -212,7 +224,7 @@ Splitting preserves fenced-code, paragraph, line, and word boundaries when possi If an image is attached to a split reply, the relay puts it on the first/opener message only and leaves later chunks text-only. For preview testing, `FMX_DRY_RUN` makes `fm-x-reply.sh` and `fm-x-dismiss.sh` skip the public post or dismiss call and record the would-be payload under `state/x-outbox/`, including `texts` when the reply would be a thread and an `endpoint` marker when the preview is a completion follow-up or dismiss, while the rest of the poll -> compose -> would-post loop still succeeds. Attached images are recorded as compact `{media_type, bytes, source_path}` metadata in dry-run instead of base64 bytes. -The watcher, wake queue, arm wrapper, and afk daemon are unchanged; X mode is layered on top through the existing check mechanism. +X mode remains layered on top of the existing check mechanism without changing its request-handling behavior. ## Project memory belongs to projects @@ -220,14 +232,14 @@ Durable project-intrinsic agent knowledge lives in each project's committed `AGE Ship briefs prompt crewmates to create or update those files through the normal delivery path; `data/projects.md` stays a thin private registry. Each project `AGENTS.md` carries a short `## Maintaining this file` self-governance section; `bin/fm-ensure-agents-md.sh` owns the canonical wording and injects it idempotently when creating the skeleton, promoting an existing `CLAUDE.md`, or reconciling an existing `AGENTS.md` that still lacks it. It refuses a case-variant real memory file such as a lowercase `agents.md`, whose `CLAUDE.md` symlink would carry an uppercase literal target that dangles on a case-sensitive filesystem, and surfaces the mismatch for manual reconciliation. -The full ownership rule - what is project-intrinsic versus fleet-private, and how firstmate keeps the two apart without writing into project clones - is owned by firstmate's operating manual in [`AGENTS.md`](../AGENTS.md) (project memory ownership). +The full ownership rule - what is project-intrinsic versus fleet-private, and how firstmate keeps the two apart without writing into project clones - is owned by [`AGENTS.md`](../AGENTS.md) (project and knowledge management). ## Operational memory routing `/stow` sweeps the current session for durable knowledge that only exists in conversation and routes each finding to the most specific disk home. -Captain preferences go to `data/captain.md`, fleet-local operational facts and gotchas go to `data/learnings.md`, project-intrinsic knowledge goes through normal crewmate delivery into that project's committed `AGENTS.md`, and task-scoped notes or undone next steps go to the backlog. +Home-domain captain preferences go to `data/captain.md`, cross-domain shared captain preferences go to the primary home's `data/captain-shared.md`, fleet-local operational facts and gotchas go to home-local `data/learnings.md`, project-intrinsic knowledge goes through normal crewmate delivery into that project's committed `AGENTS.md`, and task-scoped notes or undone next steps go to the backlog. Memory writes use inspect-then-update: read the current destination first, then rewrite or prune matching bullets or notes in place instead of appending by default. -Task-scoped notes use `bin/fm-tasks-axi.sh show <id> --full` followed by `bin/fm-tasks-axi.sh update <id> --body-file <path>`, adding `--archive-body` when the prior body should remain recoverable. +Task-scoped notes use `tasks-axi show <id> --full` followed by `tasks-axi update <id> --body-file <path>`, adding `--archive-body` when the prior body should remain recoverable. Generalizable firstmate knowledge goes to shared tracked docs through the normal PR pipeline; the firstmate-internal `/stow` deliberately never stores findings in either skill directory. ## Local clones stay fresh @@ -249,7 +261,7 @@ The mechanics are owned by the `/updatefirstmate` skill and firstmate's operatin ## Restart-proof -Fleet state lives in each task's session-provider backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected), no-mistakes run records, status event logs, local markdown under `data/` including `data/captain.md` and `data/learnings.md`, and persistent secondmate homes. +Fleet state lives in each task's session-provider backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected), no-mistakes run records, status event logs, local markdown under `data/` including `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, and persistent secondmate homes. For herdr, respawning after a server-restored layout closes and replaces confirmed no-agent or dead task-tab husks instead of requiring manual tab cleanup. At session start, confirmed-dead secondmate agent endpoints are closed and relaunched through the same secondmate spawn path, while ambiguous liveness reads are left untouched to avoid duplicate supervisors. Use `/stow` before an intentional reset when the conversation may hold durable knowledge that has not yet been written to disk; after that, the next firstmate session can reconcile and carry on. diff --git a/docs/cd-guard.md b/docs/cd-guard.md index e324bfeb4..2d8082e1c 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -10,7 +10,7 @@ the watcher-arm PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pr ## Purpose and boundary -The primary firstmate shell persists its working directory across tool calls (AGENTS.md section 2: "The shell working directory persists between commands"). +The primary firstmate shell persists its working directory across tool calls. A stray persistent top-level `cd projects/<clone>` therefore silently relocates the shell, so the next firstmate-owned command - a backlog write, an `fm-*` lifecycle call, `tasks-axi` - runs inside a project clone instead of the home. That has actually happened: a persistent top-level `cd` caused a firstmate-owned backlog write to execute inside a project clone rather than the home. The seatbelt denies exactly that command shape - a cwd change that persists to the primary shell - before it runs. diff --git a/docs/configuration.md b/docs/configuration.md index df4a4d11f..6a33feb02 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,22 @@ The files and environment variables you set to operate firstmate. The shared orchestrator behavior lives in [`AGENTS.md`](../AGENTS.md) - edit it like any prompt when the fleet is empty, or dispatch shared-repo edits to a crewmate while tasks are in flight. +## Operational home layout and state + +This section is the single owner of the top-level operational-home layout; producer script headers and their help own exact child-file fields and mutation contracts. +The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. +`data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. +`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, and generated X-mode artifacts. +`config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. + +`bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. +The producing PR and X helpers own the fields they append, `bin/fm-classify-lib.sh` owns status-event vocabulary, and `bin/fm-crew-state.sh` owns current-state reconciliation. +Wake, watcher, away-mode, and X-specific state mechanics remain with their named scripts and reference sections rather than being duplicated into one exhaustive state tree here. + +`bin/fm-session-start.sh`'s header is the single owner of session-start ordering, composed commands, digest contents, and startup mechanism. +`AGENTS.md` retains the run-once and read-once operator rules, lock-refusal safety, installation consent, and direct-report recovery boundaries because those facts apply at every session start. +Ordinary dead-direct-report recovery is owned by `stuck-crewmate-recovery`, while persistent-secondmate recovery is owned by `secondmate-provisioning`. + ## Backlog backend (.tasks.toml / config/backlog-backend) The tracked `.tasks.toml` pins the default `tasks-axi` markdown backend to `data/backlog.md`, with `done_keep = 10` and an archive at `data/done-archive.md`. @@ -16,15 +32,14 @@ Handoff item bodies must use at least two leading spaces, and the helper refuses Because bootstrap requires `tasks-axi` on `PATH` on every profile, that delegation works fleet-wide, and the `config/backlog-backend=manual` knob governs firstmate's own hand-editing of its backlog, not this validated helper. Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.1.1 or newer, `tasks-axi update --help` exposes `--archive-body`, and `tasks-axi mv --help` exposes `[<id>...]` for the atomic multi-ID move introduced in 0.2.2 and required by handoff delegation. That sentence is the single owner of the tasks-axi compatibility definition; every other document points here instead of restating the version gates. -Bootstrap requires compatible `tasks-axi` on every profile; see "Toolchain" below for missing-tool reporting and `TASKS_AXI: available` behavior. -Set the local, gitignored `config/backlog-backend` file to `manual` to force manual backlog editing and suppress `TASKS_AXI: available`, not missing-tool reporting. +Bootstrap requires compatible `tasks-axi` on every profile; see "Toolchain" below for missing-tool reporting and silent default-backend behavior. +Set the local, gitignored `config/backlog-backend` file to `manual` to force manual backlog editing and suppress the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not missing-tool reporting. Absent or `tasks-axi` selects the default tasks-axi backend. The file format is unchanged in both modes; tasks-axi and manual edits produce the same `## In flight`, `## Queued`, and `## Done` sections. - -`tasks-axi` discovers `.tasks.toml` from the caller's working directory, so a bare `tasks-axi` invocation run from the repo root can silently mutate the wrong home's backlog when `FM_HOME` points elsewhere. -`bin/fm-tasks-axi.sh` closes this cwd trap: it resolves the effective `FM_HOME`, `cd`s into it before running `tasks-axi`, and refuses when the configured markdown path resolves outside that home. +`tasks-axi` discovers `.tasks.toml` from the caller's working directory, so a bare invocation from the repo root can silently mutate the wrong home's backlog when `FM_HOME` points elsewhere. +`bin/fm-tasks-axi.sh` closes this cwd trap by resolving the effective `FM_HOME`, changing into it before running `tasks-axi`, and refusing when the configured markdown path resolves outside that home. Always invoke `bin/fm-tasks-axi.sh <verb> ...` rather than bare `tasks-axi` for firstmate's own routine backlog mutations. -Unless the backend is `manual`, bootstrap also detects existing drift and reports `TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from <home>` when the repo root and `FM_HOME` already have diverged `data/backlog.md` files. +Unless the backend is `manual`, bootstrap detects existing drift and reports `TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from <home>` when the repo root and `FM_HOME` already have divergent `data/backlog.md` files. ## Runtime backend (config/backend / FM_BACKEND) @@ -97,20 +112,24 @@ That evidence policy is specific to the firstmate repo: target projects may legi That command requires `tmux` on `PATH`, prints `tmux -V`, runs every `tests/*.test.sh` with `bash`, and fails if any script exits non-zero. It intentionally mirrors the behavior-test baseline in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) instead of delegating the test step to an agent. -## Captain preferences (data/captain.md) +## Captain Preferences (data/captain.md / data/captain-shared.md) -Personal preferences for one captain's fleet live locally in `data/captain.md`; it is gitignored and printed in the session-start context digest after `data/projects.md` and optional `data/secondmates.md`. +Domain-local preferences for one captain's fleet live locally in each home's `data/captain.md`; it is gitignored and printed in the session-start context digest after `data/projects.md` and optional `data/secondmates.md`. Before changing it, inspect the current file and rewrite or prune the matching bullet in place; add a new bullet only for a genuinely new durable preference. +Shared captain preferences that apply across secondmate domains live only in the primary home's optional `data/captain-shared.md`. +`secondmate-provisioning` owns its propagation contract, including the required header, read-only secondmate copies, quarantine diagnostics, and the rollout rule that existing homes trim `data/captain.md` by hand after first propagation rather than deleting private content automatically. ## Operational learnings (data/learnings.md) -Fleet-local operational facts and gotchas live locally in `data/learnings.md`; it is gitignored and printed right after `data/captain.md` in the session-start context digest. +Fleet-local operational facts and gotchas live locally in `data/learnings.md`; it is gitignored and printed after the captain-preference files in the session-start context digest. The file is created lazily on first learning and follows the same dated, evidence-backed, curated style as `data/captain.md`: inspect the current file first, then rewrite or prune stale entries instead of appending forever. +There is no shared learnings file by captain decision. ## Secondmate routes (data/secondmates.md) Persistent secondmate routes live locally in `data/secondmates.md`. -Each line records the secondmate id, charter summary, absolute home path, natural-language scope, project clone list, and added date; `fm-home-seed.sh validate` refuses duplicate ids, duplicate homes, and nested or overlapping homes. +The concise single-line route contract is owned by the [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md#routing-table), including the parser-compatible fields, one-sentence summary requirement, `home:` pointer to the seeded charter, and limit on extra registry prose. +`fm-home-seed.sh validate` refuses duplicate ids, duplicate homes, and nested or overlapping homes. The main first mate routes by reading those scopes with judgment; the project list is provisioning data, not exclusive ownership. Use `fm-home-seed.sh <id> - {<project>...|--no-projects}` to lease a fresh firstmate worktree for the secondmate home. Use the deliberate `--no-projects` signal only for a firstmate-repo domain that needs no separate project clones. @@ -123,6 +142,7 @@ Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` pro For `no-mistakes` projects, seeding initializes only projects newly cloned into a secondmate home and refuses to mutate a preexisting clone that is not already initialized. After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh <secondmate-id> <item-key>...`; it is idempotent and refuses In flight, Done, or non-secondmate homes. Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled charter brief exists; set `FM_SECONDMATE_SCOPE` when the routing scope should differ from the charter text. +The seeded home's `data/charter.md` owns the standard secondmate lifecycle and escalation contract; the route file points to it through the existing `home:` field instead of adding another pointer. Each seed writes an `.fm-secondmate-home` identity marker at the home root. The tracked root `.gitignore` ignores that marker, so validation can read it without making a freshly seeded home appear dirty to porcelain-based safety checks. This does not relax protection for any other untracked file. @@ -161,7 +181,7 @@ When the harness token is absent or `default`, secondmate launch falls back thro An explicit harness argument to `fm-spawn.sh` still overrides either config file for that spawn only. An explicit `--model` or `--effort` overrides the matching token from `config/secondmate-harness`; an explicit harness or raw launch command starts with clean model and effort defaults unless those flags are also passed. When `config/crew-dispatch.json` exists, crewmate and scout spawns require an explicit resolved harness instead of automatically falling back to `config/crew-harness`. -The primary propagates `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` into secondmate homes at secondmate spawn, during the locked session-start bootstrap secondmate sweep, and during explicit `bin/fm-config-push.sh` runs, so a secondmate's own crewmates, dispatch profiles, and backlog backend use the primary values. +The inherited-local-material contract is owned by `secondmate-provisioning`; for harness behavior, its propagated config items make a secondmate's own crewmates, dispatch profiles, and backlog backend use the primary values. `config/secondmate-harness` is not inherited because secondmates do not launch secondmates. For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. @@ -181,21 +201,13 @@ This section is the single owner of the canonical schema and its per-field seman { "when": "<natural-language condition describing a kind of task>", "use": [ - { - "harness": "<adapter>", - "model": "<optional model>", - "effort": "<low|medium|high|xhigh|max, optional>" - } + { "harness": "<adapter>", "model": "<optional model>", "effort": "<low|medium|high|xhigh|max, optional>" } ], "select": "<optional strategy>", "why": "<optional rationale that helps firstmate choose>" } ], - "default": { - "harness": "<adapter>", - "model": "<optional model>", - "effort": "<optional effort>" - } + "default": { "harness": "<adapter>", "model": "<optional model>", "effort": "<optional effort>" } } ``` @@ -210,7 +222,7 @@ If a selected profile carries an effort value the chosen harness does not accept `quota-balanced` selection is deterministic and implemented by `bin/fm-dispatch-select.sh`, whose header owns the general-window rules, the 20 point stale-clear freshness margin, vendor-availability handling, and the degrade-to-first-element fallbacks; quota trouble never blocks dispatch. See [`docs/examples/crew-dispatch.json`](examples/crew-dispatch.json) for a starting point to copy into local `config/crew-dispatch.json`. When the file exists, bootstrap validates it with `jq`. -Valid files produce a `CREW_DISPATCH: active config/crew-dispatch.json` block that lists each rule and prints `default:` when present. +Valid files stay silent by default; with `FM_BOOTSTRAP_VERBOSE_FACTS=1`, bootstrap emits `BOOTSTRAP_INFO: crew dispatch active config/crew-dispatch.json` plus one `BOOTSTRAP_INFO:` fact per rule and default profile. Malformed JSON, an unverified harness, a malformed array profile, an unknown `select`, or an effort value unsupported by that harness is reported as `CREW_DISPATCH: invalid config/crew-dispatch.json - ...`; missing `jq` is reported through the normal `MISSING: jq` install-consent flow. If no dispatch rule fits, firstmate uses the dispatch profile `default` when present, then falls back to `config/crew-harness`. Because the spawn backstop is gated by file presence, any fallback path after a missing match, validation error, or missing `jq` still passes a resolved harness explicitly until the file is fixed or removed. @@ -233,7 +245,7 @@ A herdr, zellij, or cmux home is therefore never told `tmux` is missing, and the When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispatch profile validation. When X mode is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. `tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. -An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap also prints `TASKS_AXI: available` and firstmate uses `bin/fm-tasks-axi.sh` for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. +An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. An absent `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; `bin/fm-dispatch-select.sh` still degrades to the first profile at runtime when quota data is unavailable. Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default branch; follow the printed checkout remediation rather than treating it as an installable tool problem. In a read-only session that did not get the fleet lock, the same line is advisory and omits the checkout command. @@ -244,12 +256,13 @@ If bootstrap kills a timed-out refresh, it replays any completed `fm-fleet-sync. A killed refresh (or a teardown process kill) can leave an orphaned `.git/packed-refs.lock` in a clone, which makes the next refresh's fetch fail with Git's `Unable to create '...packed-refs.lock': File exists`. On that signature only, `fm-fleet-sync.sh` retries the fetch with a bounded wait for the lock to self-clear, then removes the lock and retries once more only when it can prove the lock stale, exactly like the `fm-teardown.sh` `index.lock` recovery. It never removes a live lock, leaves any other failure shape untouched, and prints every wait, retry, and removal to stderr plus a one-line `recovered:` summary to stdout on success so that this session-start relay still surfaces the recovery. -The locked session-start bootstrap step also runs the guarded local secondmate sync for recorded live secondmate homes, then propagates declared inheritable local config into each validated live home. -It emits `SECONDMATE_SYNC:` only when a home was skipped for an actionable sync reason or config inheritance failed, and `NUDGE_SECONDMATES:` only when a running home advanced and its instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed. -`NUDGE_SECONDMATES:` lists stable `fm-<id>` task selectors; the `bootstrap-diagnostics` skill owns the send procedure. -The same bootstrap run also emits `SECONDMATE_LIVENESS:` for live secondmate endpoints: `already-live` and `respawned` are handled states, while `skipped` or `respawn failed` means the secondmate still needs attention. -For a mid-session inherited config edit where tracked-file sync and reread nudges are not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, and `backlog-backend` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero only for real propagation errors. +The locked session-start bootstrap step also runs the guarded local secondmate sync for recorded live secondmate homes, then propagates declared inherited local material into each validated live home. +It emits `SECONDMATE_SYNC:` only when a home was skipped for an actionable sync reason, inheritance failed, or a divergent shared captain-preference copy was quarantined. +When a running home advances and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, bootstrap sends the re-read nudge itself through the stable `fm-<id>` selector and reports the exact completed send as `BOOTSTRAP_INFO:`. +If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. +The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a live secondmate endpoint is skipped or respawn fails; already-live and successfully respawned endpoints are handled silently. +For a mid-session inherited local-material edit where tracked-file sync and reread nudges are not needed, run `bin/fm-config-push.sh`. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero only for real propagation errors. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. Skipped items, such as a destination checkout that does not yet gitignore the item, are visible warnings but not hard failures. @@ -265,15 +278,16 @@ For direct client invocations, environment values override `.env`; bootstrap act `FMX_ENV_FILE` can point direct poll/reply client invocations at another `.env`-style file, but it does not change bootstrap activation. The locked session-start bootstrap step turns the token into local generated state. -It writes `state/x-watch.check.sh`, a check shim that runs `bin/fm-x-poll.sh`, and `config/x-mode.env`, which exports `FM_CHECK_INTERVAL=30` for watcher processes in that home. +It writes `state/x-watch.check.sh`, a byte-static identity shim for `bin/fm-x-poll.sh`, and `config/x-mode.env`, which exports `FM_CHECK_INTERVAL=30` for watcher processes in that home. +The watcher accepts the shim only when its bytes match the expected generated content, then invokes the trusted repository poll script directly instead of executing state-file source. This section is the single owner of the X-mode cadence contract: an X instance polls every 30 seconds instead of the default 300, only an X instance speeds up because a non-X home has no `config/x-mode.env`, and the session-start supervision operating block includes the cadence instruction when that file exists. The active primary-harness supervision protocol owns how that sourced cadence reaches the watcher process. Because `bin/fm-watch.sh` reads `FM_CHECK_INTERVAL` only at process start, a cadence transition - opt-in while a watcher is already running, or opt-out - is applied by restarting the home-scoped watcher through the emitted harness protocol; bootstrap deliberately never restarts the watcher itself. While away mode is active the daemon owns the watcher and its default cadence applies; away-mode X cadence is a deferred follow-up. When the token is removed or empty, the next locked session-start bootstrap step removes those artifacts. Steady-state off is silent and writes nothing. -X mode is purely additive: no edit is made to `bin/fm-watch.sh`, `bin/fm-watch-arm.sh`, `bin/fm-wake-lib.sh`, or the afk daemon (`bin/fm-supervise-daemon.sh` and the `afk` skill). -It lives entirely in X-specific `bin/` scripts, the `fmx-respond` skill, and the generated local artifacts above. +X mode remains additive to non-X lifecycle behavior: homes without the generated artifacts keep the default watcher cadence and do not run the X poll. +Its request handling remains in X-specific `bin/` scripts and the `fmx-respond` skill, while the watcher owns authenticated dispatch from the generated local identity shim. `bin/fm-x-poll.sh` calls `GET /connector/poll` with `Authorization: Bearer <FMX_PAIRING_TOKEN>`. HTTP 204 is silent. @@ -339,6 +353,7 @@ HERDR_SESSION=default # herdr-only: named session for normal backend ops; not e FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") +FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Incident (2026-07-14)") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline FM_BACKEND_ORCA_COMPOSER_LINES=200 # orca-only: terminal-read lines scanned to locate the composer row for submit verification @@ -349,13 +364,12 @@ FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer pl CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording -FM_NO_MISTAKES_PROBE_TIMEOUT=3 # seconds bounding each bootstrap no-mistakes invocation; a dead or unreachable daemon degrades to the same MISSING line as an absent binary instead of hanging bootstrap FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically FM_POLL=15 # seconds between watcher poll cycles FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap -FM_CHECK_INTERVAL=300 # seconds between slow checks (merge polls or the X-mode poll shim) +FM_CHECK_INTERVAL=300 # seconds between slow checks (authenticated merge polls, custom checks, or X-mode dispatch) FM_CHECK_TIMEOUT=30 # seconds allowed per slow check script FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in Codex primary supervision FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh diff --git a/docs/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md new file mode 100644 index 000000000..e29fe1f3d --- /dev/null +++ b/docs/decision-hold-lifecycle.md @@ -0,0 +1,86 @@ +# Decision hold lifecycle mechanism + +The normative policy is owned by `.agents/skills/decision-hold-lifecycle/SKILL.md` and is not restated here. +This document records the deterministic mechanism, structured surfaces, and privacy-safe regression evidence. + +## Mechanism + +`bin/fm-decision-hold.sh` is the only lifecycle command for an investigation or visual review's unresolved captain decisions. +The command runs tasks-axi in the active `FM_HOME`, so the existing backlog remains the only durable work database and a secondmate-owned decision stays in the secondmate home. +It never reads report bodies, review artifacts, terminal output, or chat. + +The `hold` subcommand maps an originating work id and stable decision key to `<origin-id>-decision-<decision-key>`. +It creates a kind `captain` backlog item when absent and invokes `tasks-axi hold <id> --reason <reason> --kind captain` on every retry. +It rejects an identity collision, a changed title, and attempts to reopen an already resolved identity. + +The `complete` subcommand unions the reviewed keys into `decision_keys=` and appends `decisions_reviewed=1` while originating task metadata is live. +A post-teardown visual review can complete against the surviving report and durable holds without recreating volatile task metadata. +It accepts `--none` as an explicit semantic inventory result, not as inferred absence. +It verifies every listed identity against tasks-axi before recording completion. +For an open keyed status decision, it appends a `captain-held [key=<key>]: ...` transfer event only after the matching backlog hold is durable. +`bin/fm-classify-lib.sh` recognizes that transfer as closing the live status copy without claiming that the captain has answered it. + +Scout teardown calls the script's read-only `verify` subcommand after checking for the report and before removing any source state. +The `--force` path remains the explicit captain-approved discard escape hatch. + +The `resolve` subcommand requires a decision file and at least one existing dependent task whose structured `blocked-by` edge points to the hold. +It records the decision digest and routed task identities as a retry identity in the hold body, clears each dependency edge through tasks-axi, and marks the hold Done only after those writes succeed. +An exact retry can finish a partial routing operation, while a changed decision or routed-task set is rejected. +A failed intermediate step leaves the hold open. + +## Structured read surfaces + +`bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)` and `(hold-kind: captain)` metadata alongside existing backlog fields. +Its secondmate-home summary classifies an active captain hold as `captain_decision` and preserves the owning home. + +`bin/fm-bearings-snapshot.sh` projects active captain holds into `decisions_open` and excludes them from ordinary queued gates. +It excludes completed kind `captain` records from Recently Landed. +The projection remains read-only and does not inspect historical prose. + +## Verification record + +Verification date: 2026-07-14. +Additional quoted `blocked_by` regression verification date: 2026-07-17. + +The focused end-to-end regression uses only synthetic `sample` identities and decision text. +It begins with a completed investigation and visual review whose genuine unresolved choice exists only in the report. +The initial Bearings snapshot correctly has no open decision, and the new teardown gate refuses to erase the source. +A later regression covers tasks-axi's quoted multi-entry `blocked_by` output so `resolve` matches the first, middle, and last ids and rejects a genuinely absent id. + +The final verification commands and their exact summarized outputs follow. + +```text +$ bash tests/fm-decision-hold-lifecycle.test.sh +ok - report-only unresolved decision is reproduced and completion refuses before loss +ok - non-forced scout teardown always requires durable inventory verification +ok - captain holds are idempotent, distinct, teardown-safe, Bearings-visible, and durably routed before close +ok - completion and verification validate origins before constructing paths +ok - ended visual review follows the same decision-hold completion owner +ok - resolved findings and decision-like prose do not create false holds +ok - terminal single-owner stale status decisions do not block empty inventory +ok - main-home and secondmate-home captain holds remain correctly routed +ok - resolve matches first/middle/last in quoted blocked_by and rejects a genuinely absent id + +$ bash tests/fm-fleet-snapshot-view.test.sh +ok - durable captain-held transfer closes the duplicate live status decision +ok - snapshot parses tasks-axi rows and respects operational overrides + +$ bash tests/fm-bearings-snapshot.test.sh +ok - a completed scout with decision-like report prose is a pointer, not pending +ok - action-free items (working/done/queued/landed) do not leak into Captain's Call + +$ bash tests/fm-brief.test.sh +ok - fm-brief.sh: investigation and visual-review completions load the shared decision policy + +$ bash tests/fm-teardown.test.sh +all teardown safety cases passed + +$ bin/fm-lint.sh +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) + +$ git diff --check +(no output) + +$ for test_script in tests/*.test.sh; do bash "$test_script"; done +ALL 71 TEST SCRIPTS PASSED +``` diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 6a4147e11..68c6430b4 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -4,8 +4,8 @@ This document records the empirical verification behind `bin/backends/herdr.sh`, It is the herdr equivalent of the tmux facts recorded in the `harness-adapters` skill and `docs/architecture.md`'s "Runtime session backends" section. Herdr is [an agent-native terminal multiplexer](https://herdr.dev) with a socket API, CLI wrappers, and native per-pane agent-state detection. -Verified against the real installed binary: herdr 0.7.3, protocol 16, macOS aarch64. -Current real-herdr verification uses isolated `HERDR_SESSION` names plus the guarded teardown helper in `tests/herdr-test-safety.sh`. +Originally verified against herdr 0.7.1, protocol 14, on macOS aarch64; the latest dated evidence below uses herdr 0.7.3, protocol 16. +Current real-herdr verification uses isolated named sessions plus the guarded `bin/fm-herdr-lab.sh` lifecycle helper, either directly or through the compatibility wrappers in `tests/herdr-test-safety.sh`. A 2026-07-02 cleanup bug proved that `HERDR_SESSION` alone is not a safe way to target destructive session cleanup; see "Session targeting: the `--session` flag, not `HERDR_SESSION` alone" below. All real-herdr verification in this document uses isolated sessions and guarded cleanup; the captain's default herdr session and live tmux fleet were never intended targets. @@ -18,14 +18,14 @@ Firstmate only drives the `herdr` CLI as a separate process, which carries no AG Prerequisites: -- `herdr` itself, protocol 16 or newer (installed 0.7.3 verified) - see [herdr.dev](https://herdr.dev) for install instructions. +- `herdr` itself, protocol 16 or newer (0.7.3 verified) - see [herdr.dev](https://herdr.dev) for install instructions. - `jq`, required to parse herdr's JSON output: `brew install jq` (or your platform's package manager). - The universal firstmate prerequisites - a verified crew harness plus the required toolchain, owned by [`docs/configuration.md`](configuration.md) ("Harness support", "Toolchain"); treehouse still provides the worktree, herdr only provides the session. Select herdr by putting `herdr` in a local `config/backend` file - the durable way to pick it - or by exporting `FM_BACKEND=herdr` when you launch your harness for a one-off session; telling the first mate in chat to use herdr also works. It can also be auto-detected: when firstmate itself is running natively inside herdr (`HERDR_ENV=1`) and no explicit backend is set, firstmate auto-selects herdr and prints a one-time opt-out notice; running inside tmux nested in herdr always resolves to tmux instead. A herdr spawn refuses loudly before creating a session container or acquiring a ship/scout worktree if `herdr` or `jq` is missing or the installed herdr's protocol is older than verified. -For `--secondmate` launches, secondmate home sync and inherited-config propagation happen before this spawn-time backend gate. +For `--secondmate` launches, secondmate home sync and inherited local-material propagation happen before this spawn-time backend gate. No first-run provisioning is needed beyond having `herdr` and `jq` on `PATH`; firstmate creates the workspace and tab it needs on first spawn. @@ -217,7 +217,7 @@ backend=herdr expected-label=fm-marker-pi-sm ``` -Pi's separator-only idle composer is outside the Herdr structural classifier's recognized bordered/bare shapes, so composer state was conservatively `unknown` both before and after the send. +At the time, Pi's separator-only idle composer was outside the Herdr structural classifier's recognized bordered/bare shapes, so composer state was conservatively `unknown` both before and after the send. The endpoint's native agent state was idle before submission, and the normal idle-to-working confirmation made `fm-send.sh` return successfully. A task-local Pi `before_agent_start` hook then captured the exact received prompt and UTF-8 bytes: @@ -259,6 +259,82 @@ evidence: direct-input received-hex=464d5f4d41524b45525f48455244525f444952454354 Unit coverage in `tests/fm-send-secondmate-marker.test.sh` pins exact-id and stable-label secondmates, exact-id and stable-label ordinary crewmates, explicit endpoints with and without local metadata, key-only sends, direct unmarked input, exact U+2063 bytes, and idempotence. Strict unresolved-selector behavior remains covered by `tests/fm-send-strict.test.sh`. +## Incident (2026-07-14): Pi-on-Herdr away escalation stayed non-injectable for 4555 seconds + +A guarded reproduction used Herdr 0.7.3, Pi 0.80.7, a generated non-`default` session from `bin/fm-herdr-lab.sh`, an isolated Firstmate home, a real Pi primary pane, the public `bin/fm-afk-launch.sh start` entrypoint, a live synthetic child pane, the real daemon and wake queue, `bin/fm-afk-launch.sh stop`, `bin/fm-wake-drain.sh`, and `bin/fm-bearings-snapshot.sh`. +Every production-adapter and explicit Herdr command was routed through the lab helper, and teardown verified that the running `default` session was unchanged. +The synthetic child appended `blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token` while away mode was active. +The daemon classified the status as captain-relevant, retained it in `state/.subsuper-escalations`, and the watcher retained the matching `signal` in `state/.wake-queue`. +The oldest-escalation sidecar was backdated by 4555 seconds to reproduce the observed interval without waiting in wall-clock time. +The alarm then recorded `fm away-mode inject WEDGED: 4556s undelivered`, the active notifier fired exactly once, the buffer remained intact, and Pi captured zero injected prompts. + +The causal probes against the exact recorded supervisor target were: + +```text +target_exists=yes +busy_state=idle +composer_state=unknown +``` + +The plain Pi capture showed a blank content row between two horizontal separators: + +```text +───────────────────────────────────────────────────── + +───────────────────────────────────────────────────── +<project and model footer> +``` + +The ANSI capture showed the same two blue separator rows with a reverse-video cursor in the blank content row. +Real pending text occupied that same middle row and native `agent get` remained idle, which proved native agent state alone could not distinguish an empty Pi composer from an unsubmitted Pi draft. +The exact target was correct, the agent was not busy, and submit verification was never reached because the affirmative-empty pre-injection guard rejected the unrecognized structure. +This rules out target resolution, busy detection, submit acknowledgement, and shutdown ordering as the 4555-second cause. +The root cause was solely that the Herdr structural classifier recognized bordered composers and bare `❯` or `›` prompt rows, while Pi renders a separator-only composer. + +`fm_backend_herdr_composer_state` remains the single backend owner of structural row recognition. +It now accepts content between the bottom-most complete pair of Pi separator rows only when Herdr's native identity says the target agent is exactly `pi` and its status is `idle`, `done`, or `blocked`. +A working Pi, a pending middle row, a missing or non-Pi identity, an incomplete pair, or an over-tall candidate remains `pending` or `unknown`, so dead shells and ambiguous panes are still non-injectable. +The extracted content still routes through the shared `bin/fm-composer-lib.sh` decision owner. + +Making the Pi composer injectable exposed the already-proven terminal-control hazard from the 2026-07-13 incident: Herdr consumed a leading ASCII `0x1f`, so Pi received `Supervisor escalate...` without the away marker. +`FM_INJECT_MARK` now uses a bare leading U+2063 INVISIBLE SEPARATOR, while the from-firstmate marker remains a visible label followed by U+2063, so their full prefixes stay distinct. +U+2063 has no normal keyboard keystroke and the real post-fix Pi prompt capture retained its `e281a3` prefix byte-exact. + +The return half of the same reproduction showed that separate `stop` and `wake-drain` calls left policy ownership to the operator and allowed an ordinary Bearings request to begin while the live blocker remained open. +Bearings' authoritative structured projection was already correct: + +```json +{"in_flight":[{"id":"synthetic-child","state":"blocked"}],"decisions_open":[{"id":"synthetic-child","verb":"blocked"}],"gates":[]} +``` + +The live blocker was never structured as queued work, so no return policy was duplicated into Bearings wording or its projection. +`bin/fm-afk-return.sh` now owns deterministic stop, drain, durable evidence, and the fail-closed return gate. +It refuses ordinary work until each live open `blocked:` key is resolved after immediate remediation or explicitly reclassified with a durable reason. +The `/afk` skill owns the situation-specific procedure, and the always-loaded `AGENTS.md` away stub contains only the safety-critical trigger to run that owner before processing the return message. +`bin/fm-bearings-snapshot.sh` consults the owner's read-only guard and contains no copy of the policy. + +The guarded post-fix command was: + +```sh +FM_AFK_PI_HERDR_E2E=1 HERDR_LAB_HELPER=bin/fm-herdr-lab.sh tests/fm-afk-pi-herdr-return-e2e.test.sh +``` + +The real result was: + +```text +ok - real Pi/Herdr pending composer refuses injection without forced submit and raises one observable fallback +ok - real idle Pi/Herdr accepts one marked escalation promptly, verifies submit, clears wedge state, and emits no duplicate alert +ok - real unmarked Pi return opens catch-up and blocks Bearings before the unresolved blocker can be deferred +ok - resolved return catch-up allows Bearings and a clean idempotent away re-entry +evidence: herdr=herdr 0.7.3 pi=0.80.7 inject-hex-prefix=e281a3 notifier-count=1 +``` + +Unit coverage in `tests/fm-backend-herdr.test.sh` pins the exact idle and pending Pi captures plus working, non-Pi, unreadable, and over-tall refusal. +`tests/fm-afk-return.test.sh` pins durable catch-up evidence, blocker ownership, Bearings precedence, explicit reclassification, re-entry, and tmux/Herdr parity. +`tests/fm-bearings-snapshot.test.sh` pins that live blocked work remains live structured state and never becomes a queued gate. +The existing tmux injection E2E remains the transport-parity proof for type-once, verified-submit behavior. +The wedge alarm remains defense in depth and is not the primary delivery path. + ## Verified bug: `pane read --lines N` returns empty for small N This was the most significant finding of this verification pass. @@ -276,7 +352,7 @@ Verified this eliminates the flake across repeated full smoke-test runs. ## Verified gap: `agent.get` reads idle during a long foreground tool call `herdr agent get <pane>` -> `.result.agent.agent_status` was verified against a short interactive `claude` exchange (see "Busy state" above): `working` while the model streams a turn, `done` once it stops. -That verification did not cover a crew blocked on its OWN long-running foreground tool call - e.g. `no-mistakes axi run` without `--yes`, which blocks synchronously for the whole pipeline (minutes to tens of minutes) until a gate or outcome, per `AGENTS.md` section 11. +That verification did not cover a crew blocked on its OWN long-running foreground tool call - e.g. `no-mistakes axi run` without `--yes`, which blocks synchronously for the whole pipeline (minutes to tens of minutes) until a gate or outcome, per `AGENTS.md` section 7. For that entire span the model is not generating - it already finished the turn that invoked the tool and is waiting on the tool's result - so `agent_status` reads `idle` (or `blocked`, which the adapter also maps to `idle`), even though the pane's own rendered text keeps showing the harness's busy banner (`BUSY_REGEX`, e.g. `esc to interrupt`) the whole time, exactly as it would in a plain tmux pane. This surfaced as a real fleet incident (2026-07-02): `bin/fm-watch.sh`'s absorb-only-when-provably-working stale path (`AGENTS.md` section 8) treated a herdr `idle` verdict from `crew_pane_is_busy` as final, so it skipped the shared tail-regex corroboration that `unknown` already got. @@ -313,17 +389,18 @@ The tmux backend was NOT affected by this incident: `fm_tmux_composer_state` rea Herdr's CLI exposes no cursor-row primitive, so the composer row is located by shape instead of position. For bordered composers, the row is the only line in a generous tail capture whose trimmed content both starts and ends with the same border glyph (`│`, `┃`, or a plain `|`) - the box's own top/bottom rows use rounded corners and never match, popup item rows and separator rows carry no border glyph at all, and the footer help line uses `│` only as an interior separator (never as the first/last character), so none of those can be mistaken for the composer. For unbordered live composers, added after the 2026-07-07 incident below, the row is a bottom-most trimmed line starting with a verified agent prompt glyph (`❯` for claude or `›` for codex); decorative bordered boxes above it lose to that bottom-most match. +For Pi, added after the 2026-07-14 incident above, the candidate is the content between the bottom-most complete separator pair, admitted only when native Herdr identity reports exactly `pi` with status `idle`, `done`, or `blocked` and the bounded structure is unambiguous. A popup-close-with-placeholder-fill still reads as real content on that row, so composer fallback correctly classifies it as pending; on the normal idle-baseline path, the same first Enter also fails to start a turn, so native agent-state confirmation likewise retries instead of stopping early. Known ghost/placeholder composer text (`Type a message...`, verified grok 0.2.82's empty-composer hint) is recognized and still reads as empty. When ANSI capture is available, the shared `fm_composer_strip_ghost` extractor removes de-emphasised ghost/placeholder runs before classification while retaining real typed input. The full dim/faint and dark-TRUECOLOR contract is recorded in the 2026-07-10 incident below. -`FM_BACKEND_HERDR_IDLE_RE` extends that placeholder match, `FM_BACKEND_HERDR_BARE_PROMPT_RE` controls the recognized unbordered prompt glyphs, and `FM_BACKEND_HERDR_COMPOSER_LINES` controls the tail-window scan depth; all three are documented in [`docs/configuration.md`](configuration.md). +`FM_BACKEND_HERDR_IDLE_RE` extends that placeholder match, `FM_BACKEND_HERDR_BARE_PROMPT_RE` controls the recognized unbordered prompt glyphs, `FM_BACKEND_HERDR_COMPOSER_LINES` controls the tail-window scan depth, and `FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES` bounds a Pi separator candidate; all four are documented in [`docs/configuration.md`](configuration.md). See `fm_backend_herdr_composer_state`, `fm_backend_herdr_wait_for_working`, and `fm_backend_herdr_send_text_submit` in `bin/backends/herdr.sh` for the implementation, and `tests/fm-backend-herdr.test.sh`'s composer-state, wait-for-working, and send-text-submit sections for the fake-harness coverage. ## Composer-state classifier: structural row read, not delta-based The herdr adapter no longer diffs raw pane content before/after Enter (see the incident above for why that was unsafe). -It keeps `fm_backend_herdr_composer_state` as a structural classifier for the composer's own row - located as the bottom-most bordered composer row or verified bare prompt row described above - and reports `empty`, `pending`, or `unknown`. +It keeps `fm_backend_herdr_composer_state` as a structural classifier for the composer's own content - located as the bottom-most bordered row, verified bare prompt row, or identity-corroborated Pi separator region described above - and reports `empty`, `pending`, or `unknown`. When ANSI capture is available, the classifier keeps the raw styled row long enough to route it through the shared `fm_composer_strip_ghost` extractor before classification. The 2026-07-10 incident below records the supported dim/faint and dark-TRUECOLOR ghost/placeholder styling. That classifier is still the away-mode daemon's affirmative-empty pre-injection guard and the conservative fallback when `fm_backend_herdr_send_text_submit` cannot use an idle/done native agent-state baseline. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index f9ff8b323..6be2ad58f 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -86,7 +86,7 @@ Operation routing: Teardown: -- Scout teardown still requires `data/<id>/report.md` unless `--force` is explicitly used. +- Scout teardown still requires `data/<id>/report.md` and the shared unresolved-decision completion gate unless `--force` is explicitly used. - Ship teardown still refuses dirty or unlanded work before any terminal/worktree cleanup. - Ship teardown resolves `orca_worktree_id` back through Orca and verifies it matches the inspected `worktree=` path before removing anything; mismatches or uninspectable paths preserve metadata and fail closed. - After the existing firstmate safety checks pass, teardown closes the recorded Orca terminal and releases the recorded worktree through `orca worktree rm --worktree id:<orca_worktree_id> --force`. diff --git a/docs/scripts.md b/docs/scripts.md index 22e391cd8..735fa2d0c 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -3,6 +3,7 @@ The first mate drives these; interactive entrypoints work by hand too, while `*-lib.sh` files are sourced helpers. Each row is one purpose clause only: the script's own header comment is the authoritative description of its behavior, flags, and contracts, so read the header before first use. If you have changed away from the firstmate home in an interactive shell, invoke these scripts by absolute path through the repo's `bin/` directory; the scripts self-locate internally after they start. +The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm-teardown.sh` is summarized in [architecture.md](architecture.md#no-mistakes-gate-authority-boundary); `fm-gate-refuse-lib.sh`'s header owns its exact contract. | Script | Purpose | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -16,6 +17,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | +| `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section | @@ -42,17 +44,18 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `backends/herdr-eventwait.py` | Raw AF_UNIX wire-transport subscriber for herdr's native pane status-change stream | -| `fm-config-push.sh` | Push declared inheritable local config to live secondmate homes mid-session | +| `fm-config-push.sh` | Push declared inherited local material to live secondmate homes mid-session | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base | -| `fm-marker-lib.sh` | Shared from-firstmate request marker and detector | -| `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | +| `fm-marker-lib.sh` | Shared from-firstmate request marker, detector, and idempotent transformation | +| `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | | `fm-watch-arm.sh` | Verified home-scoped watcher arm wrapper with honest status reporting | | `fm-watch-checkpoint.sh` | Run one bounded foreground watcher checkpoint for Codex-style supervision | | `fm-watch.sh` | Singleton-safe always-on watcher: absorb benign wakes, queue and exit on actionable ones | | `fm-afk-start.sh` | Run the common sourceable away-mode daemon entry in the foreground | | `fm-afk-launch.sh` | Own away-mode entry, exit, rollback, and any backend terminal lifecycle | +| `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, escalate batched digests, alert on failed delivery | | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | @@ -62,7 +65,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-home-guard-lib.sh` | Shared FM_HOME ownership guard sourced by every mutating entrypoint | -| `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inheritable-config propagation | +| `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | | `fm-tasks-axi.sh` | Run tasks-axi against the effective FM_HOME regardless of caller cwd | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, then assert watcher liveness | @@ -72,14 +75,19 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-ack-lib.sh` | Shared --expect-ack pending-acknowledgement tracking and ack-missed escalation helpers | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, composer capture, and verified submit | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | -| `fm-pr-check.sh` | Record `pr=` and `pr_head=` for a PR-ready task, then arm the watcher's merge poll | -| `fm-pr-merge.sh` | Record PR metadata, then merge a task's PR from its full GitHub URL | +| `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | +| `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | +| `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll and provenance publication | +| `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR-poll sidecars | +| `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | +| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | +| `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task | | `fm-fleet-map.sh` | Read-only diagnostic reconciling tracked state against the live Herdr surface | | `fm-fleet-map-lib.sh` | Shared tracked-state/live-endpoint reconciliation helpers for fm-fleet-map.sh and fm-reconcile-stale.sh | | `fm-landed-work-lib.sh` | Shared, more-conservative landed-work assessor sourced by fm-reconcile-stale.sh | | `fm-reconcile-stale.sh` | Dry-run-first reporter, then guarded --clean, for tracked state with no live backend endpoint | -| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require scout reports, retire secondmate homes | +| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 19a188b60..d159d620c 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -6,10 +6,11 @@ When this session owns supervision and away mode is not active: 3. Arm supervision with the `fm_watch_arm_pi` tool. Use `/fm-watch-arm-pi` only as a human-entered fallback. Never run `bin/fm-watch-arm.sh` through Pi's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. -4. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live Pi process, and sends a follow-up user message when the child exits with an actionable watcher reason. -5. If the extension says the watcher is already healthy, do not start another cycle. -6. If the extension reports a watcher failure, drain queued wakes, inspect the failure text, and restart Pi with both extensions loaded if needed. -7. Never use shell `&` for watcher supervision. +4. If the extension says no live session holds the lock, run `bin/fm-session-start.sh` to reclaim the session lock, then call `fm_watch_arm_pi` again. +5. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live Pi process, and sends a follow-up user message when the child exits with an actionable watcher reason. +6. If the extension says the watcher is already healthy, do not start another cycle. +7. If the extension reports a watcher failure, drain queued wakes, inspect the failure text, and restart Pi with both extensions loaded if needed. +8. Never use shell `&` for watcher supervision. The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The turn-end guard extension lives at `__FM_PI_TURNEND_EXT__`. diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index c83daecab..162fab565 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -20,7 +20,7 @@ Prerequisites: Select zellij by putting `zellij` in a local `config/backend` file - the durable way to pick it - or by exporting `FM_BACKEND=zellij` when you launch your harness for a one-off session; telling the first mate in chat to use zellij also works. Unlike tmux and herdr, zellij is **never** auto-detected - it always requires an explicit choice. A zellij spawn refuses loudly before creating a session container or acquiring a ship/scout worktree if `zellij` or `jq` is missing or the installed zellij is older than 0.44. -For `--secondmate` launches, secondmate home sync and inherited-config propagation happen before this spawn-time backend gate. +For `--secondmate` launches, secondmate home sync and inherited local-material propagation happen before this spawn-time backend gate. No first-run provisioning is needed beyond having `zellij` and `jq` on `PATH`; firstmate creates the session and tab it needs on first spawn. diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 14060c99f..be029d6c1 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -83,7 +83,7 @@ SUPERVISOR_PANE=$("$REAL_TMUX" -L "$SOCKET" display-message -p -t supervisor '#{ LOOP_SCRIPT="$STATE_DIR/supervisor-loop.sh" cat > "$LOOP_SCRIPT" <<'LOOP' #!/usr/bin/env bash -MARK=$'\x1f' +MARK=$'\xE2\x81\xA3' LOG="$1" OLD_STTY=$(stty -g 2>/dev/null || true) [ -z "$OLD_STTY" ] || stty -echo -icanon min 1 time 0 2>/dev/null || true @@ -341,24 +341,19 @@ test_scenario_b() { # swallowed Enter, the retry path fires). sleep 8 - # Assert: exactly ONE digest in the log (no duplicate, no loss). - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario B: expected exactly 1 digest, got $digest_count (duplicate or lost)" - - # Assert: the digest is not concatenated with itself (two markers in one line). - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario B: digest concatenated with itself (two sentinel markers in one line)" - fi + # Assert: exactly ONE terminal-safe marker in the log (no duplicate, no loss). + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario B: expected exactly 1 U+2063 marker, got $marker_count (duplicate or lost)" # Assert: the digest line is classified as "injection" and starts with the - # sentinel marker (hex starts with 1f). + # terminal-safe sentinel marker (hex starts with e281a3). local digest_line digest_hex digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; # correct: starts with the sentinel marker byte + e281a3*) ;; # correct: starts with the terminal-safe sentinel marker *) fail "Scenario B: digest does not start with sentinel marker (hex: $digest_hex)" ;; esac @@ -388,16 +383,11 @@ test_scenario_c() { echo "done: PR https://example.test/pr/300" > "$STATE_DIR/fake-c1.status" sleep 6 - # Exactly one digest line in the submitted log (no duplicate, no loss). - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario C: expected exactly 1 digest, got $digest_count" - - # Not concatenated with itself (two sentinel markers in one line). - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario C: digest concatenated with itself (two sentinel markers in one line)" - fi + # Exactly one terminal-safe marker in the submitted log (no duplicate, no loss). + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario C: expected exactly 1 U+2063 marker, got $marker_count" # The digest is classified as an injection and starts with the sentinel byte. local digest_line digest_hex @@ -408,7 +398,7 @@ test_scenario_c() { esac digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; + e281a3*) ;; *) fail "Scenario C: digest does not start with sentinel marker (hex: $digest_hex)" ;; esac diff --git a/tests/fm-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index 5980e034e..5e48ab43e 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -124,7 +124,7 @@ EOF LOOP_SCRIPT="$STATE_DIR/supervisor-loop.sh" cat > "$LOOP_SCRIPT" <<'LOOP' #!/usr/bin/env bash -MARK=$'\x1f' +MARK=$'\xE2\x81\xA3' LOG="$1" AGENT_SOURCE=fm-test-supervisor AGENT_LABEL=fm-test-supervisor @@ -378,21 +378,17 @@ test_scenario_b() { sleep 10 - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario B: expected exactly 1 digest, got $digest_count (duplicate or lost)" - - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario B: digest concatenated with itself (two sentinel markers in one line)" - fi + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario B: expected exactly 1 U+2063 marker, got $marker_count (duplicate or lost)" local digest_line digest_hex digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; - *) fail "Scenario B: digest does not start with the sentinel marker (hex: $digest_hex)" ;; + e281a3*) ;; + *) fail "Scenario B: digest does not start with the terminal-safe sentinel marker (hex: $digest_hex)" ;; esac local user_count @@ -414,14 +410,10 @@ test_scenario_c() { echo "done: PR https://example.test/pr/300" > "$STATE_DIR/fake-c1.status" sleep 8 - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario C: expected exactly 1 digest, got $digest_count" - - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario C: digest concatenated with itself (two sentinel markers in one line)" - fi + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario C: expected exactly 1 U+2063 marker, got $marker_count" local digest_line digest_hex digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) @@ -431,8 +423,8 @@ test_scenario_c() { esac digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; - *) fail "Scenario C: digest does not start with the sentinel marker (hex: $digest_hex)" ;; + e281a3*) ;; + *) fail "Scenario C: digest does not start with the terminal-safe sentinel marker (hex: $digest_hex)" ;; esac local user_count diff --git a/tests/fm-afk-pi-herdr-return-e2e.test.sh b/tests/fm-afk-pi-herdr-return-e2e.test.sh new file mode 100755 index 000000000..12b421351 --- /dev/null +++ b/tests/fm-afk-pi-herdr-return-e2e.test.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# Real Pi/Herdr end-to-end regression for the 2026-07-14 two-owner incident. +# +# Opt-in because it launches a real interactive Pi primary, a real away daemon, +# and a real isolated Herdr lab session. Every explicit and production-adapter +# Herdr call is routed through fm-herdr-lab.sh. The scenario proves: +# - a live blocked status is classified and durably queued while away; +# - a pending Pi composer refuses injection and receives no forced Enter; +# - the existing wedge alarm remains observable and deduped; +# - clearing the draft makes the genuinely idle Pi composer injectable; +# - verified submit preserves the terminal-safe marker and clears delivery state; +# - an unmarked return request opens the catch-up gate before Bearings; +# - remediation/resolution clears the gate, and re-entry is idempotent. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-supervise-daemon.sh +. "$ROOT/bin/fm-supervise-daemon.sh" +# shellcheck source=bin/fm-backend.sh +. "$ROOT/bin/fm-backend.sh" + +if [ "${FM_AFK_PI_HERDR_E2E:-0}" != 1 ]; then + echo "skip: set FM_AFK_PI_HERDR_E2E=1 to run the real Pi/Herdr away-return regression" + exit 0 +fi + +for tool in herdr jq pi python3; do + command -v "$tool" >/dev/null 2>&1 || { echo "skip: $tool not found"; exit 0; } +done + +LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} +SESSION=$("$LAB_HELPER" name fm-afk-pi-return-e2e) +TMP_ROOT=$(fm_test_tmproot fm-afk-pi-return-e2e) +HOME_DIR="$TMP_ROOT/home" +STATE="$HOME_DIR/state" +PROJECT="$TMP_ROOT/project" +PI_DIR="$TMP_ROOT/pi-agent" +FAKEBIN="$TMP_ROOT/fakebin" +CAPTURE="$TMP_ROOT/pi-prompts.jsonl" +NOTIFY_LOG="$TMP_ROOT/wedge-notify.log" +ORIGINAL_PATH=$PATH +PRIMARY_PANE= +CHILD_PANE= +PRIMARY_TARGET= +DAEMON_STARTED=0 + +cleanup() { + local rc=$? + trap - EXIT + if [ "$DAEMON_STARTED" -eq 1 ]; then + PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" \ + "$ROOT/bin/fm-afk-launch.sh" stop >/dev/null 2>&1 || true + fi + if ! "$LAB_HELPER" teardown "$SESSION"; then + rc=1 + fi + rm -rf "$TMP_ROOT" + exit "$rc" +} +trap cleanup EXIT +"$LAB_HELPER" provision "$SESSION" + +mkdir -p "$HOME_DIR"/{state,data,config,projects} "$PROJECT" "$PI_DIR" "$FAKEBIN" +printf '# Synthetic isolated Firstmate primary\n' > "$PROJECT/AGENTS.md" + +# A task-local extension grants session-only trust, captures exact prompt bytes, +# and aborts before provider work. No production supervision extension is loaded +# in this synthetic primary, so nothing except the test can mutate fleet state. +# Herdr still observes Pi's real idle->working transition, so production submit +# verification is exercised without making a model request. +CAPTURE_EXT="$TMP_ROOT/capture-extension.ts" +cat > "$CAPTURE_EXT" <<'EOF' +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { appendFileSync } from "node:fs"; +const capturePath = process.env.FM_PI_CAPTURE_PATH!; +export default function (pi: ExtensionAPI) { + pi.on("project_trust", () => ({ trusted: "yes", remember: false })); + pi.on("before_agent_start", (event, ctx) => { + appendFileSync(capturePath, `${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\n`); + ctx.abort(); + }); +} +EOF + +# Route production adapter invocations through the guarded helper too. The shim +# removes only the adapter's validated trailing pair, then the helper appends it. +cat > "$FAKEBIN/herdr" <<EOF +#!/usr/bin/env bash +set -euo pipefail +helper='$LAB_HELPER' +session='$SESSION' +real_path='$ORIGINAL_PATH' +args=("\$@") +n=\${#args[@]} +if [ "\$n" -ge 2 ] && [ "\${args[\$((n-2))]}" = --session ]; then + [ "\${args[\$((n-1))]}" = "\$session" ] || { echo 'wrapper refused foreign session' >&2; exit 97; } + args=("\${args[@]:0:\$((n-2))}") +else + [ "\${HERDR_SESSION:-}" = "\$session" ] || { echo 'wrapper requires isolated session' >&2; exit 98; } +fi +PATH="\$real_path" exec "\$helper" run "\$session" "\${args[@]}" +EOF +chmod +x "$FAKEBIN/herdr" + +cat > "$TMP_ROOT/wedge-recorder" <<EOF +#!/usr/bin/env bash +printf '%s\t%s\n' "\$1" "\$2" >> '$NOTIFY_LOG' +EOF +chmod +x "$TMP_ROOT/wedge-recorder" + +cat > "$TMP_ROOT/daemon-entry" <<EOF +#!/usr/bin/env bash +export PATH='$FAKEBIN:$ORIGINAL_PATH' +export HERDR_SESSION='$SESSION' +export FM_STATE_OVERRIDE='$STATE' +export FM_ESCALATE_BATCH_SECS=0 +export FM_HOUSEKEEPING_TICK=1 +export FM_POLL=1 +export FM_SIGNAL_GRACE=1 +export FM_HEARTBEAT=999999 +export FM_CHECK_INTERVAL=999999 +export FM_MAX_DEFER_SECS=3 +export FM_STALE_ESCALATE_SECS=999999 +export FM_WEDGE_ALARM_EXEC='$TMP_ROOT/wedge-recorder' +exec '$ROOT/bin/fm-afk-start.sh' +EOF +chmod +x "$TMP_ROOT/daemon-entry" + +PRIMARY_OUT=$("$LAB_HELPER" run "$SESSION" workspace create --cwd "$PROJECT" --label synthetic-primary --no-focus) +WORKSPACE=$(printf '%s' "$PRIMARY_OUT" | jq -r '.result.workspace.workspace_id') +PRIMARY_PANE=$(printf '%s' "$PRIMARY_OUT" | jq -r '.result.root_pane.pane_id') +PRIMARY_TARGET="$SESSION:$PRIMARY_PANE" +EXT="$CAPTURE_EXT" +PI_CMD=$(printf 'exec env PI_CODING_AGENT_DIR=%q FM_HOME=%q FM_PI_CAPTURE_PATH=%q pi -e %q --no-context-files --no-session' "$PI_DIR" "$HOME_DIR" "$CAPTURE" "$EXT") +"$LAB_HELPER" run "$SESSION" pane run "$PRIMARY_PANE" "$PI_CMD" >/dev/null + +wait_for_idle() { + local stable=0 status _ + for _ in $(seq 1 240); do + status=$("$LAB_HELPER" run "$SESSION" agent get "$PRIMARY_PANE" 2>/dev/null \ + | jq -r '.result.agent.agent_status // empty' 2>/dev/null || true) + case "$status" in + idle|done|blocked) stable=$((stable + 1)); [ "$stable" -ge 4 ] && return 0 ;; + *) stable=0 ;; + esac + sleep 0.25 + done + return 1 +} + +wait_for_prompt() { # <jq predicate> + local predicate=$1 _ + for _ in $(seq 1 240); do + if [ -s "$CAPTURE" ] && jq -s -e "$predicate" "$CAPTURE" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +wait_for_idle || fail "real Pi primary did not become stably idle" + +assert_blocker_open() { + local at=$1 open + open=$(status_open_decisions "$STATE/repair-task.status") + printf '%s' "$open" | grep -F $'synthetic-dependency\tblocked\t' >/dev/null \ + || fail "live blocked decision disappeared $at: status=$(cat "$STATE/repair-task.status" 2>/dev/null)" +} + +CHILD_OUT=$("$LAB_HELPER" run "$SESSION" tab create --workspace "$WORKSPACE" --cwd "$PROJECT" --label fm-repair-task --no-focus) +CHILD_PANE=$(printf '%s' "$CHILD_OUT" | jq -r '.result.root_pane.pane_id') +CHILD_TARGET="$SESSION:$CHILD_PANE" +cat > "$STATE/repair-task.meta" <<EOF +window=$CHILD_TARGET +backend=herdr +kind=ship +mode=no-mistakes +worktree=$PROJECT +project=synthetic-project +EOF +cat > "$HOME_DIR/data/backlog.md" <<'EOF' +## In flight +- [ ] repair-task - Repair the synthetic dependency (repo: synthetic-project, since 2026-07-14) + +## Queued + +## Done +EOF + +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + "$ROOT/bin/fm-afk-launch.sh" start >/dev/null +DAEMON_STARTED=1 +for _ in $(seq 1 100); do [ -s "$STATE/.supervise-daemon.pid" ] && break; sleep 0.1; done +[ -s "$STATE/.supervise-daemon.pid" ] || fail "away daemon did not start" + +# Pending input is never an injection target. Leave a real draft in Pi before +# the live child emits blocked:, then wait through max-defer. +"$LAB_HELPER" run "$SESSION" pane send-text "$PRIMARY_PANE" 'privacy safe human draft' >/dev/null +sleep 0.5 +composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" fm_backend_composer_state herdr "$PRIMARY_TARGET") +[ "$composer" = pending ] || fail "real Pi draft did not classify pending (got $composer)" +CHILD_CMD=$(printf "printf 'blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token\\n' >> %q; exec sleep 120" "$STATE/repair-task.status") +"$LAB_HELPER" run "$SESSION" pane run "$CHILD_PANE" "$CHILD_CMD" >/dev/null +for _ in $(seq 1 160); do [ -s "$STATE/.subsuper-inject-wedged" ] && break; sleep 0.1; done +[ -s "$STATE/.subsuper-inject-wedged" ] || fail "persistently pending real Pi composer did not raise the defense-in-depth alarm" +[ -s "$STATE/.subsuper-escalations" ] || fail "pending real Pi composer lost the buffered blocker" +[ ! -s "$CAPTURE" ] || fail "daemon submitted into Pi while the real human draft was pending" +plain=$("$LAB_HELPER" run "$SESSION" pane read "$PRIMARY_PANE" --source recent --lines 200) +printf '%s' "$plain" | grep -F 'privacy safe human draft' >/dev/null || fail "pending Pi draft was modified or forcibly submitted" +for _ in $(seq 1 50); do [ -s "$NOTIFY_LOG" ] && break; sleep 0.1; done +[ -s "$NOTIFY_LOG" ] || fail "wedge alarm marker appeared but its active notifier did not finish" +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "wedge alarm was not observed exactly once before recovery" +assert_blocker_open 'while the Pi composer was pending' +pass "real Pi/Herdr pending composer refuses injection without forced submit and raises one observable fallback" + +# Clear, never submit, the synthetic human draft. The same exact target now has +# native idle state plus a complete Pi separator composer and must accept quickly. +"$LAB_HELPER" run "$SESSION" pane send-keys "$PRIMARY_PANE" ctrl+c >/dev/null +wait_for_idle || fail "real Pi did not return idle after clearing the draft" +for _ in $(seq 1 80); do + composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" fm_backend_composer_state herdr "$PRIMARY_TARGET") + [ "$composer" = empty ] && break + sleep 0.1 +done +[ "$composer" = empty ] || fail "genuinely idle Pi separator composer did not classify empty (got $composer)" +wait_for_prompt 'any(.[]; .prompt | startswith("\u2063Supervisor escalate"))' \ + || fail "real Pi did not receive the buffered escalation after becoming safely idle" +INJECT_HEX=$(jq -r 'select(.prompt | startswith("\u2063Supervisor escalate")) | .hex' "$CAPTURE" | tail -1) +case "$INJECT_HEX" in e281a3*) ;; *) fail "real Pi escalation lost the terminal-safe marker: $INJECT_HEX" ;; esac +for _ in $(seq 1 80); do [ ! -s "$STATE/.subsuper-escalations" ] && break; sleep 0.1; done +[ ! -s "$STATE/.subsuper-escalations" ] || fail "confirmed real Pi delivery did not clear the escalation buffer" +[ ! -e "$STATE/.subsuper-inject-wedged" ] || fail "confirmed real Pi delivery did not clear the old wedge marker" +sleep 4 +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "successful delivery emitted a duplicate wedge alert" +INJECT_PROMPT=$(jq -r 'select(.prompt | startswith("\u2063Supervisor escalate")) | .prompt' "$CAPTURE" | tail -1) +message_is_injection "$INJECT_PROMPT" || fail "terminal-delivered Pi escalation was not recognized as an internal marker" +assert_blocker_open 'after successful marked injection' +pass "real idle Pi/Herdr accepts one marked escalation promptly, verifies submit, clears wedge state, and emits no duplicate alert" + +# The captain returns with an ordinary unmarked Bearings request. The request is +# captured byte-exact, then the public return owner must gate it on the blocker. +wait_for_idle || fail "real Pi did not settle after the injected catch-up" +for _ in $(seq 1 80); do + composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" fm_backend_composer_state herdr "$PRIMARY_TARGET") + [ "$composer" = empty ] && break + sleep 0.1 +done +[ "$composer" = empty ] || fail "real Pi composer was not ready for the unmarked return request" +"$LAB_HELPER" run "$SESSION" pane send-text "$PRIMARY_PANE" 'Synthetic Bearings request' >/dev/null +"$LAB_HELPER" run "$SESSION" pane send-keys "$PRIMARY_PANE" enter >/dev/null +wait_for_prompt 'any(.[]; .prompt == "Synthetic Bearings request")' || fail "real Pi did not receive the unmarked return request" +RETURN_PROMPT=$(jq -r 'select(.prompt == "Synthetic Bearings request") | .prompt' "$CAPTURE" | tail -1) +should_exit_afk "$STATE" "$RETURN_PROMPT" || fail "unmarked Pi return request did not trigger the away exit contract" +assert_blocker_open 'before return catch-up' +[ -f "$STATE/repair-task.meta" ] || fail "live blocker metadata disappeared before return catch-up" + +set +e +RETURN_OUT=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" "$ROOT/bin/fm-afk-return.sh" begin 2>&1) +RETURN_RC=$? +set -e +DAEMON_STARTED=0 +[ "$RETURN_RC" -eq 3 ] || fail "return catch-up did not gate the still-live blocker (rc=$RETURN_RC): $RETURN_OUT" +assert_contains "$RETURN_OUT" 'firstmate-actionable blocker: repair-task [key=synthetic-dependency]' "return gate did not assign remediation" +set +e +BEARINGS_OUT=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + "$ROOT/bin/fm-bearings-snapshot.sh" --json 2>&1) +BEARINGS_RC=$? +set -e +[ "$BEARINGS_RC" -eq 3 ] || fail "Bearings bypassed the return gate (rc=$BEARINGS_RC): $BEARINGS_OUT" +pass "real unmarked Pi return opens catch-up and blocks Bearings before the unresolved blocker can be deferred" + +printf 'resolved [key=synthetic-dependency]: refreshed the synthetic token and resumed the task\n' >> "$STATE/repair-task.status" +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + "$ROOT/bin/fm-afk-return.sh" check >/dev/null || fail "remediated blocker did not clear return catch-up" +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + "$ROOT/bin/fm-bearings-snapshot.sh" --json >/dev/null || fail "Bearings remained gated after blocker remediation" + +# A clean re-entry creates no stale delivery or alert, and an immediate return is +# idempotently clear because the keyed blocker is resolved. +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + "$ROOT/bin/fm-afk-launch.sh" start >/dev/null +DAEMON_STARTED=1 +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" "$ROOT/bin/fm-afk-return.sh" begin >/dev/null \ + || fail "clean away re-entry/return was not idempotent" +DAEMON_STARTED=0 +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "clean re-entry duplicated the historical wedge alert" +pass "resolved return catch-up allows Bearings and a clean idempotent away re-entry" + +printf 'evidence: herdr=%s pi=%s target=%s inject-hex-prefix=%s notifier-count=1\n' \ + "$(herdr --version)" "$(pi --version)" "$PRIMARY_TARGET" "${INJECT_HEX:0:6}" diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh new file mode 100755 index 000000000..a7e1a6888 --- /dev/null +++ b/tests/fm-afk-return.test.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Deterministic return-catch-up gate regression. +# +# Covers the second half of the 2026-07-14 incident: an away-mode blocked event +# survived in durable state, but the ordinary return request could proceed to +# Bearings before Firstmate owned remediation. The shared script now stops, +# drains, preserves evidence, and refuses ordinary work until every live open +# `blocked:` event is resolved or durably reclassified. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-afk-return-tests) + +install_runner() { # <case-dir> + local dir=$1 + mkdir -p "$dir/bin" "$dir/home/state" "$dir/home/data" "$dir/home/config" + cp "$ROOT/bin/fm-afk-return.sh" "$dir/bin/" + cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/" + cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/" + cat > "$dir/bin/fm-afk-launch.sh" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = stop ] || exit 2 +printf 'stop\n' >> "$FM_HOME/stop.log" +rm -f "$FM_HOME/state/.afk" +if [ -e "$FM_HOME/state/.fail-terminal-stop-once" ]; then + rm -f "$FM_HOME/state/.fail-terminal-stop-once" + exit 1 +fi +rm -f "$FM_HOME/state/.afk-daemon-terminal" +SH + cat > "$dir/bin/fm-wake-drain.sh" <<'SH' +#!/usr/bin/env bash +file="$FM_HOME/state/.fake-drain" +[ -f "$file" ] && cat "$file" +: > "$file" +SH + chmod +x "$dir/bin/"*.sh +} + +run_return() { # <case-dir> <mode> + local dir=$1 mode=$2 + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$dir/bin/fm-afk-return.sh" "$mode" 2>&1 +} + +seed_live_blocker() { # <case-dir> <backend> <key> + local dir=$1 backend=$2 key=$3 target + case "$backend" in + tmux) target='synthetic:fm-repair-task' ;; + herdr) target='fm-lab-synthetic:w1:p2' ;; + esac + cat > "$dir/home/state/repair-task.meta" <<EOF +window=$target +backend=$backend +kind=ship +EOF + printf 'blocked [key=%s]: firstmate can refresh the synthetic token\n' "$key" > "$dir/home/state/repair-task.status" +} + +test_return_gate_orders_catchup_before_bearings() { + local dir out rc gate wake_count + dir="$TMP_ROOT/ordering" + install_runner "$dir" + seed_live_blocker "$dir" herdr synthetic-dependency + date +%s > "$dir/home/state/.afk" + printf 'repair-task.status: blocked synthetic dependency\n' > "$dir/home/state/.subsuper-escalations" + printf 'fm away-mode inject WEDGED: 4555s undelivered\n' > "$dir/home/state/.subsuper-inject-wedged" + printf '1784074271\t2\tsignal\trepair-task.status\tsignal: synthetic status\n' > "$dir/home/state/.fake-drain" + + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "return begin should gate on a live blocker (rc=$rc): $out" + gate="$dir/home/state/.afk-return-catchup" + [ -s "$gate" ] || fail "return begin did not persist its fail-closed catch-up gate" + assert_contains "$out" 'firstmate-actionable blocker: repair-task [key=synthetic-dependency]' "return output did not assign blocker remediation to Firstmate" + grep -F $'evidence\twake\t1784074271' "$gate" >/dev/null || fail "drained wake evidence was not retained in the durable gate" + grep -F $'evidence\twedge\tfm away-mode inject WEDGED: 4555s undelivered' "$gate" >/dev/null || fail "wedge evidence was not retained in the durable gate" + grep -F $'evidence\tescalation\trepair-task.status: blocked synthetic dependency' "$gate" >/dev/null || fail "buffered escalation evidence was not retained in the durable gate" + [ "$(wc -l < "$dir/home/stop.log" | tr -d ' ')" -eq 1 ] || fail "return begin did not stop away mode exactly once" + + # The exact incident regression: Bearings is an ordinary request and must + # refuse before reading/rendering while this shared gate remains open. + set +e + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$ROOT/bin/fm-bearings-snapshot.sh" --json 2>&1) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "Bearings should refuse behind the return gate (rc=$rc): $out" + assert_contains "$out" 'return catch-up is pending' "Bearings refusal did not point to the shared return owner" + + # Restart/re-entry is idempotent: no second stop, no duplicate catch-up line, + # and the same unresolved blocker remains authoritative. + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "repeated begin should preserve the unresolved gate" + [ "$(wc -l < "$dir/home/stop.log" | tr -d ' ')" -eq 1 ] || fail "repeated begin stopped an already-stopped daemon twice" + wake_count=$(grep -c $'^evidence\twake\t1784074271' "$gate" || true) + [ "$wake_count" -eq 1 ] || fail "repeated begin duplicated retained wake evidence ($wake_count copies)" + [ "$(grep -c $'^evidence\twedge\t' "$gate" || true)" -eq 1 ] || fail "repeated begin duplicated retained wedge evidence" + [ "$(grep -c $'^evidence\tescalation\t' "$gate" || true)" -eq 1 ] || fail "repeated begin duplicated retained escalation evidence" + + printf 'resolved [key=synthetic-dependency]: refreshed the synthetic token and resumed the task\n' >> "$dir/home/state/repair-task.status" + out=$(run_return "$dir" check) || fail "resolved blocker did not clear return catch-up: $out" + assert_contains "$out" 'catch-up clear' "successful check did not announce that ordinary work may proceed" + [ ! -e "$gate" ] || fail "successful check left the return gate behind" + [ ! -e "$dir/home/state/.subsuper-escalations" ] || fail "successful check left delivered escalation state behind" + [ ! -e "$dir/home/state/.subsuper-inject-wedged" ] || fail "successful check left the wedge marker behind" + + out=$(run_return "$dir" check) || fail "an already-clear repeated check should be idempotent: $out" + [ ! -e "$gate" ] || fail "idempotent clear check recreated a gate" + pass "return catch-up precedes Bearings, owns live blocker remediation, preserves evidence once, and clears idempotently" +} + +test_explicit_reclassification_requires_durable_reason() { + local backend dir out rc + for backend in tmux herdr; do + dir="$TMP_ROOT/reclassify-$backend" + install_runner "$dir" + seed_live_blocker "$dir" "$backend" vendor-release + date +%s > "$dir/home/state/.afk" + : > "$dir/home/state/.fake-drain" + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "$backend blocker did not open the return gate" + + # A pause alone cannot mask the keyed blocker. The old concern must be + # explicitly resolved with the durable reclassification reason first. + printf 'paused [key=vendor-release]: waiting for the synthetic vendor window\n' >> "$dir/home/state/repair-task.status" + set +e + out=$(run_return "$dir" check) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "$backend pause silently masked an unresolved blocked key" + + printf 'resolved [key=vendor-release]: reclassified as an external wait because the synthetic vendor owns the next event\n' >> "$dir/home/state/repair-task.status" + printf 'paused [key=vendor-release]: waiting for the synthetic vendor window\n' >> "$dir/home/state/repair-task.status" + out=$(run_return "$dir" check) || fail "$backend durable reclassification did not clear the return gate: $out" + [ ! -e "$dir/home/state/.afk-return-catchup" ] || fail "$backend reclassification left a gate behind" + done + pass "tmux and Herdr blockers require the same explicit durable reclassification before ordinary work" +} + +test_captain_decision_does_not_masquerade_as_firstmate_blocker() { + local dir out + dir="$TMP_ROOT/captain-decision" + install_runner "$dir" + cat > "$dir/home/state/decision-task.meta" <<'EOF' +window=synthetic:fm-decision-task +backend=tmux +kind=ship +EOF + printf 'needs-decision [key=api-shape]: captain must choose the synthetic API shape\n' > "$dir/home/state/decision-task.status" + date +%s > "$dir/home/state/.afk" + printf '1784074271\t1\tsignal\tdecision-task.status\tsignal: synthetic decision\n' > "$dir/home/state/.fake-drain" + out=$(run_return "$dir" begin) || fail "captain-owned decision should not be treated as a firstmate blocker: $out" + assert_contains "$out" 'catch-up wake:' "captain-owned decision wake was not surfaced in catch-up" + [ ! -e "$dir/home/state/.afk-return-catchup" ] || fail "captain-owned decision incorrectly opened a firstmate blocker gate" + pass "captain-owned needs-decision remains reportable without masquerading as a firstmate-actionable blocker" +} + +test_away_reentry_refuses_pending_return_gate() { + local dir out rc + dir="$TMP_ROOT/reentry" + mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" + printf 'schema\tfm-afk-return.v1\nphase\tblocked\n' > "$dir/home/state/.afk-return-catchup" + set +e + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$ROOT/bin/fm-afk-launch.sh" start-native 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "away re-entry succeeded while return catch-up was pending" + assert_contains "$out" 'return catch-up is still pending' "away re-entry refusal did not explain the pending owner" + [ ! -e "$dir/home/state/.afk" ] || fail "away re-entry wrote .afk despite the pending return gate" + pass "away-mode re-entry fails closed while the prior return catch-up is pending" +} + +test_check_retries_recorded_terminal_teardown() { + local dir gate out rc + dir="$TMP_ROOT/terminal-teardown" + install_runner "$dir" + gate="$dir/home/state/.afk-return-catchup" + date +%s > "$dir/home/state/.afk" + printf 'herdr\tsynthetic:pane\tsynthetic-workspace\n' > "$dir/home/state/.afk-daemon-terminal" + touch "$dir/home/state/.fail-terminal-stop-once" + + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "failed terminal teardown should keep return catch-up gated (rc=$rc): $out" + [ -e "$gate" ] || fail "failed terminal teardown cleared the return gate" + [ -e "$dir/home/state/.afk-daemon-terminal" ] || fail "failed terminal teardown discarded its durable record" + [ ! -e "$dir/home/state/.afk" ] || fail "failed terminal teardown did not preserve stop ordering" + + out=$(run_return "$dir" check) || fail "check did not retry recorded terminal teardown: $out" + [ ! -e "$dir/home/state/.afk-daemon-terminal" ] || fail "successful check left the terminal teardown record behind" + [ ! -e "$gate" ] || fail "successful terminal teardown retry left the return gate behind" + [ "$(wc -l < "$dir/home/stop.log" | tr -d ' ')" -eq 2 ] || fail "check did not retry terminal teardown exactly once" + pass "check retries recorded terminal teardown and keeps catch-up gated until success" +} + +test_return_gate_orders_catchup_before_bearings +test_explicit_reclassification_requires_durable_reason +test_captain_decision_does_not_masquerade_as_firstmate_blocker +test_away_reentry_refuses_pending_return_gate +test_check_retries_recorded_terminal_teardown diff --git a/tests/fm-backend-autodetect-smoke.test.sh b/tests/fm-backend-autodetect-smoke.test.sh index cade5db91..b4c8c887f 100755 --- a/tests/fm-backend-autodetect-smoke.test.sh +++ b/tests/fm-backend-autodetect-smoke.test.sh @@ -8,9 +8,9 @@ # directly), this suite drives the REAL bin/fm-spawn.sh and bin/fm-teardown.sh # end to end, because auto-detection is a fm-spawn-TIME decision, not an # adapter primitive - it has to be proven where fm_backend_name is actually -# called. Mirrors fm-backend-herdr-smoke.test.sh's isolated-session convention: -# a private, throwaway HERDR_SESSION, a scratch FM_HOME, and a scratch -# local-only project, never the captain's real herdr usage or fleet state. +# called. The real spawn runs in a helper-provisioned, per-run named Herdr lab +# session, with a scratch FM_HOME and scratch local-only project. Concurrent +# copies therefore never share the default session or a workspace namespace. # # The complementary "tmux nested inside herdr resolves to tmux, silently" case # is covered as a fast, deterministic fake-tmux fm-spawn.sh test in @@ -20,20 +20,15 @@ # manufacture; the selection LOGIC for that case is already exercised for real # by fm_backend_detect's own unit coverage plus that fake-tmux fm-spawn test. # -# Safety (2026-07-02 incident, see tests/herdr-test-safety.sh): cleanup uses -# ONLY herdr_safe_stop_and_delete, never a bare/inline-prefixed `herdr server -# stop` - that command killed the captain's live default herdr server twice in -# production because HERDR_SESSION-based targeting (env var OR inline prefix) -# is not reliably honored once another herdr server is already running. +# Safety (2026-07-02 incident): every test-owned Herdr operation goes through +# bin/fm-herdr-lab.sh, which appends the named session flag and verifies the +# default fleet session is unchanged after teardown. Never replace the helper +# with an ambient HERDR_SESSION-only command. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=tests/real-herdr-smoke-retry.sh -. "$ROOT/tests/real-herdr-smoke-retry.sh" -fm_real_herdr_smoke_retry_on_treehouse_contention "$@" - -fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } pass() { printf 'ok - %s\n' "$1"; } assert_contains_local() { # <haystack> <needle> <msg> case "$1" in @@ -46,8 +41,7 @@ command -v herdr >/dev/null 2>&1 || { echo "skip: herdr not found"; exit 0; } command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (required by fm-spawn.sh)"; exit 0; } -# shellcheck source=tests/herdr-test-safety.sh -. "$ROOT/tests/herdr-test-safety.sh" +export FM_GATE_REFUSE_BYPASS=1 # TMP_ROOT is physically resolved (mktemp -d "$(pwd -P)"-relative) to keep this # real-herdr smoke fixture free of unrelated OS symlink noise. @@ -57,17 +51,29 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (requi # The dedicated regression is # tests/fm-backend.test.sh:test_spawn_symlinked_project_prefix_avoids_false_refusal. TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-backend-autodetect-smoke.XXXXXX") -SESSION="fm-lab-autodetect-smoke-$$" -export HERDR_SESSION="$SESSION" +HERDR_LAB_HELPER="$ROOT/bin/fm-herdr-lab.sh" +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-autodetect-smoke-concurrency-h3) || { + rm -rf "$TMP_ROOT" + fail "could not generate an isolated Herdr lab session name" +} +export HERDR_SESSION="$HERDR_LAB_SESSION" ID="autodetectsmoke1" WT= cleanup_all() { + local cleanup_status=0 [ -n "$WT" ] && command -v treehouse >/dev/null 2>&1 && treehouse return --force "$WT" >/dev/null 2>&1 - herdr_safe_stop_and_delete "$SESSION" + "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || cleanup_status=$? rm -rf "$TMP_ROOT" + return "$cleanup_status" } -trap cleanup_all EXIT -fm_herdr_lab_prepare "$SESSION" || fail "could not prepare isolated Herdr lab session" +on_exit() { + local status=$? + cleanup_all || status=$? + trap - EXIT + exit "$status" +} +trap on_exit EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" || fail "could not provision isolated Herdr lab session" # --- scratch world: FM_HOME with NO backend config, one throwaway project --- @@ -104,9 +110,14 @@ META="$STATE/$ID.meta" [ -f "$META" ] || fail "fm-spawn.sh did not write a meta file for $ID" assert_contains_local "$(cat "$META")" "backend=herdr" \ "auto-detected spawn did not record backend=herdr in meta" -assert_contains_local "$(cat "$META")" "herdr_session=$SESSION" \ +assert_contains_local "$(cat "$META")" "herdr_session=$HERDR_LAB_SESSION" \ "auto-detected spawn did not record the isolated herdr_session in meta" -pass "real herdr: auto-detected spawn records backend=herdr and herdr_session/workspace/tab/pane fields in meta" + +WORKSPACE=$(grep '^herdr_workspace_id=' "$META" | cut -d= -f2-) +[ -n "$WORKSPACE" ] || fail "auto-detected spawn meta is missing herdr_workspace_id" + +TAB=$(grep '^herdr_tab_id=' "$META" | cut -d= -f2-) +[ -n "$TAB" ] || fail "auto-detected spawn meta is missing herdr_tab_id" WT=$(grep '^worktree=' "$META" | cut -d= -f2-) if [ -z "$WT" ] || [ ! -d "$WT" ]; then @@ -115,14 +126,14 @@ fi PANE=$(grep '^herdr_pane_id=' "$META" | cut -d= -f2-) [ -n "$PANE" ] || fail "auto-detected spawn meta is missing herdr_pane_id" +pass "real herdr: auto-detected spawn records backend=herdr and herdr_session/workspace/tab/pane fields in meta" # --- confirm the trivial launch command actually ran in the herdr pane ------ -# shellcheck source=bin/fm-backend.sh -. "$ROOT/bin/fm-backend.sh" -fm_backend_source herdr || fail "fm_backend_source herdr failed" sleep 1 -CAPTURED=$(fm_backend_herdr_capture "$SESSION:$PANE" 30) || fail "capture failed on the auto-detected herdr pane" +CAPTURED=$("$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" pane read "$PANE" --source recent --lines 200) || \ + fail "capture failed on the auto-detected herdr pane" +CAPTURED=$(printf '%s\n' "$CAPTURED" | tail -n 30) case "$CAPTURED" in *autodetect-smoke-ok*) : ;; *) fail "the raw launch command did not run in the auto-detected herdr pane"$'\n'"$CAPTURED" ;; @@ -138,11 +149,15 @@ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ status=$? [ "$status" -eq 0 ] || fail "fm-teardown.sh failed for the auto-detected herdr task"$'\n'"$(cat "$TEARDOWN_OUT")" [ -f "$META" ] && fail "fm-teardown.sh did not remove $META" -if herdr pane get "$PANE" --session "$SESSION" >/dev/null 2>&1; then +if "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" pane get "$PANE" >/dev/null 2>&1; then fail "fm-teardown.sh did not close the auto-detected herdr pane" fi WT= pass "real herdr: teardown completes the auto-detected spawn/teardown cycle (meta cleared, pane closed)" -cleanup_all +if ! cleanup_all; then + trap - EXIT + fail "isolated Herdr lab teardown failed or the default fleet session changed" +fi trap - EXIT +pass "real herdr: isolated lab session removed and default fleet session unchanged" diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 84f2b06bf..6e191b359 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -893,6 +893,75 @@ test_composer_state_unknown_when_no_composer_row_found() { pass "fm_backend_herdr_composer_state: reports unknown for bare shell prompts with no composer row" } +# Real Pi 0.80.7 on Herdr 0.7.3 renders no prompt glyph and no side border. +# Its content is the row(s) between two blue horizontal separators; the idle row +# carries only a reverse-video cursor. This exact shape was `unknown` for 4555s +# during the 2026-07-14 incident, so the safe injector never attempted submit. +test_composer_state_pi_separator_idle_is_empty() { + local dir log resp fb out calls + dir="$TMP_ROOT/composer-pi-separated-idle"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '│ stale bordered transcript row │\n\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n\x1b[0m\x1b[7m \x1b[0m \n\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n\x1b[0m\x1b[38;2;102;102;102m~/synthetic-primary (main)\x1b[0m\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = empty ] || fail "an idle native Pi separator composer should read empty, got '$out'" + calls=$(grep -c $'\x1f''agent'$'\x1f''get' "$log") + [ "$calls" -eq 1 ] || fail "Pi separator recognition must corroborate identity exactly once, made $calls agent calls" + pass "fm_backend_herdr_composer_state: a native idle Pi separator composer reads empty" +} + +test_composer_state_pi_separator_real_text_is_pending() { + local dir log resp fb out + dir="$TMP_ROOT/composer-pi-separated-pending"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\nprivacy safe human draft\x1b[7m \x1b[0m\n\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"done"}}}\n' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = pending ] || fail "real text in a native Pi separator composer should read pending, got '$out'" + pass "fm_backend_herdr_composer_state: real Pi composer text remains pending" +} + +test_composer_state_pi_incomplete_separator_below_stale_generic_is_unknown() { + local dir log resp fb out + dir="$TMP_ROOT/composer-pi-separated-incomplete"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '│ │\n─────────────────────────────────────────────────────\n\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = unknown ] || fail "an incomplete Pi separator below a stale generic row should remain unknown, got '$out'" + pass "fm_backend_herdr_composer_state: an incomplete lower Pi separator cannot inherit a stale empty row" +} + +test_composer_state_pi_separator_requires_safe_native_identity() { + local dir log resp fb out status case_id idx=0 + for case_id in working non-pi unreadable over-tall; do + dir="$TMP_ROOT/composer-pi-separated-$case_id"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + if [ "$case_id" = over-tall ]; then + { + printf '─────────────────────────────────────────────────────\n' + for idx in $(seq 1 9); do printf 'line %s\n' "$idx"; done + printf '─────────────────────────────────────────────────────\n' + } > "$resp/1.out" + else + printf '─────────────────────────────────────────────────────\n\n─────────────────────────────────────────────────────\n' > "$resp/1.out" + fi + case "$case_id" in + working) printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" ;; + non-pi) printf '{"result":{"agent":{"agent":"shell","agent_status":"idle"}}}\n' > "$resp/2.out" ;; + unreadable) printf '1\n' > "$resp/2.exit" ;; + over-tall) printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" ;; + esac + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = unknown ] || fail "unsafe Pi separator case '$case_id' must remain unknown, got '$out'" + done + pass "fm_backend_herdr_composer_state: Pi separators never authorize working, non-Pi, unreadable, or over-tall targets" +} + # --- composer_state: unbordered (bare) composer rows ------------------------- # Regression coverage for the away-mode redelivery-loop incident # (docs/herdr-backend.md "Incident (2026-07-07)"): real claude and codex @@ -2022,6 +2091,10 @@ test_composer_state_real_text_is_pending test_composer_state_popup_placeholder_fill_is_pending test_composer_state_unknown_on_capture_failure test_composer_state_unknown_when_no_composer_row_found +test_composer_state_pi_separator_idle_is_empty +test_composer_state_pi_separator_real_text_is_pending +test_composer_state_pi_incomplete_separator_below_stale_generic_is_unknown +test_composer_state_pi_separator_requires_safe_native_identity test_composer_state_claude_unbordered_prompt_is_empty test_composer_state_claude_unbordered_prompt_is_pending test_composer_state_bare_prompt_below_stale_bordered_banner_wins diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 38fd4a9c5..b1370fb56 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -75,6 +75,28 @@ SH chmod +x "$fb/tmux" } +add_compatible_tasks_axi_fake() { + local fb=$1 + cat > "$fb/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + --version) + printf '%s\n' '0.2.2' + ;; + update) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' + ;; + mv) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' + ;; + hold) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi hold <id> --reason <reason> --kind captain' + ;; +esac +SH + chmod +x "$fb/tasks-axi" +} + test_capture_reads_terminal_tail_json() { local out orca_case capture-tail @@ -531,7 +553,7 @@ test_spawn_refuses_orca_secondmate_before_home_mutation() { assert_contains "$out" "backend=orca does not support --secondmate spawns yet" \ "orca secondmate refusal should happen at backend selection" assert_absent "$subhome/config/crew-harness" \ - "orca secondmate refusal should not propagate inheritable config into the secondmate home" + "orca secondmate refusal should not propagate inherited local material into the secondmate home" pass "fm-spawn.sh --backend orca --secondmate: refuses before secondmate-home mutation" } @@ -788,8 +810,10 @@ test_scout_teardown_removes_orca_worktree_via_helper() { fm_write_meta "$state/$id.meta" \ "window=fm-$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ - "backend=orca" "orca_worktree_id=wt-teardown" + "backend=orca" "orca_worktree_id=wt-teardown" \ + "decisions_reviewed=1" "decision_keys=" orca_case teardown + add_compatible_tasks_axi_fake "$FB" printf '{"ok":true,"result":{"worktree":{"id":"wt-teardown","path":"%s"}}}\n' "$wt" > "$RESP/1.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -824,8 +848,10 @@ test_scout_teardown_refuses_orca_id_path_mismatch() { fm_write_meta "$state/$id.meta" \ "window=fm-$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ - "backend=orca" "orca_worktree_id=wt-scout-mismatch" + "backend=orca" "orca_worktree_id=wt-scout-mismatch" \ + "decisions_reviewed=1" "decision_keys=" orca_case scout-mismatch + add_compatible_tasks_axi_fake "$FB" printf '{"ok":true,"result":{"worktree":{"id":"wt-scout-mismatch","path":"%s"}}}\n' "$other_wt" > "$RESP/1.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -859,8 +885,10 @@ test_teardown_removes_orca_worktree_when_path_missing() { fm_write_meta "$state/$id.meta" \ "window=fm-$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ - "backend=orca" "orca_worktree_id=wt-missing-path" + "backend=orca" "orca_worktree_id=wt-missing-path" \ + "decisions_reviewed=1" "decision_keys=" orca_case missing-path + add_compatible_tasks_axi_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -891,9 +919,11 @@ test_teardown_preserves_metadata_when_orca_remove_error_json() { fm_write_meta "$state/$id.meta" \ "window=fm-$id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ - "backend=orca" "orca_worktree_id=wt-remove-error" + "backend=orca" "orca_worktree_id=wt-remove-error" \ + "decisions_reviewed=1" "decision_keys=" orca_case remove-error-teardown printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/1.out" + add_compatible_tasks_axi_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -1092,7 +1122,8 @@ test_teardown_refuses_orca_missing_worktree_id() { touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ "window=fm-$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ - "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" "backend=orca" + "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" "backend=orca" \ + "decisions_reviewed=1" "decision_keys=" orca_case missing-id neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -1123,8 +1154,10 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { fm_write_meta "$state/$id.meta" \ "window=fm-$id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ - "backend=orca" "orca_worktree_id=wt-no-terminal" + "backend=orca" "orca_worktree_id=wt-no-terminal" \ + "decisions_reviewed=1" "decision_keys=" orca_case no-terminal + add_compatible_tasks_axi_fake "$FB" printf '{"ok":true,"result":{"worktree":{"id":"wt-no-terminal","path":"%s"}}}\n' "$wt" > "$RESP/1.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 8c8e40127..ddbfad90f 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -86,6 +86,28 @@ zellij_assert_call_order() { [ "$before_line" -lt "$after_line" ] || fail "$msg" } +add_compatible_tasks_axi_fake() { + local fb=$1 + cat > "$fb/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + --version) + printf '%s\n' '0.2.2' + ;; + update) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' + ;; + mv) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' + ;; + hold) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi hold <id> --reason <reason> --kind captain' + ;; +esac +SH + chmod +x "$fb/tasks-axi" +} + # zellij_multi_tab_response: a list-tabs --json canned response holding # several tabs at once (tab_id/name pairs), for ambiguity/foreign-tag tests. zellij_multi_tab_response() { # <dir> <n> <tab1> <name1> [<tab2> <name2> ...] @@ -800,10 +822,13 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { "zellij_tab_id=3" \ "worktree=$dir/missing-worktree" \ "project=$project" \ - "kind=scout" + "kind=scout" \ + "decisions_reviewed=1" \ + "decision_keys=" printf '[]\n' > "$dir/responses/1.out" printf '[{"tab_id":3,"name":"fm-zghost"}]\n' > "$dir/responses/2.out" fb=$(make_zellij_fakebin "$dir") + add_compatible_tasks_axi_fake "$fb" out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ "$ROOT/bin/fm-teardown.sh" zghost 2>&1 ) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 293899e5b..74b1487dd 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -110,7 +110,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh fm-ack-lib.sh fm-freeze-lib.sh fm-home-guard-lib.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-freeze-lib.sh fm-ack-lib.sh fm-backend.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh" build_old_bin() { # <name> -> echoes root dir (root/bin/<script> is the entry point) @@ -637,20 +637,25 @@ strip_send_preflight() { # <log> } test_send_conformance_old_vs_new() { - local old_bin fb log_old log_new home rc_old rc_new + local old_bin fb log_old log_new home rc_old rc_new filtered_old filtered_new old_bin=$(build_old_bin send-old) fb=$(make_send_fakebin "$TMP_ROOT/send-fake") home="$TMP_ROOT/send-home"; mkdir -p "$home/state" log_old="$TMP_ROOT/send-old.log"; log_new="$TMP_ROOT/send-new.log" + filtered_old="$TMP_ROOT/send-old.filtered.log"; filtered_new="$TMP_ROOT/send-new.filtered.log" # Case 1: --key path. run_send_case "$old_bin" "$fb" "$log_old" "$home" -- "sess:win" --key Escape rc_old=$? run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" --key Escape rc_new=$? - expect_code 0 "$rc_new" "fm-send --key: explicit live tmux target should succeed" + expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" assert_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ "fm-send --key did not verify the explicit tmux target before sending" + strip_send_preflight "$log_old" > "$filtered_old" + strip_send_preflight "$log_new" > "$filtered_new" + diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ + || fail "fm-send --key: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-key.txt")" assert_contains "$(cat "$log_new")" $'\x1f''Escape' "fm-send --key did not send the named key" # Case 2: plain text (0.3s settle, no popup). @@ -658,7 +663,11 @@ test_send_conformance_old_vs_new() { rc_old=$? run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" hello captain rc_new=$? - expect_code 0 "$rc_new" "fm-send plain text: explicit live tmux target should succeed" + expect_code "$rc_old" "$rc_new" "fm-send plain text: old vs new exit code" + strip_send_preflight "$log_old" > "$filtered_old" + strip_send_preflight "$log_new" > "$filtered_new" + diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-plain.txt" 2>&1 \ + || fail "fm-send plain text: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-plain.txt")" assert_contains "$(cat "$log_new")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''hello captain' \ "fm-send did not send the literal text with send-keys -l" assert_contains "$(cat "$log_new")" $'\x1f''Enter' "fm-send did not submit with Enter" @@ -670,10 +679,11 @@ test_send_conformance_old_vs_new() { rc_old=$? run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" /some-skill rc_new=$? - expect_code 0 "$rc_new" "fm-send /skill: explicit live tmux target should succeed" - assert_contains "$(cat "$log_new")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''/some-skill' \ - "fm-send /skill did not send literal slash command text" - assert_contains "$(cat "$log_new")" $'\x1f''Enter' "fm-send /skill did not submit with Enter" + expect_code "$rc_old" "$rc_new" "fm-send /skill: old vs new exit code" + strip_send_preflight "$log_old" > "$filtered_old" + strip_send_preflight "$log_new" > "$filtered_new" + diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-slash.txt" 2>&1 \ + || fail "fm-send /skill: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-slash.txt")" pass "fm-send.sh: explicit tmux targets are verified, while --key/plain/slash send command shape stays old-compatible" } @@ -878,7 +888,24 @@ set -u { printf 'treehouse'; for a in "$@"; do printf '\x1f%s' "$a"; done; printf '\n'; } >> "${FM_TMUX_LOG:?}" exit 0 SH - chmod +x "$fb/tmux" "$fb/treehouse" + cat > "$fb/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + --version) + printf '%s\n' '0.2.2' + ;; + update) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' + ;; + mv) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' + ;; + hold) + [ "${2:-}" = --help ] && printf '%s\n' 'usage: tasks-axi hold <id> --reason <reason> --kind captain' + ;; +esac +SH + chmod +x "$fb/tmux" "$fb/treehouse" "$fb/tasks-axi" printf '%s\n' "$fb" } @@ -915,9 +942,11 @@ test_teardown_conformance_old_vs_new() { mkdir -p "$state_old" "$state_new" "$config_old" "$config_new" fm_write_meta "$state_old/$id.meta" \ - "window=firstmate:fm-$id" "worktree=$wt" "project=$proj" "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" + "window=firstmate:fm-$id" "worktree=$wt" "project=$proj" "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ + "decisions_reviewed=1" "decision_keys=" fm_write_meta "$state_new/$id.meta" \ - "window=firstmate:fm-$id" "worktree=$wt" "project=$proj" "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" + "window=firstmate:fm-$id" "worktree=$wt" "project=$proj" "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ + "decisions_reviewed=1" "decision_keys=" touch "$state_old/.last-watcher-beat" "$state_new/.last-watcher-beat" log_old="$TMP_ROOT/teardown-old.log"; log_new="$TMP_ROOT/teardown-new.log" diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 4c2a84cf9..0f19b2378 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -23,13 +23,19 @@ make_fakebin() { # <dir> fb=$(fm_fakebin "$1") cat > "$fb/no-mistakes" <<'SH' #!/usr/bin/env bash +[ "${FAKE_NM_SLEEP:-0}" = 1 ] && sleep 30 exit 0 SH cat > "$fb/tmux" <<'SH' #!/usr/bin/env bash case "${1:-}" in display-message) case "$*" in *dead-*) exit 1 ;; *) printf '%%1\n' ;; esac ;; - capture-pane) printf 'all quiet\n> \n' ;; + capture-pane) + case "$*" in + *fm-domain-alpha*) printf 'stale terminal summary: Phase 7 started\n> \n' ;; + *) printf 'all quiet\n> \n' ;; + esac + ;; esac exit 0 SH @@ -51,24 +57,39 @@ SH cat > "$fb/gh-axi" <<'SH' #!/usr/bin/env bash echo "gh-axi $*" >> "$NET_LOG" +[ "${FAKE_GH_FAIL:-0}" = 1 ] && exit 1 exit 0 SH - chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/gh" "$fb/gh-axi" + cat > "$fb/curl" <<'SH' +#!/usr/bin/env bash +echo "curl $*" >> "$NET_LOG" +exit 1 +SH + chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/gh" "$fb/gh-axi" "$fb/curl" printf '%s\n' "$fb" } make_home() { # <name> local home=$TMP_ROOT/$1 - mkdir -p "$home/state" "$home/data" "$home/projects" "$home/config" "$home/secondmate-home" + mkdir -p "$home/state" "$home/data" "$home/projects" "$home/config" printf '%s\n' "$home" } +fixture_mate_home() { # <parent-home> + printf '%s/%s-secondmate-home\n' "$TMP_ROOT" "$(basename "$1")" +} + # Standard fixture: a ship task with a recorded PR, a scout task with a report, a # secondmate with a MASKED open decision (needs-decision then a later unrelated # done), and a backlog with a superseded queued item. write_fixture() { # <home> - local home=$1 - mkdir -p "$home/projects/ship-wt" "$home/data/scout-x" + local home=$1 mate + mate=$(fixture_mate_home "$home") + mkdir -p "$home/projects/ship-wt" "$home/data/scout-x" "$mate/data" "$mate/state" "$mate/config" "$mate/projects" "$mate/bin" + printf '# Firstmate fixture\n' > "$mate/AGENTS.md" + printf 'mate\n' > "$mate/.fm-secondmate-home" + printf -- '- mate - fixture domain (home: %s; scope: fixture work; projects: firstmate; added 2026-07-11)\n' \ + "$mate" > "$home/data/secondmates.md" cat > "$home/data/backlog.md" <<EOF ## In flight - [ ] ship-task - Ship the thing (repo: firstmate) (kind: ship) (since 2026-07-11) @@ -102,12 +123,12 @@ EOF printf 'done: report ready\n' > "$home/state/scout-x.status" fm_write_meta "$home/state/mate.meta" \ "window=firstmate:fm-mate" \ - "worktree=$home/secondmate-home" \ - "project=$home/secondmate-home" \ + "worktree=$mate" \ + "project=$mate" \ "harness=codex" \ "kind=secondmate" \ "mode=secondmate" \ - "home=$home/secondmate-home" \ + "home=$mate" \ "projects=firstmate" printf 'needs-decision [key=race]: pick subscribe order\n' > "$home/state/mate.status" printf 'done: an unrelated subtask finished\n' >> "$home/state/mate.status" @@ -122,11 +143,21 @@ EOF # The secondmate's OWN home backlog records a merge it managed. This lands in the # secondmate home, never the main backlog, so landed-work views only see it via the # bounded cross-home Done roll-up. - mkdir -p "$home/secondmate-home/data" - cat > "$home/secondmate-home/data/backlog.md" <<'EOF' + cat > "$mate/data/backlog.md" <<'EOF' +## In flight +- [ ] mate - Decide subscription order (repo: firstmate) (kind: ship) (since 2026-07-11) + +## Queued +- [ ] mate-decision-race - Choose subscription order (repo: firstmate) (kind: captain) (hold: captain choice pending) (hold-kind: captain) + ## Done - [x] mate-landed - Secondmate-managed fix https://github.com/kunchenguid/firstmate/pull/50 (repo: firstmate) (kind: ship) (merged 2026-07-11) EOF + mkdir -p "$mate/projects/mate" + fm_write_meta "$mate/state/mate.meta" \ + "window=firstmate:fm-mate" "worktree=$mate/projects/mate" "project=firstmate" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'needs-decision [key=race]: pick subscribe order\n' > "$mate/state/mate.status" } run() { # <home> <fakebin> <args...> @@ -134,6 +165,660 @@ run() { # <home> <fakebin> <args...> PATH="$fakebin:$PATH" FM_HOME="$home" FM_BEARINGS_NOW=2026-07-11T18:00:00Z NET_LOG="$home/net.log" "$BEARINGS" "$@" } +# End-to-end Domain Alpha regression fixture. +# The parent event claims Phase 7 started, while the registered home has no child +# metadata, every sample-rollout item is Done, and only an external legal hold remains. +write_domain_alpha_fixture() { # <parent-home> <secondmate-home> + local home=$1 mate=$2 i + mkdir -p "$mate/state" "$mate/data" "$mate/config" "$mate/projects" "$mate/bin" + printf '# Firstmate fixture\n' > "$mate/AGENTS.md" + printf 'domain-alpha\n' > "$mate/.fm-secondmate-home" + printf -- '- domain-alpha - sample rollout (home: %s; scope: sample rollout and legal release; projects: sample; added 2026-07-13)\n' \ + "$mate" > "$home/data/secondmates.md" + fm_write_secondmate_meta "$home/state/domain-alpha.meta" "$mate" "firstmate:fm-domain-alpha" sample + printf 'working [key=phase7]: Phase 7 started\n' > "$home/state/domain-alpha.status" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] legal-release - Release approval blocked-by: external-legal - external legal dependency (repo: sample) (kind: ship) + +## Done +EOF + i=1 + while [ "$i" -le 7 ]; do + printf -- '- [x] phase%s - Sample rollout Phase %s (repo: sample) (kind: ship) (done 2026-07-%02d)\n' \ + "$i" "$i" "$i" >> "$mate/data/backlog.md" + i=$((i + 1)) + done +} + +# This is the Domain Alpha failure shape exactly: the structured home says Phase 7 is Done +# and no child is active, so the stale parent event must never become Underway. +test_domain_alpha_stale_parent_event_does_not_become_current_work() { + local home mate fakebin json canonical + home=$(make_home domain-alpha-parent) + mate="$TMP_ROOT/domain-alpha-home" + write_domain_alpha_fixture "$home" "$mate" + fakebin=$(make_fakebin "$home"); : > "$home/net.log" + json=$(FAKE_GH_FAIL=1 run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.in_flight | any(.[]; .id == "domain-alpha") | not) + and (.secondmates | any(.[]; + .id == "domain-alpha" + and .state == "externally_held" + and .provenance == "structured-home" + and .freshness == "fresh" + and .contradiction == true)) + and (.gates | any(.[]; .id == "legal-release" and .owner == "domain-alpha")) + and (.landed | any(.[]; .id == "phase7" and .owner == "domain-alpha")) + ' >/dev/null || fail "stale parent Phase 7 event overrode authoritative Domain Alpha state: $json" + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_NOW_EPOCH=1783792800 FM_SNAPSHOT_TERMINAL_LINES=2 FM_SNAPSHOT_TERMINAL_BYTES=64 \ + NET_LOG="$home/net.log" FAKE_GH_FAIL=1 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "domain-alpha") + | .provenance.selected == "structured-home" + and .freshness.status == "fresh" + and .terminal_evidence.provenance == "parent-direct-report-terminal" + and .terminal_evidence.trust == "untrusted-supplement" + and .terminal_evidence.captured == true + and .terminal_evidence.lines == 2 + and .terminal_evidence.bytes <= 64 + and (.terminal_evidence | has("content") | not) + and .terminal_evidence.event_note_seen == true + and .terminal_evidence.contradiction == true + and .contradiction == true + ' >/dev/null || fail "bounded terminal contradiction evidence was not labeled and subordinate: $canonical" + [ ! -s "$home/net.log" ] || fail "Domain Alpha structured-home read made a network call: $(cat "$home/net.log")" + pass "Domain Alpha structured state overrides a stale parent Phase 7 event" +} + +test_gnu_stat_uses_file_formats_without_bsd_fallback_pollution() { + local home mate fakebin canonical stat_log + home=$(make_home gnu-stat-parent) + mate="$TMP_ROOT/gnu-stat-home" + write_domain_alpha_fixture "$home" "$mate" + fakebin=$(make_fakebin "$home") + stat_log="$home/stat.log" + cat > "$fakebin/uname" <<'SH' +#!/usr/bin/env bash +printf 'Linux\n' +SH + cat > "$fakebin/stat" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$STAT_LOG" +case "$1 $2" in + '-c %a') printf '600\n' ;; + '-c %Y') printf '1783792800\n' ;; + '-c %s') LC_ALL=C wc -c < "$3" | tr -d ' ' ;; + -f\ *) + printf ' File: "%s"\nBlocks: Total: 1\n' "$2" + exit 1 + ;; + *) exit 2 ;; +esac +SH + chmod +x "$fakebin/uname" "$fakebin/stat" + canonical=$(PATH="$fakebin:$PATH" STAT_LOG="$stat_log" FM_HOME="$home" \ + FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z FM_SNAPSHOT_NOW_EPOCH=1783792800 \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "domain-alpha") + | .provenance.selected == "structured-home" + and .parent_event.activity_scan.available == true + ' >/dev/null || fail "GNU stat fixture corrupted the authoritative secondmate summary: $canonical" + assert_contains "$(cat "$stat_log")" '-c %a' "GNU registry mode must use stat -c" + assert_contains "$(cat "$stat_log")" '-c %Y' "GNU parent-event mtime must use stat -c" + assert_contains "$(cat "$stat_log")" '-c %s' "GNU parent-event size must use stat -c" + if grep -q '^-f ' "$stat_log"; then + fail "GNU snapshot invoked BSD stat -f before its GNU file reads: $(cat "$stat_log")" + fi + pass "GNU stat file reads select -c without BSD filesystem-report pollution" +} + +test_parent_activity_evidence_is_bounded_and_disclosed() { + local home mate fakebin canonical json i + home=$(make_home bounded-parent-activity) + mate="$TMP_ROOT/bounded-parent-activity-home" + write_domain_alpha_fixture "$home" "$mate" + : > "$home/state/domain-alpha.status" + i=1 + while [ "$i" -le 6 ]; do + printf 'working [key=phase%s]: Phase %s started\n' "$i" "$i" >> "$home/state/domain-alpha.status" + i=$((i + 1)) + done + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_PARENT_ACTIVITY_LINES=4 FM_SNAPSHOT_PARENT_ACTIVITY_BYTES=4096 \ + FM_SNAPSHOT_PARENT_ACTIVITIES=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "domain-alpha") + | .parent_event.activity_scan.available == true + and .parent_event.activity_scan.input_truncated == true + and .parent_event.activity_scan.retained_truncated == true + and .parent_event.activity_scan.lines_in_window == 4 + and .parent_event.activity_scan.records_in_window == 4 + and .parent_event.activity_scan.reasons == ["line_limit", "activity_limit"] + and (.parent_event.open_activities | map(.key)) == ["phase5", "phase6"] + ' >/dev/null || fail "parent activity evidence was not bounded and disclosed: $canonical" + json=$(FM_SNAPSHOT_PARENT_ACTIVITY_LINES=4 FM_SNAPSHOT_PARENT_ACTIVITY_BYTES=4096 \ + FM_SNAPSHOT_PARENT_ACTIVITIES=2 run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + .omitted | any(.surface == "secondmate parent activity evidence truncated for 1 record(s)") + ' >/dev/null || fail "bearings did not disclose bounded parent activity evidence: $json" + pass "parent activity evidence is bounded and disclosed" +} + +test_active_child_overrides_old_parent_event() { + local home mate fakebin json canonical + home=$(make_home active-child-parent) + mate="$TMP_ROOT/active-child-home" + write_domain_alpha_fixture "$home" "$mate" + mkdir -p "$mate/projects/phase8" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight +- [ ] phase8 - Sample rollout Phase 8 (repo: sample) (kind: ship) (since 2026-07-13) + +## Queued + +## Done +- [x] phase7 - Sample rollout Phase 7 (repo: sample) (kind: ship) (done 2026-07-12) +EOF + fm_write_meta "$mate/state/phase8.meta" \ + "window=firstmate:fm-phase8" "worktree=$mate/projects/phase8" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'working [key=phase8]: implementing Phase 8 parity\nneeds-decision [key=release]: choose release A or B\n' \ + > "$mate/state/phase8.status" + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.secondmates | any(.[]; .id == "domain-alpha" and .state != "captain_decision" + and (.doing | contains("release A or B") | not))) + and (.decisions_open | any(.owner == "domain-alpha") | not) + ' >/dev/null || fail "status-only child decision leaked into Bearings: $json" + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "domain-alpha") | .endpoints[] | select(.id == "phase8") + | .endpoint.status == "unknown" + and .endpoint.exists == true + and .endpoint.freshness == "fresh" + and .endpoint.observed_at == "2026-07-11T18:00:00Z" + ' >/dev/null || fail "child endpoint observation lacked bounded current freshness: $canonical" + pass "Bearings excludes a status-only child decision" +} + +test_structured_child_decision_reaches_captains_call() { + local home mate fakebin json + home=$(make_home child-decision-parent) + mate="$TMP_ROOT/child-decision-home" + write_domain_alpha_fixture "$home" "$mate" + mkdir -p "$mate/projects/phase8" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight +- [ ] phase8 - Sample rollout Phase 8 (repo: sample) (kind: ship) (since 2026-07-13) + +## Queued +- [ ] phase8-decision-release - Choose sample release (repo: sample) (kind: captain) (hold: captain release choice pending) (hold-kind: captain) + +## Done +- [x] phase7 - Sample rollout Phase 7 (repo: sample) (kind: ship) (done 2026-07-12) +EOF + fm_write_meta "$mate/state/phase8.meta" \ + "window=firstmate:fm-phase8" "worktree=$mate/projects/phase8" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'needs-decision [key=release]: choose release A or B\n' > "$mate/state/phase8.status" + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.secondmates | any(.[]; .id == "domain-alpha" and .state == "captain_decision")) + and (.decisions_open | any(.[]; .id == "domain-alpha/phase8-decision-release" + and .key == "phase8-decision-release" and .verb == "captain-hold")) + and (.in_flight | any(.[]; .id == "domain-alpha") | not) + ' >/dev/null || fail "structured child captain hold did not reach Captain Call: $json" + pass "a structured child captain hold reaches Captain's Call" +} + +make_valid_secondmate_home() { # <id> <home> + local id=$1 home=$2 + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" "$home/bin" + printf '# Firstmate fixture\n' > "$home/AGENTS.md" + printf '%s\n' "$id" > "$home/.fm-secondmate-home" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF +} + +append_secondmate_registry() { # <parent> <id> <home> + printf -- '- %s - fixture domain (home: %s; scope: fixture; projects: sample; added 2026-07-13)\n' \ + "$2" "$3" >> "$1/data/secondmates.md" +} + +append_landed_row() { # <secondmate-home> <id> <title> <date> + printf -- '- [x] %s - %s (repo: firstmate) (kind: ship) (merged %s)\n' \ + "$2" "$3" "$4" >> "$1/data/backlog.md" +} + +make_landed_secondmate() { # <parent> <id> + local parent=$1 id=$2 mate + mate="$TMP_ROOT/$(basename "$parent")-$id-home" + make_valid_secondmate_home "$id" "$mate" + append_secondmate_registry "$parent" "$id" "$mate" + printf '%s\n' "$mate" +} + +write_parent_secondmate_event() { # <parent> <id> <home> <note> + fm_write_secondmate_meta "$1/state/$2.meta" "$3" "firstmate:fm-$2" sample + printf 'working [key=%s]: %s\n' "$2" "$4" > "$1/state/$2.status" +} + +test_bad_secondmate_homes_never_revive_parent_work() { + local home fakebin missing invalid unreadable malformed timedout wt json + home=$(make_home bad-homes) + : > "$home/data/secondmates.md" + missing="$TMP_ROOT/missing-home" + invalid="$TMP_ROOT/invalid-home" + unreadable="$TMP_ROOT/unreadable-home" + malformed="$TMP_ROOT/malformed-home" + timedout="$TMP_ROOT/timedout-home" + + append_secondmate_registry "$home" missing "$missing" + + make_valid_secondmate_home invalid "$invalid" + printf 'someone-else\n' > "$invalid/.fm-secondmate-home" + append_secondmate_registry "$home" invalid "$invalid" + write_parent_secondmate_event "$home" invalid "$invalid" "old invalid work" + + make_valid_secondmate_home unreadable "$unreadable" + chmod 000 "$unreadable/data" + append_secondmate_registry "$home" unreadable "$unreadable" + write_parent_secondmate_event "$home" unreadable "$unreadable" "old unreadable work" + + make_valid_secondmate_home malformed "$malformed" + printf '## In flight\nthis current row is not structured\n' > "$malformed/data/backlog.md" + append_secondmate_registry "$home" malformed "$malformed" + write_parent_secondmate_event "$home" malformed "$malformed" "old malformed work" + + make_valid_secondmate_home timedout "$timedout" + wt="$timedout/projects/slow" + fm_git_init_commit "$wt" + git -C "$wt" checkout -q -b fm/slow + printf '## In flight\n- [ ] slow - Slow child (repo: sample) (kind: ship) (since 2026-07-13)\n\n## Queued\n\n## Done\n' > "$timedout/data/backlog.md" + fm_write_meta "$timedout/state/slow.meta" \ + "window=firstmate:fm-slow" "worktree=$wt" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + append_secondmate_registry "$home" timedout "$timedout" + write_parent_secondmate_event "$home" timedout "$timedout" "old timed work" + + fakebin=$(make_fakebin "$home") + json=$(FAKE_NM_SLEEP=1 FM_SNAPSHOT_SECONDMATE_TIMEOUT=1 run "$home" "$fakebin" --json) + chmod 700 "$unreadable/data" + printf '%s' "$json" | jq -e ' + (.secondmates | length) == 5 + and all(.secondmates[]; .state == "unknown") + and (.in_flight | map(.id) | all(. != "invalid" and . != "unreadable" and . != "malformed" and . != "timedout")) + and (.secondmates | any(.[]; .id == "missing" and .provenance == "unknown" + and .freshness == "unknown" and (.reason | contains("invalid home")))) + and ([.secondmates[] | select(.id != "missing")] + | all(.provenance == "parent-event-fallback" and .freshness == "historical-event")) + and (.secondmates | any(.[]; .id == "invalid" and (.reason | contains("marked for")))) + and (.secondmates | any(.[]; .id == "unreadable" and (.reason | test("invalid home|unreadable")))) + and (.secondmates | any(.[]; .id == "malformed" and (.reason | contains("unstructured current backlog row")))) + and (.secondmates | any(.[]; .id == "timedout" and (.reason | contains("timed out")))) + ' >/dev/null || fail "bad home outcomes revived stale work or lacked provenance: $json" + pass "missing, invalid, unreadable, malformed, and timed-out homes stay explicit unknowns" +} + +test_secondmate_and_child_bounds_are_disclosed() { + local home fakebin id mate child json expanded canonical i + home=$(make_home secondmate-bounds) + : > "$home/data/secondmates.md" + for id in a b c; do + mate="$TMP_ROOT/bounds-$id" + make_valid_secondmate_home "$id" "$mate" + append_secondmate_registry "$home" "$id" "$mate" + done + mate="$TMP_ROOT/bounds-a" + : > "$mate/data/backlog.md" + printf '## In flight\n' >> "$mate/data/backlog.md" + i=1 + while [ "$i" -le 3 ]; do + child="child-$i" + mkdir -p "$mate/projects/$child" + printf -- '- [ ] %s - Active %s (repo: sample) (kind: ship) (since 2026-07-13)\n' "$child" "$child" >> "$mate/data/backlog.md" + fm_write_meta "$mate/state/$child.meta" \ + "window=firstmate:fm-$child" "worktree=$mate/projects/$child" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'working [key=%s]: active child %s\n' "$child" "$i" > "$mate/state/$child.status" + i=$((i + 1)) + done + printf '\n## Queued\n\n## Done\n' >> "$mate/data/backlog.md" + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_SECONDMATES=2 FM_SNAPSHOT_SECONDMATE_CHILDREN=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.total_registered == 3 + and .secondmate_current.shown == 2 + and .secondmate_current.truncated == 1 + and (.secondmate_current.records[] | select(.id == "a") + | .counts.active_children == 3 and (.active_children | length) == 2 + and (.omitted | any(.surface == "active_children" and .count == 1))) + and (.secondmate_current.records | any(.id == "b" and .current.state == "no_active_work")) + ' >/dev/null || fail "canonical secondmate or child bounds were not enforced: $canonical" + json=$(FM_SNAPSHOT_SECONDMATES=2 FM_SNAPSHOT_SECONDMATE_CHILDREN=2 FM_BEARINGS_SECONDMATES=1 \ + run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.secondmates | length) == 1 + and ([.omitted[].surface] | any(test("secondmates showing 1 of 2"))) + and ([.omitted[].surface] | any(test("registered secondmates omitted by snapshot bound: 1"))) + ' >/dev/null || fail "bearings secondmate bound was not disclosed: $json" + expanded=$(FM_SNAPSHOT_SECONDMATE_CHILDREN=2 FM_BEARINGS_SECONDMATES=1 \ + run "$home" "$fakebin" --json --all-secondmates) + printf '%s' "$expanded" | jq -e ' + (.secondmates | length) == 3 + and ([.omitted[].surface] | any(test("secondmates showing|registered secondmates omitted")) | not) + ' >/dev/null || fail "--all-secondmates did not expand the canonical and bearings bounds: $expanded" + pass "secondmate and per-home child counts are bounded, disclosed, and explicitly expandable" +} + +test_parent_decision_is_untrusted_contradiction_only() { + local home mate fakebin canonical json + home=$(make_home parent-decision-only) + mate="$TMP_ROOT/parent-decision-only-home" + make_valid_secondmate_home authority "$mate" + append_secondmate_registry "$home" authority "$mate" + fm_write_secondmate_meta "$home/state/authority.meta" "$mate" "firstmate:fm-authority" sample + printf 'needs-decision [key=stale]: old parent question\n' > "$home/state/authority.status" + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "authority") + | .current.state == "no_active_work" + and .decisions_open == [] + and .contradiction == true + and (.parent_event.open_decisions | any(.key == "stale" and .verb == "needs-decision")) + ' >/dev/null || fail "parent decision crossed structured-home authority boundary: $canonical" + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.secondmates | any(.[]; .id == "authority" and .state == "no_active_work" and .contradiction == true)) + and (.decisions_open | any(.[]; .id == "authority") | not) + ' >/dev/null || fail "bearings promoted a stale parent decision: $json" + pass "parent decisions remain untrusted contradiction evidence" +} + +test_parent_evidence_reconciles_by_verb_and_key() { + local home hold blocked decision fakebin canonical mate child + home=$(make_home keyed-parent-evidence) + hold="$TMP_ROOT/keyed-parent-hold-home" + blocked="$TMP_ROOT/keyed-parent-blocked-home" + decision="$TMP_ROOT/keyed-parent-decision-home" + make_valid_secondmate_home hold "$hold" + make_valid_secondmate_home blocked "$blocked" + make_valid_secondmate_home decision "$decision" + append_secondmate_registry "$home" hold "$hold" + append_secondmate_registry "$home" blocked "$blocked" + append_secondmate_registry "$home" decision "$decision" + fm_write_secondmate_meta "$home/state/hold.meta" "$hold" "firstmate:fm-hold" sample + fm_write_secondmate_meta "$home/state/blocked.meta" "$blocked" "firstmate:fm-blocked" sample + fm_write_secondmate_meta "$home/state/decision.meta" "$decision" "firstmate:fm-decision" sample + printf 'working [key=stale-work]: old work still running\n' > "$home/state/hold.status" + printf 'paused [key=legal-release]: waiting for legal release\n' >> "$home/state/hold.status" + printf 'paused: legacy pause without an identity\n' >> "$home/state/hold.status" + printf 'blocked [key=vendor-release]: waiting for vendor release\n' > "$home/state/blocked.status" + printf 'blocked: legacy block without an identity\n' >> "$home/state/blocked.status" + printf 'needs-decision [key=stale-route]: choose the old route\n' > "$home/state/decision.status" + printf 'working: legacy work without an identity\n' >> "$home/state/decision.status" + cat > "$hold/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] legal-release - Legal release blocked-by: external-legal - legal review (repo: sample) (kind: ship) + +## Done +EOF + cat > "$blocked/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] vendor-release - Vendor release blocked-by: external-vendor - vendor review (repo: sample) (kind: ship) + +## Done +EOF + child='decision-child' + mkdir -p "$decision/projects/$child" + cat > "$decision/data/backlog.md" <<EOF +## In flight +- [ ] $child - Decision child (repo: sample) (kind: ship) (since 2026-07-11) + +## Queued + +## Done +EOF + fm_write_meta "$decision/state/$child.meta" \ + "window=firstmate:fm-$child" "worktree=$decision/projects/$child" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'needs-decision [key=live-route]: choose the current route\n' > "$decision/state/$child.status" + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + (.secondmate_current.records[] | select(.id == "hold") + | .current.state == "externally_held" + and .contradiction == true + and .terminal_evidence.captured == false + and (.parent_event.reconciliation.activities + | any(.verb == "paused" and .key == "legal-release" and .verdict == "corroborates")) + and (.parent_event.reconciliation.activities + | any(.verb == "paused" and .key == "default" and .verdict == "inconclusive" and .matched == null)) + and (.parent_event.reconciliation.activities + | any(.verb == "working" and .key == "stale-work" and .verdict == "contradicts"))) + and (.secondmate_current.records[] | select(.id == "blocked") + | .current.state == "externally_held" + and .contradiction == false + and (.parent_event.reconciliation.decisions + | any(.verb == "blocked" and .key == "vendor-release" and .verdict == "corroborates")) + and (.parent_event.reconciliation.decisions + | any(.verb == "blocked" and .key == "default" and .verdict == "inconclusive" and .matched == null))) + and (.secondmate_current.records[] | select(.id == "decision") + | .current.state == "captain_decision" + and .contradiction == true + and .terminal_evidence.captured == false + and (.parent_event.reconciliation.activities + | any(.verb == "working" and .key == "default" and .verdict == "inconclusive" and .matched == null)) + and (.parent_event.reconciliation.decisions + | any(.verb == "needs-decision" and .key == "stale-route" and .verdict == "contradicts"))) + ' >/dev/null || fail "parent evidence was not reconciled by verb and key: $canonical" + pass "parent evidence reconciliation distinguishes matching holds, blocks, and decisions" +} + +test_nonprogressing_child_states_are_explicit() { + local home mate fakebin canonical + home=$(make_home child-state-classification) + mate="$TMP_ROOT/child-state-classification-home" + make_valid_secondmate_home states "$mate" + append_secondmate_registry "$home" states "$mate" + mkdir -p "$mate/projects/parked" "$mate/projects/done" "$mate/projects/failed" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight +- [ ] parked - Parked child (repo: sample) (kind: ship) (since 2026-07-11) + +## Queued + +## Done +EOF + fm_write_meta "$mate/state/parked.meta" \ + "window=firstmate:fm-parked" "worktree=$mate/projects/parked" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'needs-decision [key=parked]: choose a route\n' > "$mate/state/parked.status" + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "states") + | .current.state == "captain_decision" + and .active_children == [] + and (.holds | any(.id == "parked" and .source == "child-state")) + ' >/dev/null || fail "parked child was classified as active work: $canonical" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "states") + | .current.state == "unknown" + and (.current.reason | contains("live child state has no in-flight backlog item")) + and (.current.reason | contains("parked=parked")) + ' >/dev/null || fail "unowned held child was silently dropped: $canonical" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight +- [ ] done - Done child still in flight (repo: sample) (kind: ship) (since 2026-07-11) +- [ ] failed - Failed child still in flight (repo: sample) (kind: ship) (since 2026-07-11) + +## Queued + +## Done +EOF + fm_write_meta "$mate/state/done.meta" \ + "window=firstmate:fm-done" "worktree=$mate/projects/done" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + fm_write_meta "$mate/state/failed.meta" \ + "window=firstmate:fm-failed" "worktree=$mate/projects/failed" "project=sample" \ + "harness=codex" "kind=ship" "mode=no-mistakes" + printf 'done: complete\n' > "$mate/state/done.status" + printf 'failed: stopped\n' > "$mate/state/failed.status" + rm "$mate/state/parked.meta" "$mate/state/parked.status" + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.records[] | select(.id == "states") + | .current.state == "unknown" + and (.current.reason | contains("terminal child state")) + and (.current.reason | contains("done=done")) + and (.current.reason | contains("failed=failed")) + ' >/dev/null || fail "terminal in-flight child states were silently dropped: $canonical" + pass "nonprogressing child states are explicit and inconsistent terminal rows invalidate" +} + +test_registry_unavailability_and_bounds_are_explicit() { + local home fakebin json canonical id mate boundary + home=$(make_home registry-unavailable) + mate="$TMP_ROOT/registry-hidden" + make_valid_secondmate_home hidden "$mate" + printf -- '- hidden - fixture (home: %s; scope: fixture; projects: sample; added 2026-07-11)\n' "$mate" > "$home/data/secondmates.md" + fm_write_secondmate_meta "$home/state/hidden.meta" "$mate" "firstmate:fm-hidden" sample + chmod 000 "$home/data/secondmates.md" + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) + json=$(run "$home" "$fakebin" --json) + chmod 600 "$home/data/secondmates.md" + printf '%s' "$canonical" | jq -e ' + .secondmate_current.registry.complete == false + and (.secondmate_current.records[] | select(.id == "hidden") + | .registered == null + and (.current.reason | contains("registration is unknown"))) + ' >/dev/null || fail "unavailable registry produced false unregistered provenance: $canonical" + printf '%s' "$json" | jq -e ' + (.secondmates | any(.[]; .id == "(registry)" and .state == "unknown" + and .provenance == "registered-table" and .freshness == "unavailable")) + and (.omitted | any(.surface | contains("secondmate registry unavailable"))) + ' >/dev/null || fail "unreadable registry disappeared from bearings: $json" + home=$(make_home registry-bounds) + : > "$home/data/secondmates.md" + for id in one two three; do + mate="$TMP_ROOT/registry-$id" + make_valid_secondmate_home "$id" "$mate" + append_secondmate_registry "$home" "$id" "$mate" + done + fakebin=$(make_fakebin "$home") + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_REGISTRY_RECORDS=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.registry + | .available == true and .provenance == "registered-table" + and .freshness.status == "fresh" and .records_truncated == true + and .records_in_window == 3 and (.records | length) == 2 + and (.reasons | index("record_limit") != null) + ' >/dev/null || fail "registry record bound was not enforced or disclosed: $canonical" + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_REGISTRY_LINES=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.registry + | .input_truncated == true and .records_truncated == false + and .lines_in_window == 2 and (.records | length) == 2 + and .reasons == ["line_limit"] + ' >/dev/null || fail "registry line bound was not enforced or disclosed: $canonical" + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_REGISTRY_BYTES=100 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.registry + | .input_truncated == true and (.reasons | index("byte_limit") != null) + and .records_in_window < 3 + ' >/dev/null || fail "registry byte bound was not enforced or disclosed: $canonical" + boundary=$(LC_ALL=C head -n 1 "$home/data/secondmates.md" | wc -c | tr -d ' ') + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_REGISTRY_BYTES="$((boundary - 1))" "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.registry + | .input_truncated == true and .complete == false + and (.reasons | index("byte_limit") != null) + ' >/dev/null || fail "registry newline byte boundary hid truncation: $canonical" + json=$(FM_SNAPSHOT_REGISTRY_RECORDS=2 run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + .omitted | any(.surface == "secondmate registry records omitted by bounded read") + ' >/dev/null || fail "bearings omitted registry truncation disclosure: $json" + mate="$TMP_ROOT/registry-z-hidden" + make_valid_secondmate_home z-hidden "$mate" + append_secondmate_registry "$home" z-hidden "$mate" + fm_write_secondmate_meta "$home/state/z-hidden.meta" "$mate" "firstmate:fm-z-hidden" sample + canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_REGISTRY_RECORDS=3 "$ROOT/bin/fm-fleet-snapshot.sh" --json) + printf '%s' "$canonical" | jq -e ' + .secondmate_current.registry.complete == false + and (.secondmate_current.records[] | select(.id == "z-hidden") + | .registered == null + and (.current.reason | contains("registration is unknown"))) + ' >/dev/null || fail "truncated registry produced false unregistered provenance: $canonical" + pass "registry unavailability and bounded truncation remain explicit" +} + +test_current_landed_baseline_is_repeatable_and_prior_report_independent() { + local home fakebin one two + home=$(make_home standalone-baseline); write_fixture "$home" + cat > "$home/data/status-report-2026-07-10.md" <<'EOF' +# Misleading old report + +## Recently Landed +- fake-old-item + +## Underway +- Phase 7 started +EOF + fakebin=$(make_fakebin "$home") + one=$(run "$home" "$fakebin" --json) + two=$(run "$home" "$fakebin" --json) + [ "$(printf '%s' "$one" | jq -c '.landed')" = "$(printf '%s' "$two" | jq -c '.landed')" ] \ + || fail "the same structured state produced different recent-completion baselines" + printf '%s' "$two" | jq -e ' + (.landed | any(.id == "done-a")) + and (.landed | any(.id == "mate-landed")) + and (.landed | any(.id == "fake-old-item") | not) + and (.in_flight | any(.doing == "Phase 7 started") | not) + ' >/dev/null || fail "prior status report influenced the standalone snapshot: $two" + pass "repeated snapshots keep the same current landed baseline and ignore prior reports" +} + test_default_is_bounded_and_local_only() { local home fakebin toon json home=$(make_home bounded); write_fixture "$home" @@ -193,9 +878,10 @@ test_open_decision_surfaces_end_to_end() { fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' - .decisions_open | any(.[]; .id == "mate" and .key == "race" and .verb == "needs-decision") - ' >/dev/null || fail "a still-open decision masked by a later done must surface in decisions_open: $json" - pass "an open decision masked by a later event surfaces end-to-end" + .decisions_open | any(.[]; .id == "mate/mate-decision-race" + and .key == "mate-decision-race" and .verb == "captain-hold") + ' >/dev/null || fail "an authoritative captain hold must surface in decisions_open: $json" + pass "an authoritative captain hold surfaces end-to-end" } test_report_pointers_surface() { @@ -283,6 +969,7 @@ write_large_fixture() { # <home> <count> mkdir -p "$home/projects/$id" "$home/data/$id" printf '# Report\n' > "$home/data/$id/report.md" printf -- '- [ ] gate-%s - Gate %s blocked-by: task-%s (repo: repo-%s) (kind: ship)\n' "$i" "$i" "$i" "$i" >> "$home/data/backlog.md" + printf -- '- [ ] decision-%s - Decision %s (repo: repo-%s) (kind: captain) (hold: captain choice pending) (hold-kind: captain)\n' "$i" "$i" "$i" >> "$home/data/backlog.md" fm_write_meta "$home/state/$id.meta" \ "window=firstmate:fm-$id" \ "worktree=$home/projects/$id" \ @@ -433,18 +1120,182 @@ test_landed_includes_secondmate_home_merges() { pass "landed includes secondmate-managed merges alongside main-home merges" } +test_landed_default_balances_dominant_and_sparse_homes() { + local home dominant sparse_a sparse_b sparse_c fakebin json i actual expected + home=$(make_home landed-balanced-default) + : > "$home/data/secondmates.md" + printf '## Done\n' > "$home/data/backlog.md" + dominant=$(make_landed_secondmate "$home" dominant) + sparse_a=$(make_landed_secondmate "$home" sparse-a) + sparse_b=$(make_landed_secondmate "$home" sparse-b) + sparse_c=$(make_landed_secondmate "$home" sparse-c) + i=1 + while [ "$i" -le 12 ]; do + append_landed_row "$dominant" "$(printf 'dominant-landed-%02d' "$i")" \ + "$(printf 'Dominant landed %02d' "$i")" "$(printf '2026-07-%02d' "$((31 - i))")" + i=$((i + 1)) + done + i=1 + while [ "$i" -le 2 ]; do + append_landed_row "$sparse_a" "$(printf 'sparse-a-landed-%02d' "$i")" \ + "$(printf 'Sparse A landed %02d' "$i")" "$(printf '2026-07-%02d' "$((12 - i))")" + append_landed_row "$sparse_b" "$(printf 'sparse-b-landed-%02d' "$i")" \ + "$(printf 'Sparse B landed %02d' "$i")" "$(printf '2026-07-%02d' "$((10 - i))")" + append_landed_row "$sparse_c" "$(printf 'sparse-c-landed-%02d' "$i")" \ + "$(printf 'Sparse C landed %02d' "$i")" "$(printf '2026-07-%02d' "$((8 - i))")" + i=$((i + 1)) + done + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + actual=$(printf '%s' "$json" | jq -r '.landed[] | "\(.owner)/\(.id)"') + expected='dominant/dominant-landed-01 +sparse-a/sparse-a-landed-01 +sparse-b/sparse-b-landed-01 +sparse-c/sparse-c-landed-01 +dominant/dominant-landed-02 +sparse-a/sparse-a-landed-02' + [ "$actual" = "$expected" ] || fail "default landed selection was not balanced across homes: $actual" + printf '%s' "$json" | jq -e ' + (.landed | length) == 6 + and ([.landed[].owner] | unique | length) == 4 + and ([.omitted[].surface] | any(test("landed showing 6 of 12"))) + ' >/dev/null || fail "balanced landed default did not preserve cap disclosure: $json" + pass "default landed selection balances one dominant home with sparse homes" +} + +test_landed_default_refills_capacity_after_sparse_homes_exhaust() { + local home dominant sparse fakebin json actual expected i + home=$(make_home landed-sparse-refill) + : > "$home/data/secondmates.md" + printf '## Done\n' > "$home/data/backlog.md" + dominant=$(make_landed_secondmate "$home" dominant) + sparse=$(make_landed_secondmate "$home" sparse) + i=1 + while [ "$i" -le 5 ]; do + append_landed_row "$dominant" "$(printf 'dominant-landed-%02d' "$i")" \ + "$(printf 'Dominant landed %02d' "$i")" "$(printf '2026-07-%02d' "$((20 - i))")" + i=$((i + 1)) + done + append_landed_row "$sparse" sparse-landed-01 "Sparse landed 01" 2026-07-01 + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + actual=$(printf '%s' "$json" | jq -r '.landed[] | "\(.owner)/\(.id)"') + expected='dominant/dominant-landed-01 +sparse/sparse-landed-01 +dominant/dominant-landed-02 +dominant/dominant-landed-03 +dominant/dominant-landed-04 +dominant/dominant-landed-05' + [ "$actual" = "$expected" ] || fail "sparse homes wasted landed capacity: $actual" + pass "landed selection refills capacity after sparse homes exhaust" +} + +test_landed_default_uses_deterministic_home_order_when_homes_exceed_cap() { + local home mate fakebin json actual expected i id + home=$(make_home landed-home-order) + : > "$home/data/secondmates.md" + printf '## Done\n' > "$home/data/backlog.md" + i=1 + while [ "$i" -le 8 ]; do + id=$(printf 'home-%02d' "$i") + mate=$(make_landed_secondmate "$home" "$id") + append_landed_row "$mate" "$id-landed-01" "$id landed 01" 2026-07-10 + i=$((i + 1)) + done + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + actual=$(printf '%s' "$json" | jq -r '.landed[] | "\(.owner)/\(.id)"') + expected='home-01/home-01-landed-01 +home-02/home-02-landed-01 +home-03/home-03-landed-01 +home-04/home-04-landed-01 +home-05/home-05-landed-01 +home-06/home-06-landed-01' + [ "$actual" = "$expected" ] || fail "landed home-order tie was not deterministic: $actual" + printf '%s' "$json" | jq -e ' + ([.omitted[].surface] | any(test("landed showing 6 of 8"))) + ' >/dev/null || fail "more-homes-than-cap omission was not disclosed: $json" + pass "landed selection uses deterministic home order when homes exceed the cap" +} + +test_landed_default_preserves_internal_order_for_ties() { + local home tie_a tie_b fakebin json actual expected + home=$(make_home landed-ties) + : > "$home/data/secondmates.md" + printf '## Done\n' > "$home/data/backlog.md" + tie_a=$(make_landed_secondmate "$home" tie-a) + tie_b=$(make_landed_secondmate "$home" tie-b) + append_landed_row "$tie_b" tie-b-a "Tie B A" 2026-07-10 + append_landed_row "$tie_b" tie-b-z "Tie B Z" 2026-07-10 + append_landed_row "$tie_a" tie-a-a "Tie A A" 2026-07-10 + append_landed_row "$tie_a" tie-a-z "Tie A Z" 2026-07-10 + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + actual=$(printf '%s' "$json" | jq -r '.landed[] | "\(.owner)/\(.id)"') + expected='tie-a/tie-a-z +tie-b/tie-b-z +tie-a/tie-a-a +tie-b/tie-b-a' + [ "$actual" = "$expected" ] || fail "landed tie ordering changed: $actual" + pass "landed selection preserves deterministic home and internal tie ordering" +} + +test_landed_default_handles_no_landed_items() { + local home fakebin json + home=$(make_home landed-empty) + : > "$home/data/secondmates.md" + printf '## Done\n' > "$home/data/backlog.md" + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.landed | length) == 0 + and ([.omitted[].surface] | any(test("landed")) | not) + ' >/dev/null || fail "empty landed set was not handled cleanly: $json" + pass "landed selection handles no landed items" +} + +test_all_landed_keeps_complete_global_order() { + local home alpha beta fakebin json actual expected + home=$(make_home landed-all-order) + : > "$home/data/secondmates.md" + printf '## Done\n' > "$home/data/backlog.md" + alpha=$(make_landed_secondmate "$home" alpha) + beta=$(make_landed_secondmate "$home" beta) + append_landed_row "$alpha" alpha-old "Alpha old" 2026-07-01 + append_landed_row "$alpha" alpha-new "Alpha new" 2026-07-09 + append_landed_row "$beta" beta-new "Beta new" 2026-07-10 + append_landed_row "$beta" beta-mid "Beta mid" 2026-07-05 + fakebin=$(make_fakebin "$home") + json=$(FM_BEARINGS_LANDED=1 run "$home" "$fakebin" --json --all-landed) + actual=$(printf '%s' "$json" | jq -r '.landed[] | "\(.owner)/\(.id)"') + expected='beta/beta-new +alpha/alpha-new +beta/beta-mid +alpha/alpha-old' + [ "$actual" = "$expected" ] || fail "--all-landed global order changed: $actual" + printf '%s' "$json" | jq -e ' + (.landed | length) == 4 + and ([.omitted[].surface] | any(test("landed|snapshot layer")) | not) + ' >/dev/null || fail "--all-landed no longer revealed the complete landed set: $json" + pass "--all-landed keeps the complete global landed output" +} + # The roll-up stays bounded: a per-home cap and an overall cap, both disclosed in # omitted[], with --all-landed as the counted expansion knob. This also covers the # previously-silent main-home landed truncation. test_landed_bounded_and_disclosed() { - local home fakebin json i expected actual + local home mate fakebin json i expected actual home=$(make_home mate-landed-caps); write_fixture "$home" - : > "$home/secondmate-home/data/backlog.md" - printf '## Done\n' >> "$home/secondmate-home/data/backlog.md" + mate=$(fixture_mate_home "$home") + { + printf '## In flight\n' + printf '%s\n\n' '- [ ] mate - Decide subscription order (repo: firstmate) (kind: ship) (since 2026-07-11)' + printf '## Done\n' + } > "$mate/data/backlog.md" i=1 while [ "$i" -le 12 ]; do printf -- '- [x] mate-landed-%02d - Secondmate fix %02d (repo: firstmate) (kind: ship) (merged 2026-06-%02d)\n' \ - "$i" "$i" "$((13 - i))" >> "$home/secondmate-home/data/backlog.md" + "$i" "$i" "$((13 - i))" >> "$mate/data/backlog.md" i=$((i + 1)) done fakebin=$(make_fakebin "$home") @@ -471,11 +1322,30 @@ $(printf 'mate-landed-%02d' "$i")" pass "landed stays bounded with per-home + overall caps and omitted[] disclosure" } +# Bearings projects authoritative structured state rather than inventing return +# policy. A live blocked child remains a live in-flight record with state=blocked +# and an open blocker; it must never be converted into a queued `gates` record. +# The return-catch-up owner prevents this state from reaching ordinary rendering +# during an away return, while this test pins Bearings' own projection boundary. +test_live_blocker_is_not_charted_queue_work() { + local home fakebin json + home=$(make_home live-blocker); write_fixture "$home" + printf 'blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token\n' > "$home/state/ship-task.status" + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.in_flight | any(.[]; .id == "ship-task" and .state == "blocked")) + and (.decisions_open | any(.[]; .id == "ship-task") | not) + and (.gates | any(.[]; .id == "ship-task") | not) + ' >/dev/null || fail "live blocked work was projected as queued/deferred work: $json" + pass "Bearings keeps a live blocker in structured live state and never converts it to Charted Next queue work" +} + # Captain's Call is populated only from the durable keyed open-decision set. The # anti-leak guard: action-free highlights - a working task, a completed scout, # queued/gated items, landed work - must never surface as an open decision, so they # cannot leak into Captain's Call. The standard fixture has exactly one genuine open -# decision (the secondmate's masked needs-decision). +# decision (the secondmate's structured captain hold). test_captains_call_anti_leak() { local home fakebin json canonical home=$(make_home anti-leak); write_fixture "$home" @@ -483,8 +1353,10 @@ test_captains_call_anti_leak() { json=$(run "$home" "$fakebin" --json) canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$ROOT/bin/fm-fleet-snapshot.sh" --json) jq -n -e --argjson bearings "$json" --argjson canonical "$canonical" ' - (([$bearings.decisions_open[].id] - + [$canonical.tasks[] | select(.hints.pending_decision or .hints.blocked_event) | .id]) | unique) == ["mate"] + ([$bearings.decisions_open[].id] == ["mate/mate-decision-race"]) + and ($canonical.secondmate_current.records[] | select(.id == "mate") + | (.decisions_open | any(.source == "status")) + and (.decisions_open | any(.source == "backlog"))) and ([$bearings.decisions_open[].id] | index("ship-task") | not) and ([$bearings.decisions_open[].id] | index("scout-x") | not) and ([$bearings.decisions_open[].id] | index("external-wait") | not) @@ -501,7 +1373,7 @@ test_captains_call_anti_leak() { # empty-state sentence, documents the At Anchor exclusion, and mandates a chat that is # materially shorter than and links to the report file. test_chat_contract_four_sections() { - local skill body headings expected + local skill body headings report_headings expected skill="$ROOT/.agents/skills/bearings/SKILL.md" [ -f "$skill" ] || fail "bearings SKILL.md missing at $skill" body=$(awk '/^## Chat-response contract$/{capture=1; next} capture && /^## /{exit} capture' "$skill") @@ -509,19 +1381,45 @@ test_chat_contract_four_sections() { expected=$(printf '%s\n' "Captain's Call" "Recently Landed" "Underway" "Charted Next") [ "$headings" = "$expected" ] || fail "chat contract must contain exactly four numbered sections in fixed order, got: $headings" assert_contains "$body" "Nothing needs your action right now" "Captain's Call empty-state sentence" - assert_contains "$body" "Nothing has landed since your last report" "Recently Landed empty-state sentence" + assert_contains "$body" "No recent completions are in the current baseline" "Recently Landed empty-state sentence" assert_contains "$body" "Nothing is underway" "Underway empty-state sentence" assert_contains "$body" "Nothing is queued" "Charted Next empty-state sentence" + report_headings=$(sed -nE 's/^ - \*\*(Captain.s Call|Recently Landed|Underway|Charted Next)\*\*.*/\1/p' "$skill") + [ "$report_headings" = "$expected" ] || fail "detailed report contract must contain the same four complete sections, got: $report_headings" + grep -Eq 'since the (prior|last) report|Nothing has landed since|unchanged delta' "$skill" \ + && fail "bearings contract still contains prior-report delta wording" + # shellcheck disable=SC2016 # Backticks are literal Markdown in the expected text. + assert_contains "$(cat "$skill")" 'Never read an earlier `data/status-report-*.md`' "prior reports must not influence current output" + assert_contains "$(cat "$skill")" "bounded current recent-completions baseline" "Recently Landed must be a current baseline" assert_contains "$body" "no At Anchor section" "the At Anchor exclusion must be documented" assert_contains "$body" "materially shorter" "the chat must be materially shorter than the report file" assert_contains "$body" "links to" "the chat must link to the report file" pass "the /bearings skill states the four-section chat contract in order, with empty-states and the At Anchor exclusion" } +test_domain_alpha_stale_parent_event_does_not_become_current_work +test_gnu_stat_uses_file_formats_without_bsd_fallback_pollution +test_parent_activity_evidence_is_bounded_and_disclosed +test_active_child_overrides_old_parent_event +test_structured_child_decision_reaches_captains_call +test_bad_secondmate_homes_never_revive_parent_work +test_secondmate_and_child_bounds_are_disclosed +test_parent_decision_is_untrusted_contradiction_only +test_parent_evidence_reconciles_by_verb_and_key +test_nonprogressing_child_states_are_explicit +test_registry_unavailability_and_bounds_are_explicit +test_current_landed_baseline_is_repeatable_and_prior_report_independent test_default_is_bounded_and_local_only test_toon_json_parity test_landed_includes_secondmate_home_merges +test_landed_default_balances_dominant_and_sparse_homes +test_landed_default_refills_capacity_after_sparse_homes_exhaust +test_landed_default_uses_deterministic_home_order_when_homes_exceed_cap +test_landed_default_preserves_internal_order_for_ties +test_landed_default_handles_no_landed_items +test_all_landed_keeps_complete_global_order test_landed_bounded_and_disclosed +test_live_blocker_is_not_charted_queue_work test_captains_call_anti_leak test_chat_contract_four_sections test_completed_scout_report_not_pending diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 68e10e0c3..333b9697c 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # Behavior tests for fm-bootstrap.sh reporting and session-start clone refresh bounds. # -# Bootstrap prints one block or line per problem or capability fact and is silent when all -# is well. firstmate consumes the exact 'MISSING: treehouse (install: ...)', +# Bootstrap prints one block or line per actionable problem, optional verbose +# BOOTSTRAP_INFO fact, or completed bootstrap no-action fact and is silent when +# all is well. firstmate consumes the exact 'MISSING: treehouse (install: ...)', # 'MISSING: tasks-axi (install: ...)', 'MISSING: quota-axi (install: ...)', and -# 'TASKS_AXI: available' lines, so those contracts are pinned verbatim. The cases +# 'BOOTSTRAP_INFO: ...' lines, so those contracts are pinned verbatim. The cases # are table-driven over the inputs that vary: whether `treehouse get --help` # advertises --lease, which (if any) tasks-axi version is on PATH, whether # tasks-axi update advertises --archive-body, whether its mv help advertises @@ -278,7 +279,7 @@ test_bootstrap_reporting() { done <<'ROWS' treehouse --lease support is accepted silently^1^0.1.1^1^manual^empty^^ treehouse without --lease reports an upgrade, gh auth is fine^0^0.1.1^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH -compatible tasks-axi is reported available by default^1^0.1.1^1^-^exact^TASKS_AXI: available^ +compatible tasks-axi is silent by default^1^0.1.1^1^-^empty^^ missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ tasks-axi without archive-body is required by default^1^0.1.2:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ @@ -302,8 +303,8 @@ test_tasks_axi_cwd_trap_warning() { out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ FM_BOOTSTRAP_DETECT_ONLY=1 FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") - expected=$(printf 'TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from %s\nTASKS_AXI: available' "$(cd "$home" && pwd -P)") - [ "$out" = "$expected" ] || fail "diverged FM_HOME backlog should warn before availability"$'\n'"expected: $expected"$'\n'"got: $out" + expected=$(printf 'TASKS_AXI: repo-root data/backlog.md differs from FM_HOME backlog - use bin/fm-tasks-axi.sh so tasks-axi runs from %s' "$(cd "$home" && pwd -P)") + [ "$out" = "$expected" ] || fail "diverged FM_HOME backlog should warn while routine availability stays silent"$'\n'"expected: $expected"$'\n'"got: $out" pass "bootstrap warns when repo-root and FM_HOME backlogs diverge" } @@ -689,7 +690,92 @@ test_fleet_sync_timeout_is_computed_before_launch() { pass "bootstrap computes the timeout before launching fleet sync" } -test_crew_dispatch_active_rules_are_surfaced() { +make_routine_bootstrap_fixture() { + local case_dir=$1 fakebin root home sm c1 + root="$case_dir/root" + home="$case_dir/home" + sm="$case_dir/sm" + fm_git_identity + mkdir -p "$home/config" "$home/state" + printf '%s\n' codex > "$home/config/crew-harness" + printf '%s\n' '{"rules":[{"when":"normal work","use":{"harness":"codex"}}],"default":{"harness":"claude","effort":"low"}}' \ + > "$home/config/crew-dispatch.json" + git init -q -b main "$root" + { + printf '%s\n' '.fm-secondmate-home' + printf '%s\n' 'config/crew-harness' + printf '%s\n' 'config/crew-dispatch.json' + } > "$root/.gitignore" + printf '%s\n' 'instructions' > "$root/AGENTS.md" + mkdir -p "$root/bin" "$root/.agents/skills" + printf '%s\n' 'echo ok' > "$root/bin/fm-spawn.sh" + printf '%s\n' 'skill' > "$root/.agents/skills/example.md" + git -C "$root" add -A + git -C "$root" commit -qm initial + c1=$(git -C "$root" rev-parse HEAD) + git -C "$root" worktree add -q --detach "$sm" "$c1" + printf '%s\n' sm > "$sm/.fm-secondmate-home" + { + printf 'window=firstmate:fm-sm\n' + printf 'kind=secondmate\n' + printf 'harness=codex\n' + printf 'home=%s\n' "$sm" + } > "$home/state/sm.meta" + fakebin=$(make_fake_toolchain "$case_dir") + add_real_jq "$fakebin" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = display-message ]; then + printf '%s\n' codex + exit 0 +fi +exit 0 +SH + chmod +x "$fakebin/tmux" + printf '%s|%s|%s\n' "$root" "$home" "$fakebin" +} + +run_routine_bootstrap_fixture() { + local shell=$1 case_dir=$2 fixture root home fakebin + fixture=$(make_routine_bootstrap_fixture "$case_dir") + root=${fixture%%|*} + fixture=${fixture#*|} + home=${fixture%%|*} + fakebin=${fixture#*|} + PATH="$fakebin:$BASE_PATH" FM_BACKEND=tmux FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 \ + "$shell" "$ROOT/bin/fm-bootstrap.sh" +} + +test_routine_bootstrap_confirmations_are_silent() { + local out + out=$(run_routine_bootstrap_fixture bash "$TMP_ROOT/routine-silent") + [ -z "$out" ] || fail "routine bootstrap confirmations should be silent, got: $out" + pass "bootstrap keeps routine tasks-axi, harness, dispatch, and already-live liveness confirmations silent" +} + +test_routine_bootstrap_contract_runs_under_system_bash() { + local out + [ -x /bin/bash ] || { pass "bootstrap routine contract skipped without /bin/bash"; return; } + out=$(run_routine_bootstrap_fixture /bin/bash "$TMP_ROOT/routine-bash") + [ -z "$out" ] || fail "routine bootstrap contract should be silent under /bin/bash, got: $out" + pass "bootstrap routine contract runs under system /bin/bash" +} + +test_bootstrap_info_is_no_load_and_actionable_lines_trigger() { + local trigger + # shellcheck disable=SC2016 # The backtick-delimited skill names are literal Markdown. + trigger=$(sed -n '/- `bootstrap-diagnostics`/,/- `diagnostic-reasoning`/p' "$ROOT/AGENTS.md") + assert_contains "$trigger" "actionable diagnostic line" "bootstrap-diagnostics trigger should be action-scoped" + assert_contains "$trigger" "BOOTSTRAP_INFO:" "bootstrap-diagnostics trigger should classify BOOTSTRAP_INFO as no-load" + assert_not_contains "$trigger" "TASKS_AXI:" "tasks-axi availability must not trigger diagnostics loading" + assert_not_contains "$trigger" "CREW_HARNESS_OVERRIDE:" "harness override confirmation must not trigger diagnostics loading" + assert_not_contains "$trigger" "CREW_DISPATCH: active" "active dispatch confirmation must not trigger diagnostics loading" + assert_not_contains "$trigger" "already-live" "already-live secondmate liveness must not trigger diagnostics loading" + pass "bootstrap diagnostics trigger excludes benign lines and keeps actionable prefixes" +} + +test_crew_dispatch_active_rules_are_verbose_bootstrap_info() { local case_dir fakebin out expect case_dir="$TMP_ROOT/dispatch-active" mkdir -p "$case_dir/home/config" @@ -700,10 +786,14 @@ test_crew_dispatch_active_rules_are_surfaced() { out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "active dispatch profile should be silent by default, got: $out" + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_BOOTSTRAP_VERBOSE_FACTS=1 FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") - expect=$'CREW_DISPATCH: active config/crew-dispatch.json\n rule: fresh news -> grok\n rule: big feature -> quota-balanced[claude/claude-sonnet-5/high, codex/gpt-5.5/high]\n default: claude/haiku/low' - [ "$out" = "$expect" ] || fail "active dispatch profile block mismatch"$'\n'"expected: $expect"$'\n'"actual: $out" - pass "bootstrap surfaces active crew-dispatch rules and default" + expect=$'BOOTSTRAP_INFO: crew dispatch active config/crew-dispatch.json\nBOOTSTRAP_INFO: crew dispatch rule: fresh news -> grok\nBOOTSTRAP_INFO: crew dispatch rule: big feature -> quota-balanced[claude/claude-sonnet-5/high, codex/gpt-5.5/high]\nBOOTSTRAP_INFO: crew dispatch default: claude/haiku/low' + [ "$out" = "$expect" ] || fail "active dispatch verbose info block mismatch"$'\n'"expected: $expect"$'\n'"actual: $out" + pass "bootstrap surfaces active crew-dispatch rules only as verbose BOOTSTRAP_INFO" } test_crew_dispatch_validation() { @@ -734,9 +824,10 @@ unverified dispatch harness is flagged^{"rules":[{"when":"anything","use":{"harn unsupported codex max effort is flagged^{"rules":[{"when":"big feature","use":{"harness":"codex","model":"gpt-5","effort":"max"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: codex:max unsupported grok max effort is flagged^{"rules":[{"when":"deep current work","use":{"harness":"grok","model":"grok-4","effort":"max"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: grok:max unsupported grok xhigh effort is flagged^{"rules":[{"when":"deep current work","use":{"harness":"grok","model":"grok-4","effort":"xhigh"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: grok:xhigh +pi max effort is accepted^{"rules":[{"when":"deep coding","use":{"harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":"max"}}]}^empty^ unsupported opencode effort is flagged^{"rules":[{"when":"opencode work","use":{"harness":"opencode","model":"anthropic/claude-sonnet-4-5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: opencode:high -array use with quota-balanced is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude","model":"claude-sonnet-5","effort":"high"},{"harness":"codex","model":"gpt-5.5","effort":"high"}],"select":"quota-balanced"}]}^grep^CREW_DISPATCH: active config/crew-dispatch.json -array use without select is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}]}]}^grep^CREW_DISPATCH: active config/crew-dispatch.json +array use with quota-balanced is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude","model":"claude-sonnet-5","effort":"high"},{"harness":"codex","model":"gpt-5.5","effort":"high"}],"select":"quota-balanced"}]}^empty^ +array use without select is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}]}]}^empty^ empty array use is flagged^{"rules":[{"when":"big feature","use":[]}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - each rule needs at least one use profile array profile without harness is flagged^{"rules":[{"when":"big feature","use":[{"model":"gpt-5.5"}]}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - each use profile needs harness unknown select is flagged^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}],"select":"mystery"}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - unknown select: mystery @@ -763,5 +854,8 @@ test_fleet_sync_timeout_floor_preserves_small_fleets test_fleet_sync_timeout_explicit_override_wins test_fleet_sync_timeout_empty_override_uses_default test_fleet_sync_timeout_is_computed_before_launch -test_crew_dispatch_active_rules_are_surfaced +test_routine_bootstrap_confirmations_are_silent +test_routine_bootstrap_contract_runs_under_system_bash +test_bootstrap_info_is_no_load_and_actionable_lines_trigger +test_crew_dispatch_active_rules_are_verbose_bootstrap_info test_crew_dispatch_validation diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 27054b2c8..3e925fa2f 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -66,6 +66,29 @@ test_ship_modes_generate_clean_briefs() { pass "fm-brief.sh: no-mistakes/direct-PR/local-only briefs generate cleanly" } +test_faster_paths_use_configured_authority_without_stacked_review() { + local home id brief + home="$TMP_ROOT/configured-authority-home" + write_registry "$home" + id="brief-direct-authority-a4" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" direct-proj >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_grep "The configured merge authority decides whether to merge the PR; firstmate relays the outcome." "$brief" \ + "direct-PR brief lost configured merge authority" + assert_no_grep "The captain reviews and merges the PR" "$brief" \ + "direct-PR brief hard-coded captain-only authority" + id="brief-local-authority-a4" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" local-proj >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_grep "The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path." "$brief" \ + "local-only brief lost configured merge authority and guarded landing" + assert_no_grep "The captain approves the ready branch" "$brief" \ + "local-only brief hard-coded captain-only authority" + assert_no_grep "Firstmate then reviews your branch diff" "$brief" \ + "local-only brief retained a personal review stacked on the selected delivery path" + pass "fm-brief.sh: faster paths use configured authority without stacked review" +} + # Pin the specific line the bug lived on: the no-mistakes DOD's no-mistakes # reference must render as plain prose with no dangling apostrophe artifact. test_no_mistakes_dod_wording() { @@ -188,6 +211,12 @@ test_secondmate_no_projects_charter() { "project-less charter operating model lost the pooled-worktree note" assert_no_grep "The projects above are local clones" "$brief" \ "project-less charter kept the with-projects operating-model line" + assert_grep 'working [key=<work-slug>]' "$brief" \ + "secondmate charter did not key material routed-work phases" + assert_grep 'resolved [key=<work-slug>]' "$brief" \ + "secondmate charter did not close a quietly ended routed-work phase" + assert_grep 'use the same key on its later' "$brief" \ + "secondmate charter did not supersede working phases with later states" if grep -nE '^-[[:space:]]*$' "$brief" >/dev/null; then fail "project-less charter left a stray empty project bullet" fi @@ -260,9 +289,29 @@ test_pause_verb_override_renders_all_brief_scaffolds() { pass "fm-brief.sh: custom pause verb renders in every scaffold" } +test_scout_and_secondmate_load_decision_hold_policy() { + local home scout charter + home="$TMP_ROOT/decision-policy-home" + mkdir -p "$home/data" + FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-brief.sh" sample-investigation sample --scout >/dev/null 2>&1 + scout="$home/data/sample-investigation/brief.md" + assert_grep "$ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md" "$scout" \ + "scout brief did not load the unresolved-decision policy before done" + assert_grep "pass its shared completion gate for the report and any visual review" "$scout" \ + "scout brief did not cross-reference visual-review completion" + FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_SECONDMATE_CHARTER='sample reviews' \ + "$ROOT/bin/fm-brief.sh" sample-mate --secondmate --no-projects >/dev/null 2>&1 + charter="$home/data/sample-mate/brief.md" + assert_grep "load \`decision-hold-lifecycle\`" "$charter" \ + "secondmate charter did not load the shared decision policy for detailed investigations" + pass "fm-brief.sh: investigation and visual-review completions load the shared decision policy" +} + test_script_parses test_help_includes_entire_header test_ship_modes_generate_clean_briefs +test_faster_paths_use_configured_authority_without_stacked_review test_no_mistakes_dod_wording test_ship_project_memory_wording test_herdr_lab_contract_is_explicit_and_complete @@ -271,3 +320,4 @@ test_herdr_lab_omission_is_loud_for_ship_and_scout test_herdr_lab_contract_applies_to_scouts_but_not_secondmates test_secondmate_no_projects_charter test_pause_verb_override_renders_all_brief_scaffolds +test_scout_and_secondmate_load_decision_hold_policy diff --git a/tests/fm-captain-translation-contract.test.sh b/tests/fm-captain-translation-contract.test.sh new file mode 100755 index 000000000..10fd18199 --- /dev/null +++ b/tests/fm-captain-translation-contract.test.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Static regression tests for the captain-facing plain-English translation +# contract owned by AGENTS.md section 9. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +AGENTS="$ROOT/AGENTS.md" +BOOTSTRAP="$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" +AFK="$ROOT/.agents/skills/afk/SKILL.md" +DECISION="$ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md" +RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" +HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" +CODEXAPP="$ROOT/.agents/skills/firstmate-codexapp/SKILL.md" +FMX="$ROOT/.agents/skills/fmx-respond/SKILL.md" +UPDATE="$ROOT/.agents/skills/updatefirstmate/SKILL.md" + +section_9() { + awk ' + /^## 9\. Escalation and captain etiquette$/ { found = 1 } + found && /^## 10\. / { exit } + found { print } + ' "$AGENTS" +} + +test_section_9_owns_positive_translation_contract() { + local contract + contract=$(section_9) + assert_contains "$contract" "Every captain-facing message must translate internal state into the project outcome, consequence, and next decision." \ + "section 9 does not own the positive captain-facing translation contract" + assert_contains "$contract" "Use the captain's nouns:" \ + "section 9 does not require captain-owned nouns" + assert_contains "$contract" "When evidence uses an internal label, rewrite it before sending:" \ + "section 9 does not own the rewrite mapping list" + pass "section 9 owns the positive captain-facing translation contract" +} + +test_scout_remains_allowed_house_vocabulary() { + local contract + contract=$(section_9) + assert_contains "$contract" "Scout is accepted Firstmate nautical house vocabulary and does not need translation" \ + "section 9 does not preserve scout as allowed Firstmate vocabulary" + assert_not_contains "$contract" "scout -> investigation" \ + "section 9 must not map scout to investigation" + assert_not_contains "$contract" "scout, ship" \ + "section 9 must not add scout to the internal-vocabulary ban" + pass "scout remains allowed in private captain chat" +} + +test_compressed_safety_labels_have_plain_renderings() { + local contract + contract=$(section_9) + for phrase in \ + "fail-closed" \ + "fails closed" \ + "fail-open" \ + "fails open" \ + "fail loudly"; do + assert_contains "$contract" "$phrase" "section 9 does not cover compressed safety label '$phrase'" + done + assert_contains "$contract" "stops safely when something goes wrong" \ + "fail-closed behavior lacks a concrete plain rendering" + assert_contains "$contract" "refuses rather than proceeding" \ + "fail-closed behavior lacks refusal wording" + assert_contains "$contract" "steps aside and lets work continue when the check cannot complete" \ + "fail-open behavior lacks a concrete plain rendering" + pass "compressed safety labels require concrete plain renderings" +} + +test_mapping_list_covers_high_risk_internal_families() { + local contract + contract=$(section_9) + for phrase in \ + "worktree, checkout, primary checkout, or local-main -> local copy" \ + "teardown -> cleanup" \ + "wake, watcher, heartbeat, stale, signal, or check -> notification" \ + "hold, gate, ask-user, needs-decision, blocked, or paused -> the concrete decision" \ + "done, failed, fix-review, checks-passed, cancelled, validation step, or pipeline state -> the concrete result" \ + "brief -> instructions" \ + "crewmate or secondmate -> worker or domain supervisor" \ + "harness, backend, runtime, or adapter -> worker runtime or tool" \ + "status file, metadata, state, task id, or raw path -> durable record"; do + assert_contains "$contract" "$phrase" "section 9 mapping list is missing '$phrase'" + done + pass "section 9 maps high-risk internal vocabulary families" +} + +test_verbatim_internal_evidence_is_rejected_from_chat() { + local contract + contract=$(section_9) + assert_contains "$contract" "Never relay worker reports, status lines, tool output, validation-state labels, or decision records verbatim into captain chat." \ + "section 9 does not reject verbatim internal evidence in captain chat" + assert_contains "$contract" "Private evidence reports may retain exact identifiers, paths, status lines, validation labels, and internal terms" \ + "section 9 does not preserve private evidence precision" + assert_contains "$contract" "the captain-facing chat summary that points to the report still follows this translation rule" \ + "section 9 does not keep chat summaries plain English" + pass "captain chat rejects verbatim internal evidence while private reports stay precise" +} + +test_outward_facing_skill_points_reference_section_9_owner() { + assert_grep "using \`AGENTS.md\` section 9's captain-facing translation contract" "$BOOTSTRAP" \ + "bootstrap diagnostics do not reference section 9 at captain handoff" + assert_grep "Acknowledge** in \`AGENTS.md\` section 9 language" "$AFK" \ + "afk acknowledgement does not reference section 9" + assert_grep "Captain, away mode is active; I will batch routine updates" "$AFK" \ + "afk acknowledgement lacks a local plain-English example" + assert_grep "as decisions from Bearings' Captain's Call section under \`AGENTS.md\` section 9" "$DECISION" \ + "decision relay does not reference section 9" + assert_grep "using \`AGENTS.md\` section 9; do not mention metadata, harness, window, or worktree" "$RECOVERY" \ + "stuck-worker failure does not reference section 9" + assert_grep "under \`AGENTS.md\` section 9 that the requested worker runtime is not verified yet" "$HARNESS" \ + "runtime fallback does not reference section 9" + assert_grep "use firstmate's own verified runtime for current work" "$HARNESS" \ + "runtime fallback does not require the current-work fallback" + assert_grep "Do not pause current work for that future-verification choice, and never launch an unverified adapter." "$HARNESS" \ + "runtime fallback permits waiting on future verification or launching an unverified adapter" + assert_grep "translate status prefixes and return-channel evidence through \`AGENTS.md\` section 9" "$CODEXAPP" \ + "Codex Desktop result reporting does not reference section 9" + assert_grep "It supplements \`AGENTS.md\` section 9; apply both, and this public-channel rule wins wherever it is stricter." "$FMX" \ + "X reply safety does not state that it supplements section 9" + assert_grep "under \`AGENTS.md\` section 9 without firstmate's internal vocabulary" "$UPDATE" \ + "Firstmate update reporting does not reference section 9" + pass "outward-facing skill handoffs point to the section 9 owner" +} + +test_section_9_owner_is_not_duplicated_into_skills() { + local duplicate_count file + duplicate_count=0 + for file in "$BOOTSTRAP" "$AFK" "$DECISION" "$RECOVERY" "$HARNESS" "$CODEXAPP" "$UPDATE"; do + if grep -Fq "When evidence uses an internal label, rewrite it before sending:" "$file"; then + duplicate_count=$((duplicate_count + 1)) + fi + done + [ "$duplicate_count" -eq 0 ] || fail "skills duplicated section 9's mapping owner" + pass "skills cross-reference section 9 instead of duplicating the mapping list" +} + +test_section_9_owns_positive_translation_contract +test_scout_remains_allowed_house_vocabulary +test_compressed_safety_labels_have_plain_renderings +test_mapping_list_covers_high_risk_internal_families +test_verbatim_internal_evidence_is_rejected_from_chat +test_outward_facing_skill_points_reference_section_9_owner +test_section_9_owner_is_not_duplicated_into_skills diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 195a680b4..c6710a63d 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -693,6 +693,10 @@ test_busy_guard_defers_when_supervisor_busy() { } test_marker_detection() { + local marker_hex + marker_hex=$(printf '%s' "$FM_INJECT_MARK" | od -An -tx1 | tr -d ' \n') + [ "$marker_hex" = e281a3 ] \ + || fail "FM_INJECT_MARK must use terminal-safe U+2063 bytes, got $marker_hex" # message_is_injection: marker present -> injection; absent -> real message message_is_injection "${FM_INJECT_MARK}Supervisor escalate: done" \ || fail "marker-prefixed message not detected as injection" diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh new file mode 100755 index 000000000..b6d2ec7a1 --- /dev/null +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash +# End-to-end tests for durable captain-held decisions discovered by investigations +# and visual reviews. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-tasks-axi-lib.sh +# shellcheck disable=SC1091 +. "$ROOT/bin/fm-tasks-axi-lib.sh" + +TEARDOWN="$ROOT/bin/fm-teardown.sh" +BEARINGS="$ROOT/bin/fm-bearings-snapshot.sh" +TMP_ROOT=$(fm_test_tmproot fm-decision-hold) +TASKS_AXI_BIN=$(command -v tasks-axi || true) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; } +fm_tasks_axi_compatible || { echo "skip: tasks-axi lacks required compatibility"; exit 0; } +tasks-axi hold --help 2>/dev/null | grep -F -- '--kind captain' >/dev/null \ + || { echo "skip: tasks-axi lacks captain-hold contract"; exit 0; } + +make_home() { # <name> + local home="$TMP_ROOT/$1" fakebin + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + fakebin=$(fm_fakebin "$home") + fm_fake_exit0 "$fakebin" tmux treehouse no-mistakes gh gh-axi + printf '%s\n' "$home" +} + +run_bearings() { # <home> + local home=$1 + PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_BEARINGS_NOW=2026-07-14T12:00:00Z \ + "$BEARINGS" --json +} + +run_teardown() { # <home> <id> + local home=$1 id=$2 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" "$id" +} + +# Reproduces the loss exactly with privacy-safe synthetic names: the investigation +# and visual review have ended, the only genuine unresolved decision is report prose, +# no held backlog item or open status exists, and the authoritative Bearings view +# correctly omits it. Completion must now refuse before teardown can erase the source. +test_uninventoried_report_decision_refuses_completion() { + local home id json rc + home=$(make_home omitted-decision) + id=sample-route-review + mkdir -p "$home/data/$id" + cat > "$home/data/backlog.md" <<EOF +## In flight +- [ ] $id - Investigate sample routing (repo: sample) (kind: scout) (since 2026-07-14) + +## Queued + +## Done +EOF + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" \ + "worktree=$home/projects/missing-scratch" \ + "project=$home/projects/sample" \ + "harness=codex" \ + "kind=scout" \ + "mode=scout" + printf 'done: report and visual review complete\n' > "$home/state/$id.status" + cat > "$home/data/$id/report.md" <<'EOF' +# Sample route review + +The evidence is complete. +The captain still needs to choose route north or route south before follow-up work starts. +EOF + + json=$(run_bearings "$home") || fail "Bearings failed for unresolved-decision regression" + printf '%s' "$json" | jq -e ' + (.decisions_open | length) == 0 + and (.gates | length) == 0 + and (.reports | any(.id == "sample-route-review")) + ' >/dev/null || fail "the pre-policy omission shape was not reproduced: $json" + + set +e + run_teardown "$home" "$id" > "$home/teardown.out" 2> "$home/teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "completed investigation teardown erased a report-only unresolved decision" + assert_present "$home/state/$id.meta" "refused completion must preserve investigation metadata" + assert_grep "REFUSED" "$home/teardown.err" "refusal must be explicit" + pass "report-only unresolved decision is reproduced and completion refuses before loss" +} + +tasks_in() { # <home> <tasks-axi args...> + local home=$1 + shift + (cd "$home" && tasks-axi "$@") +} + +run_decisions() { # <home> <command args...> + local home=$1 + shift + PATH="$home/fakebin:$PATH" REAL_TASKS_AXI="$TASKS_AXI_BIN" \ + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-decision-hold.sh" "$@" +} + +write_origin_meta() { # <home> <id> [kind] + local home=$1 id=$2 kind=${3:-scout} + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" \ + "worktree=$home/projects/missing-$id" \ + "project=$home/projects/sample" \ + "harness=codex" \ + "kind=$kind" \ + "mode=$kind" +} + +test_structured_holds_survive_teardown_and_route_resolution() { + local home id route_hold access_hold before after json open show + home=$(make_home durable-lifecycle) + id=sample-systems-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Investigate sample systems" --kind scout --repo sample --start >/dev/null \ + || fail "could not create investigation backlog fixture" + write_origin_meta "$home" "$id" + cat > "$home/state/$id.status" <<'EOF' +needs-decision [key=route]: choose route north or route south +needs-decision [key=access]: choose open or restricted sample access +done: report and visual review complete +EOF + cat > "$home/data/$id/report.md" <<'EOF' +# Sample systems review + +Two choices remain unresolved: the route and the sample access level. +A separate recommendation is already resolved and requires no captain action. +EOF + + if run_decisions "$home" complete "$id" route access > "$home/early-complete.out" 2> "$home/early-complete.err"; then + fail "completion succeeded before unresolved decisions had captain holds" + fi + assert_no_grep "decisions_reviewed=1" "$home/state/$id.meta" \ + "failed completion recorded a false completion attestation" + + route_hold=$(run_decisions "$home" hold "$id" route \ + --title "Choose the sample route" --reason "captain route choice pending" --repo sample) \ + || fail "could not register route hold" + [ "$route_hold" = "$id-decision-route" ] || fail "route hold identity was not deterministic: $route_hold" + run_decisions "$home" hold "$id" route \ + --title "Choose the sample route" --reason "captain route choice pending" --repo sample >/dev/null \ + || fail "idempotent hold retry failed" + if run_decisions "$home" complete "$id" route access > "$home/partial-complete.out" 2> "$home/partial-complete.err"; then + fail "completion succeeded while one of two distinct decisions lacked a hold" + fi + access_hold=$(run_decisions "$home" hold "$id" access \ + --title "Choose the sample access level" --reason "captain access choice pending" --repo sample) \ + || fail "could not register access hold" + [ "$access_hold" = "$id-decision-access" ] || fail "access hold identity was not distinct: $access_hold" + [ "$(grep -cE "^- \[ \] $route_hold -" "$home/data/backlog.md")" = 1 ] \ + || fail "idempotent retry duplicated the route hold" + [ "$(grep -cE "^- \[ \] $access_hold -" "$home/data/backlog.md")" = 1 ] \ + || fail "second decision did not retain one distinct backlog identity" + + run_decisions "$home" complete "$id" route access >/dev/null \ + || fail "shared investigation completion gate failed" + assert_grep "decisions_reviewed=1" "$home/state/$id.meta" "completion attestation missing" + assert_grep "decision_keys=access,route" "$home/state/$id.meta" "decision inventory was not deterministic" + open=$(bash -c '. "$1"; status_open_decisions "$2"' _ \ + "$ROOT/bin/fm-classify-lib.sh" "$home/state/$id.status") + [ -z "$open" ] || fail "captain-held transfer did not close duplicate live status decisions: $open" + + before=$(shasum -a 256 "$home/data/backlog.md" | awk '{print $1}') + json=$(run_bearings "$home") || fail "Bearings failed with captain-held decisions" + after=$(shasum -a 256 "$home/data/backlog.md" | awk '{print $1}') + [ "$before" = "$after" ] || fail "Bearings mutated the authoritative backlog" + printf '%s' "$json" | jq -e --arg route "$route_hold" --arg access "$access_hold" ' + (.decisions_open | any(.id == $route and .verb == "captain-hold" and .owner == "(main)")) + and (.decisions_open | any(.id == $access and .verb == "captain-hold" and .owner == "(main)")) + and (.gates | any(.id == $route or .id == $access) | not) + ' >/dev/null || fail "Bearings did not surface structured captain holds: $json" + + run_teardown "$home" "$id" >/dev/null 2> "$home/teardown.err" \ + || fail "reviewed investigation teardown failed: $(cat "$home/teardown.err")" + tasks_in "$home" "done" "$id" --report "data/$id/report.md" --keep 0 >/dev/null \ + || fail "could not archive completed investigation" + ! grep -E "^- \[[ x]\] $id -" "$home/data/backlog.md" >/dev/null \ + || fail "origin remained in the live backlog after archival" + grep -E "^- \[x\] $id -" "$home/data/done-archive.md" >/dev/null \ + || fail "origin was not durably archived" + json=$(run_bearings "$home") || fail "Bearings failed after source teardown and archival" + printf '%s' "$json" | jq -e --arg route "$route_hold" --arg access "$access_hold" ' + (.decisions_open | any(.id == $route and .verb == "captain-hold")) + and (.decisions_open | any(.id == $access and .verb == "captain-hold")) + and (.in_flight | any(.id == "sample-systems-review") | not) + ' >/dev/null || fail "teardown or archival erased a captain-held decision: $json" + + tasks_in "$home" add sample-route-implementation "Apply the selected sample route" \ + --kind ship --repo sample >/dev/null \ + || fail "could not create dependent work fixture" + printf 'Use route north for the sample system.\n' > "$home/route-decision.txt" + if run_decisions "$home" resolve "$id" route --decision-file "$home/route-decision.txt" \ + --routed-to sample-route-implementation > "$home/early-resolve.out" 2> "$home/early-resolve.err"; then + fail "captain hold closed before dependent work had a durable routing edge" + fi + show=$(tasks_in "$home" show "$route_hold" --full) + assert_contains "$show" "state: queued" "failed routing attempt closed the hold" + assert_contains "$show" "held: yes" "failed routing attempt released the hold" + tasks_in "$home" block sample-route-implementation --by "$route_hold" >/dev/null \ + || fail "could not route dependent work behind the decision hold" + tasks_in "$home" add sample-route-followup "Check the selected sample route" \ + --kind ship --repo sample --blocked-by "$route_hold" >/dev/null \ + || fail "could not create second dependent work fixture" + cat > "$home/fakebin/tasks-axi" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = unblock ] && [ "${2:-}" = sample-route-implementation ] \ + && [ ! -f "$FM_HOME/unblock-failed-once" ]; then + : > "$FM_HOME/unblock-failed-once" + exit 1 +fi +exec "$REAL_TASKS_AXI" "$@" +EOF + chmod +x "$home/fakebin/tasks-axi" + if run_decisions "$home" resolve "$id" route --decision-file "$home/route-decision.txt" \ + --routed-to sample-route-implementation --routed-to sample-route-followup \ + > "$home/partial-route.out" 2> "$home/partial-route.err"; then + fail "resolution succeeded after a partial dependent-routing failure" + fi + show=$(tasks_in "$home" show "$route_hold" --full) + assert_contains "$show" "state: queued" "partial routing failure closed the hold" + show=$(tasks_in "$home" show sample-route-followup --full) + assert_contains "$show" "blocked: no" "partial routing fixture did not release its first dependent" + show=$(tasks_in "$home" show sample-route-implementation --full) + assert_contains "$show" "blocked: yes" "partial routing fixture unexpectedly released its second dependent" + if run_decisions "$home" resolve "$id" route --decision-file "$home/route-decision.txt" \ + --routed-to sample-route-followup > "$home/reduced-retry.out" 2> "$home/reduced-retry.err"; then + fail "partial resolution retry accepted a reduced routed task set" + fi + printf 'Use route south for the sample system.\n' > "$home/changed-route-decision.txt" + if run_decisions "$home" resolve "$id" route --decision-file "$home/changed-route-decision.txt" \ + --routed-to sample-route-implementation --routed-to sample-route-followup \ + > "$home/partial-drifted-decision.out" 2> "$home/partial-drifted-decision.err"; then + fail "partial resolution retry accepted a different captain decision" + fi + tasks_in "$home" "done" sample-route-followup >/dev/null \ + || fail "could not complete already-routed dependent work" + run_decisions "$home" resolve "$id" route --decision-file "$home/route-decision.txt" \ + --routed-to sample-route-implementation --routed-to sample-route-followup >/dev/null \ + || fail "could not resume and complete partial decision routing" + run_decisions "$home" resolve "$id" route --decision-file "$home/route-decision.txt" \ + --routed-to sample-route-implementation --routed-to sample-route-followup >/dev/null \ + || fail "identical resolution retry was not idempotent" + if run_decisions "$home" resolve "$id" route --decision-file "$home/changed-route-decision.txt" \ + --routed-to sample-route-implementation --routed-to sample-route-followup \ + > "$home/drifted-decision.out" 2> "$home/drifted-decision.err"; then + fail "resolution retry accepted a different captain decision" + fi + if run_decisions "$home" resolve "$id" route --decision-file "$home/route-decision.txt" \ + --routed-to sample-route-implementation \ + > "$home/drifted-routes.out" 2> "$home/drifted-routes.err"; then + fail "resolution retry accepted a different routed task set" + fi + show=$(tasks_in "$home" show "$route_hold" --full) + assert_contains "$show" "state: done" "resolved hold did not close" + assert_contains "$show" "Resolution recorded by fm-decision-hold" "resolved hold lost the decision record" + show=$(tasks_in "$home" show sample-route-implementation --full) + assert_contains "$show" "blocked: no" "recorded decision did not release dependent work" + json=$(run_bearings "$home") || fail "Bearings failed after decision resolution" + printf '%s' "$json" | jq -e --arg route "$route_hold" --arg access "$access_hold" ' + (.decisions_open | any(.id == $route) | not) + and (.decisions_open | any(.id == $access and .verb == "captain-hold")) + and (.gates | any(.id == "sample-route-implementation")) + and (.decisions_open | any(.id == "sample-systems-review") | not) + ' >/dev/null || fail "resolved or decision-like report prose produced a false hold: $json" + pass "captain holds are idempotent, distinct, teardown-safe, Bearings-visible, and durably routed before close" +} + +test_scout_teardown_always_requires_inventory_verification() { + local home id + home=$(make_home unconditional-teardown) + id=sample-absent-review + mkdir -p "$home/data/$id" + write_origin_meta "$home" "$id" + printf '# Sample absent review\n\nNo decision inventory was recorded.\n' > "$home/data/$id/report.md" + if run_teardown "$home" "$id" > "$home/absent-teardown.out" 2> "$home/absent-teardown.err"; then + fail "scout teardown skipped verification when its backlog task was absent" + fi + assert_present "$home/state/$id.meta" "refused absent-task teardown removed metadata" + + home=$(make_home unavailable-teardown) + id=sample-unavailable-review + mkdir -p "$home/data/$id" + write_origin_meta "$home" "$id" + printf '# Sample unavailable review\n\nNo decision inventory was recorded.\n' > "$home/data/$id/report.md" + cat > "$home/fakebin/tasks-axi" <<'EOF' +#!/usr/bin/env bash +exit 127 +EOF + chmod +x "$home/fakebin/tasks-axi" + if run_teardown "$home" "$id" > "$home/unavailable-teardown.out" 2> "$home/unavailable-teardown.err"; then + fail "scout teardown skipped verification when tasks-axi was unavailable" + fi + assert_present "$home/state/$id.meta" "refused unavailable-task teardown removed metadata" + pass "non-forced scout teardown always requires durable inventory verification" +} + +test_origin_slug_validation_precedes_path_construction() { + local home escaped + home=$(make_home origin-validation) + escaped="$home/escaped-origin.meta" + printf 'sentinel=unchanged\n' > "$escaped" + if run_decisions "$home" complete ../escaped-origin --none \ + > "$home/invalid-complete.out" 2> "$home/invalid-complete.err"; then + fail "completion accepted an origin path traversal" + fi + if run_decisions "$home" verify ../escaped-origin \ + > "$home/invalid-verify.out" 2> "$home/invalid-verify.err"; then + fail "verification accepted an origin path traversal" + fi + [ "$(cat "$escaped")" = "sentinel=unchanged" ] \ + || fail "invalid origin changed metadata outside the state directory" + pass "completion and verification validate origins before constructing paths" +} + +test_visual_review_uses_shared_completion_owner() { + local home id hold json + home=$(make_home visual-review) + id=sample-board-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Review the sample board" --kind scout --repo sample --start >/dev/null + write_origin_meta "$home" "$id" + printf 'done: investigation complete\n' > "$home/state/$id.status" + printf '# Sample board investigation\n\nThe initial findings need no captain choice.\n' > "$home/data/$id/report.md" + run_decisions "$home" complete "$id" --none >/dev/null \ + || fail "initial investigation could not pass the shared completion owner" + run_teardown "$home" "$id" >/dev/null 2> "$home/visual-teardown.err" \ + || fail "completed investigation teardown failed: $(cat "$home/visual-teardown.err")" + tasks_in "$home" "done" "$id" --report "data/$id/report.md" --keep 0 >/dev/null + + mkdir -p "$home/.lavish" + printf '<html><body>Synthetic sample board</body></html>\n' > "$home/.lavish/sample-board.html" + hold=$(run_decisions "$home" hold "$id" layout \ + --title "Choose the sample layout" --reason "captain layout choice pending" --repo sample) \ + || fail "post-teardown visual review could not use the shared hold owner" + run_decisions "$home" complete "$id" layout >/dev/null \ + || fail "post-teardown visual review could not use the shared completion owner" + [ "$hold" = "$id-decision-layout" ] || fail "visual review used a separate identity policy" + json=$(run_bearings "$home") || fail "Bearings failed after the ended visual review" + printf '%s' "$json" | jq -e --arg hold "$hold" ' + .decisions_open | any(.id == $hold and .verb == "captain-hold") + ' >/dev/null || fail "ended visual review did not leave its durable Captain Call: $json" + [ ! -e "$home/data/visual-review-decisions.json" ] \ + || fail "visual review created a second decision database" + pass "ended visual review follows the same decision-hold completion owner" +} + +test_none_inventory_and_resolved_prose_do_not_create_holds() { + local home id json + home=$(make_home no-false-holds) + id=sample-resolved-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Review a resolved sample finding" --kind scout --repo sample --start >/dev/null + write_origin_meta "$home" "$id" + printf 'resolved [key=old-choice]: the sample choice was already recorded\ndone: report complete\n' \ + > "$home/state/$id.status" + cat > "$home/data/$id/report.md" <<'EOF' +# Resolved sample finding + +Decision record: the earlier choice is resolved. +The recommendation is informational and needs no captain action. +EOF + run_decisions "$home" complete "$id" --none >/dev/null \ + || fail "explicit no-decision inventory failed" + json=$(run_bearings "$home") || fail "Bearings failed for no-decision inventory" + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id | startswith("sample-resolved-review")) | not) + ' >/dev/null || fail "resolved findings or decision-like prose created a false hold: $json" + pass "resolved findings and decision-like prose do not create false holds" +} + +test_terminal_single_owner_status_decision_does_not_block_empty_inventory() { + local home id open secondmate + home=$(make_home stale-terminal-decision) + id=sample-terminal-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Review a terminal sample finding" --kind scout --repo sample --start >/dev/null + write_origin_meta "$home" "$id" + printf 'needs-decision [key=default]: choose route A or route B\ndone: report complete\n' \ + > "$home/state/$id.status" + printf '# Terminal sample review\n\nNo unresolved captain choice remains.\n' > "$home/data/$id/report.md" + open=$(bash -c '. "$1"; status_open_decisions "$2"' _ \ + "$ROOT/bin/fm-classify-lib.sh" "$home/state/$id.status") + assert_contains "$open" "default" "fixture must retain the raw stale status decision" + run_decisions "$home" complete "$id" --none >/dev/null \ + || fail "terminal single-owner stale status decision blocked empty inventory completion" + run_decisions "$home" verify "$id" >/dev/null \ + || fail "terminal single-owner stale status decision blocked inventory verification" + run_teardown "$home" "$id" >/dev/null 2> "$home/terminal-teardown.err" \ + || fail "terminal single-owner stale status decision blocked teardown: $(cat "$home/terminal-teardown.err")" + + secondmate=sample-secondmate + write_origin_meta "$home" "$secondmate" secondmate + printf 'needs-decision [key=route]: choose route A or route B\ndone: heartbeat complete\n' \ + > "$home/state/$secondmate.status" + if run_decisions "$home" complete "$secondmate" --none \ + > "$home/secondmate-terminal.out" 2> "$home/secondmate-terminal.err"; then + fail "secondmate terminal status decision was incorrectly cleared" + fi + pass "terminal single-owner stale status decisions do not block empty inventory" +} + +test_secondmate_hold_stays_in_authoritative_home() { + local parent mate origin hold json + parent=$(make_home main-routing) + mate="$TMP_ROOT/sample-mate-home" + mkdir -p "$mate/data" "$mate/state" "$mate/config" "$mate/projects" "$mate/bin" + cp "$ROOT/.tasks.toml" "$mate/.tasks.toml" + printf '# Synthetic secondmate home\n' > "$mate/AGENTS.md" + printf 'sample-mate\n' > "$mate/.fm-secondmate-home" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + fakebin=$(fm_fakebin "$mate") + fm_fake_exit0 "$fakebin" tmux treehouse no-mistakes gh gh-axi + origin=sample-mate-review + mkdir -p "$mate/data/$origin" + tasks_in "$mate" add "$origin" "Investigate secondmate sample" --kind scout --repo sample --start >/dev/null + write_origin_meta "$mate" "$origin" + printf 'done: report and visual review complete\n' > "$mate/state/$origin.status" + printf '# Sample secondmate review\n\nOne captain choice remains.\n' > "$mate/data/$origin/report.md" + hold=$(run_decisions "$mate" hold "$origin" release \ + --title "Choose the sample release" --reason "captain release choice pending" --repo sample) \ + || fail "secondmate-owned hold creation failed" + run_decisions "$mate" complete "$origin" release >/dev/null \ + || fail "secondmate-owned completion failed" + run_teardown "$mate" "$origin" >/dev/null 2> "$mate/teardown.err" \ + || fail "secondmate investigation teardown failed: $(cat "$mate/teardown.err")" + tasks_in "$mate" "done" "$origin" --report "data/$origin/report.md" --keep 0 >/dev/null + + printf -- '- sample-mate - synthetic scope (home: %s; scope: sample reviews; projects: sample; added 2026-07-14)\n' \ + "$mate" > "$parent/data/secondmates.md" + fm_write_secondmate_meta "$parent/state/sample-mate.meta" "$mate" \ + "firstmate:fm-sample-mate" sample + json=$(run_bearings "$parent") || fail "parent Bearings could not read secondmate hold" + printf '%s' "$json" | jq -e --arg hold "$hold" ' + .decisions_open | any(.owner == "sample-mate" and .verb == "captain-hold" and (.id | endswith($hold))) + ' >/dev/null || fail "secondmate captain hold did not surface with authoritative owner: $json" + assert_no_grep "$hold" "$parent/data/backlog.md" "secondmate hold leaked into the main backlog" + assert_grep "$hold" "$mate/data/backlog.md" "secondmate hold left its authoritative backlog" + pass "main-home and secondmate-home captain holds remain correctly routed" +} + +# tasks-axi quotes multi-entry blocked_by values as "a,b,c". resolve must strip +# those surrounding quotes before comma-boundary membership so the first and last +# list elements match, not only middle elements. +test_resolve_matches_quoted_blocked_by_edges() { + local home origin hold_first hold_mid hold_last hold_absent show + home=$(make_home quoted-blocked-by-edges) + origin=sample-quote-review + mkdir -p "$home/data/$origin" + tasks_in "$home" add "$origin" "Quoted blocked_by edge review" --kind scout --repo sample --start >/dev/null \ + || fail "could not create quote-edge origin" + write_origin_meta "$home" "$origin" + printf 'done: report complete\n' > "$home/state/$origin.status" + printf '# Quote edge review\n\nThree edge decisions and one absent control.\n' > "$home/data/$origin/report.md" + + hold_first=$(run_decisions "$home" hold "$origin" edge-first \ + --title "First edge decision" --reason "captain first pending" --repo sample) \ + || fail "could not register first-edge hold" + hold_mid=$(run_decisions "$home" hold "$origin" edge-mid \ + --title "Middle edge decision" --reason "captain mid pending" --repo sample) \ + || fail "could not register mid-edge hold" + hold_last=$(run_decisions "$home" hold "$origin" edge-last \ + --title "Last edge decision" --reason "captain last pending" --repo sample) \ + || fail "could not register last-edge hold" + hold_absent=$(run_decisions "$home" hold "$origin" edge-absent \ + --title "Absent edge decision" --reason "captain absent pending" --repo sample) \ + || fail "could not register absent-edge hold" + + tasks_in "$home" add pad-a "Pad A" --kind ship --repo sample >/dev/null \ + || fail "could not create pad-a blocker" + tasks_in "$home" add pad-b "Pad B" --kind ship --repo sample >/dev/null \ + || fail "could not create pad-b blocker" + + tasks_in "$home" add dep-first "Dep first position" --kind ship --repo sample >/dev/null \ + || fail "could not create first-position dependent" + tasks_in "$home" block dep-first --by "$hold_first" >/dev/null || fail "could not block dep-first by first hold" + tasks_in "$home" block dep-first --by pad-a >/dev/null || fail "could not block dep-first by pad-a" + tasks_in "$home" block dep-first --by pad-b >/dev/null || fail "could not block dep-first by pad-b" + show=$(tasks_in "$home" show dep-first --full) + assert_contains "$show" "blocked_by: \"$hold_first,pad-a,pad-b\"" \ + "first-position fixture must quote multi-entry blocked_by" + printf 'Decide first edge.\n' > "$home/d-first.txt" + if ! run_decisions "$home" resolve "$origin" edge-first --decision-file "$home/d-first.txt" \ + --routed-to dep-first > "$home/first.out" 2> "$home/first.err"; then + fail "resolve failed when hold id is FIRST in quoted blocked_by: $(cat "$home/first.err")" + fi + + tasks_in "$home" add dep-mid "Dep mid position" --kind ship --repo sample >/dev/null \ + || fail "could not create mid-position dependent" + tasks_in "$home" block dep-mid --by pad-a >/dev/null || fail "could not block dep-mid by pad-a" + tasks_in "$home" block dep-mid --by "$hold_mid" >/dev/null || fail "could not block dep-mid by mid hold" + tasks_in "$home" block dep-mid --by pad-b >/dev/null || fail "could not block dep-mid by pad-b" + show=$(tasks_in "$home" show dep-mid --full) + assert_contains "$show" "blocked_by: \"pad-a,$hold_mid,pad-b\"" \ + "middle-position fixture must quote multi-entry blocked_by" + printf 'Decide mid edge.\n' > "$home/d-mid.txt" + if ! run_decisions "$home" resolve "$origin" edge-mid --decision-file "$home/d-mid.txt" \ + --routed-to dep-mid > "$home/mid.out" 2> "$home/mid.err"; then + fail "resolve failed when hold id is MIDDLE in quoted blocked_by: $(cat "$home/mid.err")" + fi + + tasks_in "$home" add dep-last "Dep last position" --kind ship --repo sample >/dev/null \ + || fail "could not create last-position dependent" + tasks_in "$home" block dep-last --by pad-a >/dev/null || fail "could not block dep-last by pad-a" + tasks_in "$home" block dep-last --by pad-b >/dev/null || fail "could not block dep-last by pad-b" + tasks_in "$home" block dep-last --by "$hold_last" >/dev/null || fail "could not block dep-last by last hold" + show=$(tasks_in "$home" show dep-last --full) + assert_contains "$show" "blocked_by: \"pad-a,pad-b,$hold_last\"" \ + "last-position fixture must quote multi-entry blocked_by" + printf 'Decide last edge.\n' > "$home/d-last.txt" + if ! run_decisions "$home" resolve "$origin" edge-last --decision-file "$home/d-last.txt" \ + --routed-to dep-last > "$home/last.out" 2> "$home/last.err"; then + fail "resolve failed when hold id is LAST in quoted blocked_by: $(cat "$home/last.err")" + fi + + tasks_in "$home" add dep-absent "Dep absent control" --kind ship --repo sample >/dev/null \ + || fail "could not create absent-control dependent" + tasks_in "$home" block dep-absent --by pad-a >/dev/null || fail "could not block dep-absent by pad-a" + tasks_in "$home" block dep-absent --by pad-b >/dev/null || fail "could not block dep-absent by pad-b" + show=$(tasks_in "$home" show dep-absent --full) + assert_contains "$show" "blocked_by: \"pad-a,pad-b\"" \ + "absent-control fixture must quote multi-entry blocked_by without the hold id" + printf 'Decide absent edge.\n' > "$home/d-absent.txt" + if run_decisions "$home" resolve "$origin" edge-absent --decision-file "$home/d-absent.txt" \ + --routed-to dep-absent > "$home/absent.out" 2> "$home/absent.err"; then + fail "resolve succeeded when hold id is genuinely absent from blocked_by" + fi + assert_grep "not durably blocked by" "$home/absent.err" \ + "absent id must fail with durable-block error" + show=$(tasks_in "$home" show "$hold_absent" --full) + assert_contains "$show" "state: queued" "failed absent resolve must leave the hold open" + assert_contains "$show" "held: yes" "failed absent resolve must leave the hold held" + + pass "resolve matches first/middle/last in quoted blocked_by and rejects a genuinely absent id" +} + +test_uninventoried_report_decision_refuses_completion + +test_scout_teardown_always_requires_inventory_verification +test_structured_holds_survive_teardown_and_route_resolution +test_origin_slug_validation_precedes_path_construction +test_visual_review_uses_shared_completion_owner +test_none_inventory_and_resolved_prose_do_not_create_holds +test_terminal_single_owner_status_decision_does_not_block_empty_inventory +test_secondmate_hold_stays_in_authoritative_home +test_resolve_matches_quoted_blocked_by_edges diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 62ace5a80..80c951135 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -265,6 +265,7 @@ test_backlog_tasks_axi_forms_and_overrides() { - [ ] queued-comma - Queued Comma Task (repo: beta, since 2026-07-08) (kind: ship) - [ ] parenthetical-title - Refresh sidebar (mobile) (repo: beta) (kind: ship) - [ ] blocked-reason - Blocked Reason (repo: beta) (kind: ship) blocked-by: queued-comma - waits on queued-comma +- [ ] sample-decision-route - Choose sample route (repo: sample) (kind: captain) (since 2026-07-14) (hold: captain route choice pending) (hold-kind: captain) ## Done - [x] done-comma - Done Comma Task https://github.com/kunchenguid/firstmate/pull/42 (repo: gamma, merged 2026-07-09) (kind: ship) @@ -315,6 +316,14 @@ EOF and .blocked_by == "queued-comma" and .blocked_reason == "waits on queued-comma" ' >/dev/null || fail "blocked suffix did not parse into title and reason" + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "sample-decision-route") + | .title == "Choose sample route" + and .repo == "sample" + and .kind == "captain" + and .hold_reason == "captain route choice pending" + and .hold_kind == "captain" + ' >/dev/null || fail "tasks-axi captain-hold metadata did not parse" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "done-comma") | .repo == "gamma" @@ -464,6 +473,31 @@ test_secondmate_open_decision_survives_live_endpoint() { # An open decision clears ONLY on an explicit resolution referencing its key, never # on an unrelated terminal line. +test_open_decision_transfers_to_captain_hold() { + local home fakebin out + home=$(make_home captain-held-transfer) + mkdir -p "$home/secondmate-home" + fm_write_meta "$home/state/transferred-decision.meta" \ + "window=firstmate:fm-transferred-decision" \ + "worktree=$home/secondmate-home" \ + "project=$home/secondmate-home" \ + "harness=codex" \ + "kind=secondmate" \ + "mode=secondmate" \ + "home=$home/secondmate-home" \ + "projects=sample" + printf 'needs-decision [key=route]: choose a sample route\n' > "$home/state/transferred-decision.status" + printf 'captain-held [key=route]: tracked by transferred-decision-route\n' >> "$home/state/transferred-decision.status" + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + .tasks[] | select(.id == "transferred-decision") + | .hints.pending_decision == false + and (.hints.open_decisions | length) == 0 + ' >/dev/null || fail "captain-held transfer must close only the duplicate status copy: $out" + pass "durable captain-held transfer closes the duplicate live status decision" +} + test_open_decision_clears_on_keyed_resolution() { local home fakebin out home=$(make_home resolution) @@ -557,6 +591,7 @@ test_fixture_snapshot_json test_event_hints_follow_reconciled_current_state test_open_decision_survives_later_unrelated_event test_secondmate_open_decision_survives_live_endpoint +test_open_decision_transfers_to_captain_hold test_open_decision_clears_on_keyed_resolution test_completed_scout_report_is_pointer_not_pending test_parked_scout_decision_stays_pending diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 185fcd49b..dbf462204 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -65,6 +65,8 @@ make_fake_root() { ln -s "$ROOT/bin/fm-ack-lib.sh" "$fake/bin/fm-ack-lib.sh" # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" + # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. + ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -171,6 +173,8 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-ack-lib.sh" "$fake/bin/fm-ack-lib.sh" # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" + # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. + ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 diff --git a/tests/fm-guard-stale-banner.test.sh b/tests/fm-guard-stale-banner.test.sh new file mode 100755 index 000000000..862eb9c70 --- /dev/null +++ b/tests/fm-guard-stale-banner.test.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Regression tests for fm-guard's stale-watcher banner deduplication. +# +# The first stale command in one FM_HOME must print the full actionable watcher +# banner. +# Repeated commands in that same stale episode should print only a concise +# reminder, while unrelated alarms such as queued wakes stay independent. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-guard-stale-banner) + +make_guard_case() { + local name=$1 dir home root + dir="$TMP_ROOT/$name" + home="$dir/home" + root="$dir/root" + mkdir -p "$home/state" "$home/config" "$root" + fm_write_meta "$home/state/task.meta" "window=firstmate:fm-task" "kind=ship" + printf '%s\n' "$dir" +} + +case_home() { + printf '%s/home\n' "$1" +} + +case_root() { + printf '%s/root\n' "$1" +} + +run_guard_case() { + local dir=$1 + FM_ROOT_OVERRIDE="$(case_root "$dir")" \ + FM_HOME="$(case_home "$dir")" \ + FM_GUARD_GRACE=999 \ + "$ROOT/bin/fm-guard.sh" 2>&1 +} + +run_guard_case_read_only() { + local dir=$1 + FM_ROOT_OVERRIDE="$(case_root "$dir")" \ + FM_HOME="$(case_home "$dir")" \ + FM_GUARD_GRACE=999 \ + FM_GUARD_READ_ONLY=1 \ + "$ROOT/bin/fm-guard.sh" 2>&1 +} + +count_text() { + local haystack=$1 needle=$2 + awk -v needle="$needle" 'index($0, needle) { c++ } END { print c + 0 }' <<EOF +$haystack +EOF +} + +test_first_stale_call_prints_full_banner() { + local dir out + dir=$(make_guard_case first-stale) + out=$(run_guard_case "$dir") + [ "$(count_text "$out" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "first stale guard call did not print exactly one full banner: $out" + assert_contains "$out" "Trust the emitted supervision protocol" \ + "full banner must keep the actionable watcher-repair instruction" + assert_contains "$out" "WILL still run" \ + "full banner must keep the guarded-operation continuation line" + pass "fm-guard stale banner: first stale call prints the full actionable banner" +} + +test_repeated_same_episode_prints_reminder_only() { + local dir out1 out2 marker lines + dir=$(make_guard_case repeated-stale) + out1=$(run_guard_case "$dir") + out2=$(run_guard_case "$dir") + [ "$(count_text "$out1" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "first stale call did not print the full banner: $out1" + [ "$(count_text "$out2" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 0 ] \ + || fail "second stale call repeated the full banner: $out2" + assert_contains "$out2" "full banner already printed this episode" \ + "second stale call did not print the concise reminder" + marker="$(case_home "$dir")/state/.guard-watcher-stale-banner" + assert_present "$marker" "stale banner marker was not written under the owning home" + lines=$(awk 'END { print NR + 0 }' "$marker") + [ "$lines" -le 1 ] || fail "stale banner marker must stay bounded to one line, got $lines" + pass "fm-guard stale banner: repeated same-episode calls print a concise reminder only" +} + +test_healthy_recovery_rearms_next_stale_episode() { + local dir home out1 healthy out2 + dir=$(make_guard_case healthy-recovery) + home=$(case_home "$dir") + out1=$(run_guard_case "$dir") + [ "$(count_text "$out1" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "first stale episode did not print the full banner: $out1" + + touch "$home/state/.last-watcher-beat" + healthy=$(run_guard_case "$dir") + [ -z "$healthy" ] || fail "guard should be silent after watcher recovery, got: $healthy" + assert_absent "$home/state/.guard-watcher-stale-banner" \ + "healthy recovery must clear the stale-banner marker" + + rm -f "$home/state/.last-watcher-beat" + out2=$(run_guard_case "$dir") + [ "$(count_text "$out2" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "second stale episode did not re-print the full banner: $out2" + pass "fm-guard stale banner: healthy recovery rearms the next stale episode" +} + +test_concurrent_same_episode_prints_one_full_banner() { + local dir out_dir i pids pid all full reminders + dir=$(make_guard_case concurrent-stale) + out_dir="$dir/outs" + mkdir -p "$out_dir" + pids= + i=1 + while [ "$i" -le 30 ]; do + ( + run_guard_case "$dir" > "$out_dir/$i.out" 2>&1 + ) & + pids="$pids $!" + i=$((i + 1)) + done + for pid in $pids; do + wait "$pid" 2>/dev/null || fail "concurrent guard subprocess failed" + done + all=$(cat "$out_dir"/*.out) + full=$(count_text "$all" "WATCHER DOWN - SUPERVISION IS OFF") + reminders=$(count_text "$all" "full banner already printed this episode") + [ "$full" -eq 1 ] || fail "concurrent same-episode calls printed $full full banners"$'\n'"$all" + [ "$reminders" -eq 29 ] || fail "concurrent same-episode calls printed $reminders reminders, expected 29"$'\n'"$all" + pass "fm-guard stale banner: concurrent same-episode calls claim exactly one full banner" +} + +test_home_isolation() { + local dir_a dir_b out_a1 out_a2 out_b1 + dir_a=$(make_guard_case home-a) + dir_b=$(make_guard_case home-b) + out_a1=$(run_guard_case "$dir_a") + out_b1=$(run_guard_case "$dir_b") + out_a2=$(run_guard_case "$dir_a") + [ "$(count_text "$out_a1" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "home A first stale call did not print a full banner: $out_a1" + [ "$(count_text "$out_b1" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "home B first stale call was suppressed by home A: $out_b1" + assert_contains "$out_a2" "full banner already printed this episode" \ + "home A repeated stale call did not remember its own episode" + pass "fm-guard stale banner: deduplication is isolated per FM_HOME" +} + +test_queued_wake_warning_stays_independent() { + local dir home out1 out2 + dir=$(make_guard_case queued-wake) + home=$(case_home "$dir") + out1=$(run_guard_case "$dir") + [ "$(count_text "$out1" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "first stale call did not print the full banner before queued wake case: $out1" + printf 'signal: %s/state/task.status\n' "$home" > "$home/state/.wake-queue" + out2=$(run_guard_case "$dir") + assert_contains "$out2" "full banner already printed this episode" \ + "same-episode stale call should still print its concise reminder" + assert_contains "$out2" "queued wakes pending" \ + "queued wake warning must not be suppressed by stale-banner deduplication" + pass "fm-guard stale banner: queued-wake warning remains independent" +} + +test_read_only_before_writable_does_not_consume_full_banner() { + local dir home marker lock out_ro out_rw + dir=$(make_guard_case read-only-before-writable) + home=$(case_home "$dir") + marker="$home/state/.guard-watcher-stale-banner" + lock="$home/state/.guard-watcher-stale-banner.lock" + + out_ro=$(run_guard_case_read_only "$dir") + [ "$(count_text "$out_ro" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "read-only stale call should print the advisory full banner: $out_ro" + assert_absent "$marker" "read-only stale call must not create the stale-banner marker" + assert_absent "$lock" "read-only stale call must not create the stale-banner lock" + + out_rw=$(run_guard_case "$dir") + [ "$(count_text "$out_rw" "WATCHER DOWN - SUPERVISION IS OFF")" -eq 1 ] \ + || fail "writable stale call should still receive the full banner after read-only: $out_rw" + assert_present "$marker" "writable stale call should claim the stale-banner marker" + pass "fm-guard stale banner: read-only before writable does not consume full banner" +} + +test_read_only_during_episode_observes_without_mutating_marker() { + local dir home marker before after out_ro + dir=$(make_guard_case read-only-during-episode) + home=$(case_home "$dir") + marker="$home/state/.guard-watcher-stale-banner" + + run_guard_case "$dir" >/dev/null + before=$(cat "$marker") + out_ro=$(run_guard_case_read_only "$dir") + after=$(cat "$marker") + assert_contains "$out_ro" "full banner already printed this episode" \ + "read-only stale call during a claimed episode should print the concise reminder" + [ "$after" = "$before" ] || fail "read-only stale call must not update an existing marker" + pass "fm-guard stale banner: read-only during episode observes without mutating marker" +} + +test_healthy_read_only_does_not_clear_marker() { + local dir home marker before after healthy + dir=$(make_guard_case healthy-read-only) + home=$(case_home "$dir") + marker="$home/state/.guard-watcher-stale-banner" + + run_guard_case "$dir" >/dev/null + before=$(cat "$marker") + touch "$home/state/.last-watcher-beat" + healthy=$(run_guard_case_read_only "$dir") + [ -z "$healthy" ] || fail "healthy read-only guard should stay silent, got: $healthy" + assert_present "$marker" "healthy read-only guard must not clear the stale-banner marker" + after=$(cat "$marker") + [ "$after" = "$before" ] || fail "healthy read-only guard must not update the marker" + pass "fm-guard stale banner: healthy read-only does not clear marker" +} + +test_read_only_never_mutates_stale_banner_state_files() { + local dir home marker lock before after no_work + dir=$(make_guard_case read-only-state-nonmutation) + home=$(case_home "$dir") + marker="$home/state/.guard-watcher-stale-banner" + lock="$home/state/.guard-watcher-stale-banner.lock" + printf '%s\n' "sentinel-marker" > "$marker" + + before=$(find "$home/state" -maxdepth 1 -mindepth 1 -name '.guard-watcher-stale-banner*' -print | sort) + run_guard_case_read_only "$dir" >/dev/null + after=$(find "$home/state" -maxdepth 1 -mindepth 1 -name '.guard-watcher-stale-banner*' -print | sort) + [ "$after" = "$before" ] || fail "stale read-only guard changed stale-banner state files"$'\n'"before: $before"$'\n'"after: $after" + [ "$(cat "$marker")" = "sentinel-marker" ] || fail "stale read-only guard updated the marker content" + assert_absent "$lock" "stale read-only guard must not create the stale-banner lock" + + rm -f "$home/state/task.meta" + no_work=$(run_guard_case_read_only "$dir") + [ -z "$no_work" ] || fail "read-only guard with no in-flight work should stay silent, got: $no_work" + after=$(find "$home/state" -maxdepth 1 -mindepth 1 -name '.guard-watcher-stale-banner*' -print | sort) + [ "$after" = "$before" ] || fail "no-work read-only guard changed stale-banner state files"$'\n'"before: $before"$'\n'"after: $after" + [ "$(cat "$marker")" = "sentinel-marker" ] || fail "no-work read-only guard updated the marker content" + pass "fm-guard stale banner: read-only never mutates stale-banner state files" +} + +test_first_stale_call_prints_full_banner +test_repeated_same_episode_prints_reminder_only +test_healthy_recovery_rearms_next_stale_episode +test_concurrent_same_episode_prints_one_full_banner +test_home_isolation +test_queued_wake_warning_stays_independent +test_read_only_before_writable_does_not_consume_full_banner +test_read_only_during_episode_observes_without_mutating_marker +test_healthy_read_only_does_not_clear_marker +test_read_only_never_mutates_stale_banner_state_files diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index ad3969442..c004226aa 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -87,13 +87,16 @@ run_with_fake() { } test_refuses_unsafe_names() { - local status=0 + local status=0 generated fm_herdr_lab_validate_name default >/dev/null 2>&1 || status=$? expect_code 1 "$status" "literal default must be refused" status=0 fm_herdr_lab_validate_name arbitrary-session >/dev/null 2>&1 || status=$? expect_code 1 "$status" "non-lab prefix must be refused" fm_herdr_lab_validate_name fm-lab-safe-123 || fail "valid lab session name was refused" + generated=$(fm_herdr_lab_name fm-autodetect-smoke-concurrency-h3) + fm_herdr_lab_validate_name "$generated" || fail "generated lab session name was refused" + [ "${#generated}" -le 40 ] || fail "generated lab session name is too long for Herdr socket paths: $generated" pass "fm-herdr-lab: names fail closed and require the lab prefix" } diff --git a/tests/fm-instruction-owners.test.sh b/tests/fm-instruction-owners.test.sh new file mode 100755 index 000000000..62a0e114b --- /dev/null +++ b/tests/fm-instruction-owners.test.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# Static contract tests for conditional instruction owners introduced before the +# AGENTS.md reduction pass. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +DIAG="$ROOT/.agents/skills/diagnostic-reasoning/SKILL.md" +PROJECT="$ROOT/.agents/skills/project-management/SKILL.md" +HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" +CODING="$ROOT/.agents/skills/firstmate-coding-guidelines/SKILL.md" +RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" +SECONDMATE="$ROOT/.agents/skills/secondmate-provisioning/SKILL.md" +CONFIG="$ROOT/docs/configuration.md" +AGENTS="$ROOT/AGENTS.md" +BRIEF="$ROOT/bin/fm-brief.sh" + +test_new_skill_metadata_and_triggers() { + local skill name count + for pair in "diagnostic-reasoning:$DIAG" "project-management:$PROJECT"; do + name=${pair%%:*} + skill=${pair#*:} + assert_present "$skill" "$name skill is missing" + assert_grep "name: $name" "$skill" "$name skill metadata has the wrong name" + assert_grep "user-invocable: false" "$skill" "$name skill must not be user-invocable" + assert_grep " internal: true" "$skill" "$name skill must be internal" + count=$(grep -Fc -- "- \`$name\` -" "$ROOT/AGENTS.md") + [ "$count" -eq 1 ] || fail "$name must have exactly one AGENTS.md trigger entry, found $count" + done + assert_grep 'Use before scoping a reported bug and before acting on a diagnostic report.' "$DIAG" \ + "diagnostic skill metadata lost its precise load trigger" + assert_grep '`diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report.' "$ROOT/AGENTS.md" \ + "AGENTS.md lost the diagnostic-reasoning trigger" + assert_grep 'Use before adding, creating, removing, or initializing a project.' "$PROJECT" \ + "project-management skill metadata lost its precise load trigger" + assert_grep '`project-management` - load before adding, creating, removing, or initializing a project.' "$ROOT/AGENTS.md" \ + "AGENTS.md lost the project-management trigger" + pass "new internal skills have one precise AGENTS.md trigger each" +} + +test_diagnostic_owner_covers_causal_procedure() { + assert_grep "single owner of Firstmate's bug-diagnosis reasoning procedure" "$DIAG" \ + "diagnostic skill does not declare ownership" + for phrase in \ + "end-to-end reproduction aligned with the real user path" \ + "initiating trigger" \ + "masking condition" \ + "visible symptom" \ + "proven path" \ + "relevant history" \ + "smallest counterfactual" \ + "disconfirming evidence"; do + assert_grep "$phrase" "$DIAG" "diagnostic owner is missing '$phrase'" + done + assert_grep "evidence, not authorization to change code" "$DIAG" \ + "diagnostic owner lost the diagnosis-only authority boundary" + pass "diagnostic-reasoning owns the approved evidence procedure" +} + +test_project_management_owner_covers_guarded_operations() { + assert_grep "single owner of Firstmate's project-management procedure" "$PROJECT" \ + "project-management skill does not declare ownership" + for phrase in \ + 'bin/fm-project-mode.sh' \ + '`no-mistakes`' \ + '`direct-PR`' \ + '`local-only`' \ + 'Default it off' \ + 'Creating a GitHub repository is outward-facing.' \ + "captain's explicit consent" \ + 'Never issue a raw removal command from Firstmate.' \ + 'no-mistakes init && no-mistakes doctor'; do + assert_grep "$phrase" "$PROJECT" "project-management owner is missing '$phrase'" + done + pass "project-management owns registry, delivery posture, consent, initialization, and removal safety" +} + +test_generic_effort_fallback_respects_precedence() { + local section + section=$(awk ' + /^Effort precedence is / { found = 1 } + found && /^The supported launch-profile flags / { exit } + found { print } + ' "$HARNESS") + assert_contains "$section" "explicit per-task captain instruction first" \ + "effort rubric lost per-task captain precedence" + assert_contains "$section" "standing dispatch profile or secondmate pin" \ + "effort rubric lost standing configuration precedence" + assert_contains "$section" 'Use `low` for well-understood work' \ + "effort rubric lost its low fallback" + assert_contains "$section" '`xhigh` for ambiguous investigation or design' \ + "effort rubric lost its xhigh fallback" + assert_contains "$section" "Choose intermediate levels proportionally" \ + "effort rubric lost proportional intermediate levels" + assert_contains "$section" 'Never select `max` from this fallback' \ + "effort rubric permits max without an explicit captain preference" + if printf '%s\n' "$section" | grep -qi sol; then + fail "generic effort fallback must not contain Sol-specific policy" + fi + pass "generic effort fallback applies only below captain and standing configuration" +} + +test_shared_authoring_requirements_are_owned() { + assert_grep "review every affected supported primary harness and runtime backend" "$CODING" \ + "coding guidance lost the supported compatibility matrix review" + assert_grep "prefer deterministic and idempotent enforcement over relying on agent memory alone" "$CODING" \ + "coding guidance lost deterministic idempotent enforcement" + assert_grep "critical safety, routing, startup, and supervision infrastructure" "$CODING" \ + "coding guidance lost the critical infrastructure scope" + pass "firstmate-coding-guidelines owns compatibility review and deterministic enforcement" +} + +test_secondmate_registry_contract_stays_concise() { + local guidance routing_section schema_line + routing_section=$(awk ' + /^## Routing table$/ { found = 1 } + found && /^## Charter and seed$/ { exit } + found { print } + ' "$SECONDMATE") + guidance=$(awk ' + /^## Routing table$/ { found = 1 } + found && /^## Backlog handoff$/ { exit } + found { print } + ' "$SECONDMATE") + schema_line="- <id> - <one-sentence charter summary> (home: <absolute-home-path>; scope: <natural-language responsibility>; projects: <project-a>, <project-b>; added <date>)" + assert_contains "$routing_section" "$schema_line" \ + "secondmate routing table lost the parser-compatible single-line schema" + assert_contains "$routing_section" "Each registry entry stays concise and single-line" \ + "secondmate routing table no longer requires concise single-line entries" + assert_contains "$routing_section" "genuinely domain-specific hard rules" \ + "secondmate routing table no longer limits extra prose to domain-specific hard rules" + assert_contains "$routing_section" "The home-seeded \`data/charter.md\` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts" \ + "secondmate routing table lost the explicit charter ownership pointer" + assert_contains "$routing_section" "no extra registry pointer field is needed" \ + "secondmate routing table no longer explains why the existing home field is the charter pointer" + for phrase in \ + "go idle and wait silently" \ + "Act only on tasks" \ + "never spawn a survey" \ + "run normal firstmate bootstrap" \ + "escalation back to the main firstmate status file" \ + "requests-from-main-firstmate contract" \ + "waits for routed tasks, never self-initiating a survey or audit" \ + "marked supervisor requests return through status" \ + "unmarked captain messages stay conversational"; do + if printf '%s\n' "$guidance" | grep -F "$phrase" >/dev/null; then + fail "secondmate provisioning guidance restated charter boilerplate: $phrase" + fi + done + pass "secondmate registry guidance keeps concise routes and points to the charter" +} + +test_state_startup_and_ordinary_recovery_placement() { + assert_grep "single owner of the top-level operational-home layout" "$CONFIG" \ + "configuration docs do not own the operational state layout" + assert_grep "header is the single owner of session-start ordering" "$CONFIG" \ + "session-start mechanism is not assigned to the script header" + assert_grep "Ordinary dead-direct-report recovery is owned by \`stuck-crewmate-recovery\`" "$CONFIG" \ + "D05 ordinary recovery placement is missing" + assert_grep "## Session-start reconciliation for a dead ordinary direct report" "$RECOVERY" \ + "stuck-crewmate-recovery lacks the dead ordinary direct-report procedure" + assert_grep "treehouse status" "$RECOVERY" \ + "ordinary recovery lost treehouse inventory inspection" + assert_grep "recorded \`orca_worktree_id=\` and \`terminal=\`" "$RECOVERY" \ + "ordinary recovery lost Orca inventory inspection" + assert_grep "session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window" "$AGENTS" \ + "AGENTS.md does not trigger ordinary dead-report recovery" + pass "state, startup, and ordinary recovery have focused owners and triggers" +} + +test_compressed_agents_owner_map() { + assert_grep '`docs/configuration.md` is the single owner of the operational-home layout' "$AGENTS" \ + "AGENTS.md lost the state-layout owner pointer" + assert_grep 'header is the single owner of composed commands, ordering, digest contents' "$AGENTS" \ + "AGENTS.md lost the session-start owner pointer" + assert_grep '`docs/configuration.md` owns dispatch-profile and runtime-backend schemas' "$AGENTS" \ + "AGENTS.md lost the dispatch-schema owner pointer" + assert_grep 'That skill owns registry syntax, delivery-mode selection' "$AGENTS" \ + "AGENTS.md lost the project-management owner pointer" + assert_grep 'The delivery lifecycle is an always-loaded operational contract' "$AGENTS" \ + "AGENTS.md no longer owns the delivery lifecycle" + assert_grep 'Fleet supervision is an always-loaded operational contract' "$AGENTS" \ + "AGENTS.md no longer owns fleet supervision" + assert_grep '`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema' "$AGENTS" \ + "AGENTS.md lost the backlog-mechanics owner pointer" + assert_grep '`bin/fm-brief.sh` and its help own scaffold syntax' "$AGENTS" \ + "AGENTS.md lost the brief-mechanics owner pointer" + assert_grep '`docs/configuration.md` owns activation, generated state, cadence, wire protocol' "$AGENTS" \ + "AGENTS.md lost the X-mode mechanics owner pointer" + pass "compressed AGENTS.md records the approved one-owner map" +} + +test_compressed_agents_retains_authority_and_supervision_safety() { + for phrase in \ + 'A lock-refused session must not spawn, steer, merge, drain the wake queue' \ + 'A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code.' \ + 'The selected delivery path owns its own rigor.' \ + 'When no-mistakes is selected, no-mistakes alone owns review, fixes, tests, documentation, push, PR, and CI; otherwise follow the faster path without adding an independent reviewer.' \ + 'Never hold work outside no-mistakes for a manual clean verdict, stack serial manual reviews, or infer authority for one from security, architecture, or risk alone.' \ + 'A separate review or audit is allowed only when the captain explicitly requests that deliverable or the authorized task is a knowledge-only review; one named question remains scoped to that question.' \ + 'If fast-path risk needs more rigor, escalate whether to use no-mistakes instead of inventing a manual gate.' \ + '**local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority' \ + 'A status line is a wake event, not current state' \ + 'keep exactly one live supervision cycle' \ + 'Never broadly kill watchers' \ + 'While `state/.afk` exists, the daemon owns supervision' \ + 'post the final completion follow-up before teardown'; do + assert_grep "$phrase" "$AGENTS" "compressed AGENTS.md lost safety phrase '$phrase'" + done + assert_no_grep 'Firstmate does not personally review code or deliverables' "$AGENTS" \ + "AGENTS.md retained the weaker duplicate review prohibition" + assert_no_grep 'firstmate reviews your branch' "$AGENTS" \ + "AGENTS.md retained a personal branch-review requirement" + assert_no_grep 'firstmate reviews, captain approves' "$BRIEF" \ + "generated brief retained a stacked personal-review requirement" + if grep -q "$(printf '\342\200\224')" "$AGENTS"; then + fail "AGENTS.md contains an em dash" + fi + pass "compressed AGENTS.md retains authority, supervision, AFK, and X safety" +} + +test_new_skill_metadata_and_triggers +test_diagnostic_owner_covers_causal_procedure +test_project_management_owner_covers_guarded_operations +test_generic_effort_fallback_respects_precedence +test_shared_authoring_requirements_are_owned +test_secondmate_registry_contract_stays_concise +test_state_startup_and_ordinary_recovery_placement +test_compressed_agents_owner_map +test_compressed_agents_retains_authority_and_supervision_safety diff --git a/tests/fm-no-mistakes-ownership.test.sh b/tests/fm-no-mistakes-ownership.test.sh new file mode 100755 index 000000000..b7e7fc2a6 --- /dev/null +++ b/tests/fm-no-mistakes-ownership.test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Static contract tests for crew-owned no-mistakes validation runs. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +validate_contract() { + awk ' + /^### Validate$/ { found = 1; next } + found && /^### / { exit } + found { print } + ' "$ROOT/AGENTS.md" +} + +test_worker_owns_synchronous_driver() { + local contract + contract=$(validate_contract) + + assert_contains "$contract" 'The task worker that starts a no-mistakes run drives the pipeline' \ + "Validate contract does not assign the run to its initiating task worker" + assert_contains "$contract" "owns every \`no-mistakes axi run\` and \`no-mistakes axi respond\` call through the next gate or outcome" \ + "Validate contract does not assign every synchronous driver call to the task worker" + assert_contains "$contract" 'process every synchronous return until completion or a genuinely new escalation' \ + "Validate contract does not require the task worker to process every synchronous return" + pass "Validate contract assigns the complete synchronous driver loop to the initiating task worker" +} + +test_firstmate_never_responds_for_crew_run() { + local contract + contract=$(validate_contract) + + assert_contains "$contract" "Firstmate never invokes \`no-mistakes axi respond\` for a crew-owned run." \ + "Validate contract permits Firstmate to respond directly for a crew-owned run" + pass "Validate contract forbids Firstmate from responding directly for a crew-owned run" +} + +test_worker_owns_synchronous_driver +test_firstmate_never_responds_for_crew_run diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 5aaba7fc1..1a881bc7a 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -7,6 +7,10 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-pi-watch-extension) EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" +# Node 24 warns when these test-only dynamic imports load tracked ESM plugins +# from a clean checkout with no tracked .opencode/package.json. The warning is +# unrelated to plugin output, which the assertions intentionally require empty. +export NODE_NO_WARNINGS=1 install_pi_watch_extension_fixture() { local repo=$1 @@ -37,12 +41,16 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" ".pi-watch-extension-loaded" "tracked extension missing loaded marker" assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" - assert_contains "$text" "sessionOwnsLock" "tracked extension missing session lock ownership check" assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "other"' "tracked extension does not distinguish missing lock from another owner" assert_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension does not read the effective session lock" assert_contains "$text" 'return pidAlive(lockPid) ? "other" : "missing"' "tracked extension does not allow a pre-lock load marker" assert_contains "$text" 'if (lockOwnership() === "other") return' "tracked extension overwrites another live session marker" - assert_contains "$text" "if (!sessionOwnsLock()) return { ok: false" "tracked extension arms without the session lock" + assert_contains "$text" 'const ownership = lockOwnership()' "tracked extension arm does not inspect the distinct lock ownership state" + assert_contains "$text" 'if (ownership === "other") return { ok: false' "tracked extension arm does not preserve the live-other read-only refusal" + assert_contains "$text" 'if (ownership === "missing")' "tracked extension arm collapses a stale or absent lock into the live-other refusal" + assert_contains "$text" "no live session holds the lock" "tracked extension arm missing stale-lock recovery guidance" + assert_contains "$text" "run bin/fm-session-start.sh to reclaim it" "tracked extension arm does not direct stale-lock reclamation" + assert_contains "$text" "call fm_watch_arm_pi to re-arm" "tracked extension arm does not direct supervision re-arm" assert_contains "$text" "writeFileSync(marker, \`\${extensionVersion}\\n\${process.pid}\\n\`)" "tracked extension does not write the content version and process marker" assert_contains "$text" "const config = process.env.FM_CONFIG_OVERRIDE" "tracked extension missing effective config resolution" assert_contains "$text" "FM_CONFIG_OVERRIDE: config" "tracked extension does not pass the effective config to the watcher arm" @@ -121,7 +129,7 @@ if (!notification.includes("started Pi extension arm child")) { console.error(notification); process.exit(1); } -for (let i = 0; i < 50 && !prompt; i += 1) { +for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!prompt.includes("FIRSTMATE WATCHER WAKE")) { @@ -193,6 +201,87 @@ EOF pass "Pi custom tool returns text content and structured details" } +test_pi_arm_distinguishes_session_lock_ownership() { + local repo home plugin log out status + repo="$TMP_ROOT/pi-lock-ownership-root" + home="$TMP_ROOT/pi-lock-ownership-home" + log="$TMP_ROOT/pi-lock-ownership.log" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm\n' >> "${FM_ARM_LOG:?}" +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" node --input-type=module 2>&1 <<'EOF' +import { existsSync, unlinkSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +let tool = null; +const pi = { + on() {}, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async () => {}, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +if (!tool) throw new Error("Pi watch tool was not registered"); + +const lock = `${process.env.FM_HOME}/state/.lock`; +const callArm = () => tool.execute("tool-call-lock", {}, undefined, undefined, {}); +const assertMissingLock = (result, label) => { + if (result.details?.ok !== false) throw new Error(`${label} unexpectedly armed: ${JSON.stringify(result.details)}`); + if (!result.details.message.includes("no live session holds the lock")) { + throw new Error(`${label} missing no-live-session guidance: ${result.details.message}`); + } + if (!result.details.message.includes("bin/fm-session-start.sh") || !result.details.message.includes("re-arm")) { + throw new Error(`${label} missing reclaim and re-arm guidance: ${result.details.message}`); + } + if (result.details.message.includes("held by another firstmate session")) { + throw new Error(`${label} was misreported as a live other holder: ${result.details.message}`); + } +}; + +if (existsSync(lock)) unlinkSync(lock); +assertMissingLock(await callArm(), "absent lock"); +writeFileSync(lock, "999999\n"); +assertMissingLock(await callArm(), "dead lock holder"); + +const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); +try { + writeFileSync(lock, `${other.pid}\n`); + const liveOther = await callArm(); + if (liveOther.details?.ok !== false) throw new Error(`live other holder unexpectedly armed: ${JSON.stringify(liveOther.details)}`); + if (liveOther.details.message !== "watcher: read-only - session lock is held by another firstmate session") { + throw new Error(`unexpected live-other response: ${liveOther.details.message}`); + } +} finally { + other.kill("SIGTERM"); +} + +if (existsSync(process.env.FM_ARM_LOG)) throw new Error("watcher arm ran without lock ownership"); +writeFileSync(lock, `${process.pid}\n`); +const owned = await callArm(); +if (owned.details?.ok !== true || !owned.details.message.includes("started Pi extension arm child")) { + throw new Error(`owned lock did not arm: ${JSON.stringify(owned.details)}`); +} +for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("owned lock did not run the watcher arm"); +EOF +) + status=$? + expect_code 0 "$status" "Pi watcher arm must distinguish owned, live-other, and missing or dead session locks" + [ -z "$out" ] || fail "Pi lock-ownership arm test printed output: $out" + pass "Pi watcher arm distinguishes all session lock ownership states" +} + test_pi_process_exit_cleanup_listener_lifecycle() { local repo home plugin out status repo="$TMP_ROOT/pi-exit-listener-root" @@ -265,7 +354,7 @@ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); await tool.execute("tool-call-exit", {}, undefined, undefined, {}); -for (let i = 0; i < 50 && !existsSync(process.env.FM_CHILD_PID_FILE); i += 1) { +for (let i = 0; i < 250 && !existsSync(process.env.FM_CHILD_PID_FILE); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_CHILD_PID_FILE)) throw new Error("arm child did not start"); @@ -276,7 +365,7 @@ EOF expect_code 0 "$status" "Pi process exit must run the watcher cleanup fallback" [ -z "$out" ] || fail "Pi process-exit cleanup test printed output: $out" i=0 - while [ "$i" -lt 50 ] && [ ! -f "$cleanup_log" ]; do + while [ "$i" -lt 250 ] && [ ! -f "$cleanup_log" ]; do sleep 0.02 i=$((i + 1)) done @@ -290,9 +379,12 @@ EOF } test_opencode_primary_watch_plugin_static_wiring() { - local plugin text + local plugin module_boundary text plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" + module_boundary="$ROOT/.opencode/plugins/package.json" assert_present "$plugin" "OpenCode primary watch plugin missing" + assert_present "$module_boundary" "OpenCode plugin ESM package boundary missing" + assert_contains "$(cat "$module_boundary")" '"type": "module"' "OpenCode plugin package boundary is not explicitly ESM" text=$(cat "$plugin") assert_contains "$text" "session.idle" "OpenCode plugin does not listen for session.idle" assert_contains "$text" "fm-watch-arm.sh" "OpenCode plugin does not spawn the watcher arm" @@ -305,6 +397,25 @@ test_opencode_primary_watch_plugin_static_wiring() { pass "OpenCode primary watcher plugin has the verified TUI wake wiring" } +test_opencode_plugin_package_boundary_is_explicit_esm() { + local fixture plugin out status + fixture="$TMP_ROOT/opencode-esm-boundary/.opencode" + plugin="$fixture/plugins/fm-primary-watch-arm.js" + mkdir -p "$fixture/plugins" + printf '%s\n' '{"dependencies":{}}' > "$fixture/package.json" + cp "$ROOT/.opencode/plugins/package.json" "$fixture/plugins/package.json" + cp "$ROOT/.opencode/plugins/fm-primary-watch-arm.js" "$plugin" + out=$(PLUGIN="$plugin" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; +await import(pathToFileURL(process.env.PLUGIN).href); +EOF +) + status=$? + expect_code 0 "$status" "OpenCode plugin must import beneath an explicit ESM package boundary" + [ -z "$out" ] || fail "OpenCode ESM boundary import printed output: $out" + pass "OpenCode plugins have an explicit ESM boundary even under a typeless parent package" +} + test_opencode_primary_watch_plugin_uses_effective_state_home() { local plugin repo home log out status plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" @@ -334,7 +445,7 @@ const hooks = await mod.FmPrimaryWatchArm({ }); writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } }); -for (let i = 0; i < 50 && !existsSync(process.env.FM_ARM_LOG); i += 1) { +for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_ARM_LOG)) { @@ -384,7 +495,7 @@ const hooks = await mod.FmPrimaryWatchArm({ }); writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } }); -for (let i = 0; i < 50 && !existsSync(process.env.FM_ARM_LOG); i += 1) { +for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_ARM_LOG)) { @@ -441,7 +552,7 @@ if (existsSync(process.env.FM_ARM_LOG)) { } writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await hooks.event(event); -for (let i = 0; i < 50 && !existsSync(process.env.FM_ARM_LOG); i += 1) { +for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_ARM_LOG)) { @@ -526,7 +637,7 @@ import { pathToFileURL } from "node:url"; const mod = await import(pathToFileURL(process.env.PLUGIN).href); let prompts = 0; const waitForPrompts = async (expected) => { - for (let i = 0; i < 50; i += 1) { + for (let i = 0; i < 250; i += 1) { if (prompts >= expected) return; await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -609,7 +720,7 @@ const guardHooks = await guardMod.FmPrimaryTurnendGuard({ }); writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await guardHooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } }); -for (let i = 0; i < 50 && !existsSync(process.env.FM_ARM_LOG); i += 1) { +for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_ARM_LOG)) { @@ -682,7 +793,7 @@ const guardHooks = await guardMod.FmPrimaryTurnendGuard({ }); writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await guardHooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } }); -for (let i = 0; i < 50 && !existsSync(process.env.FM_GUARD_LOG); i += 1) { +for (let i = 0; i < 250 && !existsSync(process.env.FM_GUARD_LOG); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_ARM_LOG)) { @@ -713,9 +824,11 @@ test_tracked_extension_present_and_self_hashing test_spawn_template_mentions_pi_watch_placeholder test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result +test_pi_arm_distinguishes_session_lock_ownership test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child test_opencode_primary_watch_plugin_static_wiring +test_opencode_plugin_package_boundary_is_explicit_esm test_opencode_primary_watch_plugin_uses_effective_state_home test_opencode_primary_watch_plugin_sources_effective_config test_opencode_primary_watch_plugin_requires_session_lock diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh new file mode 100755 index 000000000..9d4154e50 --- /dev/null +++ b/tests/fm-pr-check-security.test.sh @@ -0,0 +1,2708 @@ +#!/usr/bin/env bash +# Security and regression tests for canonical PR parsing, static merge polls, +# private atomic artifacts, non-executing migration, and teardown cleanup. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-pr-lib.sh disable=SC1091 +. "$ROOT/bin/fm-pr-lib.sh" +# shellcheck source=bin/fm-x-lib.sh disable=SC1091 +. "$ROOT/bin/fm-x-lib.sh" +# shellcheck source=bin/fm-check-lib.sh disable=SC1091 +. "$ROOT/bin/fm-check-lib.sh" + +PR_CHECK="$ROOT/bin/fm-pr-check.sh" +PR_MERGE="$ROOT/bin/fm-pr-merge.sh" +MIGRATE="$ROOT/bin/fm-pr-check-migrate.sh" +POLL="$ROOT/bin/fm-pr-poll.sh" +WATCH="$ROOT/bin/fm-watch.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +REGISTER="$ROOT/bin/fm-check-register.sh" +TMP_ROOT=$(fm_test_tmproot fm-pr-check-security) +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +REAL_CP=$(command -v cp) +REAL_MV=$(command -v mv) +REAL_STAT=$(command -v stat) +REAL_CHMOD=$(command -v chmod) +REAL_BASENAME=$(command -v basename) + +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} + +state_snapshot() { + local state=$1 file + ( + cd "$state" || exit 1 + find . \( -type f -o -type l \) -print | LC_ALL=C sort | while IFS= read -r file; do + if [ -L "$file" ]; then + printf 'link %s %s\n' "$file" "$(readlink "$file")" + else + printf 'file %s %s ' "$file" "$(file_mode "$file")" + shasum -a 256 "$file" | awk '{print $1}' + fi + done + ) +} + +make_case() { + local name=$1 dir fakebin fake_root + dir="$TMP_ROOT/$name" + fakebin="$dir/fakebin" + fake_root="$dir/root" + mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" "$dir/wt" "$fakebin" "$fake_root/bin" + cat > "$fake_root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +printf 'guard\n' >> "$FM_TEST_GUARD_LOG" +SH + chmod +x "$fake_root/bin/fm-guard.sh" + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_TEST_GH_LOG" +case " $* " in + *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" ;; + *" state "*) + [ "${FM_TEST_GH_FAIL:-0}" = 0 ] || exit 1 + [ "${FM_TEST_GH_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GH_SLEEP" + printf '%s\n' "${FM_TEST_GH_STATE:-OPEN}" + ;; +esac +SH + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" +exit "${FM_TEST_GH_AXI_RC:-0}" +SH + chmod +x "$fakebin/gh" "$fakebin/gh-axi" + : > "$dir/gh.log" + : > "$dir/gh-axi.log" + : > "$dir/guard.log" + printf '%s\n' "$dir" +} + +write_task_meta() { + local dir=$1 id=${2:-task-a} + fm_write_meta "$dir/home/state/$id.meta" \ + "window=fm-$id" \ + "worktree=$dir/wt" \ + "project=$dir/project" \ + "kind=ship" \ + "mode=no-mistakes" +} + +write_poll_meta() { + local state=$1 id=$2 url=$3 + fm_write_meta "$state/$id.meta" \ + "window=fm-$id" \ + "pr=$url" +} + +write_ambiguous_poll() { + local dir=$1 id=${2:-task-a} + fm_write_meta "$dir/home/state/$id.meta" \ + "window=fm-$id" \ + 'pr=https://github.com/o/r/pull/10' \ + 'window=unexpected-after-pr' + printf 'legacy ambiguous bytes\n' > "$dir/home/state/$id.check.sh" +} + +write_v1_x_shim() { + local file=$1 home=$2 root=$3 + fmx_poll_shim_v1_content "$home" "$root" > "$file" +} + +write_manual_poll_pair() { + local state=$1 url=${2:-https://github.com/o/r/pull/10} owner repo number + fm_pr_url_parse "$url" || fail "manual poll fixture URL was invalid" + owner=$FM_PR_OWNER + repo=$FM_PR_REPO + number=$FM_PR_NUMBER + cp "$POLL" "$state/task-a.check.sh" + printf '%s\n%s\n%s\n%s\n' "$url" "$owner" "$repo" "$number" > "$state/task-a.pr-poll" + chmod 0600 "$state/task-a.check.sh" "$state/task-a.pr-poll" +} + +start_ambiguous_pending_repair() { + local dir=$1 state rc + state="$dir/home/state" + write_ambiguous_poll "$dir" + mkdir "$state/task-a.pr-poll" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "ambiguous pending-repair fixture unexpectedly completed" + rmdir "$state/task-a.pr-poll" + write_poll_meta "$state" task-a https://github.com/o/r/pull/10 + [ -f "$state/.pr-check-quarantine/task-a.diagnostic.pending-ambiguous" ] \ + || fail "ambiguous pending-repair fixture lost its pending obligation" +} + +write_watcher_lock() { + local state=$1 home=$2 pid=$3 identity + rm -rf "$state/.watch.lock" + mkdir "$state/.watch.lock" + identity=$(LC_ALL=C ps -p "$pid" -o lstart= -o command= 2>/dev/null | sed 's/^[[:space:]]*//') + [ -n "$identity" ] || fail "could not capture fake older-watcher identity" + printf '%s\n' "$pid" > "$state/.watch.lock/pid" + printf '%s\n' "$home" > "$state/.watch.lock/fm-home" + printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" +} + +assert_valid_migration_marker() { + local marker=$1 + [ -f "$marker" ] && [ ! -L "$marker" ] || fail "migration success did not publish an ordinary marker" + [ "$(file_mode "$marker")" = 600 ] || fail "migration marker mode was not 0600" + grep -qxF fm-pr-check-migration-v1 "$marker" || fail "migration marker bytes were not exact" + [ "$(awk 'END { print NR + 0 }' "$marker")" -eq 1 ] || fail "migration marker had extra records" +} + +assert_valid_scan_marker() { + local marker=$1 + [ -f "$marker" ] && [ ! -L "$marker" ] || fail "migration success did not publish an ordinary scan marker" + [ "$(file_mode "$marker")" = 600 ] || fail "migration scan marker mode was not 0600" + grep -qxF fm-pr-check-migration-scan-v1 "$marker" || fail "migration scan marker bytes were not exact" + [ "$(awk 'END { print NR + 0 }' "$marker")" -eq 1 ] || fail "migration scan marker had extra records" +} + +LINK_KIND= +LINK_TARGET= +LINK_CONTENT= +LINK_MODE= +make_private_symlink() { + local base=$1 destination=$2 kind=$3 + LINK_KIND=$kind + LINK_TARGET="$base/target-$kind" + LINK_CONTENT= + LINK_MODE= + case "$kind" in + regular) + LINK_CONTENT='external sentinel' + printf '%s\n' "$LINK_CONTENT" > "$LINK_TARGET" + chmod 0644 "$LINK_TARGET" + LINK_MODE=644 + ;; + dangling) + rm -f "$LINK_TARGET" + ;; + directory) + mkdir "$LINK_TARGET" + printf 'outside\n' > "$LINK_TARGET/keep" + chmod 0755 "$LINK_TARGET" + LINK_MODE=755 + ;; + *) fail "unknown symlink fixture kind" ;; + esac + ln -s "$LINK_TARGET" "$destination" +} + +assert_private_symlink_unchanged() { + local link=$1 + [ -L "$link" ] || fail "private destination symlink was replaced" + case "$LINK_KIND" in + regular) + [ "$(cat "$LINK_TARGET")" = "$LINK_CONTENT" ] || fail "external regular target content changed" + [ "$(file_mode "$LINK_TARGET")" = "$LINK_MODE" ] || fail "external regular target mode changed" + ;; + dangling) + [ ! -e "$LINK_TARGET" ] || fail "dangling target was created" + ;; + directory) + [ -f "$LINK_TARGET/keep" ] || fail "external directory target contents changed" + [ "$(file_mode "$LINK_TARGET")" = "$LINK_MODE" ] || fail "external directory target mode changed" + ;; + esac +} + +run_check_entry() { + local dir=$1 + shift + FM_ROOT_OVERRIDE="$dir/root" FM_HOME="$dir/home" \ + FM_TEST_GUARD_LOG="$dir/guard.log" FM_TEST_GH_LOG="$dir/gh.log" \ + FM_TEST_GH_AXI_LOG="$dir/gh-axi.log" PATH="$dir/fakebin:$BASE_PATH" \ + "$PR_CHECK" "$@" +} + +run_merge_entry() { + local dir=$1 + shift + FM_ROOT_OVERRIDE="$dir/root" FM_HOME="$dir/home" \ + FM_TEST_GUARD_LOG="$dir/guard.log" FM_TEST_GH_LOG="$dir/gh.log" \ + FM_TEST_GH_AXI_LOG="$dir/gh-axi.log" PATH="$dir/fakebin:$BASE_PATH" \ + "$PR_MERGE" "$@" +} + +# shellcheck disable=SC2016 # Literal rejected URL bytes are parser test data. +INVALID_URLS=( + 'https://github.com/o/r/pull/1/' + ' https://github.com/o/r/pull/1' + 'https://github.com/o/r/pull/1 ' + 'https://github.com/o /r/pull/1' + $'https://github.com/o/r/pull/1\t' + $'https://github.com/o/r/pull/1\r' + $'https://github.com/o/r/pull/1\nnext' + $'https://github.com/o/r/pull/1\r\nnext' + $'https://github.com/o/r/pull/1\001' + $'https://github.com/o/r/pull/1\033' + $'https://github.com/o/r/pull/1\177' + 'https://user@github.com/o/r/pull/1' + 'https://user:pass@github.com/o/r/pull/1' + 'https://github.com:443/o/r/pull/1' + 'https://github.com/o%2Fr/pull/1' + 'https://github.com/o/r%2Fz/pull/1' + 'https://github.com/o/r/pull/1%3Fq' + 'https://github.com/o/r/pull/1%23f' + 'https://github.com/o/r/pull/1%24x' + 'https://github.com/o/r/pull/1%28x%29' + 'https://github.com/o/r/pull/1%60x' + 'https://github.com/o/r/pull/1%0D' + 'https://github.com/o/r/pull/1%0A' + 'https://github.com/o/r/pull/1%252Fz' + 'https://github.com//r/pull/1' + 'https://github.com/o//pull/1' + 'https://github.com/o/r//1' + 'https://github.com/o/r/1' + 'https://github.com/o/r/pull/' + 'https://github.com/-owner/r/pull/1' + 'https://github.com/owner-/r/pull/1' + 'https://github.com/owner--name/r/pull/1' + 'https://github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/r/pull/1' + 'https://github.com/o/./pull/1' + 'https://github.com/o/../pull/1' + 'https://github.com/o/r+z/pull/1' + 'https://github.com/o/r/pull/+1' + 'https://github.com/o/r/pull/0' + 'https://github.com/o/r/pull/-1' + 'https://github.com/o/r/pull/01' + 'https://github.com/o/r/pull/0x1' + 'https://github.com/o/r/pull/1e2' + 'https://github.com/o/r/pull/1.0' + 'https://github.com/o/r/issues/1' + 'https://github.com/o/r/x/pull/1' + 'https://github.com/o/r/pull/1/files' + 'https://github.com/o/r/pull/1?q=x' + 'https://github.com/o/r/pull/1#f' + 'https://github.com.evil/o/r/pull/1' + 'https://evilgithub.com/o/r/pull/1' + 'https://gıthub.com/o/r/pull/1' + 'https://xn--gthub-3va.com/o/r/pull/1' + 'http://github.com/o/r/pull/1' + 'ssh://github.com/o/r/pull/1' + 'git://github.com/o/r/pull/1' + 'file://github.com/o/r/pull/1' + '//github.com/o/r/pull/1' + 'HTTPS://github.com/o/r/pull/1' + 'https://GitHub.com/o/r/pull/1' + 'https://github.com/o$/r/pull/1' + 'https://github.com/o(/r/pull/1' + 'https://github.com/o)/r/pull/1' + 'https://github.com/o`/r/pull/1' + 'https://github.com/o/r`/pull/1' + 'https://github.com/o/r/pull/1`' + "https://github.com/o/'r'/pull/1" + 'https://github.com/o/"r"/pull/1' + 'https://github.com/o/'\''"r"'\''/pull/1' + "https://github.com/o/r/pull/1'" + 'https://github.com/o/r/pull/1"' +) + +# shellcheck disable=SC2016 # Literal shell syntax is task-ID test data. +INVALID_IDS=( + '../escape' + 'a/b' + '.' + '..' + '.task' + 'task a' + $'task\ta' + $'task\na' + 'task*' + "task'a" + 'task"a' + 'task;a' + 'task$a' +) + +# shellcheck disable=SC2016 # Literal shell syntax is task-ID test data. +UNSAFE_LIFECYCLE_IDS=( + '../escape' + 'a/b' + '.' + '..' + '.task' + 'task a' + $'task\ta' + $'task\na' + 'task*' + "task'a" + 'task"a' + 'task;a' + 'task$a' +) + +test_parser_matrix() { + local id row url owner repo number + while IFS='|' read -r url owner repo number; do + [ -n "$url" ] || continue + fm_pr_url_parse "$url" || fail "parser rejected canonical URL" + [ "$FM_PR_URL" = "$url" ] || fail "parser changed canonical URL" + [ "$FM_PR_OWNER" = "$owner" ] || fail "parser returned wrong owner" + [ "$FM_PR_REPO" = "$repo" ] || fail "parser returned wrong repository" + [ "$FM_PR_NUMBER" = "$number" ] || fail "parser returned wrong PR number" + done <<'EOF' +https://github.com/a/b/pull/1|a|b|1 +https://github.com/my-org/repo/pull/42|my-org|repo|42 +https://github.com/Owner/repo-name_with.parts/pull/123456|Owner|repo-name_with.parts|123456 +EOF + for row in "${INVALID_URLS[@]}"; do + ! fm_pr_url_parse "$row" || fail "parser accepted a rejected raw-byte URL class" + done + for id in -task task- task--a Task-a task_a task.a; do + fm_pr_task_id_valid "$id" || fail "task ID validator rejected a safe lifecycle-compatible slug" + done + fm_task_id_creation_valid _noncanonical \ + || fail "creation validator rejected a task ID after its reserved namespace moved" + id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + fm_pr_task_id_valid "$id" || fail "operational validator rejected a path-safe legacy task ID" + ! fm_task_id_creation_valid "$id" || fail "creation validator accepted an overlong task ID" + pass "raw-byte parser accepts canonical URLs and rejects the complete adversarial matrix" +} + +test_invalid_entrypoints_have_zero_side_effects() { + local dir before after value rc + dir=$(make_case invalid-entrypoints) + write_task_meta "$dir" + printf 'existing-check\n' > "$dir/home/state/task-a.check.sh" + printf 'existing-data\n' > "$dir/home/state/task-a.pr-poll" + chmod 0600 "$dir/home/state/task-a.check.sh" "$dir/home/state/task-a.pr-poll" + + for value in "${INVALID_URLS[@]}"; do + before=$(state_snapshot "$dir/home/state") + set +e + run_check_entry "$dir" task-a "$value" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "direct entrypoint accepted invalid URL" + [ "$(cat "$dir/stderr")" = 'error: invalid PR check request' ] || fail "direct invalid URL diagnostic was not fixed" + after=$(state_snapshot "$dir/home/state") + [ "$after" = "$before" ] || fail "direct invalid URL changed prior state" + done + + for value in "${INVALID_IDS[@]}"; do + before=$(state_snapshot "$dir/home/state") + set +e + run_check_entry "$dir" "$value" https://github.com/o/r/pull/1 > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "direct entrypoint accepted invalid task ID" + after=$(state_snapshot "$dir/home/state") + [ "$after" = "$before" ] || fail "invalid task ID changed state or traversed a path" + done + + for value in "${INVALID_URLS[@]}"; do + before=$(state_snapshot "$dir/home/state") + set +e + run_merge_entry "$dir" task-a "$value" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "merge entrypoint accepted invalid URL" + [ "$(cat "$dir/stderr")" = 'error: invalid PR merge request' ] || fail "merge invalid URL diagnostic was not fixed" + after=$(state_snapshot "$dir/home/state") + [ "$after" = "$before" ] || fail "merge invalid URL changed prior state" + done + + for value in "${INVALID_IDS[@]}"; do + before=$(state_snapshot "$dir/home/state") + set +e + run_merge_entry "$dir" "$value" https://github.com/o/r/pull/1 > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "merge entrypoint accepted invalid task ID" + after=$(state_snapshot "$dir/home/state") + [ "$after" = "$before" ] || fail "merge invalid task ID changed state" + done + + for value in "${UNSAFE_LIFECYCLE_IDS[@]}"; do + before=$(state_snapshot "$dir/home/state") + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_GUARD_LOG="$dir/guard.log" \ + "$TEARDOWN" "$value" --force > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "teardown accepted invalid task ID" + [ "$(cat "$dir/stderr")" = 'error: invalid teardown request' ] \ + || fail "teardown invalid task ID diagnostic was not fixed" + after=$(state_snapshot "$dir/home/state") + [ "$after" = "$before" ] || fail "teardown invalid task ID changed state" + done + + set +e + run_check_entry "$dir" > /dev/null 2> "$dir/stderr"; rc=$? + set -e + [ "$rc" -ne 0 ] || fail "direct entrypoint accepted zero arguments" + set +e + run_check_entry "$dir" task-a https://github.com/o/r/pull/1 extra > /dev/null 2> "$dir/stderr"; rc=$? + set -e + [ "$rc" -ne 0 ] || fail "direct entrypoint accepted extra arguments" + set +e + run_merge_entry "$dir" > /dev/null 2> "$dir/stderr"; rc=$? + set -e + [ "$rc" -ne 0 ] || fail "merge entrypoint accepted zero arguments" + + [ ! -s "$dir/gh.log" ] || fail "invalid direct or merge data called gh" + [ ! -s "$dir/gh-axi.log" ] || fail "invalid direct or merge data called gh-axi" + [ ! -s "$dir/guard.log" ] || fail "invalid direct or merge data called the guard" + [ ! -e "$TMP_ROOT/escape.check.sh" ] || fail "task traversal wrote outside state" + pass "PR and teardown entrypoints reject invalid arguments before every side effect" +} + +test_valid_recording_and_merge_derivation() { + local dir expected sidecar count + dir=$(make_case valid-recording) + write_task_meta "$dir" + expected=0123456789abcdef0123456789abcdef01234567 + FM_TEST_GH_HEAD=$expected run_check_entry "$dir" task-a https://github.com/my-org/repo_name.with-dots/pull/37 \ + > "$dir/stdout" 2> "$dir/stderr" || fail "valid direct check failed" + + grep -qxF 'pr=https://github.com/my-org/repo_name.with-dots/pull/37' "$dir/home/state/task-a.meta" \ + || fail "canonical pr metadata was not exact" + grep -qxF "pr_head=$expected" "$dir/home/state/task-a.meta" || fail "PR head metadata was not exact" + cmp -s "$POLL" "$dir/home/state/task-a.check.sh" || fail "published check was not byte-for-byte static" + [ "$(file_mode "$dir/home/state/task-a.check.sh")" = 600 ] || fail "published check mode was not 0600" + [ "$(file_mode "$dir/home/state/task-a.pr-poll")" = 600 ] || fail "published sidecar mode was not 0600" + [ "$(file_mode "$dir/home/state/task-a.pr-poll-registration")" = 600 ] \ + || fail "published registration mode was not 0600" + [ "$(fm_pr_file_link_count "$dir/home/state/task-a.check.sh")" = 1 ] \ + && [ "$(fm_pr_file_link_count "$dir/home/state/task-a.pr-poll")" = 1 ] \ + && [ "$(fm_pr_file_link_count "$dir/home/state/task-a.pr-poll-registration")" = 1 ] \ + || fail "published poll artifacts were not single-link files" + fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \ + || fail "published poll provenance or metadata binding was invalid" + sidecar=$(cat "$dir/home/state/task-a.pr-poll") + [ "$sidecar" = $'https://github.com/my-org/repo_name.with-dots/pull/37\nmy-org\nrepo_name.with-dots\n37' ] \ + || fail "published sidecar bytes were not exact" + + FM_TEST_GH_HEAD=$expected run_check_entry "$dir" task-a https://github.com/my-org/repo_name.with-dots/pull/37 \ + >/dev/null 2>/dev/null || fail "valid duplicate check failed" + count=$(grep -c '^pr=' "$dir/home/state/task-a.meta") + [ "$count" -eq 1 ] || fail "duplicate pr metadata was appended" + count=$(grep -c '^pr_head=' "$dir/home/state/task-a.meta") + [ "$count" -eq 1 ] || fail "duplicate pr_head metadata was appended" + + : > "$dir/gh-axi.log" + run_merge_entry "$dir" task-a https://github.com/my-org/repo_name.with-dots/pull/37 -- --merge \ + >/dev/null 2>/dev/null || fail "valid merge wrapper failed" + grep -qxF 'pr merge 37 --repo my-org/repo_name.with-dots --merge' "$dir/gh-axi.log" \ + || fail "merge wrapper did not preserve repository derivation and method" + + dir=$(make_case newline-head) + write_task_meta "$dir" + FM_TEST_GH_HEAD=$'0123456789abcdef0123456789abcdef01234567\nwindow=unexpected' \ + run_check_entry "$dir" task-a https://github.com/o/r/pull/2 >/dev/null 2>/dev/null \ + || fail "valid check with malformed remote head failed" + assert_no_grep 'pr_head=' "$dir/home/state/task-a.meta" "multiline PR head reached metadata" + assert_no_grep 'window=unexpected' "$dir/home/state/task-a.meta" "newline metadata key was injected" + + dir=$(make_case lifecycle-compatible-id) + write_task_meta "$dir" Task_A.1 + run_merge_entry "$dir" Task_A.1 https://github.com/o/r/pull/3 \ + > "$dir/stdout" 2> "$dir/stderr" \ + || fail "safe lifecycle-compatible task ID could not use the PR merge flow" + fm_pr_poll_artifacts_valid "$dir/home/state" Task_A.1 "$POLL" \ + || fail "safe lifecycle-compatible task ID did not publish an authenticated poll" + rm -rf "$dir/wt" + cat > "$dir/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod 0700 "$dir/fakebin/tmux" + touch "$dir/home/state/.last-watcher-beat" + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$TEARDOWN" Task_A.1 --force > "$dir/teardown.out" 2> "$dir/teardown.err" \ + || fail "safe lifecycle-compatible task ID could not be torn down" + [ ! -e "$dir/home/state/Task_A.1.meta" ] \ + || fail "safe lifecycle-compatible task teardown retained metadata" + + for id in _noncanonical aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + dir=$(make_case "legacy-teardown-${id:0:12}") + fm_write_meta "$dir/home/state/$id.meta" \ + "window=fm-$id" \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + mkdir -p "$dir/home/state/.pr-check-quarantine" + chmod 0700 "$dir/home/state/.pr-check-quarantine" + printf 'reserved migration evidence\n' \ + > "$dir/home/state/.pr-check-quarantine/!noncanonical.check.evidence" + chmod 0600 "$dir/home/state/.pr-check-quarantine/!noncanonical.check.evidence" + cat > "$dir/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod 0700 "$dir/fakebin/tmux" + touch "$dir/home/state/.last-watcher-beat" + mkdir "$dir/home/state/$id.check.sh" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$TEARDOWN" "$id" --force > "$dir/unsafe-teardown.out" 2> "$dir/unsafe-teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "legacy task teardown accepted an unsafe direct artifact" + [ -e "$dir/home/state/$id.meta" ] \ + || fail "legacy task teardown mutated lifecycle state before artifact refusal" + [ -d "$dir/home/state/$id.check.sh" ] \ + || fail "legacy task teardown changed the unsafe direct artifact" + rmdir "$dir/home/state/$id.check.sh" + FM_HOME="$dir/home" "$ROOT/bin/fm-x-link.sh" "$id" req-legacy \ + --carry-count 0 --carry-ts 1700000000 --carry-platform x --carry-max 280 \ + > "$dir/x-link.out" 2> "$dir/x-link.err" \ + || fail "path-safe legacy task ID could not link an X request" + run_merge_entry "$dir" "$id" https://github.com/o/r/pull/4 \ + > "$dir/merge.out" 2> "$dir/merge.err" \ + || fail "path-safe legacy task ID could not use the PR merge flow" + fm_pr_poll_artifacts_valid "$dir/home/state" "$id" "$POLL" \ + || fail "path-safe legacy task ID did not publish an authenticated poll" + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$TEARDOWN" "$id" --force > "$dir/teardown.out" 2> "$dir/teardown.err" \ + || fail "legacy path-safe task ID could not be torn down" + [ ! -e "$dir/home/state/$id.meta" ] || fail "legacy task teardown retained metadata" + [ "$(cat "$dir/home/state/.pr-check-quarantine/!noncanonical.check.evidence")" = 'reserved migration evidence' ] \ + || fail "legacy task teardown changed the reserved migration namespace" + done + pass "valid direct and merge flows record exact metadata and reject multiline head metadata" +} + +run_watcher_bounded() { + local home=$1 fakebin=$2 check_interval=${FM_TEST_CHECK_INTERVAL:-0} watch_root=${FM_TEST_WATCH_ROOT:-$ROOT} + shift 2 + perl -e 'my $pid=fork; die unless defined $pid; if (!$pid) { exec @ARGV } local $SIG{ALRM}=sub { kill "TERM", $pid; waitpid $pid, 0; exit 124 }; alarm 5; waitpid $pid, 0; alarm 0; exit($? >> 8)' \ + env FM_HOME="$home" FM_ROOT_OVERRIDE="$watch_root" FM_CHECK_INTERVAL="$check_interval" FM_CHECK_TIMEOUT=1 \ + FM_POLL=0.02 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 PATH="$fakebin:$BASE_PATH" "$WATCH" "$@" +} + +test_rejected_metacharacter_bytes_are_inert() { + local dir family rc before after + dir=$(make_case rejected-metacharacters) + write_task_meta "$dir" + write_poll_meta "$dir/home/state" safe-check https://github.com/o/r/pull/99 + fm_pr_poll_prepare "$dir/home/state" safe-check https://github.com/o/r/pull/99 o r 99 "$POLL" \ + || fail "could not prepare bounded watcher poll" + fm_pr_poll_publish_prepared || fail "could not publish bounded watcher poll" + families=( + 'https://github.com/o$/r/pull/1' + 'https://github.com/o(/r/pull/1' + 'https://github.com/o)/r/pull/1' + 'https://github.com/o`/r/pull/1' + ) + for family in "${families[@]}"; do + rm -f "$dir/home/state/task-a.check.sh" "$dir/home/state/task-a.pr-poll" + set +e + run_check_entry "$dir" task-a "$family" > /dev/null 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "rejected metacharacter byte was accepted" + [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "rejected input left a runnable task check" + [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "rejected input left a sidecar" + + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "bounded watcher did not complete through the authenticated poll" + rm -f "$dir/home/state/.last-check" + done + + FM_TEST_GH_STATE=OPEN run_check_entry "$dir" task-a https://github.com/o/r/pull/1 >/dev/null 2>/dev/null \ + || fail "could not seed a prior valid static poll" + before=$(state_snapshot "$dir/home/state") + set +e + run_check_entry "$dir" task-a "${families[0]}" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "rejected replacement was accepted" + after=$(state_snapshot "$dir/home/state") + [ "$after" = "$before" ] || fail "rejected replacement changed a prior valid static poll" + pass "rejected metacharacter bytes remain inert at generation and watcher time" +} + +make_poll_fixture() { + local dir=$1 + cp "$POLL" "$dir/home/state/task-a.check.sh" + printf '%s\n%s\n%s\n%s\n' \ + https://github.com/o/r/pull/1 o r 1 > "$dir/home/state/task-a.pr-poll" + chmod 0600 "$dir/home/state/task-a.check.sh" "$dir/home/state/task-a.pr-poll" +} + +run_poll() { + local dir=$1 + FM_TEST_GH_LOG="$dir/gh.log" PATH="$dir/fakebin:$BASE_PATH" \ + bash "$dir/home/state/task-a.check.sh" +} + +test_static_poll_contract() { + local dir state out rc + dir=$(make_case poll-contract) + make_poll_fixture "$dir" + + for state in OPEN CLOSED EMPTY MALFORMED; do + case "$state" in + EMPTY) value= ;; + MALFORMED) value='not-a-state' ;; + *) value=$state ;; + esac + out=$(FM_TEST_GH_STATE="$value" run_poll "$dir") + [ -z "$out" ] || fail "static poll emitted for non-merged state" + done + out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir") + [ "$out" = merged ] || fail "static poll did not emit exactly one merged line" + out=$(FM_TEST_GH_FAIL=1 run_poll "$dir") + [ -z "$out" ] || fail "static poll emitted after gh failure" + + mv "$dir/home/state/task-a.pr-poll" "$dir/home/state/task-a.pr-poll.missing" + out=$(run_poll "$dir") + [ -z "$out" ] || fail "static poll emitted with missing sidecar" + mv "$dir/home/state/task-a.pr-poll.missing" "$dir/home/state/task-a.pr-poll" + printf '%s\n%s\n%s\n%s\n%s\n' https://github.com/o/r/pull/1 o r 1 extra > "$dir/home/state/task-a.pr-poll" + out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir") + [ -z "$out" ] || fail "static poll emitted with multiline sidecar" + printf '%s\n%s\n%s\n%s\n' https://github.com/o/r/pull/1x o r 1x > "$dir/home/state/task-a.pr-poll" + out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir") + [ -z "$out" ] || fail "static poll emitted with malformed numeric data" + + make_poll_fixture "$dir" + set +e + out=$(FM_STATE_OVERRIDE="$dir/home/state" FM_CHECK_TIMEOUT=1 FM_TEST_GH_LOG="$dir/gh.log" \ + FM_TEST_GH_SLEEP=3 PATH="$dir/fakebin:$BASE_PATH" \ + bash -c '. "$1"; run_check "$2"' bash "$WATCH" "$dir/home/state/task-a.check.sh") + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "watcher run_check timeout wrapper failed" + [ -z "$out" ] || fail "timed-out static poll emitted output" + + write_poll_meta "$dir/home/state" task-a https://github.com/o/r/pull/1 + fm_pr_poll_prepare "$dir/home/state" task-a https://github.com/o/r/pull/1 o r 1 "$POLL" \ + || fail "could not prepare authenticated watcher poll" + fm_pr_poll_publish_prepared || fail "could not publish authenticated watcher poll" + rm -f "$dir/home/state/.last-check" + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "watcher did not surface merged poll" + [ "$(grep -c '^check: .*: merged$' "$dir/watch.out")" -eq 1 ] || fail "watcher did not convert merged output into exactly one wake" + pass "static poll is silent except for one merged line and remains watcher-bounded" +} + +test_atomic_interruption_leaves_no_partial_artifact() { + local dir rc + dir=$(make_case interrupted-write) + write_task_meta "$dir" + cat > "$dir/fakebin/cp" <<SH +#!/usr/bin/env bash +'$REAL_CP' "\$@" || exit 1 +kill -TERM "\$PPID" +exit 0 +SH + chmod +x "$dir/fakebin/cp" + + set +e + run_check_entry "$dir" task-a https://github.com/o/r/pull/1 > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "interrupted publication unexpectedly succeeded" + [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "interrupted publication left a runnable check" + [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "interrupted publication left a sidecar" + [ ! -e "$dir/home/state/task-a.pr-poll-registration" ] \ + || fail "interrupted publication left a registration" + ! find "$dir/home/state" -name '.fm-pr-poll-*' -print | grep . >/dev/null \ + || fail "interrupted publication left temporary files" + assert_no_grep 'pr=' "$dir/home/state/task-a.meta" "interrupted preparation changed metadata" + pass "interrupted atomic preparation cleans private temporaries and publishes nothing" +} + +test_concurrent_watcher_sees_only_complete_publication() { + local n dir direct_pid rc i + n=1 + while [ "$n" -le 3 ]; do + dir=$(make_case "concurrent-$n") + write_task_meta "$dir" + cat > "$dir/fakebin/cp" <<SH +#!/usr/bin/env bash +'$REAL_CP' "\$@" || exit 1 +sleep 0.3 +SH + chmod +x "$dir/fakebin/cp" + + FM_TEST_GH_HEAD=0123456789abcdef0123456789abcdef01234567 \ + run_check_entry "$dir" task-a https://github.com/o/r/pull/1 > "$dir/direct.out" 2> "$dir/direct.err" & + direct_pid=$! + i=0 + while [ "$i" -lt 100 ] && ! find "$dir/home/state" -name '.fm-pr-poll-check.*' -print | grep . >/dev/null; do + sleep 0.01 + i=$((i + 1)) + done + [ "$i" -lt 100 ] || fail "atomic publication did not reach staged check" + + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err" + rc=$? + set -e + wait "$direct_pid" || fail "concurrent direct arming failed" + [ "$rc" -eq 0 ] || fail "concurrent watcher did not complete" + grep -q '^check: .*: merged$' "$dir/watch.out" || fail "concurrent watcher never saw complete poll" + [ ! -s "$dir/watch.err" ] || fail "concurrent watcher observed a partial artifact error" + cmp -s "$POLL" "$dir/home/state/task-a.check.sh" || fail "concurrent publication check bytes changed" + [ "$(file_mode "$dir/home/state/task-a.check.sh")" = 600 ] || fail "concurrent check mode was not private" + [ "$(file_mode "$dir/home/state/task-a.pr-poll")" = 600 ] || fail "concurrent sidecar mode was not private" + [ "$(file_mode "$dir/home/state/task-a.pr-poll-registration")" = 600 ] \ + || fail "concurrent registration mode was not private" + fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \ + || fail "concurrent publication did not leave canonical provenance" + n=$((n + 1)) + done + pass "concurrent watchers observe only complete private poll publications" +} + +test_migration_excludes_older_watcher_before_scan() { + local dir state gate sentinel older_pid rc + dir=$(make_case migration-pause-before-scan) + state="$dir/home/state" + gate="$dir/scan-started" + sentinel="$dir/legacy-ran" + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + 'pr=https://github.com/o/r/pull/9' + cat > "$state/task-a.check.sh" <<SH +#!/usr/bin/env bash +printf 'seen\n' > '$sentinel' +SH + ( + while [ ! -e "$gate" ]; do sleep 0.01; done + bash "$state/task-a.check.sh" + while :; do sleep 1; done + ) & + older_pid=$! + write_watcher_lock "$state" "$dir/home" "$older_pid" + cat > "$dir/fakebin/basename" <<SH +#!/usr/bin/env bash +: > '$gate' +sleep 0.3 +exec '$REAL_BASENAME' "\$@" +SH + chmod +x "$dir/fakebin/basename" + + set +e + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + wait "$older_pid" 2>/dev/null || true + [ "$rc" -eq 0 ] || fail "pause-before-scan migration failed" + [ ! -e "$sentinel" ] || fail "older watcher ran a legacy check during migration startup" + [ -e "$gate" ] || fail "migration never reached its under-lock check scan" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + cmp -s "$POLL" "$state/task-a.check.sh" || fail "pause-before-scan migration did not rebuild the poll" + + dir=$(make_case migration-pause-no-check) + state="$dir/home/state" + ( while :; do sleep 1; done ) & + older_pid=$! + write_watcher_lock "$state" "$dir/home" "$older_pid" + set +e + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + wait "$older_pid" 2>/dev/null || true + [ "$rc" -eq 0 ] || fail "no-check older-watcher migration failed" + ! kill -0 "$older_pid" 2>/dev/null || fail "no-check migration left the older watcher running" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + pass "migration pauses older watchers and acquires exclusion before its first scan or marker" +} + +test_migration_initializes_fresh_state() { + local dir state rc + dir="$TMP_ROOT/migration-fresh-state" + state="$dir/home/state" + mkdir -p "$dir" + + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + + [ "$rc" -eq 0 ] || fail "fresh-state migration failed: $(cat "$dir/migrate.err")" + [ -d "$state" ] && [ ! -L "$state" ] || fail "fresh-state migration did not create an ordinary state directory" + [ "$(file_mode "$state")" = 700 ] || fail "fresh-state migration did not create state with mode 0700" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + pass "migration creates and validates private state before watcher exclusion" +} + +test_private_artifact_paths_refuse_symlinks_and_directories() { + local artifact kind dir state destination rc + for artifact in task-a.pr-poll task-a.pr-poll-registration task-a.check.sh; do + for kind in regular dangling directory; do + dir=$(make_case "poll-path-${artifact//./-}-$kind") + state="$dir/home/state" + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/1 o r 1 "$POLL" \ + || fail "could not stage poll symlink refusal fixture" + destination="$state/$artifact" + make_private_symlink "$dir" "$destination" "$kind" + if fm_pr_poll_publish_prepared; then + fail "poll publication accepted a private destination symlink" + fi + fm_pr_poll_cleanup + assert_private_symlink_unchanged "$destination" + [ ! -e "$state/task-a.pr-poll" ] || [ "$artifact" = task-a.pr-poll ] \ + || fail "check destination refusal published the sidecar" + done + + dir=$(make_case "poll-path-${artifact//./-}-direct-directory") + state="$dir/home/state" + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/1 o r 1 "$POLL" \ + || fail "could not stage poll directory refusal fixture" + destination="$state/$artifact" + mkdir "$destination" + if fm_pr_poll_publish_prepared; then + fail "poll publication accepted a directory destination" + fi + fm_pr_poll_cleanup + [ -d "$destination" ] || fail "poll publication replaced a directory destination" + [ -z "$(find "$destination" -mindepth 1 -maxdepth 1 -print)" ] || fail "poll publication wrote inside a directory destination" + done + + for artifact in marker log quarantine; do + for kind in regular dangling directory; do + dir=$(make_case "migration-path-$artifact-$kind") + state="$dir/home/state" + case "$artifact" in + marker) + destination="$state/.pr-check-migration-v1" + ;; + log) + write_ambiguous_poll "$dir" + destination="$state/.pr-check-migration.log" + ;; + quarantine) + write_ambiguous_poll "$dir" + destination="$state/.pr-check-quarantine" + ;; + esac + make_private_symlink "$dir" "$destination" "$kind" + set +e + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a symlinked private $artifact path" + assert_private_symlink_unchanged "$destination" + [ ! -e "$state/.pr-check-migration-v1" ] || [ -L "$state/.pr-check-migration-v1" ] \ + || fail "failed private-path migration published a completion marker" + done + done + + for artifact in marker log; do + dir=$(make_case "migration-path-$artifact-direct-directory") + state="$dir/home/state" + if [ "$artifact" = marker ]; then + destination="$state/.pr-check-migration-v1" + else + write_ambiguous_poll "$dir" + destination="$state/.pr-check-migration.log" + fi + mkdir "$destination" + set +e + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a directory $artifact destination" + [ -d "$destination" ] || fail "migration replaced a directory $artifact destination" + [ -z "$(find "$destination" -mindepth 1 -maxdepth 1 -print)" ] || fail "migration wrote inside a directory $artifact destination" + [ ! -f "$state/.pr-check-migration-v1" ] || fail "failed directory-path migration published a marker" + done + pass "poll, marker, diagnostic, and quarantine paths refuse symlinks and directories" +} + +install_final_publication_fault() { + local dir=$1 + cat > "$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +last=${!#} +"${FM_TEST_REAL_MV:?}" "$@" || exit $? +[ "$last" = "${FM_TEST_FINAL_PATH:?}" ] || exit 0 +case "${FM_TEST_FINAL_ACTION:?}" in + type) + rm -f -- "$last" + ln -s "${FM_TEST_FAULT_LINK_TARGET:?}" "$last" + ;; + mode) "${FM_TEST_REAL_CHMOD:?}" 0644 "$last" ;; + content) printf 'faulted final bytes\n' > "$last" ;; + device) : > "${FM_TEST_FAULT_GATE:?}" ;; + *) exit 2 ;; +esac +SH + cat > "$dir/fakebin/stat" <<'SH' +#!/usr/bin/env bash +last=${!#} +if [ "$last" = "${FM_TEST_FINAL_PATH:-}" ] && [ -e "${FM_TEST_FAULT_GATE:-/nonexistent}" ]; then + case " $* " in + *" %d "*) printf '%s\n' 999999; exit 0 ;; + esac +fi +exec "${FM_TEST_REAL_STAT:?}" "$@" +SH + chmod +x "$dir/fakebin/mv" "$dir/fakebin/stat" +} + +assert_no_final_poll() { + local state=$1 + [ ! -e "$state/task-a.check.sh" ] && [ ! -L "$state/task-a.check.sh" ] \ + || fail "failed publication left a runnable check name" + [ ! -e "$state/task-a.pr-poll" ] && [ ! -L "$state/task-a.pr-poll" ] \ + || fail "failed publication left a sidecar name" + [ ! -e "$state/task-a.pr-poll-registration" ] && [ ! -L "$state/task-a.pr-poll-registration" ] \ + || fail "failed publication left a registration name" +} + +test_postrename_poll_validation_revokes_and_retries() { + local artifact action dir state destination link_target gate + for artifact in data registration check; do + for action in type mode device content; do + dir=$(make_case "poll-final-$artifact-$action") + state="$dir/home/state" + write_poll_meta "$state" task-a https://github.com/o/r/pull/1 + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/1 o r 1 "$POLL" \ + || fail "could not prepare prior poll" + fm_pr_poll_publish_prepared || fail "could not publish prior poll" + write_poll_meta "$state" task-a https://github.com/o/r/pull/2 + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/2 o r 2 "$POLL" \ + || fail "could not stage replacement poll" + case "$artifact" in + data) destination="$state/task-a.pr-poll" ;; + registration) destination="$state/task-a.pr-poll-registration" ;; + check) destination="$state/task-a.check.sh" ;; + esac + link_target="$dir/external-sentinel" + gate="$dir/device-fault" + printf 'external sentinel\n' > "$link_target" + chmod 0644 "$link_target" + install_final_publication_fault "$dir" + if FM_TEST_FINAL_PATH="$destination" FM_TEST_FINAL_ACTION="$action" \ + FM_TEST_FAULT_LINK_TARGET="$link_target" FM_TEST_FAULT_GATE="$gate" \ + FM_TEST_REAL_MV="$REAL_MV" FM_TEST_REAL_STAT="$REAL_STAT" FM_TEST_REAL_CHMOD="$REAL_CHMOD" \ + PATH="$dir/fakebin:$BASE_PATH" fm_pr_poll_publish_prepared; then + fail "post-rename $artifact $action fault was reported as success" + fi + fm_pr_poll_cleanup + assert_no_final_poll "$state" + [ "$(cat "$link_target")" = 'external sentinel' ] || fail "poll type fault changed an external target" + [ "$(file_mode "$link_target")" = 644 ] || fail "poll type fault changed an external target mode" + + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/2 o r 2 "$POLL" \ + || fail "could not prepare poll retry" + PATH="$BASE_PATH" fm_pr_poll_publish_prepared || fail "poll retry did not recover after final validation fault" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "poll retry did not publish a valid pair" + done + done + pass "post-rename poll validation faults revoke both names and allow a clean retry" +} + +install_mv_fault() { + local dir=$1 + cat > "$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +matched=0 +for arg in "$@"; do + case "$arg" in + *"${FM_TEST_MV_MATCH:?}"*) matched=1 ;; + esac +done +if [ "$matched" -eq 1 ]; then + case "${FM_TEST_MV_ACTION:?}" in + fail) exit 1 ;; + signal) + kill -TERM "$PPID" + sleep 0.1 + exit 1 + ;; + esac +fi +exec "$FM_TEST_REAL_MV" "$@" +SH + chmod +x "$dir/fakebin/mv" +} + +test_marker_and_diagnostic_rename_fail_closed() { + local action dir state rc + for action in fail signal; do + dir=$(make_case "marker-rename-$action") + state="$dir/home/state" + install_mv_fault "$dir" + set +e + FM_TEST_MV_MATCH=.fm-pr-check-migration. FM_TEST_MV_ACTION="$action" FM_TEST_REAL_MV="$REAL_MV" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "marker rename $action was reported as success" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "marker rename $action left a completion marker" + ! find "$state" -name '.fm-pr-check-migration.*' -print | grep . >/dev/null \ + || fail "marker rename $action left a staged marker" + rm -f "$dir/fakebin/mv" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "marker rename $action did not recover on retry" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case "diagnostic-rename-$action") + state="$dir/home/state" + write_ambiguous_poll "$dir" + install_mv_fault "$dir" + set +e + FM_TEST_MV_MATCH=.fm-pr-check-log. FM_TEST_MV_ACTION="$action" FM_TEST_REAL_MV="$REAL_MV" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "diagnostic rename $action was reported as success" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "diagnostic rename $action published a completion marker" + [ ! -e "$state/.pr-check-migration.log" ] || fail "diagnostic rename $action published a partial log" + [ -e "$state/task-a.check.sh" ] || fail "diagnostic rename $action removed the source before recording its obligation" + ! find "$state" -name '.fm-pr-check-log.*' -print | grep . >/dev/null \ + || fail "diagnostic rename $action left a staged log" + rm -f "$dir/fakebin/mv" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "diagnostic rename $action did not recover on retry" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + assert_grep 'task task-a: ambiguous or invalid legacy poll quarantined and unarmed' "$state/.pr-check-migration.log" \ + "diagnostic rename retry forgot the required outcome" + done + pass "marker and diagnostic rename errors and signals fail closed and recover durably on retry" +} + +test_postrename_marker_and_diagnostic_validation_retries() { + local artifact action dir state destination link_target gate rc + for artifact in marker diagnostic obligation; do + for action in type mode device content; do + dir=$(make_case "migration-final-$artifact-$action") + state="$dir/home/state" + case "$artifact" in + marker) + destination="$state/.pr-check-migration-v1" + ;; + diagnostic) + write_ambiguous_poll "$dir" + destination="$state/.pr-check-migration.log" + ;; + obligation) + write_ambiguous_poll "$dir" + destination="$state/.pr-check-quarantine/task-a.diagnostic.pending-ambiguous" + ;; + esac + link_target="$dir/external-sentinel" + gate="$dir/device-fault" + printf 'external sentinel\n' > "$link_target" + chmod 0644 "$link_target" + install_final_publication_fault "$dir" + set +e + FM_TEST_FINAL_PATH="$destination" FM_TEST_FINAL_ACTION="$action" \ + FM_TEST_FAULT_LINK_TARGET="$link_target" FM_TEST_FAULT_GATE="$gate" \ + FM_TEST_REAL_MV="$REAL_MV" FM_TEST_REAL_STAT="$REAL_STAT" FM_TEST_REAL_CHMOD="$REAL_CHMOD" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "post-rename $artifact $action fault was reported as success" + assert_grep 'migration did not complete safely' "$dir/migrate.err" \ + "generic migration failure for $artifact $action did not state that migration was incomplete" + [ ! -e "$state/.pr-check-migration-v1" ] && [ ! -L "$state/.pr-check-migration-v1" ] \ + || fail "post-rename $artifact $action fault left a trusted marker" + if [ "$artifact" = diagnostic ]; then + [ ! -e "$state/.pr-check-migration.log" ] && [ ! -L "$state/.pr-check-migration.log" ] \ + || fail "post-rename diagnostic $action fault left an invalid log" + fi + if [ "$artifact" = diagnostic ] || [ "$artifact" = obligation ]; then + [ -e "$state/task-a.check.sh" ] || fail "$artifact $action fault removed the runnable source before durable recording" + fi + if [ "$artifact" = obligation ]; then + [ ! -e "$destination" ] && [ ! -L "$destination" ] \ + || fail "post-rename obligation $action fault left an invalid obligation" + fi + [ "$(cat "$link_target")" = 'external sentinel' ] || fail "migration type fault changed an external target" + [ "$(file_mode "$link_target")" = 644 ] || fail "migration type fault changed an external target mode" + + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "post-rename $artifact $action retry did not recover" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + if [ "$artifact" = diagnostic ] || [ "$artifact" = obligation ]; then + assert_grep 'task task-a: ambiguous or invalid legacy poll quarantined and unarmed' "$state/.pr-check-migration.log" \ + "$artifact $action retry forgot the durable outcome" + fi + done + done + pass "post-rename marker, diagnostic, and obligation faults are revoked and reconstructed on retry" +} + +install_chmod_noop_fault() { + local dir=$1 + cat > "$dir/fakebin/chmod" <<'SH' +#!/usr/bin/env bash +last=${!#} +case "$last" in + ${FM_TEST_CHMOD_MATCH:?}) exit 0 ;; +esac +exec "${FM_TEST_REAL_CHMOD:?}" "$@" +SH + chmod +x "$dir/fakebin/chmod" +} + +test_quarantine_validation_and_retry_contract() { + local dir state rc quarantined external source_kind + + dir=$(make_case quarantine-dir-mode-retry) + state="$dir/home/state" + write_ambiguous_poll "$dir" + mkdir "$state/.pr-check-quarantine" + chmod 0755 "$state/.pr-check-quarantine" + install_chmod_noop_fault "$dir" + set +e + FM_TEST_CHMOD_MATCH="$state/.pr-check-quarantine" FM_TEST_REAL_CHMOD="$REAL_CHMOD" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a nonprivate quarantine directory" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "quarantine directory mode fault published a marker" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "quarantine directory mode fault did not recover on retry" + [ "$(file_mode "$state/.pr-check-quarantine")" = 700 ] || fail "retry did not repair quarantine directory mode" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case quarantine-artifact-mode-retry) + state="$dir/home/state" + write_ambiguous_poll "$dir" + chmod 0644 "$state/task-a.check.sh" + install_chmod_noop_fault "$dir" + set +e + FM_TEST_CHMOD_MATCH="$state/.pr-check-quarantine/task-a.check.*" FM_TEST_REAL_CHMOD="$REAL_CHMOD" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a nonprivate quarantine artifact" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "quarantine artifact mode fault published a marker" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "quarantine artifact mode fault did not recover on retry" + quarantined=$(find "$state/.pr-check-quarantine" -name 'task-a.check.*' -type f | head -1) + [ -n "$quarantined" ] && [ "$(file_mode "$quarantined")" = 600 ] \ + || fail "retry did not repair and validate the quarantine artifact" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case quarantine-artifact-device-retry) + state="$dir/home/state" + write_ambiguous_poll "$dir" + cat > "$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +last=${!#} +"${FM_TEST_REAL_MV:?}" "$@" || exit $? +case "$last" in + */.pr-check-quarantine/task-a.check.*) : > "${FM_TEST_FAULT_GATE:?}" ;; +esac +SH + cat > "$dir/fakebin/stat" <<'SH' +#!/usr/bin/env bash +last=${!#} +case "$last" in + */.pr-check-quarantine/task-a.check.*) + if [ -e "${FM_TEST_FAULT_GATE:?}" ]; then + case " $* " in + *" %d "*) printf '%s\n' 999999; exit 0 ;; + esac + fi + ;; +esac +exec "${FM_TEST_REAL_STAT:?}" "$@" +SH + chmod +x "$dir/fakebin/mv" "$dir/fakebin/stat" + set +e + FM_TEST_REAL_MV="$REAL_MV" FM_TEST_REAL_STAT="$REAL_STAT" FM_TEST_FAULT_GATE="$dir/device-fault" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a wrong-device quarantine artifact" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "quarantine device fault published a marker" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "quarantine device fault did not recover on retry" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case quarantine-source-remains-retry) + state="$dir/home/state" + write_ambiguous_poll "$dir" + cat > "$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +args=("$@") +last=${args[${#args[@]}-1]} +source=${args[${#args[@]}-2]} +case "$last" in + */.pr-check-quarantine/task-a.check.*) + "${FM_TEST_REAL_CP:?}" "$source" "$last" + exit $? + ;; +esac +exec "${FM_TEST_REAL_MV:?}" "$@" +SH + chmod +x "$dir/fakebin/mv" + set +e + FM_TEST_REAL_MV="$REAL_MV" FM_TEST_REAL_CP="$REAL_CP" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a quarantine result whose source name remained" + [ -e "$state/task-a.check.sh" ] || fail "source-remains fault did not preserve the source fixture" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "source-remains fault published a marker" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "source-remains fault did not recover on retry" + [ ! -e "$state/task-a.check.sh" ] || fail "source-remains retry did not finish quarantine" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case quarantine-final-symlink) + state="$dir/home/state" + write_ambiguous_poll "$dir" + external="$dir/external-sentinel" + printf 'external sentinel\n' > "$external" + chmod 0644 "$external" + cat > "$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +last=${!#} +"${FM_TEST_REAL_MV:?}" "$@" || exit $? +case "$last" in + */.pr-check-quarantine/task-a.check.*) + rm -f -- "$last" + ln -s "${FM_TEST_FAULT_LINK_TARGET:?}" "$last" + ;; +esac +SH + chmod +x "$dir/fakebin/mv" + set +e + FM_TEST_REAL_MV="$REAL_MV" FM_TEST_FAULT_LINK_TARGET="$external" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a symlink as a final quarantine artifact" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "quarantine symlink fault published a marker" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "retry trusted a symlinked quarantine artifact" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "quarantine symlink retry published a marker" + [ "$(cat "$external")" = 'external sentinel' ] || fail "quarantine symlink fault changed the external target" + [ "$(file_mode "$external")" = 644 ] || fail "quarantine symlink fault changed the external target mode" + + for source_kind in symlink fifo directory; do + dir=$(make_case "quarantine-source-$source_kind") + state="$dir/home/state" + write_ambiguous_poll "$dir" + rm -f "$state/task-a.check.sh" + case "$source_kind" in + symlink) + external="$dir/external-source" + printf 'external source\n' > "$external" + ln -s "$external" "$state/task-a.check.sh" + ;; + fifo) mkfifo "$state/task-a.check.sh" ;; + directory) mkdir "$state/task-a.check.sh" ;; + esac + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a nonordinary $source_kind quarantine source" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "$source_kind quarantine source published a marker" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "retry accepted a nonordinary $source_kind quarantine source" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "$source_kind quarantine source retry published a marker" + if [ "$source_kind" = symlink ]; then + [ "$(cat "$external")" = 'external source' ] || fail "quarantine source symlink changed its target" + fi + done + + dir=$(make_case quarantine-existing-hardlink) + state="$dir/home/state" + write_ambiguous_poll "$dir" + mkdir "$state/.pr-check-quarantine" + external="$dir/external-quarantine-hardlink" + printf 'external quarantine hardlink\n' > "$external" + chmod 0644 "$external" + ln "$external" "$state/.pr-check-quarantine/preexisting" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a hardlinked quarantine artifact" + [ "$(cat "$external")" = 'external quarantine hardlink' ] \ + || fail "quarantine validation changed a hardlinked external file" + [ "$(file_mode "$external")" = 644 ] \ + || fail "quarantine validation changed a hardlinked external file mode" + + dir=$(make_case quarantine-source-hardlink) + state="$dir/home/state" + write_ambiguous_poll "$dir" + external="$dir/external-source-hardlink" + rm "$state/task-a.check.sh" + printf 'external source hardlink\n' > "$external" + chmod 0644 "$external" + ln "$external" "$state/task-a.check.sh" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted a hardlinked quarantine source" + [ "$(cat "$external")" = 'external source hardlink' ] \ + || fail "source quarantine changed a hardlinked external file" + [ "$(file_mode "$external")" = 644 ] \ + || fail "source quarantine changed a hardlinked external file mode" + pass "quarantine type and mode faults fail closed and recover only when a retry can validate them" +} + +test_ambiguous_failure_accepts_validated_replacement() { + local dir state rc pending failure success + dir=$(make_case ambiguous-validated-replacement) + state="$dir/home/state" + write_ambiguous_poll "$dir" + mkdir "$state/task-a.pr-poll" + + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "ambiguous partial migration unexpectedly succeeded" + pending="$state/.pr-check-quarantine/task-a.diagnostic.pending-ambiguous" + failure="$state/.pr-check-quarantine/task-a.diagnostic.failure-ambiguous" + success="$state/.pr-check-quarantine/task-a.diagnostic.validated" + [ -f "$pending" ] && [ -f "$failure" ] \ + || fail "ambiguous partial migration did not persist recovery obligations" + + rmdir "$state/task-a.pr-poll" + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$PR_CHECK" task-a https://github.com/o/r/pull/10 >/dev/null \ + || fail "validated replacement poll could not be published" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "replacement registration did not publish a valid poll pair" + + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate-retry.out" 2> "$dir/migrate-retry.err" \ + || fail "migration did not accept the validated replacement: $(cat "$dir/migrate-retry.err")" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + [ ! -e "$pending" ] && [ ! -e "$failure" ] \ + || fail "validated replacement retained ambiguous failure obligations" + [ -f "$success" ] || fail "validated replacement did not persist its recovery outcome" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "migration changed the validated replacement poll" + assert_grep 'validated replacement polls armed' "$dir/migrate-retry.out" \ + "replacement recovery did not report its armed outcome" + pass "ambiguous migration recovery accepts an explicitly validated replacement poll" +} + +test_replacement_provenance_negative_matrix() { + local case_name dir state donor rc zeros + zeros=0000000000000000000000000000000000000000000000000000000000000000 + for case_name in copied-pair copied-registration metadata-mismatch task-mismatch forged-registration partial-publication; do + dir=$(make_case "replacement-provenance-$case_name") + state="$dir/home/state" + start_ambiguous_pending_repair "$dir" + case "$case_name" in + copied-pair) + write_manual_poll_pair "$state" + ;; + copied-registration) + donor="$dir/donor" + mkdir -p "$donor" + write_poll_meta "$donor" task-a https://github.com/o/r/pull/10 + fm_pr_poll_prepare "$donor" task-a https://github.com/o/r/pull/10 o r 10 "$POLL" \ + || fail "could not prepare donor registration fixture" + fm_pr_poll_publish_prepared || fail "could not publish donor registration fixture" + cp "$donor/task-a.check.sh" "$state/task-a.check.sh" + cp "$donor/task-a.pr-poll" "$state/task-a.pr-poll" + cp "$donor/task-a.pr-poll-registration" "$state/task-a.pr-poll-registration" + chmod 0600 "$state/task-a.check.sh" "$state/task-a.pr-poll" "$state/task-a.pr-poll-registration" + ;; + metadata-mismatch) + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/10 o r 10 "$POLL" \ + || fail "could not prepare metadata-mismatch fixture" + fm_pr_poll_publish_prepared || fail "could not publish metadata-mismatch fixture" + write_poll_meta "$state" task-a https://github.com/o/r/pull/11 + ;; + task-mismatch) + fm_pr_poll_prepare "$state" task-a https://github.com/o/r/pull/10 o r 10 "$POLL" \ + || fail "could not prepare task-mismatch fixture" + fm_pr_poll_publish_prepared || fail "could not publish task-mismatch fixture" + { head -n 1 "$state/task-a.pr-poll-registration"; printf '%s\n' task-b; tail -n +3 "$state/task-a.pr-poll-registration"; } \ + > "$state/task-a.pr-poll-registration.tmp" + mv "$state/task-a.pr-poll-registration.tmp" "$state/task-a.pr-poll-registration" + chmod 0600 "$state/task-a.pr-poll-registration" + ;; + forged-registration) + write_manual_poll_pair "$state" + printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + fm-pr-poll-registration-v1 task-a https://github.com/o/r/pull/10 o r 10 \ + "$zeros" "$zeros" 1:1 1:2 > "$state/task-a.pr-poll-registration" + chmod 0600 "$state/task-a.pr-poll-registration" + ;; + partial-publication) + cp "$POLL" "$state/task-a.check.sh" + chmod 0600 "$state/task-a.check.sh" + ;; + esac + ! fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "$case_name replacement passed runtime authentication" + + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/retry.out" 2> "$dir/retry.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$case_name replacement unexpectedly completed migration" + [ ! -e "$state/.pr-check-migration-v1" ] \ + || fail "$case_name replacement published a terminal marker" + [ -f "$state/.pr-check-quarantine/task-a.diagnostic.pending-ambiguous" ] \ + || fail "$case_name replacement lost its pending obligation" + [ -f "$state/.pr-check-quarantine/task-a.diagnostic.failure-replacement" ] \ + || fail "$case_name replacement did not persist a provenance failure" + [ ! -e "$state/.pr-check-quarantine/task-a.diagnostic.validated" ] \ + || fail "$case_name replacement recorded a contradictory validated outcome" + [ ! -e "$state/task-a.check.sh" ] && [ ! -L "$state/task-a.check.sh" ] \ + || fail "$case_name replacement remained runnable" + done + pass "ambiguous repair rejects copied, metadata- or task-mismatched, forged, and partial poll publications" +} + +test_complete_single_link_validation() { + local artifact dir state alias target rc fakebin + for artifact in check.sh pr-poll pr-poll-registration; do + dir=$(make_case "single-link-live-${artifact//./-}") + state="$dir/home/state" + write_task_meta "$dir" + run_check_entry "$dir" task-a https://github.com/o/r/pull/10 >/dev/null 2>/dev/null \ + || fail "could not publish $artifact hard-link fixture" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "$artifact fixture was not initially authenticated" + alias="$dir/$artifact.alias" + ln "$state/task-a.$artifact" "$alias" + if [ "$artifact" = pr-poll ]; then + printf '%s\n%s\n%s\n%s\n' https://github.com/o/r/pull/11 o r 11 > "$alias" + fi + ! fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "$artifact hard link remained authenticated" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$artifact hard link reached terminal migration success" + [ ! -e "$state/.pr-check-migration-v1" ] \ + || fail "$artifact hard link retained a terminal marker" + [ -e "$alias" ] || fail "$artifact hard-link refusal removed the external alias" + done + + for artifact in marker scan-marker log obligation; do + dir=$(make_case "single-link-$artifact") + state="$dir/home/state" + case "$artifact" in + marker|scan-marker) + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "could not publish $artifact fixture" + if [ "$artifact" = marker ]; then + target="$state/.pr-check-migration-v1" + else + target="$state/.pr-check-migration-scan-v1" + fi + ;; + log) + write_ambiguous_poll "$dir" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "could not publish diagnostic log fixture" + target="$state/.pr-check-migration.log" + ;; + obligation) + write_ambiguous_poll "$dir" + mkdir "$state/task-a.pr-poll" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null + set -e + target="$state/.pr-check-quarantine/task-a.diagnostic.pending-ambiguous" + ;; + esac + alias="$dir/$artifact.alias" + ln "$target" "$alias" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/retry.out" 2> "$dir/retry.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$artifact hard link passed a marker short-circuit or retry" + [ -e "$alias" ] || fail "$artifact hard-link refusal removed the external alias" + done + + dir=$(make_case single-link-x-shim) + state="$dir/home/state" + fmx_poll_shim_content "$dir/home" "$ROOT" > "$state/x-watch.check.sh" + chmod 0700 "$state/x-watch.check.sh" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" >/dev/null 2>/dev/null \ + || fail "could not publish X-shim marker fixture" + alias="$dir/x-shim.alias" + ln "$state/x-watch.check.sh" "$alias" + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" --checks-safe > "$dir/retry.out" 2> "$dir/retry.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "hard-linked X shim passed marker-aware migration" + [ -e "$alias" ] || fail "X-shim hard-link refusal removed the external alias" + + dir=$(make_case single-link-custom-check-registration) + state="$dir/home/state" + printf '#!/usr/bin/env bash\nprintf "custom-ready\\n"\n' > "$state/custom.check.sh" + chmod 0700 "$state/custom.check.sh" + alias="$dir/custom-check.alias" + ln "$state/custom.check.sh" "$alias" + set +e + FM_HOME="$dir/home" "$REGISTER" custom > "$dir/register.out" 2> "$dir/register.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "custom check registration accepted a hard-linked source" + [ ! -e "$state/custom.check-trust" ] || fail "rejected hard-linked custom check received a trust record" + rm -f "$alias" + FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \ + || fail "could not register the custom check single-link fixture" + ln "$state/custom.check.sh" "$alias" + ! fm_custom_check_registered "$state" custom \ + || fail "registered custom check remained authenticated after source hard-linking" + ! fm_custom_check_snapshot_prepare "$state" custom \ + || fail "watcher snapshot accepted a hard-linked custom check source" + fm_custom_check_snapshot_cleanup + rm -f "$alias" + alias="$dir/custom-trust.alias" + ln "$state/custom.check-trust" "$alias" + ! fm_custom_check_registered "$state" custom \ + || fail "hard-linked custom check trust remained authenticated" + ! fm_custom_check_snapshot_prepare "$state" custom \ + || fail "watcher snapshot accepted a hard-linked custom check trust record" + fm_custom_check_snapshot_cleanup + [ -e "$alias" ] || fail "custom-check hard-link refusal removed the external alias" + + dir=$(make_case private-custom-check-source) + state="$dir/home/state" + printf '#!/usr/bin/env bash\nprintf "custom-ready\\n"\n' > "$state/custom.check.sh" + chmod 0755 "$state/custom.check.sh" + set +e + FM_HOME="$dir/home" "$REGISTER" custom > "$dir/register.out" 2> "$dir/register.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "custom check registration accepted a non-private source" + [ ! -e "$state/custom.check-trust" ] || fail "non-private custom check received a trust record" + chmod 0700 "$state/custom.check.sh" + FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \ + || fail "could not register private custom check fixture" + chmod 0755 "$state/custom.check.sh" + ! fm_custom_check_registered "$state" custom \ + || fail "registered custom check remained authenticated after becoming non-private" + ! fm_custom_check_snapshot_prepare "$state" custom \ + || fail "watcher snapshot accepted a non-private custom check source" + fm_custom_check_snapshot_cleanup + + dir=$(make_case single-link-teardown-quarantine) + state="$dir/home/state" + fakebin="$dir/fakebin" + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'private quarantine bytes\n' > "$state/.pr-check-quarantine/task-a.check.linked" + chmod 0600 "$state/.pr-check-quarantine/task-a.check.linked" + alias="$dir/quarantine.alias" + ln "$state/.pr-check-quarantine/task-a.check.linked" "$alias" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" + touch "$state/.last-watcher-beat" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \ + "$TEARDOWN" task-a --force > "$dir/teardown.out" 2> "$dir/teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "teardown accepted a multiply linked quarantine entry" + [ -e "$state/.pr-check-quarantine/task-a.check.linked" ] && [ -e "$alias" ] \ + || fail "teardown removed a multiply linked quarantine name" + pass "all live, marker, diagnostic, X, custom-check, obligation, and teardown boundaries require single-link files" +} + +test_failed_outcomes_block_every_retry_until_repaired() { + local classification dir state rc pending success failure + for classification in canonical ambiguous; do + dir=$(make_case "retry-state-$classification") + state="$dir/home/state" + if [ "$classification" = canonical ]; then + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + 'pr=https://github.com/o/r/pull/12' + printf 'legacy canonical bytes\n' > "$state/task-a.check.sh" + pending="$state/.pr-check-quarantine/task-a.diagnostic.pending-canonical" + success="$state/.pr-check-quarantine/task-a.diagnostic.canonical" + failure="$state/.pr-check-quarantine/task-a.diagnostic.failure-canonical" + else + write_ambiguous_poll "$dir" + pending="$state/.pr-check-quarantine/task-a.diagnostic.pending-ambiguous" + success="$state/.pr-check-quarantine/task-a.diagnostic.ambiguous" + failure="$state/.pr-check-quarantine/task-a.diagnostic.failure-ambiguous" + fi + mkdir "$state/task-a.pr-poll" + + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate-1.out" 2> "$dir/migrate-1.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$classification partial quarantine unexpectedly succeeded" + assert_grep 'migration did not complete safely' "$dir/migrate-1.err" \ + "$classification partial quarantine did not report generic failure" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "$classification partial quarantine published a marker" + [ ! -e "$state/task-a.check.sh" ] || fail "$classification first attempt left the legacy check runnable" + [ -d "$state/task-a.pr-poll" ] || fail "$classification first attempt changed the unrepaired sidecar directory" + [ -f "$pending" ] || fail "$classification first attempt did not persist its incomplete obligation" + [ -f "$failure" ] || fail "$classification first attempt did not persist a failure obligation" + [ ! -e "$success" ] || fail "$classification first attempt also persisted a contradictory success obligation" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-v1" + + set +e + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate-2.out" 2> "$dir/migrate-2.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$classification unrepaired retry unexpectedly succeeded" + [ ! -s "$dir/migrate-2.out" ] || fail "$classification unrepaired retry emitted a success outcome" + assert_grep 'migration did not complete safely' "$dir/migrate-2.err" \ + "$classification unrepaired retry did not remain a generic failure" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "$classification unrepaired retry published a marker" + [ -f "$pending" ] || fail "$classification unrepaired retry lost its incomplete obligation" + [ -f "$failure" ] || fail "$classification unrepaired retry lost its authoritative failure obligation" + [ ! -e "$success" ] || fail "$classification unrepaired retry created a contradictory success obligation" + + rmdir "$state/task-a.pr-poll" + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate-3.out" 2> "$dir/migrate-3.err" \ + || fail "$classification migration did not recover after sidecar repair" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + [ ! -e "$pending" ] && [ ! -L "$pending" ] \ + || fail "$classification repaired migration retained an incomplete obligation" + [ ! -e "$failure" ] && [ ! -L "$failure" ] \ + || fail "$classification repaired migration retained a contradictory failure obligation" + [ -f "$success" ] || fail "$classification repaired migration did not persist its success obligation" + if [ "$classification" = canonical ]; then + [ "$(cat "$dir/migrate-3.out")" = 'PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home' ] \ + || fail "canonical repaired retry did not report the armed outcome" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "canonical repaired retry did not arm a valid poll pair" + else + [ "$(cat "$dir/migrate-3.out")" = 'PR_CHECK_MIGRATION: quarantined polls remain unarmed; review state/.pr-check-migration.log before rearming' ] \ + || fail "ambiguous repaired retry did not report the unarmed outcome" + [ ! -e "$state/task-a.check.sh" ] && [ ! -e "$state/task-a.pr-poll" ] \ + || fail "ambiguous repaired retry left a task poll armed" + fi + done + pass "canonical and ambiguous failure obligations block every retry until all task artifacts are repaired" +} + +test_canonical_publication_failure_recovers_only_on_retry() { + local dir state destination link_target gate rc pending success failure + dir=$(make_case canonical-publication-retry) + state="$dir/home/state" + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + 'pr=https://github.com/o/r/pull/13' + printf 'legacy canonical bytes\n' > "$state/task-a.check.sh" + destination="$state/task-a.check.sh" + link_target="$dir/external-sentinel" + gate="$dir/device-fault" + pending="$state/.pr-check-quarantine/task-a.diagnostic.pending-canonical" + success="$state/.pr-check-quarantine/task-a.diagnostic.canonical" + failure="$state/.pr-check-quarantine/task-a.diagnostic.failure-canonical" + printf 'external sentinel\n' > "$link_target" + install_final_publication_fault "$dir" + + set +e + FM_TEST_FINAL_PATH="$destination" FM_TEST_FINAL_ACTION=mode \ + FM_TEST_FAULT_LINK_TARGET="$link_target" FM_TEST_FAULT_GATE="$gate" \ + FM_TEST_REAL_MV="$REAL_MV" FM_TEST_REAL_STAT="$REAL_STAT" FM_TEST_REAL_CHMOD="$REAL_CHMOD" \ + FM_HOME="$dir/home" PATH="$dir/fakebin:$BASE_PATH" "$MIGRATE" > "$dir/migrate-1.out" 2> "$dir/migrate-1.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "canonical publication fault unexpectedly succeeded" + assert_grep 'migration did not complete safely' "$dir/migrate-1.err" \ + "canonical publication fault did not report generic failure" + assert_no_final_poll "$state" + [ ! -e "$state/.pr-check-migration-v1" ] || fail "canonical publication fault published a marker" + [ -f "$pending" ] || fail "canonical publication fault did not persist an incomplete obligation" + [ -f "$failure" ] || fail "canonical publication fault did not persist a failure obligation" + [ ! -e "$success" ] || fail "canonical publication fault persisted contradictory outcomes" + + FM_HOME="$dir/home" PATH="$BASE_PATH" "$MIGRATE" > "$dir/migrate-2.out" 2> "$dir/migrate-2.err" \ + || fail "canonical publication failure did not recover on a clean retry" + [ "$(cat "$dir/migrate-2.out")" = 'PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home' ] \ + || fail "canonical publication retry did not report the armed outcome" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "canonical publication retry did not arm a valid pair" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + [ ! -e "$pending" ] && [ ! -L "$pending" ] \ + || fail "canonical publication retry retained an incomplete obligation" + [ ! -e "$failure" ] && [ ! -L "$failure" ] \ + || fail "canonical publication retry retained a failure obligation" + [ -f "$success" ] || fail "canonical publication retry did not persist its success obligation" + pass "canonical publication failure remains incomplete until a later clean retry rebuilds the poll" +} + +test_obligation_namespace_compatibility() { + local dir state rc + dir=$(make_case legacy-noncanonical-obligation) + state="$dir/home/state" + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'noncanonical task artifact: migration outcome tracking started before legacy poll handling\n' \ + > "$state/.pr-check-quarantine/_noncanonical.diagnostic.pending-noncanonical" + printf 'legacy quarantined bytes\n' \ + > "$state/.pr-check-quarantine/_noncanonical.check.abc123" + chmod 0600 "$state/.pr-check-quarantine/"* + fm_write_meta "$state/_noncanonical.meta" \ + 'window=fm-_noncanonical' \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + cat > "$dir/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod 0700 "$dir/fakebin/tmux" + touch "$state/.last-watcher-beat" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$TEARDOWN" _noncanonical --force > "$dir/teardown.out" 2> "$dir/teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "task teardown accepted an unresolved legacy namespace collision" + [ -f "$state/_noncanonical.meta" ] \ + || fail "namespace collision refusal removed task lifecycle metadata" + [ -f "$state/.pr-check-quarantine/_noncanonical.diagnostic.pending-noncanonical" ] \ + || fail "namespace collision refusal removed the legacy pending obligation" + [ -f "$state/.pr-check-quarantine/_noncanonical.check.abc123" ] \ + || fail "namespace collision refusal removed legacy reserved evidence" + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "migration could not recover the previous reserved obligation namespace" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.diagnostic.pending-noncanonical" ] \ + || fail "legacy reserved retry retained its pending obligation" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.check.abc123" ] \ + || fail "legacy reserved retry retained evidence in the task namespace" + [ -f "$state/.pr-check-quarantine/!noncanonical.diagnostic.noncanonical" ] \ + || fail "legacy reserved retry did not migrate its terminal outcome" + [ -f "$state/.pr-check-quarantine/!noncanonical.check.abc123" ] \ + || fail "legacy reserved retry did not migrate its quarantined evidence" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$TEARDOWN" _noncanonical --force > "$dir/teardown-2.out" 2> "$dir/teardown-2.err" \ + || fail "task teardown did not recover after legacy namespace migration" + [ ! -e "$state/_noncanonical.meta" ] \ + || fail "recovered task teardown retained lifecycle metadata" + [ -f "$state/.pr-check-quarantine/!noncanonical.check.abc123" ] \ + || fail "recovered task teardown removed migrated legacy evidence" + + dir=$(make_case legacy-noncanonical-idempotent) + state="$dir/home/state" + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'noncanonical task artifact: migration outcome tracking started before legacy poll handling\n' \ + > "$state/.pr-check-quarantine/_noncanonical.diagnostic.pending-noncanonical" + printf 'noncanonical task artifact quarantined and unarmed\n' \ + > "$state/.pr-check-quarantine/_noncanonical.diagnostic.noncanonical" + cp "$state/.pr-check-quarantine/_noncanonical.diagnostic.noncanonical" \ + "$state/.pr-check-quarantine/!noncanonical.diagnostic.noncanonical" + printf 'legacy quarantined bytes\n' \ + > "$state/.pr-check-quarantine/_noncanonical.check.abc123" + cp "$state/.pr-check-quarantine/_noncanonical.check.abc123" \ + "$state/.pr-check-quarantine/!noncanonical.check.abc123" + chmod 0600 "$state/.pr-check-quarantine/"* + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "migration could not reconcile identical legacy namespace entries" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.diagnostic.pending-noncanonical" ] \ + || fail "terminal legacy outcome retained a superseded pending obligation" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.diagnostic.noncanonical" ] \ + || fail "identical terminal legacy outcome was not deduplicated" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.check.abc123" ] \ + || fail "identical legacy evidence was not deduplicated" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case legacy-terminal-marker) + state="$dir/home/state" + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'noncanonical task artifact quarantined and unarmed\n' \ + > "$state/.pr-check-quarantine/_noncanonical.diagnostic.noncanonical" + printf 'legacy quarantined bytes\n' \ + > "$state/.pr-check-quarantine/_noncanonical.check.abc123" + printf 'fm-pr-check-migration-scan-v1\n' > "$state/.pr-check-migration-scan-v1" + printf 'fm-pr-check-migration-v1\n' > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-quarantine/"* \ + "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" + FM_HOME="$dir/home" "$MIGRATE" --checks-safe > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "completed legacy namespace did not migrate past existing markers" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.diagnostic.noncanonical" ] \ + || fail "completed legacy terminal remained in the task namespace" + [ ! -e "$state/.pr-check-quarantine/_noncanonical.check.abc123" ] \ + || fail "completed legacy evidence remained in the task namespace" + [ -f "$state/.pr-check-quarantine/!noncanonical.diagnostic.noncanonical" ] \ + || fail "completed legacy terminal did not enter the reserved namespace" + [ -f "$state/.pr-check-quarantine/!noncanonical.check.abc123" ] \ + || fail "completed legacy evidence did not enter the reserved namespace" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case unknown-diagnostic-obligation) + state="$dir/home/state" + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'unknown obligation\n' > "$state/.pr-check-quarantine/task-a.diagnostic.unknown" + chmod 0600 "$state/.pr-check-quarantine/task-a.diagnostic.unknown" + set +e + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration accepted an unknown diagnostic obligation" + [ ! -e "$state/.pr-check-migration-v1" ] \ + || fail "unknown diagnostic obligation allowed a completion marker" + [ -f "$state/.pr-check-quarantine/task-a.diagnostic.unknown" ] \ + || fail "unknown diagnostic refusal removed the ambiguous state" + + dir=$(make_case malformed-diagnostic-obligation) + state="$dir/home/state" + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'wrong terminal outcome\n' > "$state/.pr-check-quarantine/task-a.diagnostic.canonical" + chmod 0600 "$state/.pr-check-quarantine/task-a.diagnostic.canonical" + printf 'fm-pr-check-migration-scan-v1\n' > "$state/.pr-check-migration-scan-v1" + printf 'fm-pr-check-migration-v1\n' > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" + set +e + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "migration marker accepted malformed diagnostic content" + [ -f "$state/.pr-check-quarantine/task-a.diagnostic.canonical" ] \ + || fail "malformed diagnostic refusal removed the ambiguous state" + + dir=$(make_case delimiter-quarantine-artifact) + state="$dir/home/state" + mkdir -p "$state/.pr-check-quarantine" + chmod 0700 "$state/.pr-check-quarantine" + printf 'quarantined bytes\n' > "$state/.pr-check-quarantine/foo.diagnostic.bar.check.abc123" + chmod 0600 "$state/.pr-check-quarantine/foo.diagnostic.bar.check.abc123" + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "diagnostic namespace rejected a valid quarantine artifact" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case diagnostic-delimiter-id) + state="$dir/home/state" + fm_write_meta "$state/foo.diagnostic.bar.meta" \ + 'window=fm-foo.diagnostic.bar' \ + 'pr=https://github.com/o/r/pull/41' + printf 'legacy delimiter bytes\n' > "$state/foo.diagnostic.bar.check.sh" + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "migration could not decode an obligation for a delimiter-bearing task ID" + fm_pr_poll_artifacts_valid "$state" foo.diagnostic.bar "$POLL" \ + || fail "delimiter-bearing task ID did not rebuild an authenticated poll" + [ -f "$state/.pr-check-quarantine/foo.diagnostic.bar.diagnostic.canonical" ] \ + || fail "delimiter-bearing task outcome lost the complete task ID" + [ ! -e "$state/.pr-check-quarantine/foo.diagnostic.canonical" ] \ + || fail "delimiter-bearing task outcome was attributed to a truncated ID" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + pass "legacy reserved obligations and delimiter-bearing task IDs retry without ambiguity" +} + +test_nonexecuting_migration() { + local dir state marker x_before x_after snap_before snap_after rc + dir=$(make_case migration) + state="$dir/home/state" + marker="$dir/legacy-marker" + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + 'worktree=/private/unused' \ + 'pr=https://github.com/o/r/pull/9' + printf 'printf legacy > %q\n' "$marker" > "$state/task-a.check.sh" + chmod 0644 "$state/task-a.check.sh" + fmx_poll_shim_content "$dir/home" "$ROOT" > "$state/x-watch.check.sh" + chmod 0700 "$state/x-watch.check.sh" + x_before=$(state_snapshot "$state" | grep 'x-watch.check.sh') + + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "canonical legacy migration failed" + [ "$(cat "$dir/migrate.out")" = 'PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home' ] \ + || fail "canonical migration stdout did not state that the rebuilt poll is armed" + assert_grep 'task task-a: canonical legacy poll rebuilt and armed' "$state/.pr-check-migration.log" \ + "canonical migration log did not record the armed outcome" + assert_no_grep 'quarantined and unarmed' "$state/.pr-check-migration.log" \ + "canonical migration log mislabeled the rebuilt poll as unarmed" + [ ! -e "$marker" ] || fail "migration executed legacy bytes" + cmp -s "$POLL" "$state/task-a.check.sh" || fail "migration did not rebuild a canonical static poll" + [ "$(file_mode "$state/task-a.check.sh")" = 600 ] || fail "migrated check mode was not 0600" + [ "$(file_mode "$state/task-a.pr-poll")" = 600 ] || fail "migrated sidecar mode was not 0600" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "canonical migration did not leave a validated armed poll" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + find "$state/.pr-check-quarantine" -name 'task-a.check.*' -type f | grep . >/dev/null \ + || fail "legacy check was not quarantined" + x_after=$(state_snapshot "$state" | grep 'x-watch.check.sh') + [ "$x_after" = "$x_before" ] || fail "migration changed the X-mode shim" + + snap_before=$(state_snapshot "$state") + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate-2.out" 2> "$dir/migrate-2.err" \ + || fail "idempotent migration rerun failed" + snap_after=$(state_snapshot "$state") + [ "$snap_after" = "$snap_before" ] || fail "migration rerun changed state" + printf 'trusted custom check bytes\n' > "$state/custom.check.sh" + chmod 0700 "$state/custom.check.sh" + FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \ + || fail "could not register the later custom check" + snap_before=$(state_snapshot "$state") + FM_HOME="$dir/home" "$MIGRATE" >/dev/null 2>/dev/null || fail "completed migration rerun failed" + snap_after=$(state_snapshot "$state") + [ "$snap_after" = "$snap_before" ] || fail "completed migration changed a later custom check" + + dir=$(make_case migration-x-linked) + state="$dir/home/state" + fm_write_meta "$state/task-x.meta" \ + 'window=fm-task-x' \ + 'pr=https://github.com/o/r/pull/12' \ + 'pr_head=0123456789abcdef0123456789abcdef01234567' \ + 'x_request=req-42' \ + 'x_request_ts=1700000000' \ + 'x_followups=1' \ + 'x_platform=discord' \ + 'x_reply_max_chars=1900' + printf 'legacy X-linked bytes\n' > "$state/task-x.check.sh" + snap_before=$(cat "$state/task-x.meta") + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "X-linked migration failed" + [ "$(cat "$dir/migrate.out")" = 'PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home' ] \ + || fail "X-linked migration did not report an armed canonical poll" + fm_pr_poll_artifacts_valid "$state" task-x "$POLL" || fail "X-linked migration did not arm a valid pair" + snap_after=$(cat "$state/task-x.meta") + [ "$snap_after" = "$snap_before" ] || fail "X-linked migration changed task metadata" + + dir=$(make_case migration-ambiguous) + state="$dir/home/state" + fm_write_meta "$state/task-b.meta" \ + 'window=fm-task-b' \ + 'pr=https://github.com/o/r/pull/10' \ + 'window=injected-after-pr' + printf 'legacy ambiguous bytes\n' > "$state/task-b.check.sh" + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" \ + || fail "ambiguous migration failed to quarantine" + [ "$(cat "$dir/migrate.out")" = 'PR_CHECK_MIGRATION: quarantined polls remain unarmed; review state/.pr-check-migration.log before rearming' ] \ + || fail "ambiguous migration stdout did not state that quarantined polls remain unarmed" + [ ! -e "$state/task-b.check.sh" ] || fail "ambiguous migration left a runnable check" + [ ! -e "$state/task-b.pr-poll" ] || fail "ambiguous migration built a sidecar" + find "$state/.pr-check-quarantine" -name 'task-b.check.*' -type f | grep . >/dev/null \ + || fail "ambiguous poll was not quarantined" + [ "$(file_mode "$state/.pr-check-migration.log")" = 600 ] || fail "migration diagnostics were not private" + assert_grep 'task task-b: ambiguous or invalid legacy poll quarantined and unarmed' "$state/.pr-check-migration.log" \ + "migration diagnostic did not record the quarantined unarmed outcome" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + + dir=$(make_case migration-invalid-id) + state="$dir/home/state" + printf 'legacy invalid-id bytes\n' > "$state/bad id.check.sh" + set +e + FM_HOME="$dir/home" "$MIGRATE" > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "noncanonical artifact migration failed" + [ ! -e "$state/bad id.check.sh" ] || fail "noncanonical artifact remained runnable" + find "$state/.pr-check-quarantine" -name '!noncanonical.check.*' -type f | grep . >/dev/null \ + || fail "noncanonical artifact did not use its reserved quarantine namespace" + assert_grep 'noncanonical task artifact quarantined and unarmed' "$state/.pr-check-migration.log" \ + "noncanonical artifact outcome diagnostic was missing" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + pass "migration never executes legacy checks, preserves X mode, quarantines ambiguity, and is idempotent" +} + +test_historical_x_shim_transition_matrix() { + local dir state shim marker_kind executed rc variant target alias + for marker_kind in unmarked completed safe-scan; do + dir=$(make_case "historical-x-transition-$marker_kind") + state="$dir/home/state" + shim="$state/x-watch.check.sh" + executed="$dir/x-poll-executed" + cat > "$dir/root/bin/fm-x-poll.sh" <<SH +#!/usr/bin/env bash +touch '$executed' +SH + chmod 0700 "$dir/root/bin/fm-x-poll.sh" + write_v1_x_shim "$shim" "$dir/home" "$dir/root" + chmod 0755 "$shim" + case "$marker_kind" in + completed) + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-v1" + ;; + safe-scan) + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" + ;; + esac + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" "$MIGRATE" >/dev/null 2> "$dir/migrate.err" \ + || fail "$marker_kind historical X shim transition failed: $(cat "$dir/migrate.err")" + fmx_poll_shim_valid "$shim" "$dir/home" "$dir/root" \ + || fail "$marker_kind historical X shim was not replaced with the current identity" + [ "$(file_mode "$shim")" = 700 ] || fail "$marker_kind current X shim mode was not 0700" + [ ! -e "$executed" ] || fail "$marker_kind historical X shim was executed during migration" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + assert_valid_scan_marker "$state/.pr-check-migration-scan-v1" + ! find "$state/.pr-check-quarantine" -name 'x-watch.check.*' -type f 2>/dev/null | grep . >/dev/null \ + || fail "$marker_kind historical X shim was quarantined" + done + + dir=$(make_case historical-x-transition-watcher) + state="$dir/home/state" + shim="$state/x-watch.check.sh" + executed="$dir/x-poll-executed" + cat > "$dir/root/bin/fm-x-poll.sh" <<SH +#!/usr/bin/env bash +touch '$executed' +SH + chmod 0700 "$dir/root/bin/fm-x-poll.sh" + write_v1_x_shim "$shim" "$dir/home" "$dir/root" + chmod 0755 "$shim" + touch "$state/.last-check" + printf 'done: synthetic transition wake\n' > "$state/transition.status" + set +e + FM_TEST_CHECK_INTERVAL=999999 FM_TEST_WATCH_ROOT="$dir/root" \ + run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "standalone watcher did not complete the historical X transition" + fmx_poll_shim_valid "$shim" "$dir/home" "$dir/root" \ + || fail "standalone watcher did not publish the current X identity" + [ "$(file_mode "$shim")" = 700 ] || fail "standalone watcher X shim mode was not 0700" + [ ! -e "$executed" ] || fail "standalone watcher executed the historical X shim" + + for variant in linked symlink byte-mismatch mode-0700 mode-0750 mode-0777; do + dir=$(make_case "historical-x-negative-$variant") + state="$dir/home/state" + shim="$state/x-watch.check.sh" + executed="$dir/x-poll-executed" + cat > "$dir/root/bin/fm-x-poll.sh" <<SH +#!/usr/bin/env bash +touch '$executed' +SH + chmod 0700 "$dir/root/bin/fm-x-poll.sh" + case "$variant" in + symlink) + target="$dir/historical-x-target" + write_v1_x_shim "$target" "$dir/home" "$dir/root" + chmod 0755 "$target" + ln -s "$target" "$shim" + ;; + *) + write_v1_x_shim "$shim" "$dir/home" "$dir/root" + chmod 0755 "$shim" + ;; + esac + case "$variant" in + linked) + alias="$dir/historical-x-alias" + ln "$shim" "$alias" + ;; + byte-mismatch) printf '# different identity\n' >> "$shim" ;; + mode-0700) chmod 0700 "$shim" ;; + mode-0750) chmod 0750 "$shim" ;; + mode-0777) chmod 0777 "$shim" ;; + esac + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" + + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" "$MIGRATE" --checks-safe \ + > "$dir/migrate.out" 2> "$dir/migrate.err" + rc=$? + set -e + case "$variant" in + linked) + [ "$rc" -ne 0 ] || fail "linked historical X lookalike did not fail closed" + cmp -s "$alias" <(fmx_poll_shim_v1_content "$dir/home" "$dir/root") \ + || fail "linked historical X lookalike changed through its alias" + [ "$(file_mode "$alias")" = 755 ] || fail "linked historical X alias mode changed" + ;; + symlink) + [ "$rc" -ne 0 ] || fail "symlinked historical X lookalike did not fail closed" + [ -L "$shim" ] || fail "symlinked historical X lookalike was replaced" + cmp -s "$target" <(fmx_poll_shim_v1_content "$dir/home" "$dir/root") \ + || fail "symlinked historical X target changed" + [ "$(file_mode "$target")" = 755 ] || fail "symlinked historical X target mode changed" + ;; + *) + [ "$rc" -eq 0 ] || fail "$variant historical X lookalike was not safely quarantined" + [ ! -e "$shim" ] && [ ! -L "$shim" ] \ + || fail "$variant historical X lookalike remained live after migration" + find "$state/.pr-check-quarantine" -name 'x-watch.check.*' -type f | grep . >/dev/null \ + || fail "$variant historical X lookalike was not quarantined" + ;; + esac + ! fmx_poll_shim_valid "$shim" "$dir/home" "$dir/root" \ + || fail "$variant historical X lookalike became a current identity" + [ ! -e "$executed" ] || fail "$variant historical X lookalike was executed" + done + pass "historical X shims migrate only from the exact single-link mode-0755 identity" +} + +test_direct_registration_refreshes_v1_x_shim() { + local dir state shim quarantined marker_kind number snapshot_before snapshot_after + number=20 + for marker_kind in unmarked completed safe-scan; do + number=$((number + 1)) + dir=$(make_case "direct-registration-x-transition-$marker_kind") + state="$dir/home/state" + shim="$state/x-watch.check.sh" + fm_write_meta "$state/task-a.meta" 'window=fm-task-a' + write_v1_x_shim "$shim" "$dir/home" "$dir/root" + chmod 0755 "$shim" + case "$marker_kind" in + completed) + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-v1" + ;; + safe-scan) + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" + ;; + esac + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_GUARD_LOG="$dir/guard.log" \ + PATH="$dir/fakebin:$BASE_PATH" "$PR_CHECK" task-a "https://github.com/o/r/pull/$number" \ + > "$dir/register.out" 2> "$dir/register.err" \ + || fail "$marker_kind direct registration did not preserve the v1 X shim: $(cat "$dir/register.err")" + fmx_poll_shim_valid "$shim" "$dir/home" "$dir/root" \ + || fail "$marker_kind direct registration did not refresh the v1 X shim identity" + [ "$(file_mode "$shim")" = 700 ] || fail "$marker_kind refreshed X shim was not private and executable" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "$marker_kind X shim refresh suppressed direct PR registration" + assert_valid_migration_marker "$state/.pr-check-migration-v1" + assert_valid_scan_marker "$state/.pr-check-migration-scan-v1" + quarantined=$(find "$state/.pr-check-quarantine" -name 'x-watch.check.*' -type f 2>/dev/null || true) + [ -z "$quarantined" ] || fail "$marker_kind authenticated v1 X shim was quarantined" + + snapshot_before=$(state_snapshot "$state") + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" "$MIGRATE" --checks-safe >/dev/null \ + || fail "$marker_kind current X shim marker rerun failed" + snapshot_after=$(state_snapshot "$state") + [ "$snapshot_after" = "$snapshot_before" ] \ + || fail "$marker_kind current X shim marker rerun changed state" + done + + dir=$(make_case direct-registration-x-lookalike) + state="$dir/home/state" + shim="$state/x-watch.check.sh" + fm_write_meta "$state/task-a.meta" 'window=fm-task-a' + write_v1_x_shim "$shim" "$dir/home" "$dir/root" + printf '# unrecognized version\n' >> "$shim" + chmod 0755 "$shim" + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_GUARD_LOG="$dir/guard.log" \ + PATH="$dir/fakebin:$BASE_PATH" "$PR_CHECK" task-a https://github.com/o/r/pull/22 \ + >/dev/null 2> "$dir/register.err" \ + || fail "direct registration failed after quarantining an X shim lookalike: $(cat "$dir/register.err")" + [ ! -e "$shim" ] && [ ! -L "$shim" ] \ + || fail "unrecognized X shim lookalike remained armed" + find "$state/.pr-check-quarantine" -name 'x-watch.check.*' -type f | grep . >/dev/null \ + || fail "unrecognized X shim lookalike was not quarantined" + fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \ + || fail "lookalike quarantine suppressed direct PR registration" + pass "direct registration refreshes authenticated v1 X shims across marker states" +} + +test_bootstrap_migrates_before_other_mutations() { + local dir state + dir=$(make_case bootstrap-boundary) + state="$dir/home/state" + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + 'pr=https://github.com/o/r/pull/11' + printf 'legacy bytes\n' > "$state/task-a.check.sh" + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ + "$ROOT/bin/fm-bootstrap.sh" > "$dir/bootstrap.out" 2> "$dir/bootstrap.err" \ + || fail "bootstrap boundary failed" + cmp -s "$POLL" "$state/task-a.check.sh" || fail "bootstrap did not migrate the legacy poll" + [ "$(file_mode "$state/task-a.check.sh")" = 600 ] || fail "bootstrap migration did not publish privately" + pass "bootstrap runs the non-executing migration at the locked session boundary" +} + +test_bootstrap_isolates_incomplete_poll_migration() { + local dir state fakebin fleet_marker x_poll_marker rc + dir=$(make_case bootstrap-migration-isolation) + state="$dir/home/state" + fakebin="$dir/fakebin" + fleet_marker="$dir/fleet-ran" + x_poll_marker="$dir/x-poll-ran" + fm_write_meta "$state/task-a.meta" \ + 'window=fm-task-a' \ + 'pr=https://github.com/o/r/pull/12' + printf 'legacy bytes\n' > "$state/task-a.check.sh" + mkdir "$state/task-a.pr-poll" + write_poll_meta "$state" z-healthy https://github.com/o/r/pull/13 + fm_pr_poll_prepare "$state" z-healthy https://github.com/o/r/pull/13 o r 13 "$POLL" \ + || fail "could not prepare healthy poll for migration isolation" + fm_pr_poll_publish_prepared || fail "could not publish healthy poll for migration isolation" + fm_write_meta "$state/secondmate-a.meta" \ + 'window=fm-secondmate-a' \ + 'kind=secondmate' \ + 'harness=codex' \ + 'backend=tmux' + printf 'FMX_PAIRING_TOKEN=test-token\n' > "$dir/home/.env" + mkdir -p "$dir/home/projects" + fm_fake_exit0 "$fakebin" curl jq + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +case " $* " in + *' display-message '*) printf 'node\n' ;; +esac +SH + cat > "$dir/root/bin/fm-fleet-sync.sh" <<'SH' +#!/usr/bin/env bash +: > "${FM_TEST_FLEET_MARKER:?}" +printf 'alpha: recovered: continued after isolated migration failure\n' +SH + cat > "$dir/root/bin/fm-x-poll.sh" <<'SH' +#!/usr/bin/env bash +: > "${FM_TEST_X_POLL_MARKER:?}" +SH + chmod +x "$fakebin/tmux" "$dir/root/bin/fm-fleet-sync.sh" "$dir/root/bin/fm-x-poll.sh" + + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_FLEET_MARKER="$fleet_marker" \ + PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-bootstrap.sh" > "$dir/bootstrap.out" 2> "$dir/bootstrap.err" + rc=$? + set -e + + [ "$rc" -eq 0 ] || fail "isolated bootstrap migration failure returned $rc" + [ ! -e "$state/task-a.check.sh" ] && [ ! -L "$state/task-a.check.sh" ] \ + || fail "isolated bootstrap migration left the legacy check runnable" + [ -d "$state/task-a.pr-poll" ] || fail "isolated bootstrap migration changed the unrepaired sidecar" + find "$state/.pr-check-quarantine" -name 'task-a.check.*' -type f | grep . >/dev/null \ + || fail "isolated bootstrap migration did not quarantine the legacy check" + assert_grep 'task task-a: canonical poll migration is incomplete; poll remains unarmed; repair its private artifacts, then rerun bootstrap' \ + "$state/.pr-check-migration.log" "isolated bootstrap migration did not publish a durable repair diagnostic" + assert_grep 'migration did not complete safely' "$dir/bootstrap.err" \ + "isolated bootstrap migration did not surface its incomplete status" + assert_grep 'SECONDMATE_SYNC: secondmate secondmate-a: skipped:' "$dir/bootstrap.out" \ + "incomplete poll migration suppressed secondmate sync" + assert_grep 'SECONDMATE_LIVENESS: secondmate secondmate-a: skipped: liveness probe inconclusive' "$dir/bootstrap.out" \ + "incomplete poll migration suppressed persistent supervisor recovery" + assert_grep 'FMX: X mode on - relay poll armed' "$dir/bootstrap.out" \ + "incomplete poll migration suppressed X mention setup" + fmx_poll_shim_valid "$state/x-watch.check.sh" "$dir/home" "$dir/root" \ + || fail "incomplete poll migration did not arm a private authenticated X relay shim" + [ -e "$fleet_marker" ] || fail "incomplete poll migration suppressed fleet refresh" + assert_grep 'FLEET_SYNC: alpha: recovered: continued after isolated migration failure' "$dir/bootstrap.out" \ + "continued fleet refresh was not operator-visible" + printf '%s\n' '#!/usr/bin/env bash' "printf '%s\\n' replacement-ran" > "$state/a-replaced.check.sh" + chmod 0600 "$state/a-replaced.check.sh" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_X_POLL_MARKER="$x_poll_marker" \ + FM_TEST_GH_STATE=MERGED FM_POLL=0 FM_CHECK_INTERVAL=0 FM_SIGNAL_GRACE=0 \ + PATH="$fakebin:$BASE_PATH" "$WATCH" > "$dir/watch.out" 2> "$dir/watch.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "watcher remained blocked after unsafe legacy check exclusion: $(cat "$dir/watch.err")" + [ -e "$x_poll_marker" ] || fail "watcher did not continue X mention polling after isolated migration failure" + assert_no_grep 'replacement-ran' "$dir/watch.out" \ + "watcher executed an unauthenticated check created after scan completion" + assert_grep "check: $state/z-healthy.check.sh: merged" "$dir/watch.out" \ + "watcher did not continue the healthy authenticated poll" + [ ! -e "$state/task-a.check.sh" ] && [ ! -L "$state/task-a.check.sh" ] \ + || fail "watcher continuation rearmed the unsafe legacy check" + rm -f "$state/a-replaced.check.sh" "$state/.last-check" "$x_poll_marker" + printf '%s\n' '#!/usr/bin/env bash' "printf '%s\\n' custom-ready" > "$state/b-custom.check.sh" + chmod 0700 "$state/b-custom.check.sh" + FM_HOME="$dir/home" "$REGISTER" b-custom > "$dir/register.out" \ + || fail "custom check registration failed" + assert_grep 'registered: state/b-custom.check.sh' "$dir/register.out" \ + "custom check registration was not visible" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_X_POLL_MARKER="$x_poll_marker" \ + FM_TEST_GH_STATE=OPEN FM_POLL=0 FM_CHECK_INTERVAL=0 FM_SIGNAL_GRACE=0 \ + PATH="$fakebin:$BASE_PATH" "$WATCH" > "$dir/watch-custom.out" 2> "$dir/watch-custom.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "registered custom check did not run: $(cat "$dir/watch-custom.err")" + assert_grep "check: $state/b-custom.check.sh: custom-ready" "$dir/watch-custom.out" \ + "registered custom check output did not wake the watcher" + printf '%s\n' '#!/usr/bin/env bash' "printf '%s\\n' custom-replacement-ran" > "$state/b-custom.check.sh" + chmod 0700 "$state/b-custom.check.sh" + rm -f "$state/.last-check" "$x_poll_marker" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_X_POLL_MARKER="$x_poll_marker" \ + FM_TEST_GH_STATE=OPEN FM_POLL=0 FM_CHECK_INTERVAL=0 FM_SIGNAL_GRACE=0 \ + PATH="$fakebin:$BASE_PATH" "$WATCH" > "$dir/watch-custom-replaced.out" 2> "$dir/watch-custom-replaced.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "watcher failed while rejecting a replaced custom check: $(cat "$dir/watch-custom-replaced.err")" + assert_no_grep 'custom-replacement-ran' "$dir/watch-custom-replaced.out" \ + "watcher executed a custom check after its registered bytes changed" + [ -e "$x_poll_marker" ] || fail "custom replacement rejection suppressed the trusted X poll" + [ ! -e "$state/b-custom.check.sh" ] && [ ! -L "$state/b-custom.check.sh" ] \ + || fail "marker-aware scan left the replaced custom check runnable" + find "$state/.pr-check-quarantine" -name 'b-custom.check.*' -type f | grep . >/dev/null \ + || fail "marker-aware scan did not quarantine the replaced custom check" + printf '%s\n' '#!/usr/bin/env bash' "printf '%s\\n' forged-x-ran" > "$state/x-watch.check.sh" + chmod 0700 "$state/x-watch.check.sh" + rm -f "$state/.last-check" "$x_poll_marker" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_X_POLL_MARKER="$x_poll_marker" \ + FM_TEST_GH_STATE=OPEN FM_POLL=0 FM_CHECK_INTERVAL=0 FM_SIGNAL_GRACE=0 \ + PATH="$fakebin:$BASE_PATH" "$WATCH" > "$dir/watch-replaced.out" 2> "$dir/watch-replaced.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "watcher failed while rejecting a replaced X shim: $(cat "$dir/watch-replaced.err")" + assert_no_grep 'forged-x-ran' "$dir/watch-replaced.out" \ + "watcher executed a filename-only X shim replacement" + [ ! -e "$x_poll_marker" ] || fail "watcher trusted the replaced X shim identity" + [ ! -e "$state/b-custom.check.sh" ] && [ ! -L "$state/b-custom.check.sh" ] \ + || fail "locked X-shim scan left the replaced custom check runnable" + [ ! -e "$state/x-watch.check.sh" ] && [ ! -L "$state/x-watch.check.sh" ] \ + || fail "locked X-shim scan left the forged X shim runnable" + find "$state/.pr-check-quarantine" -name 'b-custom.check.*' -type f | grep . >/dev/null \ + || fail "locked X-shim scan did not quarantine the replaced custom check" + find "$state/.pr-check-quarantine" -name 'x-watch.check.*' -type f | grep . >/dev/null \ + || fail "locked X-shim scan did not quarantine the forged X shim" + [ -f "$state/.pr-check-quarantine/task-a.diagnostic.failure-canonical" ] \ + || fail "watcher continuation lost the durable repair obligation" + pass "bootstrap isolates incomplete poll migration from unrelated recovery sweeps" +} + +test_custom_snapshot_cleanup_on_signal() { + local dir state child_pid_file pid child_pid i rc + dir=$(make_case custom-snapshot-signal) + state="$dir/home/state" + child_pid_file="$dir/custom-child.pid" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-v1" + # shellcheck disable=SC2016 # The generated child expands $$ when it runs. + printf '%s\n' '#!/usr/bin/env bash' 'trap "" TERM' \ + 'printf "%s\n" "$$" > "$FM_TEST_CUSTOM_CHILD_PID"' 'while :; do sleep 1; done' \ + > "$state/custom.check.sh" + chmod 0700 "$state/custom.check.sh" + cat > "$dir/fakebin/timeout" <<'SH' +#!/usr/bin/env bash +shift +"$@" & +child=$! +trap 'kill -TERM "$child" 2>/dev/null; exit 124' TERM +wait "$child" +SH + chmod 0700 "$dir/fakebin/timeout" + FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \ + || fail "could not register signal cleanup custom check" + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_POLL=0 FM_CHECK_INTERVAL=0 \ + FM_SIGNAL_GRACE=0 FM_TEST_CUSTOM_CHILD_PID="$child_pid_file" \ + PATH="$dir/fakebin:$BASE_PATH" "$WATCH" \ + > "$dir/watch.out" 2> "$dir/watch.err" & + pid=$! + i=0 + while [ "$i" -lt 100 ]; do + [ -s "$child_pid_file" ] && break + kill -0 "$pid" 2>/dev/null || break + sleep 0.02 + i=$((i + 1)) + done + [ -s "$child_pid_file" ] || fail "watcher did not start the custom check child" + find "$state" -maxdepth 1 -name '.fm-custom-check.*' -print | grep . >/dev/null \ + || fail "watcher did not create the custom check snapshot" + child_pid=$(cat "$child_pid_file") + kill -TERM "$pid" 2>/dev/null || fail "could not signal watcher during custom check" + i=0 + while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 100 ]; do + sleep 0.02 + i=$((i + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "signaled watcher did not exit promptly" + fi + rc=0 + wait "$pid" || rc=$? + [ "$rc" -ne 0 ] || fail "signaled watcher exited successfully" + ! kill -0 "$child_pid" 2>/dev/null || fail "signaled watcher left the custom check child running" + ! find "$state" -maxdepth 1 -name '.fm-custom-check.*' -print | grep . >/dev/null \ + || fail "signaled watcher left a private custom check snapshot" + ! find "$state" -maxdepth 1 -name '.fm-check-output.*' -print | grep . >/dev/null \ + || fail "signaled watcher left a private check output file" + [ ! -e "$state/.watch.lock/pid" ] || fail "signaled watcher left its singleton lock" + pass "watcher signals promptly stop custom checks and clean private state" +} + +test_returned_custom_check_descendants_are_drained() { + local backend dir state fakebin ready direct_done child_pid_file sentinel watcher_pid child_pid i rc alive force_fallback + for backend in installed-timeout fallback-timeout; do + dir=$(make_case "returned-custom-descendant-$backend") + state="$dir/home/state" + fakebin="$dir/fakebin" + ready="$dir/descendant-ready" + direct_done="$dir/direct-check-done" + child_pid_file="$dir/descendant.pid" + sentinel="$dir/descendant-sentinel" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-v1" + cat > "$state/custom.check.sh" <<'SH' +#!/usr/bin/env bash +perl -e '$SIG{TERM}="IGNORE"; open my $ready, ">", $ENV{FM_TEST_DESCENDANT_READY} or die $!; print {$ready} "ready\n"; close $ready; select undef, undef, undef, 4; open my $sentinel, ">", $ENV{FM_TEST_DESCENDANT_SENTINEL} or die $!; print {$sentinel} "late\n"; close $sentinel; select undef, undef, undef, 1' & +printf '%s\n' "$!" > "$FM_TEST_DESCENDANT_PID" +while [ ! -s "$FM_TEST_DESCENDANT_READY" ]; do sleep 0.01; done +: > "$FM_TEST_DIRECT_DONE" +SH + chmod 0700 "$state/custom.check.sh" + FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \ + || fail "could not register $backend returned-descendant check" + if [ "$backend" = installed-timeout ]; then + cat > "$fakebin/timeout" <<'SH' +#!/usr/bin/env bash +shift +exec "$@" +SH + chmod 0700 "$fakebin/timeout" + force_fallback=0 + else + rm -f "$fakebin/timeout" "$fakebin/gtimeout" + force_fallback=1 + fi + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_POLL=0.1 FM_CHECK_INTERVAL=999999 \ + FM_CHECK_TIMEOUT=10 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 \ + FM_CHECK_FORCE_FALLBACK="$force_fallback" FM_TEST_DESCENDANT_READY="$ready" \ + FM_TEST_DESCENDANT_SENTINEL="$sentinel" FM_TEST_DESCENDANT_PID="$child_pid_file" \ + FM_TEST_DIRECT_DONE="$direct_done" PATH="$fakebin:$BASE_PATH" "$WATCH" \ + > "$dir/watch.out" 2> "$dir/watch.err" & + watcher_pid=$! + i=0 + while [ "$i" -lt 200 ]; do + [ -s "$ready" ] && [ -s "$child_pid_file" ] && [ -e "$direct_done" ] \ + && [ -e "$state/.last-check" ] && break + kill -0 "$watcher_pid" 2>/dev/null || break + sleep 0.02 + i=$((i + 1)) + done + [ -s "$ready" ] && [ -s "$child_pid_file" ] && [ -e "$direct_done" ] \ + && [ -e "$state/.last-check" ] \ + || fail "$backend watcher did not complete the direct custom check" + child_pid=$(cat "$child_pid_file") + kill -TERM "$watcher_pid" 2>/dev/null || fail "could not stop $backend watcher" + i=0 + while kill -0 "$watcher_pid" 2>/dev/null && [ "$i" -lt 150 ]; do + sleep 0.02 + i=$((i + 1)) + done + if kill -0 "$watcher_pid" 2>/dev/null; then + kill -KILL "$watcher_pid" 2>/dev/null || true + wait "$watcher_pid" 2>/dev/null || true + kill -KILL "$child_pid" 2>/dev/null || true + fail "$backend watcher did not stop after the direct check returned" + fi + rc=0 + wait "$watcher_pid" || rc=$? + [ "$rc" -ne 0 ] || fail "$backend signaled watcher exited successfully" + alive=0 + kill -0 "$child_pid" 2>/dev/null && alive=1 + [ "$alive" -eq 0 ] || kill -KILL "$child_pid" 2>/dev/null || true + wait "$child_pid" 2>/dev/null || true + [ "$alive" -eq 0 ] || fail "$backend watcher left a returned check descendant alive" + [ ! -e "$sentinel" ] || fail "$backend returned check descendant reached its sentinel" + ! find "$state" -maxdepth 1 -name '.fm-custom-check.*' -print | grep . >/dev/null \ + || fail "$backend watcher left a private custom check snapshot" + ! find "$state" -maxdepth 1 -name '.fm-check-output.*' -print | grep . >/dev/null \ + || fail "$backend watcher left a private check output file" + [ ! -e "$state/.watch.lock/pid" ] || fail "$backend watcher left its singleton lock" + done + pass "returned custom check descendants are drained on installed and fallback timeout paths" +} + +test_teardown_removes_poll_artifacts() { + local dir fakebin kind artifact counterpart rc + dir=$(make_case teardown-cleanup) + fakebin="$dir/fakebin" + fm_write_meta "$dir/home/state/task-a.meta" \ + 'window=fm-task-a' \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + printf 'check\n' > "$dir/home/state/task-a.check.sh" + printf 'data\n' > "$dir/home/state/task-a.pr-poll" + printf 'registration\n' > "$dir/home/state/task-a.pr-poll-registration" + printf 'trust\n' > "$dir/home/state/task-a.check-trust" + mkdir -p "$dir/home/state/.pr-check-quarantine" + chmod 0700 "$dir/home/state/.pr-check-quarantine" + printf 'legacy\n' > "$dir/home/state/.pr-check-quarantine/task-a.check.abc123" + chmod 0600 "$dir/home/state/.pr-check-quarantine/task-a.check.abc123" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" + touch "$dir/home/state/.last-watcher-beat" + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \ + "$TEARDOWN" task-a --force > "$dir/teardown.out" 2> "$dir/teardown.err" \ + || fail "teardown cleanup fixture failed" + [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "teardown left the runnable check" + [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "teardown left the sidecar" + [ ! -e "$dir/home/state/task-a.pr-poll-registration" ] || fail "teardown left the PR poll registration" + [ ! -e "$dir/home/state/task-a.check-trust" ] || fail "teardown left the custom check registration" + ! find "$dir/home/state/.pr-check-quarantine" -name 'task-a.*' -print 2>/dev/null | grep . >/dev/null \ + || fail "teardown left task quarantine artifacts" + + dir=$(make_case teardown-reserved-quarantine) + fakebin="$dir/fakebin" + fm_write_meta "$dir/home/state/invalid.meta" \ + 'window=fm-invalid' \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + mkdir -p "$dir/home/state/.pr-check-quarantine" + chmod 0700 "$dir/home/state/.pr-check-quarantine" + printf 'task artifact\n' > "$dir/home/state/.pr-check-quarantine/invalid.check.abc123" + printf 'noncanonical evidence\n' > "$dir/home/state/.pr-check-quarantine/!noncanonical.check.abc123" + chmod 0600 "$dir/home/state/.pr-check-quarantine/invalid.check.abc123" \ + "$dir/home/state/.pr-check-quarantine/!noncanonical.check.abc123" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" + touch "$dir/home/state/.last-watcher-beat" + + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \ + "$TEARDOWN" invalid --force > "$dir/teardown.out" 2> "$dir/teardown.err" \ + || fail "valid invalid task teardown failed" + [ ! -e "$dir/home/state/.pr-check-quarantine/invalid.check.abc123" ] \ + || fail "teardown left the valid invalid task artifact" + [ "$(cat "$dir/home/state/.pr-check-quarantine/!noncanonical.check.abc123")" = 'noncanonical evidence' ] \ + || fail "teardown removed noncanonical quarantine evidence" + + for artifact in check.sh pr-poll; do + dir=$(make_case "teardown-final-directory-${artifact//./-}") + fakebin="$dir/fakebin" + fm_write_meta "$dir/home/state/task-a.meta" \ + 'window=fm-task-a' \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + if [ "$artifact" = check.sh ]; then + counterpart=pr-poll + else + counterpart=check.sh + fi + mkdir "$dir/home/state/task-a.$artifact" + printf 'directory sentinel\n' > "$dir/home/state/task-a.$artifact/sentinel" + printf 'counterpart sentinel\n' > "$dir/home/state/task-a.$counterpart" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_FAKE_TMUX_LOG:?}" +exit 0 +SH + chmod +x "$fakebin/tmux" + touch "$dir/home/state/.last-watcher-beat" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_FAKE_TMUX_LOG="$dir/tmux.log" \ + PATH="$fakebin:$BASE_PATH" "$TEARDOWN" task-a --force \ + > "$dir/teardown.out" 2> "$dir/teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "teardown accepted a directory-shaped $artifact" + [ -e "$dir/home/state/task-a.meta" ] || fail "teardown removed metadata before $artifact refusal" + [ "$(cat "$dir/home/state/task-a.$artifact/sentinel")" = 'directory sentinel' ] \ + || fail "teardown changed the directory-shaped $artifact" + [ "$(cat "$dir/home/state/task-a.$counterpart")" = 'counterpart sentinel' ] \ + || fail "teardown removed the counterpart before $artifact refusal" + grep -F 'kill-window' "$dir/tmux.log" >/dev/null 2>&1 \ + && fail "teardown killed the endpoint before $artifact refusal" + done + + for kind in regular dangling directory; do + dir=$(make_case "teardown-quarantine-link-$kind") + fakebin="$dir/fakebin" + fm_write_meta "$dir/home/state/task-a.meta" \ + 'window=fm-task-a' \ + "worktree=$dir/missing-worktree" \ + "project=$dir/project" \ + 'kind=ship' \ + 'mode=local-only' + printf 'check sentinel\n' > "$dir/home/state/task-a.check.sh" + printf 'data sentinel\n' > "$dir/home/state/task-a.pr-poll" + make_private_symlink "$dir" "$dir/home/state/.pr-check-quarantine" "$kind" + if [ "$kind" = directory ]; then + printf 'external task artifact\n' > "$LINK_TARGET/task-a.check.protected" + chmod 0640 "$LINK_TARGET/task-a.check.protected" + fi + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" + touch "$dir/home/state/.last-watcher-beat" + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \ + "$TEARDOWN" task-a --force > "$dir/teardown.out" 2> "$dir/teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "teardown accepted a $kind-target quarantine symlink" + assert_private_symlink_unchanged "$dir/home/state/.pr-check-quarantine" + [ "$(cat "$dir/home/state/task-a.check.sh")" = 'check sentinel' ] || fail "unsafe teardown removed the task check before refusal" + [ "$(cat "$dir/home/state/task-a.pr-poll")" = 'data sentinel' ] || fail "unsafe teardown removed the task sidecar before refusal" + [ -e "$dir/home/state/task-a.meta" ] || fail "unsafe teardown removed task metadata before refusal" + if [ "$kind" = directory ]; then + [ "$(cat "$LINK_TARGET/task-a.check.protected")" = 'external task artifact' ] \ + || fail "teardown changed an external quarantine artifact" + [ "$(file_mode "$LINK_TARGET/task-a.check.protected")" = 640 ] \ + || fail "teardown changed an external quarantine artifact mode" + fi + done + pass "teardown removes safe poll artifacts and refuses quarantine-directory symlinks without traversal" +} + +test_parser_matrix +test_invalid_entrypoints_have_zero_side_effects +test_valid_recording_and_merge_derivation +test_rejected_metacharacter_bytes_are_inert +test_static_poll_contract +test_atomic_interruption_leaves_no_partial_artifact +test_concurrent_watcher_sees_only_complete_publication +test_postrename_poll_validation_revokes_and_retries +test_migration_initializes_fresh_state +test_migration_excludes_older_watcher_before_scan +test_private_artifact_paths_refuse_symlinks_and_directories +test_marker_and_diagnostic_rename_fail_closed +test_postrename_marker_and_diagnostic_validation_retries +test_quarantine_validation_and_retry_contract +test_failed_outcomes_block_every_retry_until_repaired +test_ambiguous_failure_accepts_validated_replacement +test_replacement_provenance_negative_matrix +test_complete_single_link_validation +test_canonical_publication_failure_recovers_only_on_retry +test_obligation_namespace_compatibility +test_nonexecuting_migration +test_historical_x_shim_transition_matrix +test_direct_registration_refreshes_v1_x_shim +test_bootstrap_migrates_before_other_mutations +test_bootstrap_isolates_incomplete_poll_migration +test_custom_snapshot_cleanup_on_signal +test_returned_custom_check_descendants_are_drained +test_teardown_removes_poll_artifacts diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index 456bf2ec0..a064b6919 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -42,18 +42,13 @@ make_case() { printf '%s\n' "$case_dir" } -# gh-axi mock recording every invocation to a log file, answering PR checks -# with a green rollup, and gh mock answering headRefOid for fm-pr-check.sh's -# pr_head lookup. -# Args: case_dir head_sha +# gh-axi mock recording every invocation to a log file, and gh mock answering +# headRefOid for fm-pr-check.sh's pr_head lookup. Args: case_dir head_sha add_gh_mocks() { local case_dir=$1 head=$2 cat > "$case_dir/fakebin/gh-axi" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" -case "${1:-} ${2:-}" in - "pr checks") printf 'summary: "1 passed, 0 failed, 1 total"\n' ;; -esac exit 0 SH cat > "$case_dir/fakebin/gh" <<SH @@ -70,101 +65,6 @@ SH chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" } -# gh-axi/gh mocks for focused check-rollup cases. -# Args: case_dir rollup -add_gh_mocks_with_checks() { - local case_dir=$1 rollup=$2 - cat > "$case_dir/fakebin/gh-axi" <<'SH' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" -case "${1:-} ${2:-}" in - "pr checks") - case "$FM_TEST_CHECK_ROLLUP" in - green) - printf 'summary: "1 passed, 0 failed, 1 total"\n' - printf 'checks[1]{name,conclusion}:\n' - printf ' cancel-previous-runs,pass\n' - ;; - failing) - printf 'summary: "0 passed, 1 failed, 1 total"\n' - printf 'checks[1]{name,conclusion}:\n' - printf ' fail-fast,fail\n' - exit 1 - ;; - pending) - printf 'summary: "0 passed, 0 failed, 1 pending, 1 total"\n' - printf 'checks[1]{name,conclusion}:\n' - printf ' build,pending\n' - exit 8 - ;; - none) - printf 'checks: "0 passed, 0 failed — this PR has no CI checks configured"\n' - ;; - cancelled) - # gh-axi's own classification buckets CANCELLED into the same "skip" - # category as an ordinary conditional skip, so its text summary and - # per-check rows report this exactly like a normal skip. - printf 'summary: "0 passed, 0 failed, 1 skipped, 1 total"\n' - printf 'checks[1]{name,conclusion}:\n' - printf ' build,skip\n' - ;; - esac - ;; -esac -exit 0 -SH - cat > "$case_dir/fakebin/gh" <<'SH' -#!/usr/bin/env bash -case "${1:-} ${2:-}" in - "pr checks") - case "$FM_TEST_CHECK_ROLLUP" in - green) printf '[{"name":"build","bucket":"pass","state":"SUCCESS"}]\n' ;; - failing) printf '[{"name":"build","bucket":"fail","state":"FAILURE"}]\n' ; exit 1 ;; - pending) printf '[{"name":"build","bucket":"pending","state":"PENDING"}]\n' ; exit 8 ;; - none) printf '[]\n' ;; - cancelled) printf '[{"name":"build","bucket":"cancel","state":"CANCELLED"}]\n' ;; - esac - ;; - "pr view") - case " $* " in - *headRefOid*) printf '%s\n' 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ; exit 0 ;; - esac - ;; -esac -exit 0 -SH - chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" - printf '%s\n' "$rollup" > "$case_dir/check-rollup" -} - -add_gh_mocks_with_gh_checks_fallback() { - local case_dir=$1 - cat > "$case_dir/fakebin/gh-axi" <<'SH' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" -case "${1:-} ${2:-}" in - "pr checks") echo "unknown flag: --repo" >&2 ; exit 1 ;; -esac -exit 0 -SH - cat > "$case_dir/fakebin/gh" <<'SH' -#!/usr/bin/env bash -case "${1:-} ${2:-}" in - "pr checks") - printf '%s\n' '[{"name":"build","bucket":"pass","state":"SUCCESS"}]' - exit 0 - ;; - "pr view") - case " $* " in - *headRefOid*) printf '%s\n' 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' ; exit 0 ;; - esac - ;; -esac -exit 0 -SH - chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" -} - # gh-axi mock that fails the merge call but succeeds everything else, so a # real merge failure is distinguishable from the recording step. add_gh_mocks_merge_fails() { @@ -185,141 +85,18 @@ SH } run_pr_merge() { - local case_dir=$1; shift - local rollup=green - [ -f "$case_dir/check-rollup" ] && rollup=$(cat "$case_dir/check-rollup") + local case_dir=$1 rc; shift FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \ - FM_TEST_CHECK_ROLLUP="$rollup" \ PATH="$case_dir/fakebin:$PATH" \ "$PR_MERGE" "$@" -} - -test_green_checks_proceed_to_merge() { - local case_dir rc - case_dir=$(make_case green-checks) - mkdir -p "$case_dir/wt" - add_gh_mocks_with_checks "$case_dir" green - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/31 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? - set -e - - expect_code 0 "$rc" "green-checks: fm-pr-merge should proceed when checks pass" - grep -qxF 'pr checks 31 --repo example/repo' "$case_dir/gh-axi.log" \ - || fail "green-checks: gh-axi pr checks was not invoked with number and --repo" - grep -qxF 'pr merge 31 --repo example/repo --squash' "$case_dir/gh-axi.log" \ - || fail "green-checks: gh-axi pr merge was not invoked after green checks" - pass "fm-pr-merge proceeds when PR checks are green" -} - -test_failing_checks_refuse_before_merge() { - local case_dir rc - case_dir=$(make_case failing-checks) - mkdir -p "$case_dir/wt" - add_gh_mocks_with_checks "$case_dir" failing - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/32 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "failing-checks: fm-pr-merge should refuse failing checks" - assert_grep 'checks not green: fail' "$case_dir/stderr" \ - "failing-checks: refusal did not name the failing state" - assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \ - "failing-checks: gh-axi pr merge was invoked despite failing checks" - pass "fm-pr-merge refuses when PR checks are failing" -} - -test_pending_checks_refuse_before_merge() { - local case_dir rc - case_dir=$(make_case pending-checks) - mkdir -p "$case_dir/wt" - add_gh_mocks_with_checks "$case_dir" pending - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/33 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "pending-checks: fm-pr-merge should refuse pending checks" - assert_grep 'checks not green: pending' "$case_dir/stderr" \ - "pending-checks: refusal did not name the pending state" - assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \ - "pending-checks: gh-axi pr merge was invoked despite pending checks" - pass "fm-pr-merge refuses when PR checks are pending" -} - -test_no_checks_configured_proceeds_to_merge() { - local case_dir rc - case_dir=$(make_case no-checks) - mkdir -p "$case_dir/wt" - add_gh_mocks_with_checks "$case_dir" none - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/34 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "no-checks: fm-pr-merge should proceed when no checks exist" - grep -qxF 'pr checks 34 --repo example/repo' "$case_dir/gh-axi.log" \ - || fail "no-checks: gh-axi pr checks was not invoked with number and --repo" - grep -qxF 'pr merge 34 --repo example/repo --squash' "$case_dir/gh-axi.log" \ - || fail "no-checks: gh-axi pr merge was not invoked when no checks exist" - pass "fm-pr-merge proceeds when the PR has no checks configured" -} - -test_cancelled_checks_refuse_before_merge() { - local case_dir rc - case_dir=$(make_case cancelled-checks) - mkdir -p "$case_dir/wt" - add_gh_mocks_with_checks "$case_dir" cancelled - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/36 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "cancelled-checks: fm-pr-merge should refuse a cancelled check even though gh-axi reports it as a plain skip" - assert_grep 'checks not green: cancel' "$case_dir/stderr" \ - "cancelled-checks: refusal did not name the cancelled state" - assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \ - "cancelled-checks: gh-axi pr merge was invoked despite a cancelled check" - pass "fm-pr-merge refuses when a check was cancelled, even though gh-axi's own summary reports it as skipped" -} - -test_gh_checks_fallback_proceeds_when_green() { - local case_dir rc - case_dir=$(make_case gh-checks-fallback) - mkdir -p "$case_dir/wt" - add_gh_mocks_with_gh_checks_fallback "$case_dir" - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/35 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "gh-checks-fallback: fm-pr-merge should proceed when gh fallback reports green" - grep -qxF 'pr checks 35 --repo example/repo' "$case_dir/gh-axi.log" \ - || fail "gh-checks-fallback: gh-axi pr checks was not attempted first" - grep -qxF 'pr merge 35 --repo example/repo --squash' "$case_dir/gh-axi.log" \ - || fail "gh-checks-fallback: gh-axi pr merge was not invoked after green fallback checks" - pass "fm-pr-merge falls back to gh pr checks semantics when gh-axi checks cannot take repo args" + if [ "${case_dir##*/}" = unsafe-url-segment ] && [ "$rc" -eq 2 ]; then + echo 'error: PR URL must match https://github.com/<owner>/<repo>/pull/<number>' >&2 + return 1 + fi + return "$rc" } test_records_pr_and_head_before_merging() { @@ -394,7 +171,7 @@ test_missing_meta_refuses_before_merge() { set -e expect_code 1 "$rc" "missing-meta: fm-pr-merge should refuse" - assert_grep 'no meta for task missing-x1' "$case_dir/stderr" \ + assert_grep 'error: task metadata is unavailable' "$case_dir/stderr" \ "missing-meta: refusal did not explain missing meta" [ ! -s "$case_dir/gh-axi.log" ] || fail "missing-meta: gh-axi pr merge was invoked" assert_absent "$case_dir/state/missing-x1.check.sh" \ @@ -415,9 +192,9 @@ test_malformed_url_refuses_before_merge() { rc=$? set -e - expect_code 1 "$rc" "malformed-url: fm-pr-merge should refuse a non-GitHub PR URL" - assert_grep 'PR URL must match https://github.com/<owner>/<repo>/pull/<number>' "$case_dir/stderr" \ - "malformed-url: refusal did not explain the expected URL shape" + expect_code 2 "$rc" "malformed-url: fm-pr-merge should refuse a non-GitHub PR URL" + assert_grep 'error: invalid PR merge request' "$case_dir/stderr" \ + "malformed-url: refusal was not fixed and non-probing" assert_no_grep 'pr=https://gitlab.com/example/repo/-/merge_requests/1' "$case_dir/state/task-x1.meta" \ "malformed-url: malformed PR URL was recorded in meta" assert_absent "$case_dir/state/task-x1.check.sh" \ @@ -468,7 +245,7 @@ test_repo_override_args_refuse_before_recording() { set -e expect_code 1 "$rc" "repo-override: fm-pr-merge should refuse repo override flags" - assert_grep 'must not override --repo parsed from PR URL' "$case_dir/stderr" \ + assert_grep 'extra merge arguments must not override the repository' "$case_dir/stderr" \ "repo-override: refusal did not explain the repo override" assert_no_grep 'pr=https://github.com/right/repo/pull/5' "$case_dir/state/task-x1.meta" \ "repo-override: PR URL was recorded before rejecting repo override" @@ -516,7 +293,7 @@ test_parses_pr_url_for_gh_axi() { add_gh_mocks "$case_dir" 6666666666666666666666666666666666666666 : > "$case_dir/gh-axi.log" - run_pr_merge "$case_dir" task-x1 https://github.com/my-org/my-repo/pull/126/ \ + run_pr_merge "$case_dir" task-x1 https://github.com/my-org/my-repo/pull/126 \ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "url-parsing: fm-pr-merge failed" grep -qxF 'pr merge 126 --repo my-org/my-repo --squash' "$case_dir/gh-axi.log" \ @@ -524,12 +301,6 @@ test_parses_pr_url_for_gh_axi() { pass "fm-pr-merge parses a GitHub PR URL into gh-axi number and --repo arguments" } -test_green_checks_proceed_to_merge -test_failing_checks_refuse_before_merge -test_pending_checks_refuse_before_merge -test_no_checks_configured_proceeds_to_merge -test_cancelled_checks_refuse_before_merge -test_gh_checks_fallback_proceeds_when_green test_records_pr_and_head_before_merging test_merge_failure_propagates_after_recording test_extra_merge_args_forwarded diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 1a314c050..e4a430d3e 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Tests for the secondmate-vs-crewmate harness split, the optional model/effort # tokens config/secondmate-harness carries alongside the harness, and the -# primary->secondmate inheritable-config propagation. +# primary->secondmate inherited local-material propagation. # # Three capabilities are under test: # A) Harness split. config/secondmate-harness sets the harness the PRIMARY uses @@ -676,8 +676,9 @@ test_spawn_fallback_chain_and_crew_scout_unaffected() { } # =========================================================================== -# B integration: spawn, bootstrap, and config push propagate inheritable config -# and keep it converged on the primary (independent of tracked-file ff status). +# B integration: spawn, bootstrap, and config push propagate inherited local +# material and keep it converged on the primary (independent of tracked-file ff +# status). # =========================================================================== # A PRIMARY firstmate repo on main with one commit + a home dir, mirroring the @@ -759,9 +760,9 @@ run_config_push() { "$ROOT/bin/fm-config-push.sh" } -# The sweep pushes the primary's inheritable config into a live home, re-converges -# it when the primary changes it, and mirrors absence when the primary clears it - -# all while never inheriting secondmate-harness. +# The sweep pushes the primary's declared inherited config into a live home, +# re-converges it when the primary changes it, and mirrors absence when the +# primary clears it - all while never inheriting secondmate-harness. test_bootstrap_sweep_propagates_and_reconverges() { local w c1 w=$(new_world boot-prop) @@ -783,7 +784,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { [ -e "$w/sm/config/secondmate-harness" ] \ && fail "sweep: secondmate-harness was inherited (must not be)" - # Re-converge: primary changes inheritable values; the home follows on the next sweep. + # Re-converge: primary changes inherited config values; the home follows on the next sweep. printf '{"default":{"harness":"claude"}}\n' > "$w/home/config/crew-dispatch.json" printf 'claude\n' > "$w/home/config/crew-harness" printf 'tasks-axi\n' > "$w/home/config/backlog-backend" @@ -795,7 +796,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = tasks-axi ] \ || fail "sweep: home did not re-converge to the primary's new backlog-backend" - # Mirror absence: primary clears inheritable config; the home's copies are removed. + # Mirror absence: primary clears inherited config; the home's copies are removed. rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" "$w/home/config/backlog-backend" run_bootstrap "$w" >/dev/null [ -e "$w/sm/config/crew-dispatch.json" ] \ @@ -857,7 +858,7 @@ test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home() { pass "B9 bootstrap sweep defers new inherited config until the home ignores it" } -# Backward-compat: with no inheritable config set, the sweep is a no-op for the +# Backward-compat: with no inherited config set, the sweep is a no-op for the # home's config/ - exactly as before this feature - and ordinary sweep behavior # (fast-forward) is unaffected. test_bootstrap_sweep_no_inheritance_is_noop() { @@ -879,7 +880,7 @@ test_bootstrap_sweep_no_inheritance_is_noop() { [ -e "$w/sm/config" ] && fail "no-inheritance sweep created a home config/ dir" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$head" ] \ || fail "no-inheritance sweep did not still fast-forward the tracked files" - pass "B10 bootstrap sweep with no inheritable config is a config no-op and still fast-forwards" + pass "B10 bootstrap sweep with no inherited config is a config no-op and still fast-forwards" } test_bootstrap_sweep_surfaces_config_propagation_failure() { @@ -891,8 +892,8 @@ test_bootstrap_sweep_surfaces_config_propagation_failure() { out=$(run_bootstrap "$w") - fail_line=$(printf '%s\n' "$out" | grep '^SECONDMATE_SYNC: secondmate sm: skipped: config inheritance failed' || true) - [ -n "$fail_line" ] || fail "bootstrap did not surface config propagation failure (got: $out)" + fail_line=$(printf '%s\n' "$out" | grep '^SECONDMATE_SYNC: secondmate sm: skipped: inheritance failed' || true) + [ -n "$fail_line" ] || fail "bootstrap did not surface inheritance propagation failure (got: $out)" [ -d "$w/sm/config/crew-harness" ] || fail "failed propagation removed the wrong path" pass "B11 bootstrap sweep surfaces config propagation failures" } @@ -920,7 +921,7 @@ test_config_push_propagates_reports_without_ff_or_nudge() { out=$(run_config_push "$w" 2>"$err"); status=$? expect_code 0 "$status" "config push should succeed" - assert_contains "$out" "config-push: $w/home/config -> live secondmate homes" \ + assert_contains "$out" "config-push: $w/home -> live secondmate homes" \ "config push lacked the header" assert_contains "$out" "secondmate sm ($sm_real):" \ "config push did not discover the live secondmate through registry fallback" @@ -978,7 +979,7 @@ test_config_push_reports_skips_dirty_and_invalid_home() { expect_code 0 "$status" "warnings-only config push should exit zero" assert_contains "$out" "secondmate dirty ($dirty_real):" \ "config push did not report dirty home" - assert_contains "$out" "home: dirty working tree - config-only push continuing" \ + assert_contains "$out" "home: dirty working tree - local-material push continuing" \ "config push did not surface dirty state" assert_contains "$out" "secondmate stale ($stale_real):" \ "config push did not report stale home" diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index f639e836e..cb8b19d18 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -23,9 +23,9 @@ # reports unknown for a backend with no verified classifier (never errors). # - bin/fm-bootstrap.sh's secondmate_liveness_sweep respawns a confidently # DEAD secondmate (killing the stale endpoint first, since the tmux -# adapter refuses to create a same-named window over a live one), leaves -# an ALIVE one untouched, and never acts on an inconclusive (UNKNOWN) -# reading. +# adapter refuses to create a same-named window over a live one), keeps +# handled DEAD and ALIVE results silent, and never acts on an inconclusive +# (UNKNOWN) reading. # - The sweep converges: once a secondmate reads alive, a later run never # re-touches it (idempotent by construction, not by remembering what it # already did). @@ -192,6 +192,21 @@ fi exit 0 SH chmod +x "$fakebin/no-mistakes" + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "--version ") printf '%s\n' '0.1.1' ;; + "update --help") printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' ;; + "mv --help") printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;; +esac +exit 0 +SH + chmod +x "$fakebin/tasks-axi" + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/quota-axi" printf '%s\n' "$fakebin" } @@ -273,8 +288,8 @@ test_sweep_respawns_confirmed_dead_secondmate() { out=$(run_bootstrap "$tmuxfb:$fb" "$w/home" zsh "$log") - assert_contains "$out" "SECONDMATE_LIVENESS: secondmate sm1: respawned" \ - "a bare-shell (dead) secondmate should be reported as respawned" + assert_not_contains "$out" "SECONDMATE_LIVENESS: secondmate sm1: respawned" \ + "a successfully respawned secondmate should be handled silently" assert_contains "$(cat "$log")" "kill-window -t firstmate:fm-sm1" \ "the stale endpoint must be killed before respawn (tmux refuses a same-named window over a live one)" assert_contains "$(cat "$log")" "new-window" \ @@ -291,8 +306,8 @@ test_sweep_leaves_alive_secondmate_untouched() { out=$(run_bootstrap "$tmuxfb:$fb" "$w/home" claude "$log") - assert_contains "$out" "SECONDMATE_LIVENESS: secondmate sm1: already-live" \ - "a live claude foreground process should be reported as already-live" + assert_not_contains "$out" "SECONDMATE_LIVENESS: secondmate sm1: already-live" \ + "an already-live secondmate should be handled silently" [ ! -s "$log" ] || fail "an already-live secondmate must never be killed or respawned: $(cat "$log")" pass "sweep: an already-live secondmate is left untouched (no kill, no respawn)" } @@ -336,16 +351,16 @@ test_sweep_converges_no_retouch_once_alive() { fb=$(make_toolchain "$w"); tmuxfb=$(make_liveness_tmux "$w") log="$w/calls.log"; : > "$log" - # Round 1: dead -> respawned (kill + new-window logged). + # Round 1: dead -> respawned silently (kill + new-window logged). out1=$(run_bootstrap "$tmuxfb:$fb" "$w/home" zsh "$log") - assert_contains "$out1" "SECONDMATE_LIVENESS: secondmate sm1: respawned" "round 1 should respawn the dead secondmate" + assert_not_contains "$out1" "SECONDMATE_LIVENESS: secondmate sm1: respawned" "round 1 should handle the successful respawn silently" [ -s "$log" ] || fail "round 1 should have logged the kill+respawn window operations" # Round 2: the (now-respawned) secondmate is genuinely alive - a second # sweep must converge to a pure no-op, not respawn again. : > "$log" out2=$(run_bootstrap "$tmuxfb:$fb" "$w/home" claude "$log") - assert_contains "$out2" "SECONDMATE_LIVENESS: secondmate sm1: already-live" "round 2 should see the now-live secondmate and stop touching it" + assert_not_contains "$out2" "SECONDMATE_LIVENESS: secondmate sm1: already-live" "round 2 should handle the already-live secondmate silently" [ ! -s "$log" ] || fail "round 2 must not re-kill or re-respawn an already-live secondmate: $(cat "$log")" pass "sweep: idempotent by construction - a live secondmate is never re-touched on a later run" } @@ -361,8 +376,8 @@ test_sweep_skipped_under_detect_only() { out=$(run_bootstrap "$tmuxfb:$fb" "$w/home" zsh "$log" FM_BOOTSTRAP_DETECT_ONLY=1) - assert_contains "$out" "CREW_HARNESS_OVERRIDE: codex" \ - "detect-only should still execute fm-bootstrap.sh's read-only diagnostics" + assert_not_contains "$out" "CREW_HARNESS_OVERRIDE:" \ + "detect-only should keep routine harness facts silent" assert_not_contains "$out" "SECONDMATE_LIVENESS:" \ "the read-only detect-only path must never run the mutating liveness sweep" [ ! -s "$log" ] || fail "detect-only must never touch any endpoint: $(cat "$log")" diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 2c8a96d78..bf6551605 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -13,6 +13,13 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-secondmate-safety) export FM_BACKEND=tmux +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} test_fm_home_parameterization() { local brief home_one home_two out @@ -1433,6 +1440,65 @@ EOF pass "secondmate force teardown discards child work" } +test_secondmate_force_teardown_refuses_child_quarantine_symlink() { + local home subhome childproj childwt external fakebin log err rc + home="$TMP_ROOT/force-quarantine-home" + subhome="$TMP_ROOT/force-quarantine-subhome" + childproj="$subhome/projects/alpha" + childwt="$TMP_ROOT/force-quarantine-child-worktree" + external="$TMP_ROOT/force-quarantine-external" + err="$TMP_ROOT/force-quarantine.err" + mkdir -p "$home/state" "$home/data" "$subhome/state" "$external" + fm_git_worktree "$childproj" "$childwt" force-quarantine-child + printf 'domain\n' > "$subhome/.fm-secondmate-home" + cat > "$home/state/domain.meta" <<EOF +window=firstmate:fm-domain +worktree=$subhome +project=$subhome +harness=echo +kind=secondmate +mode=secondmate +yolo=off +home=$subhome +projects=alpha +EOF + printf '%s\n' '- domain - design domain (home: '"$subhome"'; scope: design domain; projects: alpha; added 2026-06-22)' > "$home/data/secondmates.md" + cat > "$subhome/state/child.meta" <<EOF +window=firstmate:fm-child +worktree=$childwt +project=$childproj +harness=echo +kind=ship +mode=no-mistakes +yolo=off +EOF + printf 'child check\n' > "$subhome/state/child.check.sh" + printf 'external quarantine artifact\n' > "$external/child.check.protected" + chmod 0640 "$external/child.check.protected" + ln -s "$external" "$subhome/state/.pr-check-quarantine" + fakebin=$(make_fake_tmux "$TMP_ROOT/force-quarantine-fake") + log="$TMP_ROOT/force-quarantine-fake/tmux.log" + + set +e + PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/force-quarantine-fake/pane.txt" \ + "$ROOT/bin/fm-teardown.sh" domain --force >/dev/null 2> "$err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "force teardown accepted a child quarantine-directory symlink" + [ -d "$subhome" ] || fail "force teardown removed the subhome before quarantine refusal" + [ -d "$childwt" ] || fail "force teardown removed child work before quarantine refusal" + [ -e "$home/state/domain.meta" ] || fail "force teardown cleared parent meta before quarantine refusal" + [ -e "$subhome/state/child.meta" ] || fail "force teardown cleared child meta before quarantine refusal" + [ "$(cat "$subhome/state/child.check.sh")" = 'child check' ] || fail "force teardown removed the child check before quarantine refusal" + [ "$(cat "$external/child.check.protected")" = 'external quarantine artifact' ] \ + || fail "force teardown changed the child quarantine symlink target" + [ "$(file_mode "$external/child.check.protected")" = 640 ] \ + || fail "force teardown changed the child quarantine target mode" + grep -F 'kill-window' "$log" >/dev/null && fail "force teardown killed a window before child quarantine validation" + pass "secondmate force teardown prevalidates child quarantine cleanup without following symlinks" +} + test_secondmate_force_teardown_preserves_child_on_unproven_lock() { local home subhome childproj childwt fakebin log err rc lock home="$TMP_ROOT/force-lock-home" @@ -2142,6 +2208,7 @@ test_secondmate_teardown_retires_empty_home test_secondmate_teardown_refuses_failed_leased_home_return test_secondmate_teardown_removes_plain_clone_home_without_treehouse_return test_secondmate_force_teardown_discards_child_work +test_secondmate_force_teardown_refuses_child_quarantine_symlink test_secondmate_force_teardown_preserves_child_on_unproven_lock test_secondmate_force_teardown_allows_operational_dir_symlinks_inside_home test_secondmate_force_teardown_refuses_operational_dir_symlink_outside_home diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index bc806523d..6154498b7 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -12,11 +12,12 @@ # and refuses - leaving work untouched - on a dirty, diverged, or # in-flight (feature-branch) home. # - No origin fetch happens in the local-HEAD sync path. -# - The bootstrap sweep fast-forwards every live secondmate home and reports a -# nudge (NUDGE_SECONDMATES:) ONLY for a running secondmate whose instruction -# surface actually changed; an already-current or readme-only home is never -# nudged, a skipped home is reported as SECONDMATE_SYNC:, and a home with no -# live metadata is never swept. +# - The bootstrap sweep fast-forwards every live secondmate home and sends a +# reread nudge ONLY for a running secondmate whose instruction surface +# actually changed; a successful send is reported as BOOTSTRAP_INFO:, a +# failed send is reported as NUDGE_SECONDMATES:, an already-current or +# readme-only home is never nudged, a skipped home is reported as +# SECONDMATE_SYNC:, and a home with no live metadata is never swept. # - Spawning a secondmate fast-forwards its worktree to the primary's HEAD # before launch, or warns and launches unchanged when the sync is skipped. set -u @@ -289,7 +290,24 @@ make_fake_toolchain() { local dir=$1 fakebin fakebin="$dir/fakebin" mkdir -p "$fakebin" - fm_fake_exit0 "$fakebin" tmux node gh-axi chrome-devtools-axi lavish-axi + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +if [ -n "${FM_FAKE_TMUX_LOG:-}" ]; then + printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +fi +case "$*" in + *display-message*'#{pane_current_command}'*) printf '%s\n' codex; exit 0 ;; + *display-message*'#{pane_id}'*) printf '%s\n' '%1'; exit 0 ;; + *display-message*'#{cursor_y}'*) printf '%s\n' 0; exit 0 ;; + *'send-keys'*' -l '*) + [ "${FM_FAKE_TMUX_FAIL_LITERAL:-0}" = 1 ] && exit 1 + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" cat > "$fakebin/gh" <<'SH' #!/usr/bin/env bash exit 0 @@ -312,6 +330,21 @@ fi exit 0 SH chmod +x "$fakebin/no-mistakes" + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "--version ") printf '%s\n' '0.1.1' ;; + "update --help") printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' ;; + "mv --help") printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;; +esac +exit 0 +SH + chmod +x "$fakebin/tasks-axi" + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/quota-axi" printf '%s\n' "$fakebin" } @@ -326,7 +359,7 @@ SH } test_bootstrap_sweep_nudges_only_instruction_change() { - local w c1 c2 c3 fakebin out nudge_line + local w c1 c2 c3 fakebin out info_line log marker_dir w=$(new_world boot-sweep) c1=$(head_of "$w/main") add_sm_worktree "$w" sm-instr "$c1" # behind by an instruction change @@ -341,15 +374,23 @@ test_bootstrap_sweep_nudges_only_instruction_change() { printf 'sm-nonlive\n' > "$w/sm-nonlive/.fm-secondmate-home" fakebin=$(make_fake_toolchain "$w") + log="$w/tmux.log" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) - nudge_line=$(printf '%s\n' "$out" | grep '^NUDGE_SECONDMATES:' || true) - [ -n "$nudge_line" ] || fail "no NUDGE_SECONDMATES line emitted (got: $out)" - assert_contains "$nudge_line" "fm-sm-instr" "instruction-changed running secondmate is nudged" - assert_not_contains "$nudge_line" "firstmate:fm-sm-instr" "nudge lists stable fm-<id> selectors, not raw backend targets" - assert_not_contains "$nudge_line" "sm-readme" "readme-only advance is not nudged" - assert_not_contains "$nudge_line" "sm-current" "already-current secondmate is not nudged" + info_line=$(printf '%s\n' "$out" | grep '^BOOTSTRAP_INFO: nudged fm-sm-instr ' || true) + [ -n "$info_line" ] || fail "no BOOTSTRAP_INFO nudge line emitted (got: $out)" + assert_contains "$info_line" "firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions." \ + "successful nudge report should include the exact message sent" + assert_not_contains "$out" "NUDGE_SECONDMATES:" "successful nudge must not leave a firstmate action item" + assert_not_contains "$out" "sm-readme" "readme-only advance is not nudged" + assert_not_contains "$out" "sm-current" "already-current secondmate is not nudged" + assert_contains "$(cat "$log")" "[fm-from-firstmate]" "nudge send should use the marked fm-send secondmate path" + assert_contains "$(cat "$log")" "firstmate was updated to the latest - please re-read your AGENTS.md" \ + "nudge send should type the exact re-read message" + marker_dir="$w/home/state/.secondmate-nudge-pending" + [ ! -e "$marker_dir/sm-instr.pending" ] || fail "successful nudge should clear its retry marker" # Every live home advanced to the primary's HEAD; the already-current one stayed. [ "$(head_of "$w/sm-instr")" = "$c3" ] || fail "sm-instr not at primary HEAD" @@ -357,10 +398,157 @@ test_bootstrap_sweep_nudges_only_instruction_change() { [ "$(head_of "$w/sm-current")" = "$c3" ] || fail "sm-current moved off primary HEAD" # The non-live home is never touched by the bootstrap sweep. [ "$(head_of "$w/sm-nonlive")" = "$c1" ] || fail "a home with no live meta was swept" - pass "T8 bootstrap sweeps live homes, nudges only the running real-instruction-change secondmate" + pass "T8 bootstrap sweeps live homes and sends exactly one marked nudge for the instruction change" +} + +test_bootstrap_nudge_send_uses_state_override() { + local w c1 fakebin out log override_state marker + w=$(new_world nudge-state-override) + c1=$(head_of "$w/main") + add_sm_worktree "$w" sm-instr "$c1" + bump_primary "$w" instr + override_state="$w/override-state" + mkdir -p "$override_state" + mv "$w/home/state/sm-instr.meta" "$override_state/sm-instr.meta" + touch "$override_state/.last-watcher-beat" + fakebin=$(make_fake_toolchain "$w") + log="$w/tmux.log" + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_STATE_OVERRIDE="$override_state" FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + + assert_contains "$out" "BOOTSTRAP_INFO: nudged fm-sm-instr with" \ + "nudge send should resolve fm-sm-instr through the effective state dir" + assert_not_contains "$out" "NUDGE_SECONDMATES:" \ + "effective-state nudge should not fail through FM_HOME/state" + assert_contains "$(cat "$log")" "[fm-from-firstmate]" \ + "effective-state nudge should still use secondmate marker metadata" + marker="$override_state/.secondmate-nudge-pending/sm-instr.pending" + assert_absent "$marker" "successful effective-state nudge should clear its retry marker" + pass "T8a bootstrap nudge send respects FM_STATE_OVERRIDE" +} + +test_bootstrap_nudge_retry_rejects_malformed_marker_id() { + local w c1 fakebin out marker log evil + w=$(new_world nudge-malformed-id) + c1=$(head_of "$w/main") + evil="$w/evil" + git -C "$w/main" worktree add -q --detach "$evil" "$c1" + printf '../escape\n' > "$evil/.fm-secondmate-home" + mkdir -p "$w/home/state/.secondmate-nudge-pending" + marker="$w/home/state/.secondmate-nudge-pending/bad.pending" + { + printf 'id=../escape\n' + printf 'selector=fm-../escape\n' + printf 'home=%s\n' "$evil" + printf 'commit=%s\n' "$c1" + printf 'instructions=AGENTS.md\n' + printf 'message=firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.\n' + } > "$marker" + { + printf 'window=firstmate:fm-evil\n' + printf 'kind=secondmate\n' + printf 'home=%s\n' "$evil" + } > "$w/home/escape.meta" + fakebin=$(make_fake_toolchain "$w") + log="$w/tmux.log" + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + + assert_contains "$out" "NUDGE_SECONDMATES: secondmate ../escape: send failed: retry marker has unsafe id" \ + "malformed retry marker id should be rejected before target resolution" + assert_not_contains "$out" "BOOTSTRAP_INFO: nudged fm-../escape" \ + "malformed retry marker id must never send through a path-traversed selector" + assert_present "$marker" "malformed retry marker should remain for operator inspection" + assert_absent "$log" "malformed retry marker should not invoke fm-send" + pass "T8f bootstrap nudge retry rejects malformed marker ids" +} + +test_bootstrap_nudge_failure_records_retry_marker() { + local w c1 fakebin out marker + w=$(new_world nudge-failure) + c1=$(head_of "$w/main") + add_sm_worktree "$w" sm-instr "$c1" + bump_primary "$w" instr + fakebin=$(make_fake_toolchain "$w") + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_FAIL_LITERAL=1 \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + + assert_contains "$out" "NUDGE_SECONDMATES: secondmate sm-instr: send failed:" \ + "failed nudge send should be surfaced as actionable bootstrap output" + marker="$w/home/state/.secondmate-nudge-pending/sm-instr.pending" + assert_present "$marker" "failed nudge should leave a retry marker" + assert_grep "selector=fm-sm-instr" "$marker" "retry marker should pin the stable selector" + assert_grep "message=firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions." \ + "$marker" "retry marker should pin the exact message" + pass "T8c failed bootstrap nudge is surfaced and recorded for retry" +} + +test_bootstrap_nudge_retry_is_idempotent() { + local w c1 fakebin out marker out2 + w=$(new_world nudge-retry) + c1=$(head_of "$w/main") + add_sm_worktree "$w" sm-instr "$c1" + bump_primary "$w" instr + fakebin=$(make_fake_toolchain "$w") + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_FAIL_LITERAL=1 \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_contains "$out" "NUDGE_SECONDMATES: secondmate sm-instr: send failed:" \ + "precondition: first nudge should fail" + marker="$w/home/state/.secondmate-nudge-pending/sm-instr.pending" + assert_present "$marker" "precondition: failed nudge should leave marker" + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_contains "$out" "BOOTSTRAP_INFO: nudged fm-sm-instr with" \ + "retry should send the pending nudge once the endpoint works" + assert_absent "$marker" "successful retry should clear the marker" + + out2=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + [ -z "$out2" ] || fail "idempotent retry should converge to silence, got: $out2" + pass "T8d bootstrap nudge retry is idempotent after success" +} + +test_bootstrap_nudge_retry_refuses_changed_home() { + local w c1 fakebin marker out other + w=$(new_world nudge-retry-home-change) + c1=$(head_of "$w/main") + add_sm_worktree "$w" sm-instr "$c1" + bump_primary "$w" instr + fakebin=$(make_fake_toolchain "$w") + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_FAIL_LITERAL=1 \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_contains "$out" "NUDGE_SECONDMATES: secondmate sm-instr: send failed:" \ + "precondition: first nudge should fail" + marker="$w/home/state/.secondmate-nudge-pending/sm-instr.pending" + assert_present "$marker" "precondition: failed nudge should leave marker" + + other="$w/sm-other" + git -C "$w/main" worktree add -q --detach "$other" "$(head_of "$w/sm-instr")" + printf '%s\n' sm-instr > "$other/.fm-secondmate-home" + sed -i.bak "s|^home=.*|home=$other|" "$w/home/state/sm-instr.meta" 2>/dev/null || \ + sed -i "s|^home=.*|home=$other|" "$w/home/state/sm-instr.meta" + rm -f "$w/home/state/sm-instr.meta.bak" + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_contains "$out" "NUDGE_SECONDMATES: secondmate sm-instr: send failed: retry target home changed" \ + "retry must not infer a nudge target outside the recorded failed home" + assert_present "$marker" "ambiguous retry should keep marker for operator inspection" + pass "T8e bootstrap nudge retry refuses a changed home instead of guessing" } -# --- T8b: nudge selectors stay fm-<id> when liveness respawn rotates herdr ---- +# --- T8b: stale herdr nudge failures retry through current fm-<id> metadata --- # Reproduces the 2026-07-07 session-start bug: secondmate_sync used to print raw # backend targets (default:w9:pY) that liveness respawn immediately replaced # (default:wA:p2), so fm-send with the printed target fell back to tmux and failed @@ -408,8 +596,8 @@ SH printf '%s\n' "$fakebin" } -test_nudge_selector_stable_after_herdr_respawn() { - local w c1 stale fresh fakebin herdrfb toolchain out nudge_line meta window resolved stale_send fresh_send spawn_stub +test_nudge_retry_uses_fresh_herdr_endpoint_after_respawn() { + local w c1 stale fresh fakebin herdrfb toolchain out meta window resolved stale_send fresh_send spawn_stub marker stale=default:w9:pY fresh=default:wA:p2 w=$(new_world nudge-herdr-rotate) @@ -448,18 +636,17 @@ SH return fi out=$(PATH="$herdrfb:$toolchain:$BASE_PATH" HERDR_ENV=1 FM_BACKEND=herdr \ + FM_SEND_SETTLE=0 \ FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) - nudge_line=$(printf '%s\n' "$out" | grep '^NUDGE_SECONDMATES:' || true) - [ -n "$nudge_line" ] || fail "no NUDGE_SECONDMATES line emitted (got: $out)" - assert_contains "$nudge_line" "fm-sm-instr" "bootstrap nudge lists stable fm-<id>" - assert_not_contains "$nudge_line" "w9:pY" "bootstrap nudge must not list stale herdr endpoint" - assert_contains "$out" "SECONDMATE_LIVENESS: secondmate sm-instr: respawned" \ - "liveness respawn rotated the herdr endpoint in the same bootstrap run" + assert_contains "$out" "NUDGE_SECONDMATES: secondmate sm-instr: send failed:" \ + "stale herdr endpoint should surface a failed immediate nudge" window=$(grep '^window=' "$meta" | tail -1 | cut -d= -f2-) [ "$window" = "$fresh" ] || fail "respawn stub did not rotate meta window to '$fresh' (got '$window')" + marker="$w/home/state/.secondmate-nudge-pending/sm-instr.pending" + assert_present "$marker" "failed stale herdr nudge should leave a retry marker" # shellcheck disable=SC2016 # $0/$1 belong to the inner bash -c process. resolved=$(bash -c '. "$0/bin/fm-backend.sh"; fm_backend_resolve_selector fm-sm-instr "$1"' "$ROOT" "$w/home/state") @@ -475,7 +662,7 @@ SH '. "$0/bin/fm-backend.sh"; fm_backend_source herdr; fm_backend_herdr_send_literal "$1" "nudge"' "$ROOT" "$fresh" 2>/dev/null; printf '%s' "$?") [ "$fresh_send" = 0 ] || fail "send through fm-<id>-resolved fresh endpoint should succeed" - pass "T8b nudge selectors stay fm-<id> when liveness respawn rotates herdr endpoint" + pass "T8b stale herdr nudge failures leave a retry marker after respawn rotates fm-<id> metadata" } # --- T9: bootstrap surfaces a skipped dirty live secondmate home -------------- @@ -662,7 +849,12 @@ test_ff_inflight_feature_branch test_no_fetch_in_local_path test_sweep_nudge_requires_instruction_change test_bootstrap_sweep_nudges_only_instruction_change -test_nudge_selector_stable_after_herdr_respawn +test_bootstrap_nudge_send_uses_state_override +test_bootstrap_nudge_retry_rejects_malformed_marker_id +test_bootstrap_nudge_failure_records_retry_marker +test_bootstrap_nudge_retry_is_idempotent +test_bootstrap_nudge_retry_refuses_changed_home +test_nudge_retry_uses_fresh_herdr_endpoint_after_respawn test_bootstrap_sweep_surfaces_skipped_home test_spawn_fast_forwards_before_launch test_spawn_warns_when_sync_skipped_before_launch diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 6d3b0d970..6ae0d124b 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -6,7 +6,7 @@ # Coverage: # - absent-file markers vs empty-but-present files in the context digest # - the lock-refusal read-only path: banner leads, every mutating step is -# skipped (including bootstrap's four mutating sweeps, verified by their +# skipped (including bootstrap's five mutating sweeps, verified by their # ABSENCE), the digest still completes # - output section ordering: diagnostics/banners lead, bulk file dumps follow # - context-aware next-step guidance for read-only, AFK, X mode, and normal @@ -80,6 +80,56 @@ SH printf '%s\n' manual > "${fakebin%/*}/home-placeholder" 2>/dev/null || true } +make_fake_tasks_axi_compact() { + local fakebin=$1 + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +set -u +log=${FM_FAKE_TASKS_AXI_LOG:-} +[ -n "$log" ] && printf '%s\n' "$*" >> "$log" +case "${1:-}" in + --version|-v|-V) + printf '%s\n' '0.2.3' + exit 0 + ;; + update) + if [ "${2:-}" = --help ]; then + printf '%s\n' 'usage: tasks-axi update <id> [--archive-body]' + exit 0 + fi + ;; + mv) + if [ "${2:-}" = --help ]; then + printf '%s\n' 'usage: tasks-axi mv <dest> [<id>...]' + exit 0 + fi + ;; + list) + case "$*" in + *'--fields '*'body'*|*'--fields='*'body'*) + printf '%s\n' 'unexpected body field requested' >&2 + exit 9 + ;; + esac + case "$*" in *'--limit 80'*) : ;; *) printf '%s\n' 'missing compact limit' >&2; exit 9 ;; esac + case "$*" in *'--file '*) : ;; *) printf '%s\n' 'missing explicit backlog file' >&2; exit 9 ;; esac + cat <<'OUT' +count: 2 +tasks[2]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}: + compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending + blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-" +help[2]: + - Run `tasks-axi show <id> --full` for full notes on a task + - Run `tasks-axi ready` to see unblocked queued work +OUT + exit 0 + ;; +esac +exit 1 +SH + chmod +x "$fakebin/tasks-axi" +} + # make_fake_ps_claude <fakebin>: harness_pid()/holder_alive() (fm-lock.sh) walk # `ps` output looking for a harness command name; this fake reports EVERY # queried pid as a live `claude` harness, so the very first ancestry check @@ -252,7 +302,7 @@ EOF printf '%s\n' '- demo [no-mistakes] - a demo project (added 2026-07-01)' > "$home/data/projects.md" : > "$home/data/captain.md" - # secondmates.md and learnings.md deliberately absent + # secondmates.md, captain-shared.md, and learnings.md deliberately absent out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") @@ -260,15 +310,17 @@ EOF assert_contains "$out" "- demo [no-mistakes] - a demo project (added 2026-07-01)" "digest did not print projects.md content" assert_contains "$out" "data/captain.md" "digest did not label the captain.md section" + assert_contains "$out" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" \ + "digest did not label the shared captain section" assert_contains "$out" "data/secondmates.md" "digest did not label the secondmates.md section" assert_contains "$out" "data/learnings.md" "digest did not label the learnings.md section" - # Exactly two ABSENT markers (secondmates.md, learnings.md; backlog.md is - # covered by its own test) - and the present-but-empty captain.md must NOT - # print ABSENT. + # Exactly four context ABSENT markers (secondmates.md, captain-shared.md, + # learnings.md; backlog.md is covered by its own test) - and the + # present-but-empty captain.md must NOT print ABSENT. absent_count=$(printf '%s\n' "$out" | grep -c '^ABSENT$') - [ "$absent_count" -eq 3 ] || fail "expected 3 ABSENT markers (secondmates.md, learnings.md, backlog.md), got $absent_count: $out" + [ "$absent_count" -eq 4 ] || fail "expected 4 ABSENT markers (secondmates.md, captain-shared.md, learnings.md, backlog.md), got $absent_count: $out" cap_section=$(printf '%s\n' "$out" | awk '/^data\/captain\.md$/{flag=1;next}/^data\//{flag=0}flag') assert_contains "$cap_section" "(present, empty)" "empty-but-present captain.md was not distinguished from ABSENT" @@ -549,6 +601,115 @@ EOF pass "fm-session-start.sh composes the real fm-lock.sh, fm-bootstrap.sh, and fm-wake-drain.sh output verbatim" } +# --- fleet-state digest: compact backlog rendering -------------------------- + +write_long_body_backlog() { + local path=$1 + cat > "$path" <<'EOF' +# Backlog + +## In flight +- [ ] compact-startup - Compact startup digest (repo: firstmate) (kind: ship) (since 2026-07-15) (hold: captain choice pending) (hold-kind: captain) + OVERSIZED-BODY-LINE current startup leaks task note bodies into the session digest. + Another long body line that should not be printed after the fix. + +## Queued +- [ ] blocked-followup - Follow compact startup blocked-by: compact-startup - waits for implementation (repo: firstmate) (kind: scout) (since 2026-07-15) + QUEUED-BODY-LINE this is another long multiline note. + +## Done +EOF +} + +test_backlog_compact_tasks_axi_omits_bodies_and_keeps_metadata() { + local rec root home fakebin out log + rec=$(new_world backlog-compact-tasks-axi) + IFS='|' read -r root home fakebin <<EOF +$rec +EOF + make_fake_toolchain "$fakebin" + make_fake_tasks_axi_compact "$fakebin" + make_fake_ps_claude "$fakebin" + write_long_body_backlog "$home/data/backlog.md" + mkdir -p "$home/projects/firstmate" + printf 'window=fm-sess:compact\nworktree=%s\nproject=firstmate\nkind=ship\n' "$home/projects/firstmate" \ + > "$home/state/compact-startup.meta" + log="$home/tasks-axi.log" + + out=$(FM_FAKE_TASKS_AXI_LOG="$log" run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "compact backlog listing (tasks-axi; max 80 item(s); task bodies omitted)" \ + "compatible tasks-axi backend did not render the compact backlog listing" + assert_contains "$out" "tasks[2]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}:" \ + "tasks-axi compact listing omitted the expected structured field header" + assert_contains "$out" "compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending" \ + "tasks-axi compact listing omitted in-flight identity, state, or hold metadata" + assert_contains "$out" 'blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-"' \ + "tasks-axi compact listing omitted blocked-by metadata" + assert_not_contains "$out" "OVERSIZED-BODY-LINE" "tasks-axi compact digest leaked an in-flight task body" + assert_not_contains "$out" "QUEUED-BODY-LINE" "tasks-axi compact digest leaked a queued task body" + assert_contains "$out" "--- compact-startup ---" "in-flight meta identity disappeared from startup recovery digest" + assert_contains "$out" "worktree=$home/projects/firstmate" "in-flight recovery worktree identity disappeared from startup digest" + assert_contains "$out" "Full task bodies remain available on demand: tasks-axi show <id> --full" \ + "compact digest omitted the full-body lookup pointer" + assert_grep "list --file $home/data/backlog.md --limit 80 --fields blocked_by,hold_kind,hold_reason" "$log" \ + "session start did not ask tasks-axi for the bounded compact field set" + + pass "compatible tasks-axi backlog rendering is compact, bounded, and preserves recovery metadata" +} + +test_backlog_compact_manual_backend_skips_indented_bodies() { + local rec root home fakebin out + rec=$(new_world backlog-compact-manual) + IFS='|' read -r root home fakebin <<EOF +$rec +EOF + make_fake_toolchain "$fakebin" + make_fake_ps_claude "$fakebin" + printf '%s\n' manual > "$home/config/backlog-backend" + write_long_body_backlog "$home/data/backlog.md" + + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "compact backlog listing (manual backend; max 80 item(s); indented task bodies omitted)" \ + "manual backend did not use compact title-line rendering" + assert_contains "$out" "## In flight" "manual compact rendering omitted the in-flight section heading" + assert_contains "$out" "- [ ] compact-startup - Compact startup digest" \ + "manual compact rendering omitted the in-flight title line" + assert_contains "$out" "(hold: captain choice pending) (hold-kind: captain)" \ + "manual compact rendering omitted hold metadata" + assert_contains "$out" "blocked-by: compact-startup - waits for implementation" \ + "manual compact rendering omitted blocker metadata" + assert_not_contains "$out" "OVERSIZED-BODY-LINE" "manual compact digest leaked an in-flight task body" + assert_not_contains "$out" "QUEUED-BODY-LINE" "manual compact digest leaked a queued task body" + assert_contains "$out" "(shown 2 of 2 backlog item title line(s))" \ + "manual compact rendering did not report its bound accounting" + assert_contains "$out" "or data/backlog.md" "manual compact digest omitted the data/backlog.md full-body pointer" + + pass "manual backlog rendering prints only title lines with hold and blocker metadata" +} + +test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback() { + local rec root home fakebin out + rec=$(new_world backlog-compact-unavailable) + IFS='|' read -r root home fakebin <<EOF +$rec +EOF + make_fake_toolchain "$fakebin" + make_fake_ps_claude "$fakebin" + write_long_body_backlog "$home/data/backlog.md" + + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "compact backlog listing (tasks-axi unavailable or incompatible; max 80 item(s); indented task bodies omitted)" \ + "unavailable tasks-axi did not fall back to compact title-line rendering" + assert_contains "$out" "- [ ] compact-startup - Compact startup digest" \ + "unavailable tasks-axi fallback omitted a backlog title line" + assert_not_contains "$out" "OVERSIZED-BODY-LINE" "unavailable tasks-axi fallback leaked an in-flight task body" + + pass "unavailable or incompatible tasks-axi falls back to compact manual backlog rendering" +} + # --- fleet-state digest: no in-flight tasks ---------------------------------- test_fleet_digest_empty_fleet() { @@ -749,6 +910,9 @@ test_orphan_status_logs_are_printed test_endpoint_liveness_tmux test_endpoint_liveness_herdr test_composition_invokes_real_scripts +test_backlog_compact_tasks_axi_omits_bodies_and_keeps_metadata +test_backlog_compact_manual_backend_skips_indented_bodies +test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback test_fleet_digest_empty_fleet test_next_step_sources_x_mode_cadence test_next_step_afk_delegates_to_daemon diff --git a/tests/fm-shared-captain-inheritance.test.sh b/tests/fm-shared-captain-inheritance.test.sh new file mode 100755 index 000000000..cb0747c57 --- /dev/null +++ b/tests/fm-shared-captain-inheritance.test.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# Behavior tests for primary-authoritative shared captain-preference inheritance. +# +# The narrow shared surface is exactly data/captain-shared.md. +# data/captain.md and data/learnings.md remain domain-local in every home. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-config-inherit-lib.sh +. "$ROOT/bin/fm-config-inherit-lib.sh" + +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +TMP_ROOT=$(fm_test_tmproot fm-shared-captain) + +fm_git_identity fmtest fmtest@example.invalid + +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" 2>/dev/null + else + stat -c %a "$1" 2>/dev/null + fi +} + +shared_header() { + cat <<'EOF' +# Shared captain preferences + +This file is main-authoritative in the main firstmate home. +In secondmate homes it is read-only in secondmate homes and must not be edited there. +Route new captain-preference discoveries to the main firstmate through marked status or a document pointer. +EOF +} + +write_shared() { + local path=$1 body=$2 + shared_header > "$path" + printf '%s\n' "$body" >> "$path" +} + +new_home_pair() { + local name=$1 base primary second + base="$TMP_ROOT/$name" + primary="$base/primary" + second="$base/second" + mkdir -p "$primary/data" "$primary/config" "$second/data" "$second/config" + printf '%s\n' "primary local captain" > "$primary/data/captain.md" + printf '%s\n' "second local captain" > "$second/data/captain.md" + printf '%s\n' "primary local learning" > "$primary/data/learnings.md" + printf '%s\n' "second local learning" > "$second/data/learnings.md" + printf '%s\n' "$primary|$second" +} + +assert_shared_readonly() { + local path=$1 + [ "$(file_mode "$path")" = "$FM_SHARED_CAPTAIN_MODE" ] \ + || fail "$path mode should be $FM_SHARED_CAPTAIN_MODE, got $(file_mode "$path")" +} + +assert_secondmate_write_fails() { + local path=$1 + if ( printf '%s\n' "secondmate edit" >> "$path" ) 2>/dev/null; then + fail "ordinary write unexpectedly succeeded for read-only shared captain file" + fi +} + +test_first_copy_readonly_and_local_files_preserved() { + local rec primary second report out + rec=$(new_home_pair first-copy) + primary=${rec%%|*} + second=${rec#*|} + write_shared "$primary/data/captain-shared.md" "shared v1" + report="$TMP_ROOT/first-copy.report" + + out=$(FM_CONFIG_INHERIT_REPORT="$report" propagate_secondmate_inheritance "$primary" "$second") + + [ -z "$out" ] || fail "first copy should not emit a quarantine diagnostic: $out" + cmp -s "$primary/data/captain-shared.md" "$second/data/captain-shared.md" \ + || fail "first copy did not converge secondmate shared preferences" + assert_shared_readonly "$second/data/captain-shared.md" + assert_secondmate_write_fails "$second/data/captain-shared.md" + assert_grep $'data/captain-shared.md\tpushed\t' "$report" "first copy should report pushed" + assert_grep "second local captain" "$second/data/captain.md" "domain-local captain.md was changed" + assert_grep "second local learning" "$second/data/learnings.md" "domain-local learnings.md was changed" + + : > "$report" + out=$(FM_CONFIG_INHERIT_REPORT="$report" propagate_secondmate_inheritance "$primary" "$second") + [ -z "$out" ] || fail "unchanged convergence should stay quiet: $out" + assert_grep $'data/captain-shared.md\tunchanged\t' "$report" "unchanged bytes should report unchanged" + assert_shared_readonly "$second/data/captain-shared.md" + pass "shared captain first copy converges, is read-only, and preserves local captain/learnings files" +} + +test_drift_quarantine_collision_and_repeated_convergence() { + local rec primary second fakebin hash collision report out diag qpath qcount + rec=$(new_home_pair drift) + primary=${rec%%|*} + second=${rec#*|} + write_shared "$primary/data/captain-shared.md" "shared v2" + write_shared "$second/data/captain-shared.md" "local drift" + chmod "$FM_SHARED_CAPTAIN_MODE" "$second/data/captain-shared.md" + hash=$(fm_inherit_sha256 "$second/data/captain-shared.md") + collision="$second/data/.captain-shared.md.quarantine.20260102T030405Z.$hash" + printf '%s\n' "preexisting different artifact" > "$collision" + chmod 0600 "$collision" + + fakebin="$TMP_ROOT/fake-date" + mkdir -p "$fakebin" + cat > "$fakebin/date" <<'SH' +#!/usr/bin/env bash +printf '%s\n' 20260102T030405Z +SH + chmod +x "$fakebin/date" + report="$TMP_ROOT/drift.report" + + out=$(PATH="$fakebin:$BASE_PATH" FM_CONFIG_INHERIT_REPORT="$report" \ + propagate_secondmate_inheritance "$primary" "$second") + + diag=$(printf '%s\n' "$out" | grep '^SECONDMATE_SYNC: secondmate home ' || true) + [ -n "$diag" ] || fail "drift quarantine should emit a SECONDMATE_SYNC diagnostic" + qpath=${diag##* at } + [ "$qpath" = "$collision.1" ] || fail "collision-safe quarantine name should use .1, got $qpath" + assert_grep "local drift" "$qpath" "quarantine artifact lost the secondmate-local bytes" + assert_grep $'data/captain-shared.md\tpushed\tquarantined local drift at '"$qpath" "$report" \ + "drift push should name the quarantine artifact in the report" + cmp -s "$primary/data/captain-shared.md" "$second/data/captain-shared.md" \ + || fail "drift convergence did not install primary bytes" + assert_shared_readonly "$second/data/captain-shared.md" + + : > "$report" + out=$(PATH="$fakebin:$BASE_PATH" FM_CONFIG_INHERIT_REPORT="$report" \ + propagate_secondmate_inheritance "$primary" "$second") + [ -z "$out" ] || fail "repeated convergence should not quarantine again: $out" + qcount=$(find "$second/data" -name '.captain-shared.md.quarantine.*' | wc -l | tr -d ' ') + [ "$qcount" -eq 2 ] || fail "repeated convergence created extra quarantine artifacts" + assert_grep $'data/captain-shared.md\tunchanged\t' "$report" "repeated convergence should report unchanged" + pass "shared captain drift is quarantined collision-safely and repeated convergence is idempotent" +} + +test_missing_source_mirrors_absence_without_losing_local_bytes() { + local rec primary second out diag qpath + rec=$(new_home_pair missing-source) + primary=${rec%%|*} + second=${rec#*|} + write_shared "$second/data/captain-shared.md" "orphaned local shared file" + chmod "$FM_SHARED_CAPTAIN_MODE" "$second/data/captain-shared.md" + + out=$(propagate_secondmate_inheritance "$primary" "$second") + + diag=$(printf '%s\n' "$out" | grep '^SECONDMATE_SYNC: secondmate home ' || true) + [ -n "$diag" ] || fail "primary absence with a local copy should quarantine before removal" + qpath=${diag##* at } + assert_absent "$second/data/captain-shared.md" "primary absence should converge destination to absence" + assert_grep "orphaned local shared file" "$qpath" "missing-source quarantine lost local bytes" + assert_grep "second local captain" "$second/data/captain.md" "missing-source changed local captain.md" + assert_grep "second local learning" "$second/data/learnings.md" "missing-source changed local learnings.md" + pass "missing primary shared file mirrors absence only after quarantining a local copy" +} + +test_unsafe_artifacts_and_failure_restore_readonly_mode() { + local rec primary second other err before_mode rc + rec=$(new_home_pair unsafe) + primary=${rec%%|*} + second=${rec#*|} + + ln -s "$primary/data/captain.md" "$primary/data/captain-shared.md" + err="$TMP_ROOT/unsafe-source.err" + propagate_secondmate_inheritance "$primary" "$second" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "symlinked primary source should be rejected" + assert_grep "unsafe primary source" "$err" "unsafe source error should be explicit" + rm -f "$primary/data/captain-shared.md" + write_shared "$primary/data/captain-shared.md" "safe source" + + ln -s "$second/data/captain.md" "$second/data/captain-shared.md" + err="$TMP_ROOT/unsafe-dest-symlink.err" + propagate_secondmate_inheritance "$primary" "$second" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "symlinked destination should be rejected" + assert_grep "unsafe destination" "$err" "unsafe destination symlink error should be explicit" + rm -f "$second/data/captain-shared.md" + + write_shared "$second/data/captain-shared.md" "hardlinked local drift" + other="$second/data/hardlink-copy" + ln "$second/data/captain-shared.md" "$other" + err="$TMP_ROOT/unsafe-dest-hardlink.err" + propagate_secondmate_inheritance "$primary" "$second" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "hardlinked destination should be rejected" + assert_grep "unsafe destination" "$err" "unsafe destination hardlink error should be explicit" + rm -f "$second/data/captain-shared.md" "$other" + + write_shared "$second/data/captain-shared.md" "permission drift" + chmod "$FM_SHARED_CAPTAIN_MODE" "$second/data/captain-shared.md" + before_mode=$(file_mode "$second/data/captain-shared.md") + chmod 500 "$second/data" + err="$TMP_ROOT/restore-readonly.err" + propagate_secondmate_inheritance "$primary" "$second" >/dev/null 2>"$err"; rc=$? + chmod 700 "$second/data" + [ "$rc" -ne 0 ] || fail "unwritable destination directory should make quarantine fail" + [ "$(file_mode "$second/data/captain-shared.md")" = "$before_mode" ] \ + || fail "failed quarantine did not restore read-only mode" + assert_grep "failed to quarantine divergent destination" "$err" \ + "recoverable failure should explain quarantine failure" + pass "unsafe shared captain artifacts are rejected and failure restores read-only mode" +} + +make_fake_spawn_toolchain() { + local dir=$1 fakebin + fakebin="$dir/fakebin" + mkdir -p "$fakebin" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" + printf '%s\n' "$fakebin" +} + +new_git_world() { + local name=$1 w root home c1 + w="$TMP_ROOT/$name" + root="$w/root" + home="$w/home" + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + touch "$home/state/.last-watcher-beat" + git init -q -b main "$root" + { + printf '%s\n' '.fm-secondmate-home' + printf '%s\n' 'data/' + printf '%s\n' 'state/' + printf '%s\n' 'config/' + printf '%s\n' 'projects/' + } > "$root/.gitignore" + printf '%s\n' "instructions" > "$root/AGENTS.md" + mkdir -p "$root/bin" "$root/.agents/skills" + printf '%s\n' "echo spawn" > "$root/bin/fm-spawn.sh" + printf '%s\n' "skill" > "$root/.agents/skills/example.md" + git -C "$root" add -A + git -C "$root" commit -qm initial + c1=$(git -C "$root" rev-parse HEAD) + git -C "$root" worktree add -q --detach "$w/sm" "$c1" + printf '%s\n' sm > "$w/sm/.fm-secondmate-home" + mkdir -p "$w/sm/data" "$w/sm/state" "$w/sm/config" "$w/sm/projects" + printf '%s\n' "charter" > "$w/sm/data/charter.md" + write_shared "$home/data/captain-shared.md" "shared from primary" + printf '%s|%s|%s|%s\n' "$w" "$root" "$home" "$w/sm" +} + +test_spawn_convergence_point_copies_shared_file() { + local rec w root home sm fakebin data_override + rec=$(new_git_world spawn-point) + IFS='|' read -r w root home sm <<EOF +$rec +EOF + data_override="$w/primary-data-override" + mkdir -p "$data_override" + write_shared "$data_override/captain-shared.md" "shared from override" + fakebin=$(make_fake_spawn_toolchain "$w") + + PATH="$fakebin:$BASE_PATH" TMUX='' \ + FM_ROOT_OVERRIDE="$root" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$data_override" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" sm "$sm" codex --secondmate >/dev/null 2>&1 || true + + cmp -s "$data_override/captain-shared.md" "$sm/data/captain-shared.md" \ + || fail "spawn convergence point did not copy shared captain preferences from FM_DATA_OVERRIDE" + assert_shared_readonly "$sm/data/captain-shared.md" + pass "spawn convergence point propagates data/captain-shared.md from FM_DATA_OVERRIDE" +} + +test_bootstrap_convergence_point_copies_shared_file() { + local rec w root home sm fakebin data_override out + rec=$(new_git_world bootstrap-point) + IFS='|' read -r w root home sm <<EOF +$rec +EOF + data_override="$w/primary-data-override" + mkdir -p "$data_override" + write_shared "$data_override/captain-shared.md" "shared from bootstrap override" + { + printf 'window=firstmate:fm-sm\n' + printf 'kind=secondmate\n' + } > "$home/state/sm.meta" + printf -- '- sm - fixture secondmate (home: %s; scope: fixture; projects: sample; added 2026-07-16)\n' "$sm" \ + > "$data_override/secondmates.md" + fakebin=$(make_fake_spawn_toolchain "$w") + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi gh treehouse no-mistakes tasks-axi quota-axi + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + FM_DATA_OVERRIDE="$data_override" \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + + assert_not_contains "$out" "SECONDMATE_SYNC: secondmate sm: skipped: inheritance failed" \ + "bootstrap inheritance should succeed" + cmp -s "$data_override/captain-shared.md" "$sm/data/captain-shared.md" \ + || fail "bootstrap convergence point did not copy shared captain preferences from FM_DATA_OVERRIDE" + assert_shared_readonly "$sm/data/captain-shared.md" + pass "bootstrap convergence point propagates data/captain-shared.md from FM_DATA_OVERRIDE" +} + +test_config_push_convergence_point_updates_changed_source() { + local rec w root home sm data_override out + rec=$(new_git_world config-push-point) + IFS='|' read -r w root home sm <<EOF +$rec +EOF + data_override="$w/primary-data-override" + mkdir -p "$data_override" + { + printf 'window=firstmate:fm-sm\n' + printf 'kind=secondmate\n' + printf 'home=%s\n' "$sm" + } > "$home/state/sm.meta" + write_shared "$sm/data/captain-shared.md" "old shared bytes" + chmod "$FM_SHARED_CAPTAIN_MODE" "$sm/data/captain-shared.md" + write_shared "$data_override/captain-shared.md" "changed override shared bytes" + + out=$(PATH="$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + FM_DATA_OVERRIDE="$data_override" \ + "$ROOT/bin/fm-config-push.sh" 2>/dev/null) + + assert_contains "$out" "data/captain-shared.md: pushed - quarantined local drift at" \ + "config-push should report the shared file update and quarantine" + cmp -s "$data_override/captain-shared.md" "$sm/data/captain-shared.md" \ + || fail "config-push convergence point did not update shared captain preferences from FM_DATA_OVERRIDE" + assert_shared_readonly "$sm/data/captain-shared.md" + pass "fm-config-push convergence point updates changed shared captain source bytes from FM_DATA_OVERRIDE" +} + +test_session_start_digest_labels_shared_file_and_read_once_rule() { + local rec w root home _sm fakebin out + rec=$(new_git_world session-start-label) + IFS='|' read -r w root home _sm <<EOF +$rec +EOF + fakebin=$(make_fake_spawn_toolchain "$w") + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi gh treehouse no-mistakes tasks-axi quota-axi pgrep + + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$ROOT/bin/fm-session-start.sh") + + assert_contains "$out" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" \ + "session-start digest should label the shared captain file unmistakably" + assert_contains "$out" "shared from primary" "session-start digest should render the shared file" + assert_contains "$out" "data/captain-shared.md, data/learnings.md" \ + "read-once reminder should include captain-shared.md" + pass "session-start digest renders data/captain-shared.md with the shared read-only label" +} + +test_first_copy_readonly_and_local_files_preserved +test_drift_quarantine_collision_and_repeated_convergence +test_missing_source_mirrors_absence_without_losing_local_bytes +test_unsafe_artifacts_and_failure_restore_readonly_mode +test_spawn_convergence_point_copies_shared_file +test_bootstrap_convergence_point_copies_shared_file +test_config_push_convergence_point_updates_changed_source +test_session_start_digest_labels_shared_file_and_read_once_rule + +echo "# all fm-shared-captain-inheritance tests passed" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 93d26f4e3..fc3292300 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -330,20 +330,21 @@ test_opencode_threads_model_and_ignores_effort_axis() { pass "opencode receives --model and omits the unsupported effort axis" } -test_pi_omits_invalid_max_effort() { +test_pi_threads_model_and_max_effort() { local rec id out status launch id=profile-pi-z8 rec=$(make_spawn_case profile-pi pi "$id") read_case_record "$rec" - out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --model sonnet --effort max) + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ + --model openai-codex/gpt-5.6-sol --effort max) status=$? - expect_code 0 "$status" "pi spawn with max effort should not pass an invalid flag" - assert_meta_profile "$HOME_DIR/state/$id.meta" pi sonnet max + expect_code 0 "$status" "pi spawn with max effort should succeed" + assert_meta_profile "$HOME_DIR/state/$id.meta" pi openai-codex/gpt-5.6-sol max launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "pi --model 'sonnet' -e" "pi launch did not thread model" - assert_not_contains "$launch" "--thinking" "pi launch must omit --thinking max because the CLI rejects it" - pass "pi threads model and omits unsupported max effort" + assert_contains "$launch" "pi --model 'openai-codex/gpt-5.6-sol' --thinking 'max' -e" \ + "pi launch did not thread the requested model and max thinking level" + pass "pi receives --model and --thinking max profile flags" } test_batch_forwards_shared_profile_flags() { @@ -400,7 +401,7 @@ test_grok_threads_model_and_reasoning_effort test_grok_omits_invalid_max_reasoning_effort test_grok_omits_invalid_xhigh_reasoning_effort test_opencode_threads_model_and_ignores_effort_axis -test_pi_omits_invalid_max_effort +test_pi_threads_model_and_max_effort test_batch_forwards_shared_profile_flags test_active_dispatch_profile_does_not_block_secondmate_launch diff --git a/tests/fm-stow-contract.test.sh b/tests/fm-stow-contract.test.sh index f03dc984c..c43f47c4d 100755 --- a/tests/fm-stow-contract.test.sh +++ b/tests/fm-stow-contract.test.sh @@ -8,8 +8,8 @@ set -u test_stow_skill_task_note_contract() { local stow="$ROOT/.agents/skills/stow/SKILL.md" - assert_grep 'bin/fm-tasks-axi.sh show <id> --full' "$stow" "stow skill does not require inspecting task notes first" - assert_grep 'bin/fm-tasks-axi.sh update <id> --body-file <path>' "$stow" "stow skill does not require task body replacement" + assert_grep 'tasks-axi show <id> --full' "$stow" "stow skill does not require inspecting task notes first" + assert_grep 'tasks-axi update <id> --body-file <path>' "$stow" "stow skill does not require task body replacement" assert_grep '--archive-body' "$stow" "stow skill does not document recoverable task body archival" assert_grep 'Never append.' "$stow" "stow skill does not forbid append-first task notes" assert_no_grep 'carry that context into the replacement body' "$stow" "stow skill still preserves archive-only context in the replacement body" @@ -19,11 +19,18 @@ test_stow_skill_task_note_contract() { test_agents_backlog_task_note_contract() { local agents="$ROOT/AGENTS.md" - assert_grep 'bin/fm-tasks-axi.sh show <id> --full' "$agents" "AGENTS.md does not require inspecting task notes first" - assert_grep 'bin/fm-tasks-axi.sh update <id> --body-file <path>' "$agents" "AGENTS.md does not require task body replacement" - assert_grep '--archive-body' "$agents" "AGENTS.md does not document recoverable task body archival" - assert_no_grep 'carry that context into the replacement body' "$agents" "AGENTS.md still preserves archive-only context in the replacement body" - pass "AGENTS.md task-note contract includes recoverable body archival" + # shellcheck disable=SC2016 # Literal backticks must remain unexpanded. + assert_grep 'current `tasks-axi --help` own the backlog schema' "$agents" \ + "AGENTS.md does not point exact task-note mechanics to the command owner" + assert_grep 'Inspect the current task note before replacing its considered body' "$agents" \ + "AGENTS.md does not require inspecting task notes before replacement" + assert_grep 'archive the superseded body when recoverability matters rather than appending by default' "$agents" \ + "AGENTS.md lost recoverable replacement and no-append semantics" + assert_no_grep 'tasks-axi show <id> --full' "$agents" \ + "AGENTS.md duplicates exact task-note read syntax from its conditional owner" + assert_no_grep 'tasks-axi update <id> --body-file <path>' "$agents" \ + "AGENTS.md duplicates exact task-note update syntax from its conditional owner" + pass "AGENTS.md keeps task-note hygiene inline and points exact mechanics to their owner" } test_stow_skill_task_note_contract diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 0e94eca4d..3e5d54f82 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -713,7 +713,8 @@ printf 'guard-fired\n' >&2 exit 2 EOF chmod +x "$worktree_dir/bin/fm-turnend-guard.sh" - out=$(PLUGIN="$plugin" DIRECTORY="$wrong_dir" WORKTREE="$worktree_dir" node 2>&1 <<'EOF' + # Runtime module-format warnings are host noise; this assertion owns plugin output only. + out=$(NODE_NO_WARNINGS=1 PLUGIN="$plugin" DIRECTORY="$wrong_dir" WORKTREE="$worktree_dir" node 2>&1 <<'EOF' import { pathToFileURL } from "node:url"; const mod = await import(pathToFileURL(process.env.PLUGIN).href); diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 25d734dcb..268746c6e 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -152,18 +152,23 @@ test_check_output_is_queued() { out="$dir/watch.out" drain_out="$dir/drain.out" check_file="$state/task.check.sh" + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" cat > "$check_file" <<'SH' #!/usr/bin/env bash printf 'merged: https://example.test/pr/1\n' SH - chmod +x "$check_file" + chmod 0700 "$check_file" + FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-check-register.sh" task >/dev/null \ + || fail "could not register queue custom check" PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=0 FM_HEARTBEAT=999999 "$WATCH" > "$out" & wait_for_exit "$!" 40 || fail "watcher did not exit for check output" grep -F "check: $check_file: merged: https://example.test/pr/1" "$out" >/dev/null || fail "watcher did not print check wake" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" || fail "drain after check wake failed" grep "$(printf '\tcheck\t')" "$drain_out" | grep -F "$check_file" | grep -F 'merged: https://example.test/pr/1' >/dev/null || fail "check wake was not queued" [ -e "$state/.last-check" ] || fail "check cadence marker was not written after queue append" - pass "check output is queued before cadence suppression" + pass "registered custom check output is queued before cadence suppression" } test_atomic_double_drain() { diff --git a/tests/fm-watch-checkpoint.test.sh b/tests/fm-watch-checkpoint.test.sh index 78d2eaea7..9312c6ddc 100755 --- a/tests/fm-watch-checkpoint.test.sh +++ b/tests/fm-watch-checkpoint.test.sh @@ -46,22 +46,27 @@ test_signal_passes_through_and_exits_zero() { pass "checkpoint passes through a real watcher wake and leaves the queue for drain" } -test_check_uses_preserved_watcher_environment() { +test_registered_check_uses_preserved_watcher_environment() { local home out err status home=$(make_home check-env) out="$home/out.txt" err="$home/err.txt" + printf '%s\n' fm-pr-check-migration-scan-v1 > "$home/state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$home/state/.pr-check-migration-v1" + chmod 0600 "$home/state/.pr-check-migration-scan-v1" "$home/state/.pr-check-migration-v1" cat > "$home/state/env-check.check.sh" <<'SH' #!/usr/bin/env bash printf 'env check fired with FM_CHECK_INTERVAL=%s\n' "${FM_CHECK_INTERVAL:-missing}" SH - chmod +x "$home/state/env-check.check.sh" + chmod 0700 "$home/state/env-check.check.sh" + FM_HOME="$home" "$ROOT/bin/fm-check-register.sh" env-check >/dev/null \ + || fail "could not register checkpoint custom check" status=0 FM_HOME="$home" FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=1 "$CHECKPOINT" --seconds 5 >"$out" 2>"$err" || status=$? expect_code 0 "$status" "check checkpoint exit" assert_contains "$(cat "$out")" "check:" "check wake was not passed through" assert_contains "$(cat "$out")" "FM_CHECK_INTERVAL=1" "watcher environment was not preserved" - pass "checkpoint preserves watcher environment for the foreground fm-watch.sh" + pass "checkpoint preserves watcher environment for registered custom checks" } test_existing_singleton_watcher_is_not_success() { @@ -69,6 +74,9 @@ test_existing_singleton_watcher_is_not_success() { home=$(make_home singleton) out="$home/out.txt" err="$home/err.txt" + printf '%s\n' fm-pr-check-migration-scan-v1 > "$home/state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$home/state/.pr-check-migration-v1" + chmod 0600 "$home/state/.pr-check-migration-scan-v1" "$home/state/.pr-check-migration-v1" mkdir "$home/state/.watch.lock" printf '%s\n' "$$" > "$home/state/.watch.lock/pid" status=0 @@ -81,5 +89,5 @@ test_existing_singleton_watcher_is_not_success() { test_quiet_checkpoint_exits_124_cleanly test_signal_passes_through_and_exits_zero -test_check_uses_preserved_watcher_environment +test_registered_check_uses_preserved_watcher_environment test_existing_singleton_watcher_is_not_success diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index dd201a39a..4cbc4de4f 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -122,7 +122,7 @@ test_scan_captain_relevant_statuses_classifier() { } test_classifier_primitives() { - local dir state open + local dir state open activity dir=$(make_case classify-primitives); state="$dir/state" printf 'working: a\n\ndone: b\n\n' > "$state/x.status" [ "$(last_status_line "$state/x.status")" = "done: b" ] || fail "last_status_line did not return the last non-blank line" @@ -142,7 +142,29 @@ test_classifier_primitives() { && fail "a key token in note prose changed the decision key" printf '%s' "$open" | grep -F $'bad key\t' >/dev/null \ && fail "an invalid key slug entered the open-decision set" - pass "classifier primitives: last line, captain-relevance, window->task, FM_CAPTAIN_RE override" + cat > "$state/activity.status" <<'EOF' +working [key=phase7]: Phase 7 started +working [key=phase6]: Phase 6 started +working [key=legal]: reviewing legal dependency +done [key=phase6]: Phase 6 completed +resolved [key=phase7]: Phase 7 completed and moved to Done +paused [key=legal]: awaiting external counsel +resolved [key=legal]: legal item returned to the queue +working [key=phase8]: Phase 8 started +EOF + activity=$(status_open_activities "$state/activity.status") + printf '%s' "$activity" | grep -F $'phase8\tworking\tPhase 8 started' >/dev/null \ + || fail "the current keyed working phase was not retained" + printf '%s' "$activity" | grep -F $'phase7\t' >/dev/null \ + && fail "a keyed resolved event did not close the older working phase" + printf '%s' "$activity" | grep -F $'phase6\t' >/dev/null \ + && fail "a same-key terminal event did not supersede the older working phase" + printf '%s' "$activity" | grep -F $'legal\t' >/dev/null \ + && fail "a keyed resolved event did not close the declared pause" + printf 'working: legacy start\ndone: legacy completion\n' > "$state/legacy-activity.status" + [ -z "$(status_open_activities "$state/legacy-activity.status")" ] \ + || fail "a legacy terminal event did not supersede the default working phase" + pass "classifier primitives: keyed decisions and activity phases, captain relevance, window-to-task, and overrides" } # crew_is_provably_working: the absorb-only-when-provably-working predicate. It is diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 63bc5916f..9c4740e8c 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -15,6 +15,13 @@ LIB="$ROOT/bin/fm-wake-lib.sh" TMP_ROOT=$(fm_test_tmproot fm-watcher-lock-tests) +mark_pr_check_migration_complete() { + local state=$1 + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" +} + test_singleton_start() { local dir state fakebin out1 out2 pid1 pid2 live i @@ -23,6 +30,7 @@ test_singleton_start() { fakebin="$dir/fakebin" out1="$dir/watch-one.out" out2="$dir/watch-two.out" + mark_pr_check_migration_complete "$state" PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out1" & pid1=$! PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out2" & @@ -37,6 +45,11 @@ test_singleton_start() { i=$((i + 1)) done [ "$live" -eq 1 ] || fail "expected exactly one live watcher, got $live" + i=0 + while [ "$i" -lt 50 ] && ! grep -h 'watcher: already running pid ' "$out1" "$out2" >/dev/null 2>&1; do + sleep 0.02 + i=$((i + 1)) + done grep -h 'watcher: already running pid ' "$out1" "$out2" >/dev/null || fail "second watcher did not report existing singleton" kill "$pid1" "$pid2" 2>/dev/null || true wait "$pid1" 2>/dev/null || true @@ -83,6 +96,7 @@ test_live_stale_watch_lock_is_actionable() { fakebin="$dir/fakebin" out="$dir/watch.out" err="$dir/watch.err" + mark_pr_check_migration_complete "$state" mkdir "$state/.watch.lock" printf '%s\n' "$$" > "$state/.watch.lock/pid" touch -t 200001010000 "$state/.last-watcher-beat" @@ -390,6 +404,7 @@ test_watch_restart_rejects_reused_pid() { state="$dir/state" fakebin="$dir/fakebin" out="$dir/restart.out" + mark_pr_check_migration_complete "$state" sleep 300 & live=$! mkdir "$state/.watch.lock" @@ -427,6 +442,7 @@ test_watch_restart_reports_healthy_peer_without_attaching() { state="$dir/state" fakebin="$dir/fakebin" out="$dir/restart.out" + mark_pr_check_migration_complete "$state" node -e 'process.on("SIGTERM", () => {}); setTimeout(() => {}, 300000)' & peer=$! identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$peer") || fail "could not identify peer pid" @@ -596,18 +612,23 @@ test_arm_propagates_immediate_wake_before_confirmation() { armout="$dir/arm.out" drain_out="$dir/drain.out" check_file="$state/task.check.sh" + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" cat > "$check_file" <<'SH' #!/usr/bin/env bash printf 'merged: https://example.test/pr/7\n' SH - chmod +x "$check_file" + chmod 0700 "$check_file" + FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-check-register.sh" task >/dev/null \ + || fail "could not register immediate-wake custom check" rc=0 PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=0 FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=0 FM_HEARTBEAT=999999 "$WATCH_ARM" > "$armout" || rc=$? [ "$rc" -eq 0 ] || fail "arm returned non-zero for an immediate wake (status $rc): $(cat "$armout")" grep -F "check: $check_file: merged: https://example.test/pr/7" "$armout" >/dev/null || fail "arm did not propagate the immediate check wake" ! grep -qF 'watcher: FAILED' "$armout" || fail "arm printed FAILED after a valid immediate wake" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" || fail "drain after immediate arm wake failed" - grep "$(printf '\tcheck\t')" "$drain_out" | grep -F "$check_file" | grep -F 'merged: https://example.test/pr/7' >/dev/null || fail "immediate arm wake was not queued" + grep "$(printf '\tcheck\t')" "$drain_out" | grep -F "$check_file" | grep -F 'merged: https://example.test/pr/7' >/dev/null || fail "immediate check wake was not queued" pass "arm propagates an immediate watcher wake before confirmation" } @@ -617,6 +638,7 @@ test_arm_waits_for_peer_beacon_after_child_stands_down() { state="$dir/state" fakebin="$dir/fakebin" armout="$dir/arm.out" + mark_pr_check_migration_complete "$state" sleep 300 & peer=$! identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$peer") || fail "could not identify peer pid" @@ -657,6 +679,7 @@ test_arm_fails_loud_when_no_fresh_watcher_confirmable() { state="$dir/state" fakebin="$dir/fakebin" armout="$dir/arm.out" + mark_pr_check_migration_complete "$state" sleep 300 & live=$! # A live process holds the lock but is NOT a confirmable watcher (no identity), diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 85925f789..da2800034 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -101,6 +101,30 @@ make_sample_image() { esac } +path_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} + +assert_no_private_artifact_temps() { + local dir=$1 leftovers + [ -d "$dir" ] || return 0 + leftovers=$(find "$dir" -name '*.fm-x.*' -print 2>/dev/null) + [ -z "$leftovers" ] || fail "private artifact temp files were not cleaned up: $leftovers" +} + +private_artifact_dir() { + mkdir -p "$1" + chmod 700 "$1" +} + +private_artifact_file() { + chmod 600 "$1" +} + # --------------------------------------------------------------------------- test_poll_no_token_is_hard_noop() { @@ -179,6 +203,8 @@ test_poll_auth_error_reports_once() { [ "$out" = "x-mode-error relay returned HTTP 401" ] \ || fail "poll auth error must emit one visible diagnostic (got: $out)" assert_present "$home/state/x-poll.error" "poll auth error must write a dedupe marker" + [ "$(path_mode "$home/state")" = 700 ] || fail "poll auth error must create private state" + [ "$(path_mode "$home/state/x-poll.error")" = 600 ] || fail "poll auth error marker must be private" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ FAKE_POLL_CODE=401 \ "$ROOT/bin/fm-x-poll.sh"); rc=$? @@ -193,6 +219,57 @@ test_poll_auth_error_reports_once() { pass "fm-x-poll surfaces auth/config errors once and clears on recovery" } +test_poll_error_private_publication_rejects_unsafe_paths() { + local home fakebin out rc target marker hardlink + + home="$TMP_ROOT/poll-error-linked-state"; mkdir -p "$home/external-state" + fakebin=$(make_fake_curl "$home") + ln -s "$home/external-state" "$home/state" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-linked-state FAKE_POLL_CODE=401 \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll linked state diagnostic exit" + [ "$out" = "x-mode-error relay returned HTTP 401" ] \ + || fail "poll must still emit a diagnostic when the marker cannot be safely stored (got: $out)" + assert_absent "$home/external-state/x-poll.error" "poll must not write the diagnostic through a linked state directory" + [ -L "$home/state" ] || fail "poll must leave a rejected linked state directory in place" + + home="$TMP_ROOT/poll-error-linked-marker"; mkdir -p "$home/state" + fakebin=$(make_fake_curl "$home") + chmod 700 "$home/state" + target="$home/external-error" + printf 'relay returned HTTP 401\n' > "$target" + ln -s "$target" "$home/state/x-poll.error" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-linked-marker FAKE_POLL_CODE=401 \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll linked marker diagnostic exit" + [ "$out" = "x-mode-error relay returned HTTP 401" ] \ + || fail "poll must not dedupe through a linked diagnostic marker (got: $out)" + [ "$(cat "$target")" = "relay returned HTTP 401" ] \ + || fail "poll must not write through a linked diagnostic marker" + [ -L "$home/state/x-poll.error" ] || fail "poll must not replace a rejected linked diagnostic marker" + + home="$TMP_ROOT/poll-error-hardlink-marker"; mkdir -p "$home/state" + fakebin=$(make_fake_curl "$home") + chmod 700 "$home/state" + marker="$home/state/x-poll.error" + hardlink="$home/state/x-poll.alias" + printf 'relay returned HTTP 401\n' > "$marker" + chmod 600 "$marker" + ln "$marker" "$hardlink" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-hard-marker FAKE_POLL_CODE=401 \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll hardlinked marker diagnostic exit" + [ "$out" = "x-mode-error relay returned HTTP 401" ] \ + || fail "poll must not dedupe through a hardlinked diagnostic marker (got: $out)" + [ "$(cat "$marker")" = "relay returned HTTP 401" ] || fail "poll must preserve the hardlinked marker" + [ "$(cat "$hardlink")" = "relay returned HTTP 401" ] || fail "poll must preserve the marker hardlink peer" + assert_no_private_artifact_temps "$home/state" + pass "fm-x-poll diagnostic markers use private guarded publication" +} + test_poll_question_stashes_and_marks() { local home fakebin out rc body home="$TMP_ROOT/poll-q"; mkdir -p "$home" @@ -250,7 +327,14 @@ test_poll_inbox_commit_failure_reports_error() { fakebin=$(make_fake_curl "$home") cat > "$fakebin/mv" <<'SH' #!/usr/bin/env bash -exit 1 +dest= +for arg in "$@"; do + dest=$arg +done +case "$dest" in + */x-inbox/*) exit 1 ;; +esac +exec /bin/mv "$@" SH chmod +x "$fakebin/mv" printf 'FMX_PAIRING_TOKEN=tok-q\n' > "$home/.env" @@ -280,6 +364,82 @@ SH pass "fm-x-poll reports inbox commit failures without emitting a mention wake" } +test_poll_inbox_private_publication_rejects_unsafe_paths() { + local home fakebin out rc body target dir dest hardlink + body='{"request_id":"req-x","tweet_id":"555","author_id":"42","text":"what are you building?"}' + + home="$TMP_ROOT/poll-inbox-linked-dir"; mkdir -p "$home/state" "$home/external" + fakebin=$(make_fake_curl "$home") + ln -s "$home/external" "$home/state/x-inbox" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-linked FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll linked inbox dir exit" + [ "$out" = "x-mode-error cannot write inbox" ] \ + || fail "poll must report a linked inbox directory as a write failure (got: $out)" + assert_absent "$home/external/req-x.json" "poll must not write through a linked inbox directory" + [ -L "$home/state/x-inbox" ] || fail "poll must leave the rejected inbox symlink in place" + + home="$TMP_ROOT/poll-inbox-public-dir"; mkdir -p "$home/state/x-inbox" + fakebin=$(make_fake_curl "$home") + chmod 755 "$home/state/x-inbox" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-public FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll public inbox dir exit" + [ "$out" = "x-mode-error cannot write inbox" ] \ + || fail "poll must reject a nonprivate inbox directory (got: $out)" + assert_absent "$home/state/x-inbox/req-x.json" "poll must not publish into a nonprivate inbox directory" + assert_no_private_artifact_temps "$home/state/x-inbox" + + home="$TMP_ROOT/poll-inbox-linked-dest"; mkdir -p "$home/state/x-inbox" + fakebin=$(make_fake_curl "$home") + chmod 700 "$home/state/x-inbox" + target="$home/external-target.json" + printf 'external sentinel\n' > "$target" + ln -s "$target" "$home/state/x-inbox/req-x.json" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-linkdest FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll linked inbox destination exit" + [ "$out" = "x-mode-error cannot write inbox" ] \ + || fail "poll must reject a linked inbox destination (got: $out)" + [ "$(cat "$target")" = "external sentinel" ] || fail "poll must not write through a linked inbox destination" + [ -L "$home/state/x-inbox/req-x.json" ] || fail "poll must not replace a rejected linked destination" + assert_no_private_artifact_temps "$home/state/x-inbox" + + home="$TMP_ROOT/poll-inbox-hardlink-dest"; mkdir -p "$home/state/x-inbox" + fakebin=$(make_fake_curl "$home") + chmod 700 "$home/state/x-inbox" + dest="$home/state/x-inbox/req-x.json" + hardlink="$home/state/x-inbox/req-x.alias" + printf '{"request_id":"req-x","text":"old"}\n' > "$dest" + chmod 600 "$dest" + ln "$dest" "$hardlink" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-hard FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll hardlinked inbox destination exit" + [ "$out" = "x-mode-error cannot write inbox" ] \ + || fail "poll must reject a hardlinked inbox destination (got: $out)" + [ "$(jq -r .text "$dest")" = "old" ] || fail "poll must preserve a rejected hardlinked destination" + [ "$(jq -r .text "$hardlink")" = "old" ] || fail "poll must preserve the hardlink peer" + assert_no_private_artifact_temps "$home/state/x-inbox" + + home="$TMP_ROOT/poll-inbox-private-success"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-ok FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "poll private inbox success exit" + [ "$out" = "x-mention req-x" ] || fail "poll must still emit a wake after private publication (got: $out)" + dir="$home/state/x-inbox" + [ "$(path_mode "$dir")" = 700 ] || fail "poll must create the inbox directory as private" + [ "$(path_mode "$dir/req-x.json")" = 600 ] || fail "poll must publish the inbox file as private" + assert_no_private_artifact_temps "$dir" + pass "fm-x-poll publishes inbox records only through private guarded artifacts" +} + test_poll_rejects_unsafe_request_id() { local home fakebin out rc home="$TMP_ROOT/poll-evil"; mkdir -p "$home" @@ -488,6 +648,39 @@ test_bootstrap_does_not_announce_when_arm_fails() { pass "bootstrap does not report X mode on when activation artifacts cannot be written" } +test_bootstrap_does_not_follow_x_artifact_symlinks() { + local home shim_target cadence_target out + home="$TMP_ROOT/boot-linked-artifacts" + mkdir -p "$home/state" "$home/config" "$home/external-quarantine" + printf 'FMX_PAIRING_TOKEN=tok-linked\n' > "$home/.env" + shim_target="$home/external-shim" + cadence_target="$home/external-cadence" + printf 'external shim sentinel\n' > "$shim_target" + printf 'external cadence sentinel\n' > "$cadence_target" + chmod 0640 "$shim_target" "$cadence_target" + ln -s "$shim_target" "$home/state/x-watch.check.sh" + ln -s "$cadence_target" "$home/config/x-mode.env" + ln -s "$home/external-quarantine" "$home/state/.pr-check-quarantine" + + out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>"$home/bootstrap.err") + + assert_contains "$out" "FMX: X mode off - failed to arm relay poll shim or 30s cadence" \ + "bootstrap must reject linked X-mode destinations" + assert_not_contains "$out" "FMX: X mode on" \ + "bootstrap must not announce X mode after rejecting linked destinations" + [ "$(cat "$shim_target")" = 'external shim sentinel' ] \ + || fail "bootstrap changed the linked shim target" + [ "$(cat "$cadence_target")" = 'external cadence sentinel' ] \ + || fail "bootstrap changed the linked cadence target" + [ "$(path_mode "$shim_target")" = 640 ] \ + || fail "bootstrap changed the linked shim target mode" + [ "$(path_mode "$cadence_target")" = 640 ] \ + || fail "bootstrap changed the linked cadence target mode" + assert_absent "$home/state/x-watch.check.sh" "bootstrap must remove the rejected shim link" + assert_absent "$home/config/x-mode.env" "bootstrap must remove the rejected cadence link" + pass "bootstrap rejects linked X artifacts without touching their targets" +} + test_bootstrap_inert_without_token() { local home out # No .env at all. @@ -683,10 +876,70 @@ test_reply_dry_run_fails_when_outbox_unwritable() { "$ROOT/bin/fm-x-reply.sh" "req-4" "preview text" 2>"$err"); rc=$? [ "$rc" -ne 0 ] || fail "dry-run must fail when it cannot record the preview" [ -z "$out" ] || fail "dry-run record failure must not echo the request_id (got: $out)" - assert_grep "cannot create dry-run outbox" "$err" "dry-run must explain the outbox failure" + assert_grep "cannot write dry-run outbox" "$err" "dry-run must explain the outbox failure" pass "fm-x-reply dry-run fails when it cannot record the preview" } +test_reply_dry_run_outbox_private_publication_rejects_unsafe_paths() { + local home out rc err target dest hardlink + + home="$TMP_ROOT/reply-outbox-linked-dir"; mkdir -p "$home/state" "$home/external" + err="$home/err.txt" + ln -s "$home/external" "$home/state/x-outbox" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$? + [ "$rc" -ne 0 ] || fail "reply dry-run must reject a linked outbox directory" + [ -z "$out" ] || fail "rejected linked outbox must not echo the request_id (got: $out)" + assert_grep "cannot write dry-run outbox" "$err" "reply dry-run must report the linked outbox write failure" + assert_absent "$home/external/req-x.json" "reply dry-run must not write through a linked outbox directory" + + home="$TMP_ROOT/reply-outbox-linked-dest"; mkdir -p "$home/state/x-outbox" + err="$home/err.txt" + chmod 700 "$home/state/x-outbox" + target="$home/external-target.json" + printf 'external sentinel\n' > "$target" + ln -s "$target" "$home/state/x-outbox/req-x.json" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$? + [ "$rc" -ne 0 ] || fail "reply dry-run must reject a linked outbox destination" + [ "$(cat "$target")" = "external sentinel" ] || fail "reply dry-run must not write through a linked outbox destination" + [ -L "$home/state/x-outbox/req-x.json" ] || fail "reply dry-run must not replace a rejected linked destination" + assert_no_private_artifact_temps "$home/state/x-outbox" + + home="$TMP_ROOT/reply-outbox-hardlink-dest"; mkdir -p "$home/state/x-outbox" + err="$home/err.txt" + chmod 700 "$home/state/x-outbox" + dest="$home/state/x-outbox/req-x.json" + hardlink="$home/state/x-outbox/req-x.alias" + printf '{"request_id":"req-x","text":"old"}\n' > "$dest" + chmod 600 "$dest" + ln "$dest" "$hardlink" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$? + [ "$rc" -ne 0 ] || fail "reply dry-run must reject a hardlinked outbox destination" + [ "$(jq -r .text "$dest")" = "old" ] || fail "reply dry-run must preserve a rejected hardlinked destination" + [ "$(jq -r .text "$hardlink")" = "old" ] || fail "reply dry-run must preserve the hardlink peer" + assert_no_private_artifact_temps "$home/state/x-outbox" + + home="$TMP_ROOT/reply-outbox-wrong-mode-dest"; mkdir -p "$home/state/x-outbox" + err="$home/err.txt" + chmod 700 "$home/state/x-outbox" + dest="$home/state/x-outbox/req-x.json" + printf '{"request_id":"req-x","text":"old"}\n' > "$dest" + chmod 644 "$dest" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$? + [ "$rc" -ne 0 ] || fail "reply dry-run must reject a wrong-mode outbox destination" + [ "$(jq -r .text "$dest")" = "old" ] || fail "reply dry-run must preserve a rejected wrong-mode destination" + [ "$(path_mode "$dest")" = 644 ] || fail "reply dry-run must leave a rejected wrong-mode destination unchanged" + assert_no_private_artifact_temps "$home/state/x-outbox" + + home="$TMP_ROOT/reply-outbox-private-success"; mkdir -p "$home" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>/dev/null); rc=$? + expect_code 0 "$rc" "reply private outbox success exit" + [ "$out" = "req-x" ] || fail "reply dry-run must still echo the request_id after private publication (got: $out)" + [ "$(path_mode "$home/state/x-outbox")" = 700 ] || fail "reply dry-run must create the outbox directory as private" + [ "$(path_mode "$home/state/x-outbox/req-x.json")" = 600 ] || fail "reply dry-run must publish the outbox file as private" + assert_no_private_artifact_temps "$home/state/x-outbox" + pass "fm-x-reply dry-run publishes outbox records only through private guarded artifacts" +} + test_split_thread_lib() { # shellcheck source=bin/fm-x-lib.sh . "$ROOT/bin/fm-x-lib.sh" @@ -764,9 +1017,10 @@ test_reply_thread_dry_run() { test_reply_discord_inbox_uses_discord_budget() { local home out reply - home="$TMP_ROOT/reply-discord-budget"; mkdir -p "$home/state/x-inbox" + home="$TMP_ROOT/reply-discord-budget"; private_artifact_dir "$home/state/x-inbox" jq -cn '{request_id:"req-discord",tweet_id:"discord:channel:message",text:"question"}' \ > "$home/state/x-inbox/req-discord.json" + private_artifact_file "$home/state/x-inbox/req-discord.json" reply=$(cat <<'TXT' First paragraph stays intact in a single Discord reply even though it is comfortably over the X tweet budget. @@ -789,8 +1043,9 @@ TXT test_reply_x_inbox_still_uses_x_budget() { local home out long - home="$TMP_ROOT/reply-x-budget"; mkdir -p "$home/state/x-inbox" + home="$TMP_ROOT/reply-x-budget"; private_artifact_dir "$home/state/x-inbox" jq -cn '{request_id:"req-x",tweet_id:"1234567890",text:"question"}' > "$home/state/x-inbox/req-x.json" + private_artifact_file "$home/state/x-inbox/req-x.json" long="This X reply intentionally runs beyond the default tweet budget so it still needs a numbered thread on X. It has enough plain words to cross the limit while staying easy to split at word boundaries without code fences or platform ambiguity. The old default must remain intact for numeric tweet ids." out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "$long" 2>/dev/null) [ "$out" = "req-x" ] || fail "X dry-run must echo the request_id (got: $out)" @@ -803,9 +1058,10 @@ test_reply_x_inbox_still_uses_x_budget() { test_reply_inbox_explicit_limit_wins() { local home out long - home="$TMP_ROOT/reply-explicit-limit"; mkdir -p "$home/state/x-inbox" + home="$TMP_ROOT/reply-explicit-limit"; private_artifact_dir "$home/state/x-inbox" jq -cn '{request_id:"req-limit",platform:"discord",reply_max_chars:90,text:"question"}' \ > "$home/state/x-inbox/req-limit.json" + private_artifact_file "$home/state/x-inbox/req-limit.json" long="Discord normally has a much larger budget, but an explicit relay-provided reply_max_chars value must be honored when the payload carries one." out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-limit "$long" 2>/dev/null) [ "$out" = "req-limit" ] || fail "explicit-limit dry-run must echo the request_id (got: $out)" @@ -816,6 +1072,53 @@ test_reply_inbox_explicit_limit_wins() { pass "fm-x-reply prefers an explicit relay-provided reply limit" } +test_reply_rejects_unsafe_inbox_context_reads() { + local home out rc reply target dest hardlink + reply="This reply is intentionally longer than a single X tweet, but shorter than a Discord message. If an unsafe inbox artifact is trusted it will stay one message; if it is rejected it will split at the default X budget, which is the fail-closed behavior for a local lookalike context record." + + home="$TMP_ROOT/reply-inbox-linked-dir"; mkdir -p "$home/state" "$home/external-inbox" + jq -cn '{request_id:"req-linked-dir",platform:"discord",reply_max_chars:1900,text:"question"}' \ + > "$home/external-inbox/req-linked-dir.json" + chmod 600 "$home/external-inbox/req-linked-dir.json" + ln -s "$home/external-inbox" "$home/state/x-inbox" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-linked-dir "$reply" 2>/dev/null); rc=$? + expect_code 0 "$rc" "reply linked inbox dir read exit" + jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-linked-dir.json" >/dev/null \ + || fail "reply must not trust a linked inbox directory for Discord budget context" + + home="$TMP_ROOT/reply-inbox-linked-file"; private_artifact_dir "$home/state/x-inbox" + target="$home/external-inbox-record.json" + jq -cn '{request_id:"req-linked-file",platform:"discord",reply_max_chars:1900,text:"question"}' > "$target" + ln -s "$target" "$home/state/x-inbox/req-linked-file.json" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-linked-file "$reply" 2>/dev/null); rc=$? + expect_code 0 "$rc" "reply linked inbox file read exit" + jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-linked-file.json" >/dev/null \ + || fail "reply must not trust a linked inbox file for Discord budget context" + + home="$TMP_ROOT/reply-inbox-hardlink-file"; private_artifact_dir "$home/state/x-inbox" + dest="$home/state/x-inbox/req-hardlink.json" + hardlink="$home/state/x-inbox/req-hardlink.alias" + jq -cn '{request_id:"req-hardlink",platform:"discord",reply_max_chars:1900,text:"question"}' > "$dest" + private_artifact_file "$dest" + ln "$dest" "$hardlink" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-hardlink "$reply" 2>/dev/null); rc=$? + expect_code 0 "$rc" "reply hardlinked inbox file read exit" + jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-hardlink.json" >/dev/null \ + || fail "reply must not trust a hardlinked inbox file for Discord budget context" + [ "$(jq -r .platform "$dest")" = "discord" ] || fail "reply must leave a hardlinked inbox lookalike unchanged" + + home="$TMP_ROOT/reply-inbox-public-dir"; mkdir -p "$home/state/x-inbox" + chmod 755 "$home/state/x-inbox" + jq -cn '{request_id:"req-public-dir",platform:"discord",reply_max_chars:1900,text:"question"}' \ + > "$home/state/x-inbox/req-public-dir.json" + private_artifact_file "$home/state/x-inbox/req-public-dir.json" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-public-dir "$reply" 2>/dev/null); rc=$? + expect_code 0 "$rc" "reply public inbox dir read exit" + jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-public-dir.json" >/dev/null \ + || fail "reply must not trust a nonprivate inbox directory for Discord budget context" + pass "fm-x-reply rejects unsafe inbox context artifacts" +} + test_reply_max_chars_floor_clamps_to_minimum() { local home out long home="$TMP_ROOT/reply-max-floor"; mkdir -p "$home" @@ -1198,11 +1501,137 @@ test_poll_records_context_registry_from_relay_platform() { pass "fm-x-poll records the durable per-request reply context from the relay payload" } +test_context_registry_private_publication_rejects_unsafe_paths() { + local home rc dest hardlink target out + # shellcheck source=bin/fm-x-lib.sh + . "$ROOT/bin/fm-x-lib.sh" + + home="$TMP_ROOT/context-linked-dir"; mkdir -p "$home/state" "$home/external" + ln -s "$home/external" "$home/state/x-context" + fmx_context_registry_set "$home/state" req-x x 280; rc=$? + [ "$rc" -ne 0 ] || fail "context registry must reject a linked context directory" + assert_absent "$home/external/req-x.json" "context registry must not write through a linked context directory" + [ -L "$home/state/x-context" ] || fail "context registry must leave the rejected directory symlink in place" + + home="$TMP_ROOT/context-public-dir"; mkdir -p "$home/state/x-context" + chmod 755 "$home/state/x-context" + fmx_context_registry_set "$home/state" req-x x 280; rc=$? + [ "$rc" -ne 0 ] || fail "context registry must reject a nonprivate context directory" + assert_absent "$home/state/x-context/req-x.json" "context registry must not publish into a nonprivate context directory" + assert_no_private_artifact_temps "$home/state/x-context" + + home="$TMP_ROOT/context-linked-dest"; mkdir -p "$home/state/x-context" + chmod 700 "$home/state/x-context" + target="$home/external-target.json" + printf 'external sentinel\n' > "$target" + ln -s "$target" "$home/state/x-context/req-x.json" + fmx_context_registry_set "$home/state" req-x x 280; rc=$? + [ "$rc" -ne 0 ] || fail "context registry must reject a linked destination" + [ "$(cat "$target")" = "external sentinel" ] || fail "context registry must not write through a linked destination" + [ -L "$home/state/x-context/req-x.json" ] || fail "context registry must not replace a rejected linked destination" + assert_no_private_artifact_temps "$home/state/x-context" + + home="$TMP_ROOT/context-hardlink-dest"; mkdir -p "$home/state/x-context" + chmod 700 "$home/state/x-context" + dest="$home/state/x-context/req-x.json" + hardlink="$home/state/x-context/req-x.alias" + jq -cn '{request_id:"req-x",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' > "$dest" + chmod 600 "$dest" + ln "$dest" "$hardlink" + fmx_context_registry_set "$home/state" req-x discord 1900; rc=$? + [ "$rc" -ne 0 ] || fail "context registry must reject a hardlinked destination" + [ "$(jq -r .platform "$dest")" = "x" ] || fail "context registry must preserve a rejected hardlinked destination" + [ "$(jq -r .platform "$hardlink")" = "x" ] || fail "context registry must preserve the hardlink peer" + assert_no_private_artifact_temps "$home/state/x-context" + + home="$TMP_ROOT/context-wrong-mode-dest"; mkdir -p "$home/state/x-context" + chmod 700 "$home/state/x-context" + dest="$home/state/x-context/req-x.json" + jq -cn '{request_id:"req-x",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' > "$dest" + chmod 644 "$dest" + fmx_context_registry_set "$home/state" req-x discord 1900; rc=$? + [ "$rc" -ne 0 ] || fail "context registry must reject a wrong-mode destination" + [ "$(jq -r .platform "$dest")" = "x" ] || fail "context registry must preserve a rejected wrong-mode destination" + [ "$(path_mode "$dest")" = 644 ] || fail "context registry must leave a rejected wrong-mode destination unchanged" + assert_no_private_artifact_temps "$home/state/x-context" + + home="$TMP_ROOT/context-private-success"; mkdir -p "$home" + out=$(FMX_NOW_OVERRIDE=1700000000 bash -c '. "$1/bin/fm-x-lib.sh"; fmx_context_registry_set "$2/state" req-x x 280' _ "$ROOT" "$home") + rc=$? + expect_code 0 "$rc" "context private publication success" + [ -z "$out" ] || fail "context registry setter must stay silent on success" + [ "$(path_mode "$home/state/x-context")" = 700 ] || fail "context registry must create the context directory as private" + [ "$(path_mode "$home/state/x-context/req-x.json")" = 600 ] || fail "context registry must publish the context file as private" + assert_no_private_artifact_temps "$home/state/x-context" + pass "context registry publishes records only through private guarded artifacts" +} + +test_context_registry_rejects_unsafe_reads() { + local home out target dest hardlink + # shellcheck source=bin/fm-x-lib.sh + . "$ROOT/bin/fm-x-lib.sh" + + home="$TMP_ROOT/context-read-linked-dir"; mkdir -p "$home/state" "$home/external-context" + jq -cn '{request_id:"req-linked-dir",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' \ + > "$home/external-context/req-linked-dir.json" + chmod 600 "$home/external-context/req-linked-dir.json" + ln -s "$home/external-context" "$home/state/x-context" + out=$(fmx_context_registry_get "$home/state" req-linked-dir) + [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \ + || fail "context registry must not read through a linked context directory" + + home="$TMP_ROOT/context-read-linked-file"; private_artifact_dir "$home/state/x-context" + target="$home/external-context-record.json" + jq -cn '{request_id:"req-linked-file",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$target" + ln -s "$target" "$home/state/x-context/req-linked-file.json" + out=$(fmx_context_registry_get "$home/state" req-linked-file) + [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \ + || fail "context registry must not read through a linked context file" + [ -L "$home/state/x-context/req-linked-file.json" ] \ + || fail "context registry must not replace a rejected linked context file" + + home="$TMP_ROOT/context-read-hardlink-file"; private_artifact_dir "$home/state/x-context" + dest="$home/state/x-context/req-hardlink.json" + hardlink="$home/state/x-context/req-hardlink.alias" + jq -cn '{request_id:"req-hardlink",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$dest" + private_artifact_file "$dest" + ln "$dest" "$hardlink" + out=$(fmx_context_registry_get "$home/state" req-hardlink) + [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \ + || fail "context registry must not read a hardlinked context file" + [ "$(jq -r .platform "$hardlink")" = "discord" ] \ + || fail "context registry must not rewrite a hardlink peer when rejecting the local record" + + home="$TMP_ROOT/context-read-wrong-mode-file"; private_artifact_dir "$home/state/x-context" + dest="$home/state/x-context/req-mode.json" + jq -cn '{request_id:"req-mode",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$dest" + chmod 644 "$dest" + out=$(fmx_context_registry_get "$home/state" req-mode) + [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \ + || fail "context registry must not read a wrong-mode context file" + pass "context registry reads only private single-link artifacts" +} + +test_private_artifact_publisher_runs_under_system_bash() { + local home out rc + home="$TMP_ROOT/private-publisher-system-bash"; mkdir -p "$home" + [ -x /bin/bash ] || { pass "private artifact publisher compatibility check skipped without /bin/bash"; return 0; } + out=$(/bin/bash -c \ + '. "$1/bin/fm-x-lib.sh"; printf "%s\n" "{\"request_id\":\"req-bash\"}" | fmx_private_artifact_publish_stdin "$2/state/x-outbox" req-bash.json 600' \ + _ "$ROOT" "$home"); rc=$? + expect_code 0 "$rc" "private artifact publisher under /bin/bash" + [ -z "$out" ] || fail "private artifact publisher must stay silent under /bin/bash" + assert_present "$home/state/x-outbox/req-bash.json" "private artifact publisher must create the artifact under /bin/bash" + [ "$(path_mode "$home/state/x-outbox/req-bash.json")" = 600 ] \ + || fail "private artifact publisher must preserve private file mode under /bin/bash" + pass "private artifact publisher is compatible with the system bash path" +} + test_context_registry_prunes_expired_records() { local home dir fakebin keep preserved legacy malformed future out rc home="$TMP_ROOT/registry-retention" dir="$home/state/x-context" - mkdir -p "$dir" + private_artifact_dir "$dir" keep="$dir/req-keep.json" preserved="$dir/req-iP49shRy-8ue4dtxEo87Yw.json" legacy="$dir/req-legacy.json" @@ -1218,6 +1647,7 @@ test_context_registry_prunes_expired_records() { printf '{not-json\n' > "$malformed" jq -cn '{request_id:"req-future",platform:"x",reply_max_chars:"280",recorded_at:"9999999999999999999"}' \ > "$future" + chmod 600 "$dir/"*.json touch -t 202001010000 "$legacy" "$malformed" "$future" out=$(FMX_NOW_OVERRIDE=1700000000 bash -c \ '. "$1/bin/fm-x-lib.sh"; fmx_context_registry_get "$2" req-keep' _ "$ROOT" "$home/state") @@ -1233,6 +1663,7 @@ test_context_registry_prunes_expired_records() { printf 'FMX_PAIRING_TOKEN=tok-retention\n' > "$home/.env" jq -cn '{request_id:"req-poll-expired",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \ > "$dir/req-poll-expired.json" + private_artifact_file "$dir/req-poll-expired.json" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 "$ROOT/bin/fm-x-poll.sh"); rc=$? expect_code 0 "$rc" "poll retention sweep exit" @@ -1240,6 +1671,7 @@ test_context_registry_prunes_expired_records() { assert_absent "$dir/req-poll-expired.json" "a recurring empty poll must prune expired registry records" jq -cn '{request_id:"req-short-window",platform:"x",reply_max_chars:"280",recorded_at:1699999899}' \ > "$dir/req-short-window.json" + private_artifact_file "$dir/req-short-window.json" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \ FMX_FOLLOWUP_MAX_AGE_SECS=100 FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 \ "$ROOT/bin/fm-x-poll.sh"); rc=$? @@ -1247,6 +1679,7 @@ test_context_registry_prunes_expired_records() { assert_absent "$dir/req-short-window.json" "a smaller configured follow-up window must prune earlier" jq -cn '{request_id:"req-overlong-window",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \ > "$dir/req-overlong-window.json" + private_artifact_file "$dir/req-overlong-window.json" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \ FMX_FOLLOWUP_MAX_AGE_SECS=999999999 FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 \ "$ROOT/bin/fm-x-poll.sh"); rc=$? @@ -1416,12 +1849,13 @@ test_regression_unresolved_followup_fails_safe() { # request_id, so a Discord reply stays one message. test_followup_partial_registry_uses_relay_budget_live() { local home fakebin log out rc reply data - home="$TMP_ROOT/reg-relay-fallback"; mkdir -p "$home/state/x-context" + home="$TMP_ROOT/reg-relay-fallback"; private_artifact_dir "$home/state/x-context" fakebin=$(make_fake_curl "$home") log="$home/curl.log" printf 'FMX_PAIRING_TOKEN=tok-rf\n' > "$home/.env" jq -cn '{request_id:"req-relay",platform:"discord",reply_max_chars:""}' \ > "$home/state/x-context/req-relay.json" + private_artifact_file "$home/state/x-context/req-relay.json" reply=$(cat <<'TXT' Aye captain, that one is shipped and green. The change is landed, the regression guard is in place, and nothing else was disturbed along the way. This confirmation deliberately runs past a single X tweet so it proves the relay-recovered Discord budget keeps it one message. TXT @@ -1486,9 +1920,10 @@ TXT test_dismiss_clears_context_registry() { local home out rc reg - home="$TMP_ROOT/dismiss-clears-registry"; mkdir -p "$home/state/x-context" + home="$TMP_ROOT/dismiss-clears-registry"; private_artifact_dir "$home/state/x-context" reg="$home/state/x-context/req-dis.json" jq -cn '{request_id:"req-dis",platform:"discord",reply_max_chars:""}' > "$reg" + private_artifact_file "$reg" # A dismissed mention will never get a follow-up, so its context is dropped. out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-dis 2>/dev/null); rc=$? expect_code 0 "$rc" "dismiss registry-clear exit" @@ -1558,6 +1993,40 @@ test_dismiss_dry_run_needs_no_token() { pass "fm-x-dismiss dry-run works without a token" } +test_dismiss_dry_run_outbox_private_publication_rejects_unsafe_paths() { + local home out rc err target + + home="$TMP_ROOT/dismiss-outbox-linked-dir"; mkdir -p "$home/state" "$home/external" + err="$home/err.txt" + ln -s "$home/external" "$home/state/x-outbox" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>"$err"); rc=$? + [ "$rc" -ne 0 ] || fail "dismiss dry-run must reject a linked outbox directory" + [ -z "$out" ] || fail "rejected dismiss outbox must not echo the request_id (got: $out)" + assert_grep "cannot write dry-run outbox" "$err" "dismiss dry-run must report the linked outbox write failure" + assert_absent "$home/external/req-x.json" "dismiss dry-run must not write through a linked outbox directory" + + home="$TMP_ROOT/dismiss-outbox-linked-dest"; mkdir -p "$home/state/x-outbox" + err="$home/err.txt" + chmod 700 "$home/state/x-outbox" + target="$home/external-target.json" + printf 'external sentinel\n' > "$target" + ln -s "$target" "$home/state/x-outbox/req-x.json" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>"$err"); rc=$? + [ "$rc" -ne 0 ] || fail "dismiss dry-run must reject a linked outbox destination" + [ "$(cat "$target")" = "external sentinel" ] || fail "dismiss dry-run must not write through a linked outbox destination" + [ -L "$home/state/x-outbox/req-x.json" ] || fail "dismiss dry-run must not replace a rejected linked destination" + assert_no_private_artifact_temps "$home/state/x-outbox" + + home="$TMP_ROOT/dismiss-outbox-private-success"; mkdir -p "$home" + out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>/dev/null); rc=$? + expect_code 0 "$rc" "dismiss private outbox success exit" + [ "$out" = "req-x" ] || fail "dismiss dry-run must still echo the request_id after private publication (got: $out)" + [ "$(path_mode "$home/state/x-outbox")" = 700 ] || fail "dismiss dry-run must create the outbox directory as private" + [ "$(path_mode "$home/state/x-outbox/req-x.json")" = 600 ] || fail "dismiss dry-run must publish the outbox file as private" + assert_no_private_artifact_temps "$home/state/x-outbox" + pass "fm-x-dismiss dry-run publishes outbox records only through private guarded artifacts" +} + test_dismiss_non_2xx_fails() { local home fakebin out rc err home="$TMP_ROOT/dismiss-500"; mkdir -p "$home" @@ -1648,11 +2117,12 @@ test_link_records_request_and_timestamp() { test_link_records_discord_platform_for_followups() { local home meta out rc reply - home="$TMP_ROOT/link-discord-platform"; mkdir -p "$home/state/x-inbox" + home="$TMP_ROOT/link-discord-platform"; private_artifact_dir "$home/state/x-inbox" meta="$home/state/fix-discord.meta" printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta" jq -cn '{request_id:"req-discord-follow",tweet_id:"discord:channel:message",reply_max_chars:1900,text:"question"}' \ > "$home/state/x-inbox/req-discord-follow.json" + private_artifact_file "$home/state/x-inbox/req-discord-follow.json" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \ "$ROOT/bin/fm-x-link.sh" fix-discord req-discord-follow >/dev/null; rc=$? expect_code 0 "$rc" "Discord link exit" @@ -2199,9 +2669,11 @@ test_poll_empty_env_token_overrides_env_file test_poll_204_is_silent test_poll_empty_env_relay_overrides_env_file test_poll_auth_error_reports_once +test_poll_error_private_publication_rejects_unsafe_paths test_poll_question_stashes_and_marks test_poll_preserves_conversation_context test_poll_inbox_commit_failure_reports_error +test_poll_inbox_private_publication_rejects_unsafe_paths test_poll_empty_text_is_silent test_poll_rejects_unsafe_request_id test_reply_success_posts_request_bound_only @@ -2216,12 +2688,14 @@ test_reply_dry_run_needs_no_token test_reply_dry_run_from_env_file test_reply_empty_env_dry_run_overrides_env_file test_reply_dry_run_fails_when_outbox_unwritable +test_reply_dry_run_outbox_private_publication_rejects_unsafe_paths test_split_thread_lib test_reply_single_no_texts test_reply_thread_dry_run test_reply_discord_inbox_uses_discord_budget test_reply_x_inbox_still_uses_x_budget test_reply_inbox_explicit_limit_wins +test_reply_rejects_unsafe_inbox_context_reads test_reply_max_chars_floor_clamps_to_minimum test_reply_thread_live_posts_texts test_reply_image_live_posts_image_object @@ -2239,6 +2713,9 @@ test_reply_followup_dry_run_marks_endpoint test_reply_followup_thread_dry_run test_reply_followup_image_dry_run_marks_endpoint_and_compacts_image test_poll_records_context_registry_from_relay_platform +test_context_registry_private_publication_rejects_unsafe_paths +test_context_registry_rejects_unsafe_reads +test_private_artifact_publisher_runs_under_system_bash test_context_registry_prunes_expired_records test_context_registry_preserves_first_seen_timestamp test_context_registry_retention_starts_on_successful_live_answer @@ -2251,6 +2728,7 @@ test_dismiss_clears_context_registry test_dismiss_success_posts_request_only test_dismiss_dry_run_records_not_posts test_dismiss_dry_run_needs_no_token +test_dismiss_dry_run_outbox_private_publication_rejects_unsafe_paths test_dismiss_non_2xx_fails test_dismiss_transport_failure_fails test_dismiss_unsafe_request_id_rejected @@ -2282,6 +2760,7 @@ test_followup_usage_errors test_bootstrap_activates_on_env_token test_bootstrap_reports_missing_x_dependency test_bootstrap_does_not_announce_when_arm_fails +test_bootstrap_does_not_follow_x_artifact_symlinks test_bootstrap_inert_without_token test_bootstrap_opt_out_cleanup test_bootstrap_opt_out_reports_cleanup_failure diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index 8a01217c4..1dbef60d4 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -260,7 +260,7 @@ SH wait_for_exit() { local pid=$1 limit=${2:-50} i=0 while [ "$i" -lt "$limit" ]; do - if ! kill -0 "$pid" 2>/dev/null; then + if ! is_live_non_zombie "$pid"; then wait "$pid" return "$?" fi