Skip to content

fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated - #3147

Merged
kunchenguid merged 4 commits into
kunchenguid:mainfrom
3264studios:fm/declared-pause-busy-wedge-afk-handoff
Aug 27, 2026
Merged

fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated#3147
kunchenguid merged 4 commits into
kunchenguid:mainfrom
3264studios:fm/declared-pause-busy-wedge-afk-handoff

Conversation

@3264studios

Copy link
Copy Markdown
Contributor

Intent

Re-raise upstream, as a fork PR superseding PR 2215, the reduced watcher-only fix: while away mode is active, a busy pane that crosses the busy-turn bound under a DECLARED wait must be handed to the away-mode daemon undecorated, instead of being wedge-escalated.

Fix (bin/fm-watch.sh, busy_turn_bound_check): read the declaration before the afk branch. When away mode is active and the crew declared the wait (a paused: external wait or a verified captain-held transfer), hand off the PLAIN window identity and let the daemon classify, rather than running the wedge timer and handing the daemon a wake already decorated as a possible wedge (that decoration outranks the daemon's own pause verdict, so the ladder climbed once per FM_STALE_ESCALATE_SECS for the whole declared wait). The one-shot MUST be keyed on the DECLARATION (the status log signature), never on the pane hash: a busy pane's harness footer ticks on every capture, so a hash-keyed one-shot re-fires every poll and the daemon, which relaunches the watcher after each handled wake, is woken in a loop for the entire declared wait. On handoff, clear .stale-since-, .wedge-escalations- and the write-deferral chain exactly as the normal-mode absorber does, so an undeclared busy phase's timer does not resume when the declaration lifts. Leave normal-mode pause bookkeeping unwritten, because the daemon owns it in away mode. Normal-mode behaviour is unchanged, and lifting the declaration still restores the busy-pane wedge escalation.

Tests (tests/fm-watch-triage.test.sh): the regression must drive the signal apart. A TICKING pane fixture whose fake tmux renders a fresh footer on EVERY capture-pane, across at least 5 re-arms, must produce exactly ONE wake, and each round must prove the pane hash actually moved before asserting silence so the case cannot pass vacuously. Keep the static-pane case too, plus the undecorated-handoff and the lifted-declaration-restores-escalation cases.

Docs (docs/architecture.md): scope the away-mode busy-bound handoff to declared waits and record the declaration keying and the bookkeeping clearing.

Constraints: this is firstmate's own shared tracked material, so follow the firstmate-coding-guidelines skill (one-owner rule, one sentence per line in tracked Markdown, plain dash never an em dash, shellcheck-clean bin scripts, tests colocated in tests/ extending the existing suite, never an agent co-author trailer).

Explicitly OUT OF SCOPE and deferred, do not implement here: the two contested daemon behaviours (that a busy pane must not clear a still-declared wait's pause marker, and that a declared pause should outrank the enriched wedge reason inside the daemon). Upstream tests currently assert the opposite of both; they go to ONE upstream issue, not this PR. No new guarantee, subsystem, or daemon behaviour change is authorised.

Delivery: this is an outside contribution to kunchenguid/firstmate. The gate is initialised with --fork-url, so the branch is pushed to the fork (3264studios/firstmate) and the pull request is opened against kunchenguid/firstmate main. Never push to origin/upstream directly. The PR supersedes the stale, hand-raised PR #2215 (#2215), which carried these same watcher changes plus two daemon changes that are deliberately excluded here; the PR body should say it supersedes #2215. The repository's Require no-mistakes check requires the pipeline's own signature line and v1 attestation in the PR body, bound to the pushed head.

What Changed

  • bin/fm-watch.sh (busy_turn_bound_check): the declaration is now read before the afk branch. While away mode is active and the crew declared the wait (a paused: external wait or a verified captain-held transfer), a busy pane past the busy-turn bound is handed to the daemon as the plain stale: <window> identity instead of running the wedge timer and decorating the wake as a possible wedge. The one-shot is keyed on the declaration (declared:<status-log signature> in .stale-<key>) rather than the pane hash, so a harness footer that ticks on every capture wakes the daemon once per distinct declaration, not once per poll. On handoff the watcher clears .stale-since-<key>, .wedge-escalations-<key> and the write-deferral chain exactly as handle_paused_stale does, and leaves normal-mode .paused-<key> bookkeeping unwritten since the daemon owns it in away mode. Normal-mode behaviour and the undeclared away-mode wedge escalation are unchanged.
  • tests/fm-watch-triage.test.sh: two new regressions. test_afk_busy_declared_pause_hands_off_plain_stale pins the undecorated handoff on a static pane, silence on re-arm, and that lifting the declaration on the same afk, busy, over-age pane restores the wedge escalation. test_afk_busy_declared_pause_ticking_pane_hands_off_once uses a fake tmux that renders a fresh footer on every capture-pane, seeds a pre-existing wedge timer, escalation count and write-deferral marker, asserts they are cleared on the first handoff, then runs five re-arms that each prove the pane hash moved (and .count-<key> stayed at 0) before asserting no second wake, and finally drains the queue to confirm no extra stale row was queued.
  • docs/architecture.md: scopes the away-mode busy-bound handoff to declared waits and records the declaration keying and the bookkeeping clearing.

Supersedes #2215, which carried these same watcher changes plus two daemon changes that are deliberately excluded here and deferred to a separate upstream issue.

Risk Assessment

✅ Low: A small, well-bounded watcher-only change whose only altered cell (declared pause under away mode) traces correctly through the caller for static, ticking, lifted-declaration and afk-lifted paths, with normal mode and the daemon untouched, backed by non-vacuous behavioural tests; the sole note is a known deferred daemon consequence.

Testing

Ran the nine targeted watcher triage cases (the two new away-mode declared-pause cases plus the normal-mode, idle-afk, undeclared-busy-escalation and captain-held siblings) from the worktree and all pass; proved the regression by running the new cases against archives of the base commit (fails with the wedge-decorated wake climbing to demand-deep-inspection) and the intermediate hash-keyed commit (ticking case fails); and captured a per-round manual transcript of the ticking-footer scenario across 6 watcher launches showing base 3 decorated wakes, hash-keyed 6 wakes, target exactly 1 plain wake keyed on the declaration signature with clean wedge bookkeeping. No UI surface is involved, so there is no visual artifact; the docs change is prose and was not executed.

Evidence: Evidence summary (before/after table + all transcripts)

Source: Evidence summary (before/after table + all transcripts)

# Away-mode busy declared-pause handoff: before/after evidence

Scenario (all runs): away mode active (.afk), pi harness busy, spawn record past FM_BUSY_TURN_MAX_SECS,
status log declares 'paused: hosting the Lavish review, awaiting captain feedback', fake tmux renders a
fresh harness footer on EVERY capture-pane. One watcher launch + 5 re-arms (what the daemon does after each handled wake).

| commit | wakes / 6 launches | wake payload | wedge bookkeeping |
|---|---|---|---|
| base 60bedde (bug) | 3 | `stale: <win> (idle Ns, possible wedge, escalation N ...)` climbing to demand-deep-inspection | timer + escalation count climb |
| 09293e8 (hash-keyed one-shot) | 6 | plain `stale: <win>` on EVERY re-arm (wake loop) | clean |
| target 2a46b89 | 1 | plain `stale: <win>` once; .stale-<key> = declared:<status sig>; silent on 5 re-arms while the hash moves | clean |

## ticking-demo-base.txt
`` `
## base-60bedde: watcher = /tmp/fm-afk-busy-test.iQQ0ze/base/bin/fm-watch.sh
## fixture: afk active, pi harness busy, status='paused: hosting the Lavish review, awaiting captain feedback', footer ticks every capture
round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: present | escalations: none | normal-mode .paused marker: absent
round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout (idle 3s, possible wedge, escalation 1)
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: absent | escalations: 1 | normal-mode .paused marker: absent
round 3 (re-arm): captures 2->3, pane hash 1ebcf2477027 -> 17524d6eeacc | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: present | escalations: 1 | normal-mode .paused marker: absent
round 4 (re-arm): captures 3->4, pane hash 17524d6eeacc -> 0893b03cb830 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout (idle 2s, possible wedge, escalation 2)
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: absent | escalations: 2 | normal-mode .paused marker: absent
round 5 (re-arm): captures 4->5, pane hash 0893b03cb830 -> 65da6f2775e9 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: present | escalations: 2 | normal-mode .paused marker: absent
round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout (idle 3s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone)
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: absent | escalations: 3 | normal-mode .paused marker: absent
## base-60bedde result: 3 wake(s) across 6 watcher launches (1 launch + 5 re-arms)
`` `

## ticking-demo-hashkeyed.txt
`` `
## hash-keyed-09293e8: watcher = /tmp/fm-afk-busy-test.iQQ0ze/hashkeyed/bin/fm-watch.sh
## fixture: afk active, pi harness busy, status='paused: hosting the Lavish review, awaiting captain feedback', footer ticks every capture
round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = aebccbdb14ddd078e092db47951a1ae1
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 1ebcf2477027d4cbc928aeef0a2e5f06
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 3 (re-arm): captures 2->3, pane hash 1ebcf2477027 -> 17524d6eeacc | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 17524d6eeacc1b473bb7a3e8faa073b4
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 4 (re-arm): captures 3->4, pane hash 17524d6eeacc -> 0893b03cb830 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 0893b03cb8308ee9708b9d6613e1eafd
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 5 (re-arm): captures 4->5, pane hash 0893b03cb830 -> 65da6f2775e9 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 65da6f2775e9a089896bec3e452c3239
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = a5a0491b16816f1e3b2dd527d2bb3dc6
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
## hash-keyed-09293e8 result: 6 wake(s) across 6 watcher launches (1 launch + 5 re-arms)
`` `

## ticking-demo-target.txt
`` `
## target-2a46b89: watcher = /Users/talon/.no-mistakes/worktrees/9b0bfac143a1/01M10ASF2Z826A1E2TV9AEX0E0/bin/fm-watch.sh
## fixture: afk active, pi harness busy, status='paused: hosting the Lavish review, awaiting captain feedback', footer ticks every capture
round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 3 (re-arm): captures 2->3, pane hash 1ebcf2477027 -> 17524d6eeacc | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 4 (re-arm): captures 3->4, pane hash 17524d6eeacc -> 0893b03cb830 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 5 (re-arm): captures 4->5, pane hash 0893b03cb830 -> 65da6f2775e9 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
## target-2a46b89 result: 1 wake(s) across 6 watcher launches (1 launch + 5 re-arms)
`` `

## New regression tests against base (expected to fail before the fix)
`` `
=== test_afk_busy_declared_pause_hands_off_plain_stale
not ok - the away-mode busy-turn bound did not hand off the plain window identity: stale: test:fm-afk-review-scout (idle 1s, possible wedge, escalation 1)
=== test_afk_busy_declared_pause_ticking_pane_hands_off_once
not ok - the away-mode busy-turn bound did not hand off the plain window identity for a ticking pane: stale: test:fm-afk-ticking-scout (idle 501s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone)
`` `

## New regression tests against the hash-keyed intermediate 09293e8
`` `
=== test_afk_busy_declared_pause_hands_off_plain_stale
ok - away mode hands a busy declared pause to the daemon as a plain stale, and lifting the declaration restores the wedge escalation
=== all targeted cases passed
=== test_afk_busy_declared_pause_ticking_pane_hands_off_once
not ok - the away-mode handoff left the undeclared phase's wedge timer in place
`` `

## Targeted tests on target 2a46b89 (run from the worktree)
`` `
=== test_afk_busy_declared_pause_hands_off_plain_stale
ok - away mode hands a busy declared pause to the daemon as a plain stale, and lifting the declaration restores the wedge escalation
=== test_afk_busy_declared_pause_ticking_pane_hands_off_once
ok - away mode wakes the daemon once per declaration for a busy pane whose footer ticks on every capture
=== test_busy_declared_pause_is_rechecked_not_wedge_escalated
ok - a busy pane under a declared pause is rechecked on the long cadence, and lifting the pause restores the wedge escalation
=== test_afk_paused_changed_pane_hands_off_plain_stale
ok - AFK changed paused panes hand off plain stale identities for daemon-owned pause triage
=== test_afk_present_reverts_watcher_to_one_shot
ok - with .afk present the watcher reverts to one-shot so the daemon owns triage (no double-triage)
=== test_busy_pane_changing_hash_escalates_past_turn_age_bound
ok - a busy worker whose pane hash changes every poll still escalates once its completed-turn age reaches the bound
=== test_busy_pane_stable_hash_escalates_past_turn_age_bound
ok - a busy worker with a stable pane hash still escalates once its completed-turn age reaches the bound
=== test_secondmate_captain_held_resurfaces_in_normal_mode
ok - a captain-held secondmate re-surfaces on the bounded normal-mode cadence
=== test_nonterminal_stale_paused_absorbed_then_resurfaced
ok - a declared pause is absorbed on first sight, then re-surfaced as a recheck past the threshold, never wedge-escalated
=== all targeted cases passed
`` `
Evidence: Ticking-pane E2E transcript - target 2a46b89 (1 wake / 6 launches, declaration-keyed one-shot)

Source: Ticking-pane E2E transcript - target 2a46b89 (1 wake / 6 launches, declaration-keyed one-shot)

round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | WAKE -> watcher exited watcher stdout: stale: demo:fm-afk-ticking-scout .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565 wedge timer: absent | escalations: none | normal-mode .paused marker: absent round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | silent (watcher still polling after a full cycle) watcher stdout: <empty> ... round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | silent (watcher still polling after a full cycle) watcher stdout: <empty> .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565 wedge timer: absent | escalations: none | normal-mode .paused marker: absent ## target-2a46b89 result: 1 wake(s) across 6 watcher launches (1 launch + 5 re-arms)

## target-2a46b89: watcher = /Users/talon/.no-mistakes/worktrees/9b0bfac143a1/01M10ASF2Z826A1E2TV9AEX0E0/bin/fm-watch.sh
## fixture: afk active, pi harness busy, status='paused: hosting the Lavish review, awaiting captain feedback', footer ticks every capture
round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 3 (re-arm): captures 2->3, pane hash 1ebcf2477027 -> 17524d6eeacc | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 4 (re-arm): captures 3->4, pane hash 17524d6eeacc -> 0893b03cb830 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 5 (re-arm): captures 4->5, pane hash 0893b03cb830 -> 65da6f2775e9 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = declared:61:1787792128.337380565
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
## target-2a46b89 result: 1 wake(s) across 6 watcher launches (1 launch + 5 re-arms)
Evidence: Ticking-pane E2E transcript - base 60bedde (bug: wedge ladder climbs under a declared wait)

Source: Ticking-pane E2E transcript - base 60bedde (bug: wedge ladder climbs under a declared wait)

round 2 (re-arm): ... WAKE -> watcher exited watcher stdout: stale: demo:fm-afk-ticking-scout (idle 3s, possible wedge, escalation 1) round 4 (re-arm): ... WAKE -> watcher exited watcher stdout: stale: demo:fm-afk-ticking-scout (idle 2s, possible wedge, escalation 2) round 6 (re-arm): ... WAKE -> watcher exited watcher stdout: stale: demo:fm-afk-ticking-scout (idle 3s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone) ## base-60bedde result: 3 wake(s) across 6 watcher launches (1 launch + 5 re-arms)

## base-60bedde: watcher = /tmp/fm-afk-busy-test.iQQ0ze/base/bin/fm-watch.sh
## fixture: afk active, pi harness busy, status='paused: hosting the Lavish review, awaiting captain feedback', footer ticks every capture
round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: present | escalations: none | normal-mode .paused marker: absent
round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout (idle 3s, possible wedge, escalation 1)
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: absent | escalations: 1 | normal-mode .paused marker: absent
round 3 (re-arm): captures 2->3, pane hash 1ebcf2477027 -> 17524d6eeacc | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: present | escalations: 1 | normal-mode .paused marker: absent
round 4 (re-arm): captures 3->4, pane hash 17524d6eeacc -> 0893b03cb830 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout (idle 2s, possible wedge, escalation 2)
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: absent | escalations: 2 | normal-mode .paused marker: absent
round 5 (re-arm): captures 4->5, pane hash 0893b03cb830 -> 65da6f2775e9 | silent (watcher still polling after a full cycle)
   watcher stdout: <empty>
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: present | escalations: 2 | normal-mode .paused marker: absent
round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout (idle 3s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone)
   .stale-demo_fm-afk-ticking-scout = <absent>
   wedge timer: absent | escalations: 3 | normal-mode .paused marker: absent
## base-60bedde result: 3 wake(s) across 6 watcher launches (1 launch + 5 re-arms)
Evidence: Ticking-pane E2E transcript - intermediate 09293e8 hash-keyed one-shot (daemon wake loop)

Source: Ticking-pane E2E transcript - intermediate 09293e8 hash-keyed one-shot (daemon wake loop)

round 1 (first launch): ... WAKE -> watcher exited stale: demo:fm-afk-ticking-scout .stale-<key> = aebccbdb14dd... round 2 (re-arm): pane hash aebccbdb14dd -> 1ebcf2477027 | WAKE -> watcher exited stale: demo:fm-afk-ticking-scout round 3 (re-arm): ... WAKE round 4 (re-arm): ... WAKE round 5 (re-arm): ... WAKE round 6 (re-arm): ... WAKE ## hash-keyed-09293e8 result: 6 wake(s) across 6 watcher launches (1 launch + 5 re-arms)

## hash-keyed-09293e8: watcher = /tmp/fm-afk-busy-test.iQQ0ze/hashkeyed/bin/fm-watch.sh
## fixture: afk active, pi harness busy, status='paused: hosting the Lavish review, awaiting captain feedback', footer ticks every capture
round 1 (first launch): captures 0->1, pane hash - -> aebccbdb14dd | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = aebccbdb14ddd078e092db47951a1ae1
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 2 (re-arm): captures 1->2, pane hash aebccbdb14dd -> 1ebcf2477027 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 1ebcf2477027d4cbc928aeef0a2e5f06
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 3 (re-arm): captures 2->3, pane hash 1ebcf2477027 -> 17524d6eeacc | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 17524d6eeacc1b473bb7a3e8faa073b4
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 4 (re-arm): captures 3->4, pane hash 17524d6eeacc -> 0893b03cb830 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 0893b03cb8308ee9708b9d6613e1eafd
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 5 (re-arm): captures 4->5, pane hash 0893b03cb830 -> 65da6f2775e9 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = 65da6f2775e9a089896bec3e452c3239
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
round 6 (re-arm): captures 5->6, pane hash 65da6f2775e9 -> a5a0491b1681 | WAKE -> watcher exited
   watcher stdout: stale: demo:fm-afk-ticking-scout
   .stale-demo_fm-afk-ticking-scout = a5a0491b16816f1e3b2dd527d2bb3dc6
   wedge timer: absent | escalations: none | normal-mode .paused marker: absent
## hash-keyed-09293e8 result: 6 wake(s) across 6 watcher launches (1 launch + 5 re-arms)
Evidence: New regression cases against base commit (fail before the fix)

Source: New regression cases against base commit (fail before the fix)

=== test_afk_busy_declared_pause_hands_off_plain_stale not ok - the away-mode busy-turn bound did not hand off the plain window identity: stale: test:fm-afk-review-scout (idle 1s, possible wedge, escalation 1) === test_afk_busy_declared_pause_ticking_pane_hands_off_once not ok - the away-mode busy-turn bound did not hand off the plain window identity for a ticking pane: stale: test:fm-afk-ticking-scout (idle 501s, possible wedge, escalation 3, demand-deep-inspection: ...)

=== test_afk_busy_declared_pause_ticking_pane_hands_off_once
not ok - the away-mode busy-turn bound did not hand off the plain window identity for a ticking pane: stale: test:fm-afk-ticking-scout (idle 501s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone)
Evidence: Targeted fm-watch-triage cases on target (all pass)

Source: Targeted fm-watch-triage cases on target (all pass)

=== test_afk_busy_declared_pause_hands_off_plain_stale
ok - away mode hands a busy declared pause to the daemon as a plain stale, and lifting the declaration restores the wedge escalation
=== test_afk_busy_declared_pause_ticking_pane_hands_off_once
ok - away mode wakes the daemon once per declaration for a busy pane whose footer ticks on every capture
=== test_busy_declared_pause_is_rechecked_not_wedge_escalated
ok - a busy pane under a declared pause is rechecked on the long cadence, and lifting the pause restores the wedge escalation
=== test_afk_paused_changed_pane_hands_off_plain_stale
ok - AFK changed paused panes hand off plain stale identities for daemon-owned pause triage
=== test_afk_present_reverts_watcher_to_one_shot
ok - with .afk present the watcher reverts to one-shot so the daemon owns triage (no double-triage)
=== test_busy_pane_changing_hash_escalates_past_turn_age_bound
ok - a busy worker whose pane hash changes every poll still escalates once its completed-turn age reaches the bound
=== test_busy_pane_stable_hash_escalates_past_turn_age_bound
ok - a busy worker with a stable pane hash still escalates once its completed-turn age reaches the bound
=== test_secondmate_captain_held_resurfaces_in_normal_mode
ok - a captain-held secondmate re-surfaces on the bounded normal-mode cadence
=== test_nonterminal_stale_paused_absorbed_then_resurfaced
ok - a declared pause is absorbed on first sight, then re-surfaced as a recheck past the threshold, never wedge-escalated
=== all targeted cases passed
Evidence: Manual demo script used for the per-round transcripts

Source: Manual demo script used for the per-round transcripts

#!/usr/bin/env bash
# Manual E2E demo: away mode + busy pane past FM_BUSY_TURN_MAX_SECS + declared `paused:`
# wait, with a fake tmux whose harness footer ticks on EVERY capture-pane.
# Runs one watcher launch, then 5 re-arms (what the daemon does after each handled
# wake), printing per round what the daemon would see. Usage: demo.sh <repo-root> <label>
set -u
ROOT_DIR=$1; LABEL=$2
. "$ROOT_DIR/tests/.fm-watch-triage-defs.sh"   # test fixtures (make_case, wait_poll_cycle, ack_stopped_cycle, ...)
dir=$(make_case "demo-$LABEL"); state="$dir/state"; fakebin="$dir/fakebin"
out="$dir/watch.out"; window="demo:fm-afk-ticking-scout"; ticks="$dir/ticks"
statusf="$state/afk-ticking-scout.status"
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
case "${1:-}" in
  list-windows) [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "${FM_FAKE_TMUX_WINDOW#*:}"; exit 0 ;;
  capture-pane)
    n=$(( $(cat "$FM_FAKE_TMUX_TICKS" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$FM_FAKE_TMUX_TICKS"
    printf 'Working... (%d.%ds) lavish-axi poll' "$(( 7200 + n ))" "$(( n % 10 ))"; exit 0 ;;
  display-message) case "$*" in *pane_current_command*) printf '%s\n' "${FM_FAKE_TMUX_CURRENT_COMMAND:-}"; exit 0 ;; esac ;;
esac
exit 1
SH
chmod +x "$fakebin/tmux"
printf 'window=%s\nkind=scout\nharness=pi\n' "$window" > "$state/afk-ticking-scout.meta"
record_pi_busy "$state" afk-ticking-scout
printf 'paused: hosting the Lavish review, awaiting captain feedback\n' > "$statusf"
printf '%s' "$(seen_sig "$statusf")" > "$state/.seen-afk-ticking-scout_status"
key=$(printf '%s' "$window" | tr ':/.' '___')
touch -t 200001010000 "$state/afk-ticking-scout.meta"     # spawn record far past the bound
date '+%s' > "$state/.afk"                                   # away mode active
echo "## $LABEL: watcher = $ROOT_DIR/bin/fm-watch.sh"
echo "## fixture: afk active, pi harness busy, status='$(cat "$statusf")', footer ticks every capture"
wakes=0
for round in 1 2 3 4 5 6; do
  prev_hash=$(cat "$state/.hash-$key" 2>/dev/null || echo '-'); prev_ticks=$(cat "$ticks" 2>/dev/null || echo 0)
  : > "$out"
  PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_TICKS="$ticks" \
    FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \
    FM_FAKE_CREW_STATE='state: working · source: pane · harness busy (pi-ext)' \
    FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_PAUSE_RESURFACE_SECS=999 \
    FM_POLL=0.2 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \
    "$ROOT_DIR/bin/fm-watch.sh" > "$out" &
  pid=$!
  if wait_poll_cycle "$state" "$pid"; then verdict="silent (watcher still polling after a full cycle)"; reap "$pid"
  else verdict="WAKE -> watcher exited"; fi
  cur_hash=$(cat "$state/.hash-$key" 2>/dev/null || echo '-'); cur_ticks=$(cat "$ticks" 2>/dev/null || echo 0)
  [ -s "$out" ] && wakes=$((wakes + 1))
  printf 'round %d (%s): captures %s->%s, pane hash %s -> %s | %s\n' "$round" \
    "$([ "$round" = 1 ] && echo 'first launch' || echo 're-arm')" "$prev_ticks" "$cur_ticks" \
    "${prev_hash:0:12}" "${cur_hash:0:12}" "$verdict"
  printf '   watcher stdout: %s\n' "$([ -s "$out" ] && cat "$out" || echo '<empty>')"
  printf '   .stale-%s = %s\n' "$key" "$(cat "$state/.stale-$key" 2>/dev/null || echo '<absent>')"
  printf '   wedge timer: %s | escalations: %s | normal-mode .paused marker: %s\n' \
    "$([ -e "$state/.stale-since-$key" ] && echo present || echo absent)" \
    "$(cat "$state/.wedge-escalations-$key" 2>/dev/null || echo none)" \
    "$([ -e "$state/.paused-$key" ] && echo present || echo absent)"
  ack_stopped_cycle "$state" >/dev/null 2>&1 || true
done
echo "## $LABEL result: $wakes wake(s) across 6 watcher launches (1 launch + 5 re-arms)"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-watch.sh:672 - Consequence to state explicitly in the PR body and the deferred upstream issue, not a defect in this PR: with the declaration-keyed one-shot, a busy declared wait in away mode wakes the daemon exactly once. The daemon records .subsuper-paused-<task>, and after PAUSE_RESURFACE_SECS its housekeeping (bin/fm-supervise-daemon.sh:1057-1059, stale_window_is_busy -> rm -f &#34;$marker&#34;) drops that marker because the pane is busy. Nothing then re-surfaces the wait until the pane idles or the status log's signature changes, so the pre-fix noisy escalation becomes silence for the rest of the wait rather than the bounded recheck normal mode gives via handle_paused_stale. This is precisely the first contested daemon behaviour the intent defers upstream, so no change is authorised here; recommending only that the PR body/upstream issue name this trade-off so reviewers do not read the single wake as a bounded cadence.
✅ **Test** - passed

✅ No issues found.

  • tests/fm-watch-triage.test.sh targeted cases on the worktree via a transient runner (removed afterwards): test_afk_busy_declared_pause_hands_off_plain_stale, test_afk_busy_declared_pause_ticking_pane_hands_off_once, test_busy_declared_pause_is_rechecked_not_wedge_escalated, test_afk_paused_changed_pane_hands_off_plain_stale, test_afk_present_reverts_watcher_to_one_shot, test_busy_pane_changing_hash_escalates_past_turn_age_bound, test_busy_pane_stable_hash_escalates_past_turn_age_bound, test_secondmate_captain_held_resurfaces_in_normal_mode, test_nonterminal_stale_paused_absorbed_then_resurfaced - all pass
  • Regression proof: the two new cases run against a git archive of base 60bedde with the target test file dropped in - both fail with the decorated possible wedge wake (ticking case reaches escalation 3, demand-deep-inspection)
  • Regression proof: the two new cases against a git archive of intermediate commit 09293e8 (hash-keyed one-shot) - static case passes (cannot discriminate), ticking case fails (left the undeclared phase&#39;s wedge timer in place)
  • Manual E2E per-round transcript ticking-pane-demo.sh (afk + busy pi pane past bound + declared pause + footer ticking on every capture-pane, 1 launch + 5 daemon-style re-arms) against base, 09293e8, and the target worktree: 3 decorated wakes / 6 plain wakes / 1 plain wake respectively, with pane hash shown moving every round and .stale-&lt;key&gt; = declared:&lt;sig&gt; on target
  • git status --porcelain clean after removing the transient runner and temp archives
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…rated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.
@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge, with no actionable changed-code defect identified.

The watcher’s new branch is limited to over-age busy panes under verified declarations in away mode, deduplicates on the declaration, preserves undeclared escalation, and is covered across static, ticking, cleanup, and declaration-lift scenarios.

Reviews (1): Last reviewed commit: "no-mistakes(document): docs: note afk bu..." | Re-trigger Greptile

@3264studios

Copy link
Copy Markdown
Contributor Author

Both workflow runs on this PR are sitting in action_required with zero jobs
(runs 33028698319 CI and 33028698326 Require no-mistakes) - that is GitHub's
first-time-contributor gate holding pull_request workflows until a maintainer
clicks "Approve and run", not a red run. Whenever you get to it, that approval is
all they need.

Deliberately not re-pushing to try to kick them: a new head would restart the
approval wait and re-key the attestation this PR's body is bound to.

Run locally at 1cec093 in the meantime, if it saves you a cycle:

Check Result
bin/fm-lint.sh on the changed files (pinned ShellCheck 0.11.0, extended analysis) clean
bin/fm-lint-workflows.sh (pinned actionlint 1.7.12) 3 workflow files valid
bin/fm-test-run.sh --check-coverage ok, total=165
tests/fm-watch-triage.test.sh full suite exit 0, 62 ok, 0 not ok
require-no-mistakes/verify.py at the pinned action SHA, against this body and head compliant=true, exempt=false

The regression is worth a skim even if the rest is not: the away-mode handoff is
keyed on the declaration rather than the pane capture, so the ticking-footer
fixture is the case that actually discriminates. Reverting just that one line
makes the static-pane case still pass while the ticking case fails at re-arm 2.

The two daemon-side behaviours this PR deliberately leaves alone are raised as a
question in #3149 rather than changed here.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

First stamp this pass. VISION.md read in full from current main 22fa6ed90b0585280db8a87501c5d093ee40830a. Whole thread read (author fork-CI ask; Greptile success). Full diff reviewed: bin/fm-watch.sh (busy_turn_bound_check), docs/architecture.md, tests/fm-watch-triage.test.sh. No .github/workflows/*. Not disguised security. 3264studios is not blocked.

Class: corrective. Away-mode busy-turn bound was decorating a declared wait as a possible wedge and keying the one-shot on the pane hash, so a ticking footer re-woke the daemon every poll. This hands the plain stale: <window> identity once per declaration and clears wedge bookkeeping. Normal-mode and undeclared away-mode escalation are unchanged. Inspected the afk+declared branch, the declaration-keyed .stale-$key, and the two new tests (static pane + ticking-footer discriminator).

VISION (per rule, inspected evidence):

  • One captain, one interface — aligns. Stops a false wedge climb on a crew-declared wait; the interface stays honest. Evidence: busy_turn_bound_check afk+declared path + the two new tests.
  • Authority is explicit — aligns. No new capability; honors an existing declaration; undeclared still escalates. Evidence: Phase C in test_afk_busy_declared_pause_hands_off_plain_stale restores the wedge when the declaration lifts.
  • Scripts own the mechanics — aligns. Deterministic watcher classification. Evidence: bin/fm-watch.sh only.
  • A restart is a non-event — aligns. One-shot stored in .stale-$key as declared:<sig>.
  • Delegation with a spine — aligns. Strengthens a classification path without weakening undeclared escalation.
  • The fleet outlives any vendor — aligns. Footer-tick is the incident; the key is the declaration, not a harness pixel.
  • Scope — aligns. Watcher command layer. Daemon behavior is explicitly out of scope.

Does not close #3149. Body is watcher-only; the two daemon behaviors (busy pane clearing a still-declared pause marker; enriched wedge reason outranking a declared pause) are deferred. Confirmed from the body and the three-file diff (no fm-supervise-daemon.sh).

First-time fork CI: after that full-diff review I confirm approval of workflow runs 33028698319 (CI, in_progress) and 33028698326 (Require no-mistakes, success) for HEAD 1cec09347d5c73235dc4c65b1381416bf7050088. No security risk.

Not merging. Attestation matches this HEAD. MERGEABLE / UNSTABLE: portable serial 1–4 still pending. Overlap note: docs/architecture.md also moves in standing AFK hold #3090; tests/fm-watch-triage.test.sh also moves in #3135 (reap bound only, different hunk).

This is waiting on CI, not on the captain.

Merge-eligible: NO.

@kunchenguid
kunchenguid merged commit 99c1a0d into kunchenguid:main Aug 27, 2026
14 checks passed
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is merged. Thank you @3264studios — really appreciate you taking the time on this.

Class: corrective. Away-mode was wedge-escalating a busy pane that had already declared the wait, decorating the wake so the daemon's pause verdict lost. The watcher now hands the plain window identity to the daemon once per declaration (keyed on the status-log signature, not the ticking pane hash) and clears the undeclared-phase wedge bookkeeping. Normal-mode and undeclared away-mode escalation are unchanged.

VISION (per rule, inspected evidence):

  • One captain, one interface — aligns. A declared wait is no longer mis-surfaced as a possible wedge under afk. Evidence: bin/fm-watch.sh busy_turn_bound_check afk+declared branch; tests test_afk_busy_declared_pause_hands_off_plain_stale and test_afk_busy_declared_pause_ticking_pane_hands_off_once.
  • Authority is explicit — aligns. Honors the crew's own paused: / captain-held declaration; no new autonomy grant. Evidence: declaration is read before the afk branch; lifting it restores wedge escalation (Phase C).
  • Scripts own the mechanics — aligns. Deterministic one-shot on the declaration signature.
  • A restart is a non-event — aligns. Durable .stale-<key> suppressor plus cleared .stale-since / .wedge-escalations / write-deferral.
  • Delegation with a spine — aligns. Strengthens a refusal/exception path the architecture already documented.
  • The fleet outlives any vendor — aligns. Watcher mechanics, not a harness lock-in.
  • Scope — aligns. Watcher-only; daemon behaviour is explicitly out of scope.

Does not close #3149 (daemon-side busy-overrides-declaration questions, unlabeled, deliberately deferred). Supersedes already-closed #2215. No .github/workflows/*. Not disguised security.

Gates: attestation 1cec09347d5c73235dc4c65b1381416bf7050088 matched HEAD; forge CI green after first-time fork approval of runs 33028698319 (CI) and 33028698326 (Require no-mistakes); merge CLEAN. Squash-merged as 99c1a0dc82aa354e33597860c6c9ddd6c11fd8dc.

akrcdev added a commit to akrcdev/firstmate that referenced this pull request Aug 29, 2026
* fix(bin): require project clone roots during fleet sync (#2849)

* fix(bin): require a clone root before fleet-sync touches a project

Git repository discovery walks upward, so `git -C projects/<dir>` on a plain
directory nested under projects/ resolves to the enclosing repository - in a
firstmate home, the firstmate checkout itself. fm-fleet-sync.sh guarded its
candidates with `rev-parse --is-inside-work-tree`, which such a directory
passes, so every later git call read, pruned and fast-forwarded firstmate's own
default branch and reported it under the project directory's label. A running
session's AGENTS.md changed underneath it, and the report named a project that
had nothing to do with the change.

Require each candidate to be the root of its own work tree before any other git
command: compare `rev-parse --show-toplevel` against the directory's own
physical path. Both sides are physical, so a symlinked clone still compares
equal. Anything else is skipped by name, naming the repository that would have
been touched, and bootstrap relays that as a FLEET_SYNC line.

Regression coverage reproduces the wrong-repo fast-forward against a home nested
inside another repository, in both the whole-fleet and single-project forms, and
pins that a symlinked clone dir still syncs.

* no-mistakes(review): Keep enclosing fixture clean during clone-root regression

* fix(bin): retry transient Lavish poll interruptions (#2846)

* fix(procevent): retry a transient Lavish poll interruption quietly

A live Lavish listener can be cut short by the server with exactly

    error: Lavish Editor poll response was interrupted
    code: SERVER_ERROR

while the session's marks remain available. Firstmate registered raw
`lavish-axi poll` output, so the generic process-event runner captured
that transient response as a result and woke the whole fleet over what is
really an internal retry.

The Lavish adapter now registers its own listener command, which reruns
the published blocking poll up to 12 times at 5 second intervals for that
one exact two-line response. The match is deliberately narrow: real
feedback, ended and missing sessions, any other SERVER_ERROR, and the same
interruption still standing once the bound is spent all pass straight
through and are captured and announced as before. The retry is a Lavish
fact, so the generic runner stays adapter-agnostic.

`FM_LAVISH_POLL_RETRY_DELAY` is a bounded 0 to 60 second override for the
interval only, refused rather than rounded when malformed, so a test can
exercise the real bound without waiting it out.

* no-mistakes(review): Harden Lavish retry matching, validation, and cleanup

* no-mistakes(review): Bound Lavish retry staging and stabilize regression

* no-mistakes(document): docs: explain Lavish retry adoption

* no-mistakes(lint): Restore Lavish trap ShellCheck suppression

* fix(brief): stop the documented {TASK} fill from corrupting the Herdr gate (#2838)

The unguarded Herdr declaration quoted `{TASK}` in its own prose while the
scaffold instructs firstmate to replace every `{TASK}` placeholder. The
documented global replace therefore spliced the whole task body into the
middle of the safety gate's sentence, silently destroying the one contract
that exists precisely because the scaffold cannot inspect the task text.

Reword the gate to refer to the task text filled in above, leaving the
placeholder only at its genuine fill site. Rewording rather than renaming the
token keeps the unfilled-charter guards in fm-home-seed.sh and
fm-remote-home-seed.sh working unchanged.

Add a regression test that performs the documented global fill on ship and
scout scaffolds and asserts the body lands once and the gate survives.

* fix(bin): resolve the busy-state lock mtime with the platform's own stat form (#2837)

The writer lock's stale-lock branch read the lock's mtime with
`stat -f %m ... || stat -c %Y ...`. On GNU coreutils `-f` is filesystem
stat, so it consumed the format string as a path, complained on stderr,
printed a partial filesystem dump ("  File: ...") on stdout, and still
exited 0. The GNU form in the fallback therefore never ran, and the
following arithmetic evaluated the word `File`, aborting the writer under
`set -u` with "File: unbound variable".

fm-teardown.sh died there after returning the worktree, leaving
state/<id>.meta, .status, .busy-gen, .busy-state, .busy-state.lock/ and
.turn-ended behind. The surviving metadata kept the watcher monitoring an
endpoint whose agent was gone, so a finished task produced stale wakes
forever, and every re-run died identically because the abandoned lock was
never broken.

Detect the platform once and pick the right stat form, the pattern
bin/fm-watch.sh already documents, and treat any non-numeric result as
"just created" so a future portability surprise degrades to a lock-timeout
refusal rather than killing teardown mid-way.

* fix(stow): add opt-in pass horizon for memory decay (#2850)

* fix(stow): give memory decay a per-pass horizon so the clock fires

The tiered decay clocks were wall-clock only, while admission is per-pass:
each /stow admits the findings that pass produced. In a home that stows
daily those two rates diverge by the stow cadence, an entry the fleet keeps
exercising never reaches 30 days unreinforced, and memory only grows while
the pass reports decay evaluated.

Give each dated marker an optional unreinforced-pass counter and make both
tiers stale at whichever horizon comes first: 10 passes or 30 days for
aging, 3 passes or 7 days for perishable. Reinforcement clears the counter
and nothing else does, so the existing evidence-based restamp rule stays
the only way an entry renews its lease. An absent /N means zero, so entries
that stay exercised carry no extra marker bytes, and a rarely stowed home
keeps its current behaviour through the unchanged date horizon.

* no-mistakes(document): Align stow workflow with dual decay clocks

* fix(stow): make the per-pass decay horizon opt-in

The unreinforced-pass horizon shipped as a new default archival cadence,
which is a product default rather than a restoration of the existing
wall-clock contract. Keep the 30-day and 7-day horizons as the only
default clock, and put the 10-pass and 3-pass horizons behind an explicit
opt-in: config/stow-pass-horizon for the firstmate home, and the file's
own header pointer for the public skill.

With the opt-in absent no counter is written and no counter is read, so a
home that does not ask for it decays exactly as it does today.

* no-mistakes(review): Preserve frozen counters and correct archive provenance

* test(watcher): stop fixture confirmation budgets racing real child startup (#2876)

tests/fm-watcher-lock.test.sh passed in isolation but failed intermittently
under full-suite and ambient concurrent load. bin/fm-watch-arm.sh computes its
confirmation deadline immediately after forking the real child watcher, so the
child's entire fork, exec, lock acquisition and beacon publication has to land
inside that wall clock. Two cases shrank that budget to one second, leaving a
two-second window for work measured at 3.1-4.9s under CPU oversubscription, so
the arm honestly reported "FAILED - no live watcher with a fresh beacon" and
their premises collapsed. A third case ran on the production budget, but its
child must also execute a registered check before exiting: measured at 1.9-2.3s
idle and 9.1-13.1s under load, against an 11s budget.

The two cases that must confirm a real child now hold the arm to production's
own budget instead of a shrunken fixture one, the immediate-wake case gets an
explicit budget with headroom over its measured loaded cost, and the two waits
for the arm's typed failure are sized off the largest production default rather
than a fixed eight seconds.

No bin/ change and no default behavior change: the lock's fail-closed semantics,
SIGSTOP handling, stale-heartbeat detection and the arm's typed failures are
untouched. Verified 4/4 green at 3x CPU oversubscription (loadavg 75-80) after
3/3 red before the change, and CONTRIBUTING.md records the convention.

* fix(bin): deterministically order remote tool paths (#2870)

* fix(bin): order discovered tool installs by the shell's own expansion

fm_remote_job_compose_operator_path built the asdf and mise install
directories with `compgen -G`, which does not sort. Bash sorts glob
matches in pathexp.c, on the shell's own pathname-expansion path only;
`compgen -G` reaches the same glob_filename through pcomplete.c, which
sorts nothing. On bash 3.2 (macOS /bin/bash) and every bash before 5.3
that handed the composition raw readdir order, so which install of a
multi-version tool a remote job resolved was decided by directory order
on disk rather than by this composition.

Expand the globs at the call sites and let the function take the matches,
so the composition and the documented portable-PATH contract are the same
operation. Quoting the account home at the call site also stops a home
whose name contains glob metacharacters from being reinterpreted.

The colocated regression pins both the order and the mechanism: bash 5.3
moved sorting into the glob library, so an order-only assertion cannot
see the defect there.

* no-mistakes(review): Remove source-reading PATH regression guard

* fix(bin): prevent routed secondmate work from stranding (#2848)

* fix: surface stalled secondmate queues and wake handoffs

* no-mistakes(review): Make handoff wakes retryable and stall alerts crash-safe

* no-mistakes(review): Prevent duplicate handoff wakes and cover remote delivery

* no-mistakes(review): Serialize local handoffs and preserve pre-move wake intent

* no-mistakes(review): Serialize teardown with handoffs and retain remote wake confirmation

* no-mistakes(review): Reconcile correlated handoff wake delivery after crashes

* no-mistakes(review): Keep failed wakes retryable and isolate stall receipts

* no-mistakes(review): Reset known-undelivered wake attempts for durable retries

* no-mistakes(review): Refuse duplicate sends for unresolved delivery attempts

* no-mistakes(review): Atomically restore retryability after reconciled send failures

* no-mistakes(review): Serialize delivery confirmation with reconciliation

* no-mistakes(document): Document routed wake and stall supervision

* no-mistakes(lint): Fix ShellCheck expansion and subshell warnings

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(review): Retire stale wake state and defer pre-move wakes

* no-mistakes(review): Secure markers, bind batches, and preserve teardown routes

* no-mistakes(review): Preserve unresolved prepared wakes across unrelated handoffs

* no-mistakes(review): Preserve prepared wakes before unrelated moving handoffs

* no-mistakes(document): Document prepared wake batch ownership

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(review): Make local wake retirement recoverable

* no-mistakes(document): Clarify handoff recovery and teardown documentation

* fix: make macOS inbox test path portable (#2857)

* feat(bin): deliver local steers through durable task inboxes (#2856)

* feat(bin): steer local tasks by durable inbox record plus constant doorbell

Stage 1 (local steers) of the captain-adopted reframe in
data/fm-send-reliability-reframe-s1/report.md: an ordinary fm-send text
steer to a task recorded in this home is appended as a sequenced durable
record under state/<id>.inbox/ and the terminal receives only one constant
self-describing doorbell line, best-effort. The worker acknowledges by
moving the record into handled/; the watcher re-rings an unacknowledged
message on an idle pane and escalates once as an ordinary stale wake.
--resolve-key closes decisions at enqueue time, because the durable
enqueue IS delivery to the task's record. bin/fm-task-inbox-lib.sh owns
the record format, doorbell line, and re-ring ladder.

The typed plane remains for what must reach the terminal itself:
lifecycle keys, harness-native slash and codex $-skill invocations,
explicit backend targets, and the remote secondmate leg (unchanged until
the remote inbox leg ships separately). The composer classifier is
demoted from delivery proof to an advisory ring guard that skips only on
a proven pending verdict.

Verified live against claude, codex, opencode, pi, grok, and muse: each
real worker read its record, acted, and acked with the mv
(docs/verification/runtime-backends.md "Steering-inbox doorbell").

* docs(verification): flag the grok 1.0.5 composer-matrix staleness observed by the doorbell run

* test(captain-hold): read the chat-channel answer from the durable inbox record

* test: migrate fm-control's marker contrast to the inbox record and fix macOS wc padding in the tool-update suite

* no-mistakes(review): Harden inbox locking, teardown races, and acknowledgements

* no-mistakes(review): Serialize watcher actions with inbox acknowledgements

* no-mistakes(review): Bound metadata locking and tighten acknowledgement rechecks

* no-mistakes(review): Preserve exact inbox bytes and harden delivery recovery

* no-mistakes(review): Harden watcher bookkeeping against concurrent inbox teardown

* no-mistakes(document): Update inbox and typed-plane documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* revert(pipeline): keep parser-native secondmate marking and the both-failed exit out of stage 1

The CI monitor's fix changed the secondmate marking contract for
parser-native invocations (appending the marker after the text) and
softened the both-commit-and-marker-failed branch to exit 0. The merge
authority ruled the marking question out of scope for this stage-1
transport PR (follow-up: fm-send-secondmate-harness-invocation-r1) and
ruled the both-failed case a loud nonzero local failure. Restore both,
keeping the monitor's legitimate migrations and hardening.

* no-mistakes(document): Document inbox and typed-plane boundaries

* no-mistakes(document): Scope backend transport docs to typed plane

* no-mistakes(document): Clarify inbox attempt-budget documentation

* no-mistakes: apply CI fixes

* fix(send): the durable record alone governs the inbox exit status

Captain-refined ruling on the F2/Greptile finding: the durable inbox
record is what delivers the steer, so pending-reply bookkeeping trouble
after a successful enqueue never exits nonzero - a resend-inviting status
would make automated callers enqueue the delivered instruction again
under a new sequence. With the recovery marker stored the watcher
reconciles silently; with the commit and marker both lost the send
surfaces a distinct reply-tracking-degraded do-not-resend warning and
still exits 0. Nonzero remains only where nothing was delivered (or a
decision close needs its manual command). Regression: record durable +
both bookkeeping writes lost -> exit 0, one record, no duplicate.

* no-mistakes(review): Preserve inbox ordering with drain-all doorbells

* no-mistakes(review): Surface unwritable inbox ladder bookkeeping

* no-mistakes(review): Silence ladder failures after inbox acknowledgement

* no-mistakes(document): Update steering inbox documentation

* no-mistakes: apply CI fixes

* feat(bin): add fast local lint mode (#2891)

* feat: add fast local lint mode

* fix: preserve complete fm-lint help

* fix: isolate fast lint mode

* no-mistakes(document): Clarify lint mode documentation ownership

* no-mistakes: apply CI fixes

* feat(bin): deliver remote steers through durable inboxes (#2901)

* feat(bin): deliver remote secondmate steers through durable task inboxes

Stage 2 of the inbox+doorbell steer channel (stage 1: #2856). A remote
secondmate steer now crosses fm-on.sh as a durable record written
idempotently into the remote home's steering inbox plus a best-effort
remote doorbell, and the last typed-payload steer transport is deleted:

- fm-remote-secondmate-control.sh cmd_send writes the record via the new
  fm_task_inbox_write_idempotent and rings the doorbell; it no longer
  types the payload through an inner fm-send at an explicit pane target.
- fm-send.sh routes every remote text steer (harness-native included,
  which marking already reduced to chat) onto the remote inbox leg,
  retries the identical leg once on ssh 255, closes --resolve-key
  decisions at enqueue for remote too, and preserves a marked request's
  reply expectation when completion stays unknown. The exit-3-as-
  delivered remap, the 255 do-not-resend trap, and the remote typed
  submit block are removed.
- fm-task-inbox-lib.sh owns the idempotent enqueue: an exact-body re-run
  lands on the existing record, handled or not, so an ambiguous
  transport can always be safely re-run.
- Tests pin the new contract end to end (record + doorbell + no typed
  payload across ssh, one-record idempotence under an ambiguous
  transport, enqueue-time decision close, loud real failures, and the
  deleted typed-payload behaviors gone), and AGENTS.md plus
  docs/remote-secondmates.md describe the remote leg's new semantics.

* no-mistakes(review): Harden remote inbox delivery against lifecycle races

* no-mistakes(review): Enable correlation-preserving remote steer resends

* no-mistakes(review): Fail closed on stale correlation resends

* no-mistakes(review): Include home context in remote resend commands

* no-mistakes(review): Lock and revalidate remote parent routes

* no-mistakes(document): Clarify remote steer retry documentation

* no-mistakes: apply CI fixes

* feat: add persistent Pi supervision branch (#2858)

* wip: forked supervision on Pi (checkpoint before docs)

* fix(pi-branch): harden mirror delivery, fallback encoding, and session replacement

Peek-then-shift mirror flush so a failed append retries instead of dropping;
durable mirror cursor commits only after delivery into the branch;
the main fallback wake is operational-encoded like every watcher injection;
session_shutdown quiesces the generation and session_start re-arms, so /new
and /resume no longer kill the branch permanently. Registers the extension in
the strict typecheck, adds the dispatch handshake test, the branch extension
suite, the bash-level regression suite, the session-start replay test, and
the opt-in real-SDK live guard.

* test(fixtures): carry the branch-dispatch lib and lease lib into isolated fixtures

The watcher extension now imports lib/fm-branch-dispatch.ts and fm-teardown
sources fm-lease-lib.sh, so every fixture that copies or symlinks those
files in isolation gains the new sibling.

* no-mistakes(review): Prevent shutdown wake loss and serialize lease claims

* no-mistakes(review): Durably hand off wakes and retain portable leases

* no-mistakes(review): Require durable reports and clear disposed branch leases

* no-mistakes(review): Enforce per-wake outcomes and quiescent lease cleanup

* no-mistakes(review): Require wake acknowledgements and tighten branch lifecycle boundaries

* no-mistakes(review): Require complete acknowledgements and replay cleanup failures

* no-mistakes(review): Bind supervision to lock ownership and durable delivery

* no-mistakes(review): Activate branch lazily after session lock acquisition

* no-mistakes(review): Preserve undelivered mirror context across extension rebinds

* no-mistakes(review): Acknowledge startup replay only after main delivery

* no-mistakes(review): Isolate replay metadata from untrusted digest content

* no-mistakes(review): Reject duplicate reports for active wake sequences

* no-mistakes(review): Retain failed fallbacks and deduplicate outcome replay

* no-mistakes(review): Deduplicate durable outcomes and cache delivery receipts

* no-mistakes(review): Anchor wake sequence matching to outcome fields

* no-mistakes(document): Clarify Pi supervision durability contracts

* no-mistakes(lint): Fix ShellCheck issues in branch supervision scripts

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* refactor(pi-branch): collapse to confused-agent-grade guards per captain decision

Captain decision A: the lease/actor guards target the CONFUSED-AGENT threat
model bin/fm-gate-refuse-lib.sh already documents; adversarial-grade
separation is impossible in the shared-process design and is filed as
separate follow-up work. Rip out the machinery that chased it: the
generation fence and shell-provenance markers, the wrapper-tagged ancestry
walks, guard auto-claim with per-script release traps, the pending-wake
files and ack-receipt correlation (the durable wake queue already
re-presents anything unacknowledged), the delivery-receipt store with
contiguous cursor advancement, the session-start replay-metadata channel,
and the branch tool quiescence counters.

Keep the behaviors the board requires, each on its simplest implementation:
lazy per-action session-lock ownership (cold start activates after the lock
lands; a secondary session stays inert), mirror durability across extension
rebinds via the durable cursor, replay-exactly-once from the one read
cursor, the awaited operational-encoded fallback, per-generation stray-lease
cleanup, session-lock-bound lease liveness (a recycled pid or a non-Pi home
never honors a leftover lease), the loud accidental-override guards
(readonly actor prelude, cross-actor claim refusal), and the role-partition
refinements (no forced teardown, no direct relaunch for the branch).
Default-on-for-Pi is unchanged.

* no-mistakes(review): Enforce lock ownership and serialize lease mutations

* no-mistakes(review): Synchronize guard cleanup and bind leases to lock owner

* no-mistakes(review): Report outcomes before acknowledging durable wakes

* no-mistakes(review): Restrict leases to Pi and instruct main claims

* no-mistakes(review): Reject malformed lease locks and torn outcome tails

* no-mistakes(review): Validate complete outcome tails before appending

* no-mistakes(review): Guard branch side effects across session replacements

* no-mistakes(document): Update Pi supervision durability and lease documentation

* no-mistakes(lint): Suppress intentional nested-shell expansion warning

* no-mistakes: apply CI fixes

* fix(pi-branch): authorize lease releases by caller

* fix(lint): break redundant source-analysis path in fm-lease-lib.sh

fm-lease-lib.sh's lazy fallback source of fm-wake-lib.sh gave ShellCheck's
--external-sources traversal a second path into an already 1540-line file
that fm-send.sh and fm-teardown.sh also source directly, blowing up the
recursive analysis past CI's lint timeout. Mark it a source=/dev/null
analysis boundary, matching the existing fm-task-inbox-lib.sh convention.

Also restores bin/fm-lint.sh and tests/fm-lint.test.sh to the shared
serial-lint definition (dropping an unrelated parallel-sharding change
that was itself hanging and masked this root cause).

* no-mistakes(document): Correct lease caller-authorization documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* fix(bin): parallelize startup network sweeps (#2927)

* feat(bin): parallelize session-start remote secondmate network sweeps

Run per-secondmate liveness and convergence probes concurrently and overlap clone refresh, while replaying each mate's fail-closed diagnostic in original order. Ignore scratchpad* so untracked scratch no longer blocks remote sync.

Co-authored-by: Cursor <cursoragent@cursor.com>

* no-mistakes(document): Document parallel startup network sweeps

* no-mistakes(lint): Fix empty environment assignment lint warning

* no-mistakes: apply CI fixes

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: handle absent watcher wake queues (#2845)

* fix(tests): count declared-pause wakes without crashing on an absent queue

The exited-declared-pause case counts queued stale wakes by handing
state/.wake-queue straight to awk. A watcher that queues nothing never
creates that file, and awk aborts on a missing path before its END rule
runs, so the count collapses to the empty string. The next comparison
then fails as an integer-expression error and surfaces as a wake flood
with no number, hiding the real contract breach the following grep names.

Read the queue the way the drain-count assertion at the end of this file
already does: silence awk's open error and default an absent queue to
zero. Applied to all four counts in this case, including the live
external-decision gate pair whose queue an acknowledged drain can also
leave behind. An absent queue now reports "did not use the bounded
paused recheck", while a genuine flood still fails with its real count.

Fixes #2628

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* style(pi): distinguish routine and captain supervision merge notes by icon (#2934)

* style(pi): restyle supervision merge notes with a sailboat and matching pad

Secondary-session notes were flush against the TUI edge and fully tinted.
Use the sailboat prefix, Pi's default outputPad, boat-only color, and dim remainder so they sit like real messages.

* style(pi): distinguish routine and captain merge notes by icon only

Visible notes now lead with a sailboat or anchor, then only the dim outcome.
Drop the branch-merged wording and verdict brackets so the icon is the only kind signal.

* docs(pi): add the approved multi-brain architecture poster (#2938)

The markdown contract stays the owner; the still is only the visual of the idea.

* feat(pi): default branch supervision and route heartbeats (#2939)

* fix(bin): bound remote job worker supervisor restarts (#2942)

* fix(bin): bound remote worker supervisors

* no-mistakes(review): release incumbent supervisor before starting its replacement

* no-mistakes(review): wait out a healthy same-root supervisor instead of replacing it

* no-mistakes(review): narrow remote worker change to restart accounting only

* no-mistakes(document): clarify supervisor restart guard is a lifetime total

* fix: safely split supervision wake handling by actor (#2953)

* feat(bin,pi): per-actor wake consume, silent success gating, merge-poll dedup

Three related fixes to the shared wake-drain and Pi supervision-branch
dispatch machinery so a routine success is never main-blocking and a
mixed queue can safely split between actors.

1. Successful routine results no longer create main-blocking wake rows.
   fm-startup-network.sh only enqueues a check: startup-network wake when
   the deferred result is actionable (state is not "done", or the report
   carries a bootstrap-diagnostics actionable prefix); a clean success
   stays durable in the report file without ever waking the agent.

2. Per-actor wake-drain consume contract. bin/fm-wake-drain.sh now scopes
   presentation and --ack-through to the current actor
   (bin/fm-lease-lib.sh's fm_lease_actor): main keeps the original
   whole-queue cutoff behavior, unaffected. A branch actor
   (FM_SUPERVISION_ACTOR=branch, set only inside the Pi supervision
   branch's own bash tool calls) is scoped to an explicit eligible-row
   snapshot instead of a cutoff comparison, so it can never remove a row
   it was not granted - the fix for the swallow risk that used to force
   an all-or-nothing whole-queue fallback to main.
   .pi/extensions/lib/fm-branch-dispatch.ts's scopeForUnreadWake is the
   single owner of eligibility: a check-kind row (merge-confirmation
   polls, Relay mentions, credential/auth failures) is now excluded
   rather than vetoing the whole scan for a non-heartbeat wake, while a
   heartbeat review keeps its original all-or-nothing rule unchanged.
   writeEligibleRowsSnapshot publishes the exact eligible sequence
   numbers before every branch prompt; fm-primary-pi-watch.ts's offer
   still refuses a check-kind trigger outright so a main-only close is
   never itself routed to the branch.

3. A repeat identical merged-PR-poll result for an already-notified task
   is absorbed instead of enqueued again. A poll's own retirement state
   is scoped to one registration and cannot see a prior registration's
   outcome, so a task re-registered after its merge was already surfaced
   would otherwise wake main a second time for the same event.
   bin/fm-pr-lib.sh's new per-task pr-poll-merge-notified marker survives
   across re-registrations to catch that case; the first notification for
   a task still reaches main unchanged.

Regression tests colocated in tests/fm-startup-network.test.sh,
tests/fm-wake-queue.test.sh (including the mixed-queue no-swallow
property), tests/fm-pi-branch-extension.test.sh, and
tests/fm-pr-check-security.test.sh. docs/watcher-continuity.md and
docs/pi-supervision-branch.md updated for the new contracts.

* no-mistakes(review): Bind merge deduplication to canonical PR identity

* no-mistakes(review): Serialize wake row ownership across main and branch

* no-mistakes(review): Bind branch grants and deduplicate within actor claims

* no-mistakes(review): Fallback main-owned wake claims to main delivery

* no-mistakes(review): Clarify silent startup success guidance

* no-mistakes(review): Release residual branch grants after settled prompts

* no-mistakes(review): Reject truncated wake rows as corrupted

* no-mistakes(document): Document per-actor routing and silent startup success

* no-mistakes(lint): Fix ShellCheck findings in wake grant and startup test

* no-mistakes: apply CI fixes

* fix(pi): hide branch outcomes tool rows in Calm (#3024)

* Hide branch outcome tool in Pi Calm

* no-mistakes(review): Preserve stock outcomes rendering and document tool audit

* no-mistakes(review): Document branch read tool audit disposition

* no-mistakes(review): Match stock outcomes output sanitization

* no-mistakes(document): Document Calm custom-tool visibility

* fix: bind no-mistakes attestations to PR head (#3027)

* fix: delegate no-mistakes PR gate to pinned action

* no-mistakes(document): Document commit-bound no-mistakes attestations

* feat(pi): add persistent supervision branch model selection (#3028)

* feat(pi): let operators pin a cheaper supervision-branch model

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's model. A new /supervision-model
command opens Pi's own selector over Pi's own catalog of credentialed
models, plus a "Follow main" entry, and persists the pick as one
<provider>/<model-id> line in this home's gitignored
config/supervision-branch-model. Firstmate keeps no model catalog of its
own.

The branch resolves the pin at every branch build - the first wake of a
cold start and the reopen after /new, /resume, /fork, or reload - so the
choice survives all of them, and picking also releases the live branch so
the next wake reopens the same persistent branch conversation under the
new model. An absent, unreadable, or unparseable file means no pin and
keeps today's behavior byte for byte: no model option is passed and Pi
picks the branch's model exactly as before.

A pin naming a model Pi cannot hand back is never silently downgraded
onto main's model: the branch refuses to build and the wake falls back to
the captain-facing main path naming the unusable pin, which is the
extension's existing failure direction.

The choice is home-local and not part of secondmate inherited
configuration, matching the Pi Calm preference precedent.

docs/configuration.md owns the operator-facing schema. Portable
regressions cover pin-present on create and reopen, pin-absent default,
the command's persistence, cancellation, and live rebind, and both
unusable and unparseable pins. The opt-in real-SDK guard proves the
vendor surface the pin reads and that an explicit model wins over the
model a reopened session recorded.

* no-mistakes(review): Fix supervision model runtime and rebind races

* no-mistakes(review): Restrict supervision picker to isolated runtime models

* no-mistakes(document): Document supervision branch model selection

* fix(pi): make the supervision model pin authoritative on every reopen

Clearing the pin with "Follow main" removed the file but the next branch
build reopened the persistent branch session with no explicit model
override, so Pi restored the model that session had recorded - the old
pinned model - while the command reported that the branch now follows
main. The same gap meant an absent pin did not reliably mean
same-model-as-main once a home had pinned once.

The pin file's current state now decides the model on every branch build,
create and reopen alike, overriding Pi's session-state restore. With a
pin, that model. With no pin, main's own current model is applied
explicitly, tracked from the contexts Pi already hands the extension plus
its model_select event, since the branch is built at wake time with no
context of its own. Only when main's model is unknown, or this home's
stored credentials cannot run it in the isolated branch runtime, does a
build fall back to passing no override at all, which is the behavior from
before the pin existed; the branch is never refused over model choice.

The command's notification now reports the model actually applied, and
says plainly when clearing the pin could not apply main's model instead
of claiming a change that did not take effect.

No credential handling changes: the branch still relies entirely on the
stored credentials its own runtime already holds, and the picker stays
restricted to models that runtime can resolve.

Colocated regressions cover pin present on create and reopen, clearing
the pin returning a reopened branch to main's model and specifically not
the old pinned one, an unparseable pin behaving as no pin, and the
unknown-main-model fallback to no override.

* no-mistakes(review): Make unpinned supervision follow main model changes

* no-mistakes(document): Correct supervision model documentation

* feat(pi): let /supervision-model pick branch reasoning effort (#3079)

* feat(pi): let /supervision-model pick the branch's reasoning effort

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's reasoning effort any more than
it needs main's model. /supervision-model now settles both in one flow:
the existing model picker, then a follow-up effort picker built from Pi's
own supported thinking levels for the model just chosen. Firstmate keeps
no effort catalog of its own; the menu, the clamp, and the vocabulary all
come from Pi.

The pick persists as one line in this home's gitignored
config/supervision-branch-effort, independent of the model pin: a captain
may pin a model, an effort, both, or neither. The effort pin's current
state decides the branch effort on every branch build - the first wake of
a cold start and the reopen after /new, /resume, /fork, or reload - and
overrides Pi's restore of whatever level a reopened branch session
recorded, which is what keeps "Follow main" honest. With no pin, main's
own current effort is applied explicitly and followed live through Pi's
thinking_level_select event, the same way an unpinned branch already
follows main's model, and the two selections now share one build revision
so either change invalidates an in-flight build.

The branch is never refused over effort. Pi owns the clamp, so a pinned
level the branch's model cannot run becomes that model's nearest supported
level while the captain's raw pick is kept for a model that supports it,
and the command reports the level the branch will really run at rather
than the raw pin. A token Pi would not recognize at all is treated as no
pin rather than passed to that clamp, which would otherwise collapse a
typo into the model's lowest level. Only when main's effort cannot be read
either does a build pass no effort override at all, which is the behavior
from before this file existed.

Pi's own effort vocabulary is pinned by a bidirectional type assertion
against Pi's getThinkingLevel return type, so the tracked strict typecheck
against the installed package fails the moment Pi adds or removes a level.

docs/configuration.md owns the operator-facing schema for both pins.
Portable regressions cover the pin on create and reopen, model-only and
effort-only pins working independently, clearing a pin returning the
branch to main's effort, live-follow of a mid-session change, the clamp,
an unrecognized token, the unknown-main-effort fallback, and the command's
two-step flow, persistence, cancellation, and honest reporting. The opt-in
real-SDK guard proves the vendor surface all of that rests on, and also
repairs a pre-existing gap that left it unable to load the extension at
all.

* no-mistakes(review): Resolve effective branch effort honestly

* no-mistakes(document): Clarify Pi-owned effort picker behavior

* fix: keep routine supervision noise out of captain chat (#3093)

* fix(supervision): silence empty board closes and decouple the heartbeat

Two unrelated sources of noise put routine supervision events in the
captain's chat.

An empty Lavish board close - the captain reads a review surface, says
nothing, and closes it - became a check wake whose entire content was
that nothing happened. Suppress it at its source instead of routing it
anywhere: the generic runner gains a `silent` adapter seam mirroring the
existing `terminal` one, and the Lavish adapter answers it for exactly
one positively-determined shape, an `ended` session carrying no queued
content block. A silenced result is recorded durably handled so it does
not return on a later reconcile. Everything else announces unchanged - a
`Send & End` close carrying the captain's real answer, an `ended` result
still carrying content, a waiting or missing session, an unreadable
result, and every adapter that implements no `silent` command at all.
The keyed-answer feed is untouched, so suppressing an announcement never
suppresses the captain's own answer.

A fleet heartbeat was deferred to main merely because some unrelated
check row happened to be sitting unread, which put a routine fleet
review in the chat for a reason that had nothing to do with the fleet. A
check row is permanently main-owned, so it is now excluded from a
heartbeat claim rather than vetoing the scan, exactly as in every other
mode. What all-or-nothing guarantees is unchanged: the branch takes
every branch-ownable unread row or none of them, and an unresolvable
task-local row, an unknown row kind, or an unreadable queue still defers
the whole review to main. Main is still woken for the check on that
check's own triggering close, so nothing starves.

Main-only classes are unchanged and now each covered by a test: Relay
mentions, credential failures, merge confirmations, real board answers,
and watcher-failure repair. The per-actor acknowledgement and
no-cross-swallow properties are untouched.

* no-mistakes(review): Fail closed on all Lavish content headers

* no-mistakes(review): Suppress false unacknowledged status for silenced results

* fix(bin): stop a correlation token from hiding and stranding decisions (#1967)

* fix(classify): read the decision fold through a correlation token

status_line_verb stripped a trailing [key=...] from a status line's prefix
but left everything else glued to the verb, so a line carrying the
correlation token bin/fm-pending-reply-lib.sh embeds and a secondmate
echoes back matched no arm of _fm_decision_fold_line. Such a line folded as
ordinary status in both directions: a needs-decision or blocked opener never
opened its key, and a resolved or captain-held closer never closed one. The
same glued verb also hid correlated done and blocked lines from
status_is_captain_relevant and status_is_terminal_verb, and let correlated
working and resolved lines leak through the free-text fallback the
nonterminal guard was meant to stop.

The verb parse now walks whole words and drops only a token of the exact
shape a firstmate library writes - corr=<16 hex>, plus the bracketed form
bin/fm-secondmate-report.sh emits - before or after the key token, unkeyed,
or doubled. An arbitrary name=value word is deliberately NOT skipped:
skipping unknown tokens would let free text carrying an equals sign reduce
to a bare verb and impersonate a transition, which is the takeover the
strict parse and _fm_decision_key_transition_allowed exist to prevent. A
prefix with no corr= substring is returned byte-for-byte as before, so every
line without a token keeps its exact historical verb.

FM_OPEN_DECISIONS_FOLD_VERSION goes to 3, because every cursor persisted
under the previous reading carries an open set computed while correlated
lines were invisible and must be rebuilt from byte 0.

Measured over a real 383-line status log: 254 lines keep byte-identical
captain-relevance, pause, terminal-verb and captain-held verdicts, and all
129 changed lines carry a valid token - 14 correlated done/blocked/
needs-decision lines become captain-visible, and 20 correlated
working/resolved lines stop being escalated on prose alone.

* fix(review): Captain, block token-first decision impersonation

* fix(document): Clarify normalized status verb ownership

* fix(classify): reconcile the correlation-token read with the tag-stop parser

Rebasing onto main put this change beside #2280, which made verb parsing
stop at the first "[name=value]" tag. Both edit status_line_verb with
different intents, so the resolution keeps both rules rather than letting
one overwrite the other:

- #2280's tag stop is kept verbatim and now owns every BRACKETED tag,
  including the "[corr=...]" form fm-secondmate-report.sh writes. The
  bracket-unwrapping arm this branch had added to the token test is
  therefore removed as unreachable.
- This branch's token walk is kept and narrowed to the UNBRACKETED token
  fm-pending-reply-lib.sh writes, which the tag stop does not reach.

Two consequences of standing beside #2280 rather than before it:

The fold version had collided at 4: #2280 spent it on the tag-stop
parser and this branch had spent it on the token read. A cursor
persisted under #2280's reading predates this one and must still be
rebuilt, so the version moves to 5.

A bracketed impostor is dropped from the malformed-token list. On main
today "resolved [corr=deadbeef] [key=victim]:" already reads as the bare
verb, as does "resolved [anything at all] [key=victim]:", because the tag
stop ends the parse at the opening bracket regardless of content. That is
#2280's reviewed contract; asserting otherwise here would narrow it. The
unbracketed impostors it owns stay strict and still fold as prose.

Adds a consumer test for the two verb-string case arms that postdate this
branch: fm-supervise-daemon.sh's transient-stale arm and
fm-crew-state.sh's map_log_state.

* fix(review): Captain: Seed cursor migration fixture with version four

* fix(document): Clarify voice status normalization ownership

* fix(bin): Cursor-Park unter Pi-Host ohne Cursor-Identität stilllegen (#3115)

* fix(bin): Cursor-Park unter Pi-Host stilllegen.

pi-cursor-sdk lädt .cursor/hooks.json in die Pi-Sitzung und parkte einen zweiten Watcher; das erzeugte rearm-resurface und brach laufende Rückfragen ab. Bei PI_CODING_AGENT=true beendet der Park sofort, native cursor-agent Primaries bleiben unverändert.

* fix(bin): Cursor-Park trotz PI-Leak nur ohne Cursor-Identität stilllegen.

Stand-down gilt nur bei PI_CODING_AGENT=true ohne CURSOR_AGENT und ohne CURSOR_INVOKED_AS. Handgestartete cursor-agent Primaries mit geerbtem PI-Marker parken weiter.

* no-mistakes(document): Document Cursor park Pi-host stand-down

* fix(bin): no-mistakes-Mindestversion auf 1.46.0 anheben.

Die PR-Attestierung verlangt ab 1.46.0 strukturierte Pipeline-Schritte; der Bootstrap-Floor blieb bei 1.31.2 und ließ zu alte Builds zu.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(pi): make supervision model picker searchable and scrollable (#3099)

* fix(pi): make /supervision-model's model list bounded and searchable

Pi's generic extension selector renders every option at once with no
search box, so a real eligible catalog ran off the top of the terminal.
The model step now draws the same rows through Pi's own SelectList - the
bounded scrolling primitive behind Pi's /model picker - with Pi's own
Input and fuzzy filter above it for search, keeping 'Follow main' first,
the branch-runtime eligibility filter intact, and the pick branch-only.

Pi's ModelSelectorComponent is deliberately not reused: its selection
handler writes the captain's default model through Pi's settings manager,
which would move main's conversation as a side effect of pinning the
branch. The effort step's menu is a handful of levels and stays on Pi's
plain selector dialog.

* no-mistakes(document): Clarify supervision picker documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(document): Document searchable supervision model picker

* no-mistakes: apply CI fixes

* fix(bin): durably report merged pull requests (#3104)

* fix: make a landed merge leave a durable outcome

A merge was the one lifecycle event that left no record outside the
merging agent's memory. bin/fm-pr-merge.sh ended at the forge call, and a
home merging under standing authority never waits for the merge poll that
would otherwise confirm it, so three real merges reached the captain as
silence.

bin/fm-merge-outcome-lib.sh is the single owner of that record. A
secondmate home reports the landed PR upward on the same parent reply
channel its terminal-outcome backstop already uses; a main home records
it on the durable wake queue. The record is at most once per task and
canonical PR identity, and only a merge that actually landed produces one.

The merge poll feeds that same channel when it detects a merge this home
did not perform, so the captain's own forge merge and a merge firstmate
performed itself produce one consistent outcome instead of two reporting
paths. No new state file and no second watch path.

Two smaller gaps from the same failure:

- A mate charter listed its report triggers without naming a landed
  merge. Under standing merge authority nothing is ever "ready for
  review", so the enumerated list silently omitted the case that matters.
- A secondmate home seeded without its parent binding failed every
  terminal-outcome report for the same reason, and the diagnostic never
  named the binding. It does now.

* no-mistakes(review): Harden durable merge outcome reporting

* no-mistakes(review): Make merge race regression deterministic

* no-mistakes(review): Make merge outcomes retry-idempotent and forge-confirmed

* no-mistakes(review): Unify merge publication under canonical outcome marker

* no-mistakes(review): Publish merge outcomes before committing dedup markers

* no-mistakes(review): Document at-least-once merge outcome recovery

* no-mistakes(review): Use supported GitHub confirmation and update recovery docs

* no-mistakes(review): Preserve distinct merge wakes by PR identity

* no-mistakes(document): Document durable merge outcome semantics

* no-mistakes(test): Make merge outcome interleaving test deterministic

* no-mistakes(document): Clarify merge outcome documentation ownership

* fix(lint): keep the merge-outcome library an analysis boundary

bin/fm-watch.sh followed the new merge-outcome library's source graph,
which reaches the wake queue, PR identity, and secondmate parent
libraries. Expanding that inside an already-large lint root pushed
ShellCheck's external-source analysis past the bounded CI lint worker:
the Lint job was killed with SIGTERM after five silent minutes, twice,
having emitted no diagnostics at all.

Make it an analysis boundary, exactly as the transition and inbox owners
directly above and below it already are and for the same stated reason.
Coverage is unchanged because the library is a canonical lint root in its
own right and is still linted as one.

Measured locally: the watcher goes from not terminating within 120s to
9s clean, and the library alone lints in 1s clean.

* fix(bearings): preserve projections through inventory mismatches (#3129)

* fix(bearings): keep an inventory-mismatch home readable, and mark warnings as repairs

A backlog-vs-metadata inventory mismatch inside a secondmate home was being
reported as "we cannot read that home", which discarded that home's open
captain calls, queued work, landed work, and live workers from the whole
Bearings digest. The main home already treats the identical mismatch as a
harmless disclosure; this makes the secondmate path agree.

- fm-fleet-snapshot.sh: the invalidity gate now passes orphan_in_flight,
  unowned_current, and terminal_in_flight through the partial-structured
  carve-out alongside child_current_unavailable, so those homes keep their
  decisions, holds, queued, landed, and live work and leave unreadable[].
  missing_backlog and unstructured_current stay on the discard path, because
  there the backlog itself is untrustworthy.
- fm-fleet-snapshot.sh: the same three kinds no longer collapse the home's own
  classification to "unknown"; the real captain_decision / active_child_work /
  externally_held classification survives and invalidity carries the warning.
  An unavailable child state still collapses it, including when a mismatch
  masks it under strict-invalidity precedence.
- secondmate_landed.partial now keys on partial-structured trust rather than an
  unknown state, so an inventory-mismatch home is still disclosed as partial.

Ask the home that owns the wrong books to fix them:

- bin/fm-secondmate-reconcile.sh sends exactly one reconcile instruction per
  mismatch episode through the ordinary steering transport. A persistent
  mismatch keeps its episode identity and never re-nags; a changed mismatch
  earns one more ask; a repaired one is forgotten so a recurrence is asked
  about again. The parent never touches the mate's own files, and a failed send
  records nothing so the next run retries it.

Give integrity warnings their own look on the board:

- charted rows take an optional kind of "queued" (the default) or "warning".
  A warning badges "needs repair" instead of "waiting" and is excluded from the
  Charted Next count, so alarms stop reading as dispatchable queued work. No
  fifth board section, and every existing payload stays valid.

Tests pin the new policy behaviorally: the retained surfaces and classification
for all three mismatch kinds, the still-discarding unstructured_current and
missing_backlog cases, the once-per-episode reconcile ask through real durable
steering records, and the board rendering exercised through the shipped
template under a minimal DOM shim.

* no-mistakes(review): Make reconcile dedupe atomic and warnings non-dispatchable

* no-mistakes(review): Preserve reconcile identity and reject stale snapshots

* no-mistakes(review): Order snapshots uniquely and canonicalize episode identities

* no-mistakes(review): Add fire-and-forget reconcile and separate warning overflow

* no-mistakes(review): Exclude fire-and-forget from escalation and track reconcile background

* no-mistakes(review): Run reconcile enqueue inline across all adapters

* no-mistakes(review): Track reconcile clears across strict-invalidity homes

* no-mistakes(review): Persist reconcile transitions atomically

* no-mistakes(document): Document reconcile and fire-and-forget contracts

* refactor(bearings): replace the reconcile episode dedupe with a 4-hour cooldown

The reconcile ask needed to fire once per problem without nagging on every
recap. The episode-precise record that tried to do that had to be correct in
every direction at once - order two concurrent snapshots, tell a repair from a
new problem, and never lose a clear - and each direction it got wrong either
swallowed a nudge or sent a duplicate.

A per-home cooldown removes the whole class. One durable timestamp per home,
one nudge per four hours, and nothing to get stale, mis-order, or
mis-classify: a home in mismatch is asked once, later recaps stay silent, and
a mismatch still sitting there after the window earns one gentle re-nudge.

- bin/fm-secondmate-reconcile.sh: state/<id>.reconcile-nudged holds the epoch
  second of the last ask; FM_RECONCILE_COOLDOWN_SECONDS names the window. The
  episode identity, ordering generation, pending/clear transitions, and
  delivery-identity reuse are all gone. A known-undelivered send starts no
  cooldown so the next run retries it; an unconfirmed one does, because a
  duplicate ask is worse than one the mate may already hold.
- bin/fm-fleet-snapshot.sh, bin/fm-bearings-snapshot.sh: drop the snapshot
  `observation` monotonic identity, which existed only to order those records.
- bin/fm-teardown.sh: retire the cooldown record with the endpoint's other
  runtime artifacts, so reseeding a retired id is not silenced by its
  predecessor's window.

The inline durable fire-and-forget send is unchanged, and the projection fix
and the warning surface are untouched.

Tests follow the behavior: the cooldown suite now pins one ask per window, the
re-nudge after it, the four-hour boundary, per-home independence, and that the
ask stays out of a re-ring ladder that still rings an ordinary steer beside it.
The obsolete observation-ordering test is deleted with the machinery it covered.

* no-mistakes(review): Serialize reconcile cooldown commits with mate lifecycle

* no-mistakes(review): Reject stale reconcile snapshots across mate reincarnations

* no-mistakes(review): Start reconcile cooldown after delivery completes

* no-mistakes(review): Keep reconcile sends nonblocking and remove pending residue

* no-mistakes(document): Document reconcile skip and stale-endpoint behavior

* no-mistakes(lint): Fix reconcile test subshell lint warning

* no-mistakes: apply CI fixes

* fix(bin): reconcile markerless remote secondmates safely (#3140)

* fix(bin): stop dropping reconcile nudges for markerless remote secondmates

A persistent remote secondmate's parent-side state/<id>.meta never carries
spawn_gen: bin/fm-spawn.sh's spawn_remote_secondmate() is its sole writer and
never writes one, because that incarnation identity does not apply to a
remote route. fm-secondmate-reconcile.sh's row filter required a non-empty
spawn_gen matching an identifier regex, so every such row was silently
dropped before the per-row loop ever saw it: no sent/stale/failed line, no
cooldown record, nothing sent, and no trace of why.

Give a legitimately markerless persistent remote secondmate a safe substitute
identity - its recorded remote_host - instead of weakening the spawn_gen
check for rows that do have a generation:

- bin/fm-secondmate-reconcile.sh: carry host through the row projection for
  both fm-fleet-snapshot.v1 and fm-bearings.v1 documents, and admit an empty
  spawn_gen instead of filtering the row out. A new revalidate_identity()
  compares the sampled spawn_gen against current metadata when one was
  sampled (unchanged), or the sampled host against the metadata's
  remote_host when none was sampled and the metadata still carries no
  spawn_gen of its own. A row with neither a spawn_gen nor a host has no
  safe identity at all and fails loudly instead of vanishing, exactly the
  visibility the original bug lacked.
- Rows now join on the ASCII unit separator rather than @tsv: bash's
  IFS-whitespace read collapses consecutive tabs, which would have silently
  dropped a legitimately empty field again.
- bin/fm-bearings-snapshot.sh: thread host through the secondmate_reconcile
  projection so the fm-bearings.v1 path (the one bearings itself feeds to
  the reconcile hook) carries the same substitute identity.
- tests/fm-secondmate-reconcile.test.sh: end-to-end coverage through the real
  remote transport (fm-on.sh + fm-remote-secondmate-control.sh against a
  genuinely seeded remote home) for a markerless mate nudged once per
  cooldown window, a stale/replaced remote route refused exactly like the
  existing local spawn_gen case, and a row with no identity at all failing
  loudly rather than being swallowed.

* no-mistakes(review): Enforce markerless remote host identity during final delivery

* no-mistakes(document): Document markerless remote reconciliation safety

* fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated (#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.

* no-mistakes(review): key afk busy-pause handoff on declaration, clear wedge state

* no-mistakes(document): docs: scope away-mode busy-bound handoff to declared waits

* no-mistakes(document): docs: note afk busy-bound handoff in watcher header

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): name the stale submodule pin behind a pooled slot refusal (#3121)

* fix(bin): explain a pooled slot's stale submodule refusal

A pool slot whose submodule pin moved is refused with "is not clean; refusing
to discard uncommitted work", while the operator's own `git status` in that slot
reads clean. The message names no submodule, no pin, and no remedy, so the
refusal is unreadable and the slot looks wedged for no reason. That is the
failure that jammed three slots in a row when a submodule pin moved.

The refusal itself was never the bug and is unchanged: the gate still refuses,
and still touches nothing. It now distinguishes the one case it can prove and
says what it found - the submodule, the pin the slot has, the pin the base
records, and the command that clears it.

The diagnosis is deliberately conservative, because ` M <path>` alone cannot
tell a stale pin from real work. An entry is reported as stale only when every
reported entry is a gitlink whose submodule is internally clean and whose
recorded pin actually differs. A submodule holding uncommitted work, untracked
files, or an unpushed commit therefore keeps the original uncommitted-work
refusal, even when its pin is also stale - the remedy command would be wrong
there, and the conservative refusal is the safe answer.

Nothing is converged, synced, initialized, or deleted. There is no new failure
path: a slot that launched before still launches, a slot that refused before
still refuses, and projects that configure a submodule `ignore` are read exactly
as before. Paths are read with core.quotePath=false so a non-ASCII submodule is
named rather than falling back to the unreadable message.

Tests keep the reproductions that prove the message is accurate: the stale-pin
diagnosis (which fails against the previous refusal), work inside a submodule
still refused as uncommitted work, and a stale pin carrying real work refused
conservatively rather than called stale. Each asserts the slot is left untouched.

* no-mistakes(review): require remote containment before calling a submodule pin stale

* fix(bin): stop printing a remedy the containment check cannot stand behind

The stale-pin diagnosis printed `git submodule update --checkout` as the command
that clears the slot. The containment check behind it reads local refs only and
never fetches, because this gate has to stay usable offline. A remote-tracking
ref that has gone stale - its upstream branch deleted or force-pushed, and never
pruned - still reads as containment, so a commit that is really unpushed can look
contained and that command would move the submodule off it.

Naming the submodule and both pins is the whole point of the diagnosis: it turns
"is not clean", on a slot whose own `git status` reads clean, into a statement of
which submodule drifted and where it drifted from. The operator can choose the
remedy from that, seeing the whole picture. Printing an instruction that rests on
a judgement which can be fooled is worse than printing none, so it is dropped.

The limitation is now stated where it applies, in the script header and beside
the check itself, rather than left for a reader to discover.

No fetch is added: the gate stays offline-safe by design. Nothing else changes -
the same conditions are refused, the slot is still never touched, and a submodule
carrying real work or an unpushed commit still keeps the conservative
uncommitted-work refusal.

* no-mistakes(review): bound submodule containment probe to first commit

* fix(pi): prevent stale captain outcome re-emissions (#3154)

* fix(pi): type captain supervision outcomes so main relays them

A captain-relevant branch outcome reached main as a bare user message with
no marker of origin or required action, written in main's own captain-facing
voice, landing in a tail that often already held several such notes. Pi keeps
only a custom message's content when it builds the provider request, so
customType and display never reach the model and content was the only place
that identity could live.

Main could not tell an incoming outcome from its own earlier answer and
sometimes re-emitted that answer instead of relaying the outcome, losing it.
Measured against real Pi 0.84.1 on openai-codex/gpt-5.6-sol: 6 failures in 24
turns, rising to 3 in 6 once one stale answer was already in the tail, which
is how one captain conversation saw six identical messages in a row. The same
scenario with the outcome typed failed 0 times in 14 turns.

Wrap only the captain-verdict note in the branch-outcome operational kind
owned by bin/fm-operational-input.sh. Delivery is otherwise unchanged: still
display: false, still one triggerTurn follow-up, so the turn remains the
single captain-visible outcome and no hidden note is ever shown twice.
Routine notes stay plain because their renderer reads the glyph off the front
of that same string. An outcome that cannot be encoded degrades to the same
instruction as plain text rather than being lost, matching this file's stated
failure direction.

The existing assertions could not catch this: they pin the sendMessage
options and never look at what main receives. Add a portable regression that
classifies the delivered payload with the real protocol executable, and a
live guard that runs the real Pi SDK's own convertToLlm to prove content is
the entire model-visible payload.

* no-mistakes(document): Document typed Pi captain outcomes

* fix(bin): keep a declared wait on the pause cadence under a busy pane or enriched wedge (#3155)

* fix(bin): keep a busy pane from retiring a still-declared wait's window

The away-mode daemon's pause re-surface recheck (housekeeping step 2b) read a
busy pane as "the crew resumed" and dropped the declared-wait marker, without
re-reading that the crew's own latest status line still declared the wait.

That inference is not safe, because a declared wait can legitimately hold a pane
busy: a worker sitting on a long foreground call keeps that call live for as long
as the wait lasts. The marker is then cleared while the declaration still stands,
and migrate_watcher_pause_markers recreates it with a fresh timestamp on the very
next tick, so the window restarts forever and the wait never matures into its one
bounded recheck.

Away mode makes that terminal. Since the watcher half landed, a busy pane under a
declared wait is handed to the daemon exactly once per declaration and never woken
again while the declaration stands (bin/fm-watch.sh, busy_turn_bound_check), so
this recheck is the only thing left that can re-surface the pane at all. Measured
end to end on a throwaway state root, away mode active, a pi pane busy past
FM_BUSY_TURN_MAX_SECS, status still `paused:`, over six PAUSE_RESURFACE_SECS
windows: 0 captain-facing rechecks before this change, 6 after - one per window,
with the marker reset each time.

The fix drops only the busy arm of the 2b probe, leaving it an endpoint-readability
check: exit code 2 still means the capture failed, so the endpoint is gone and the
marker goes. The loop head above already drops the marker the moment the status
line stops declaring the wait, so nothing else is needed to end …
adibirzu pushed a commit to adibirzu/firstmate that referenced this pull request Aug 30, 2026
…rmat

Resolve a semantic conflict the merge of origin/main (#18) could not flag.
#18 made fm-marker-lib.sh the single owner of the per-window marker key and
moved it to an injective `v2-<hex>` format, converting every fixture in
tests/fm-watch-triage.test.sh to the watch_marker_key oracle - base leaves
zero inline derivations in that file.

Two fixtures added on this branch (kunchenguid#3147, kunchenguid#3155) live in regions #18 never
touched, so git merged them cleanly while they kept deriving the key with
the pre-v2 `tr ':/.' '___'` form. They therefore seeded .stale-since-/
.wedge-escalations- markers under a name the watcher no longer reads, and
the away-mode lifted-pause phase waited for an escalation that could not
arrive:

  not ok - a lifted pause on an away-mode over-age busy pane no longer
           wedge-escalates

Point both at watch_marker_key, matching how base keys every other fixture
in this file. The task-keyed `_stale_key` families in fm-supervise-daemon.sh,
fm-push-transition-lib.sh and their fixtures are a separate contract that
base ships in the old form deliberately, and are left alone.
DereKk8 added a commit to DereKk8/firstmate that referenced this pull request Aug 31, 2026
* fix: bind no-mistakes attestations to PR head (#3027)

* fix: delegate no-mistakes PR gate to pinned action

* no-mistakes(document): Document commit-bound no-mistakes attestations

* feat(pi): add persistent supervision branch model selection (#3028)

* feat(pi): let operators pin a cheaper supervision-branch model

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's model. A new /supervision-model
command opens Pi's own selector over Pi's own catalog of credentialed
models, plus a "Follow main" entry, and persists the pick as one
<provider>/<model-id> line in this home's gitignored
config/supervision-branch-model. Firstmate keeps no model catalog of its
own.

The branch resolves the pin at every branch build - the first wake of a
cold start and the reopen after /new, /resume, /fork, or reload - so the
choice survives all of them, and picking also releases the live branch so
the next wake reopens the same persistent branch conversation under the
new model. An absent, unreadable, or unparseable file means no pin and
keeps today's behavior byte for byte: no model option is passed and Pi
picks the branch's model exactly as before.

A pin naming a model Pi cannot hand back is never silently downgraded
onto main's model: the branch refuses to build and the wake falls back to
the captain-facing main path naming the unusable pin, which is the
extension's existing failure direction.

The choice is home-local and not part of secondmate inherited
configuration, matching the Pi Calm preference precedent.

docs/configuration.md owns the operator-facing schema. Portable
regressions cover pin-present on create and reopen, pin-absent default,
the command's persistence, cancellation, and live rebind, and both
unusable and unparseable pins. The opt-in real-SDK guard proves the
vendor surface the pin reads and that an explicit model wins over the
model a reopened session recorded.

* no-mistakes(review): Fix supervision model runtime and rebind races

* no-mistakes(review): Restrict supervision picker to isolated runtime models

* no-mistakes(document): Document supervision branch model selection

* fix(pi): make the supervision model pin authoritative on every reopen

Clearing the pin with "Follow main" removed the file but the next branch
build reopened the persistent branch session with no explicit model
override, so Pi restored the model that session had recorded - the old
pinned model - while the command reported that the branch now follows
main. The same gap meant an absent pin did not reliably mean
same-model-as-main once a home had pinned once.

The pin file's current state now decides the model on every branch build,
create and reopen alike, overriding Pi's session-state restore. With a
pin, that model. With no pin, main's own current model is applied
explicitly, tracked from the contexts Pi already hands the extension plus
its model_select event, since the branch is built at wake time with no
context of its own. Only when main's model is unknown, or this home's
stored credentials cannot run it in the isolated branch runtime, does a
build fall back to passing no override at all, which is the behavior from
before the pin existed; the branch is never refused over model choice.

The command's notification now reports the model actually applied, and
says plainly when clearing the pin could not apply main's model instead
of claiming a change that did not take effect.

No credential handling changes: the branch still relies entirely on the
stored credentials its own runtime already holds, and the picker stays
restricted to models that runtime can resolve.

Colocated regressions cover pin present on create and reopen, clearing
the pin returning a reopened branch to main's model and specifically not
the old pinned one, an unparseable pin behaving as no pin, and the
unknown-main-model fallback to no override.

* no-mistakes(review): Make unpinned supervision follow main model changes

* no-mistakes(document): Correct supervision model documentation

* feat(pi): let /supervision-model pick branch reasoning effort (#3079)

* feat(pi): let /supervision-model pick the branch's reasoning effort

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's reasoning effort any more than
it needs main's model. /supervision-model now settles both in one flow:
the existing model picker, then a follow-up effort picker built from Pi's
own supported thinking levels for the model just chosen. Firstmate keeps
no effort catalog of its own; the menu, the clamp, and the vocabulary all
come from Pi.

The pick persists as one line in this home's gitignored
config/supervision-branch-effort, independent of the model pin: a captain
may pin a model, an effort, both, or neither. The effort pin's current
state decides the branch effort on every branch build - the first wake of
a cold start and the reopen after /new, /resume, /fork, or reload - and
overrides Pi's restore of whatever level a reopened branch session
recorded, which is what keeps "Follow main" honest. With no pin, main's
own current effort is applied explicitly and followed live through Pi's
thinking_level_select event, the same way an unpinned branch already
follows main's model, and the two selections now share one build revision
so either change invalidates an in-flight build.

The branch is never refused over effort. Pi owns the clamp, so a pinned
level the branch's model cannot run becomes that model's nearest supported
level while the captain's raw pick is kept for a model that supports it,
and the command reports the level the branch will really run at rather
than the raw pin. A token Pi would not recognize at all is treated as no
pin rather than passed to that clamp, which would otherwise collapse a
typo into the model's lowest level. Only when main's effort cannot be read
either does a build pass no effort override at all, which is the behavior
from before this file existed.

Pi's own effort vocabulary is pinned by a bidirectional type assertion
against Pi's getThinkingLevel return type, so the tracked strict typecheck
against the installed package fails the moment Pi adds or removes a level.

docs/configuration.md owns the operator-facing schema for both pins.
Portable regressions cover the pin on create and reopen, model-only and
effort-only pins working independently, clearing a pin returning the
branch to main's effort, live-follow of a mid-session change, the clamp,
an unrecognized token, the unknown-main-effort fallback, and the command's
two-step flow, persistence, cancellation, and honest reporting. The opt-in
real-SDK guard proves the vendor surface all of that rests on, and also
repairs a pre-existing gap that left it unable to load the extension at
all.

* no-mistakes(review): Resolve effective branch effort honestly

* no-mistakes(document): Clarify Pi-owned effort picker behavior

* fix: keep routine supervision noise out of captain chat (#3093)

* fix(supervision): silence empty board closes and decouple the heartbeat

Two unrelated sources of noise put routine supervision events in the
captain's chat.

An empty Lavish board close - the captain reads a review surface, says
nothing, and closes it - became a check wake whose entire content was
that nothing happened. Suppress it at its source instead of routing it
anywhere: the generic runner gains a `silent` adapter seam mirroring the
existing `terminal` one, and the Lavish adapter answers it for exactly
one positively-determined shape, an `ended` session carrying no queued
content block. A silenced result is recorded durably handled so it does
not return on a later reconcile. Everything else announces unchanged - a
`Send & End` close carrying the captain's real answer, an `ended` result
still carrying content, a waiting or missing session, an unreadable
result, and every adapter that implements no `silent` command at all.
The keyed-answer feed is untouched, so suppressing an announcement never
suppresses the captain's own answer.

A fleet heartbeat was deferred to main merely because some unrelated
check row happened to be sitting unread, which put a routine fleet
review in the chat for a reason that had nothing to do with the fleet. A
check row is permanently main-owned, so it is now excluded from a
heartbeat claim rather than vetoing the scan, exactly as in every other
mode. What all-or-nothing guarantees is unchanged: the branch takes
every branch-ownable unread row or none of them, and an unresolvable
task-local row, an unknown row kind, or an unreadable queue still defers
the whole review to main. Main is still woken for the check on that
check's own triggering close, so nothing starves.

Main-only classes are unchanged and now each covered by a test: Relay
mentions, credential failures, merge confirmations, real board answers,
and watcher-failure repair. The per-actor acknowledgement and
no-cross-swallow properties are untouched.

* no-mistakes(review): Fail closed on all Lavish content headers

* no-mistakes(review): Suppress false unacknowledged status for silenced results

* fix(bin): stop a correlation token from hiding and stranding decisions (#1967)

* fix(classify): read the decision fold through a correlation token

status_line_verb stripped a trailing [key=...] from a status line's prefix
but left everything else glued to the verb, so a line carrying the
correlation token bin/fm-pending-reply-lib.sh embeds and a secondmate
echoes back matched no arm of _fm_decision_fold_line. Such a line folded as
ordinary status in both directions: a needs-decision or blocked opener never
opened its key, and a resolved or captain-held closer never closed one. The
same glued verb also hid correlated done and blocked lines from
status_is_captain_relevant and status_is_terminal_verb, and let correlated
working and resolved lines leak through the free-text fallback the
nonterminal guard was meant to stop.

The verb parse now walks whole words and drops only a token of the exact
shape a firstmate library writes - corr=<16 hex>, plus the bracketed form
bin/fm-secondmate-report.sh emits - before or after the key token, unkeyed,
or doubled. An arbitrary name=value word is deliberately NOT skipped:
skipping unknown tokens would let free text carrying an equals sign reduce
to a bare verb and impersonate a transition, which is the takeover the
strict parse and _fm_decision_key_transition_allowed exist to prevent. A
prefix with no corr= substring is returned byte-for-byte as before, so every
line without a token keeps its exact historical verb.

FM_OPEN_DECISIONS_FOLD_VERSION goes to 3, because every cursor persisted
under the previous reading carries an open set computed while correlated
lines were invisible and must be rebuilt from byte 0.

Measured over a real 383-line status log: 254 lines keep byte-identical
captain-relevance, pause, terminal-verb and captain-held verdicts, and all
129 changed lines carry a valid token - 14 correlated done/blocked/
needs-decision lines become captain-visible, and 20 correlated
working/resolved lines stop being escalated on prose alone.

* fix(review): Captain, block token-first decision impersonation

* fix(document): Clarify normalized status verb ownership

* fix(classify): reconcile the correlation-token read with the tag-stop parser

Rebasing onto main put this change beside #2280, which made verb parsing
stop at the first "[name=value]" tag. Both edit status_line_verb with
different intents, so the resolution keeps both rules rather than letting
one overwrite the other:

- #2280's tag stop is kept verbatim and now owns every BRACKETED tag,
  including the "[corr=...]" form fm-secondmate-report.sh writes. The
  bracket-unwrapping arm this branch had added to the token test is
  therefore removed as unreachable.
- This branch's token walk is kept and narrowed to the UNBRACKETED token
  fm-pending-reply-lib.sh writes, which the tag stop does not reach.

Two consequences of standing beside #2280 rather than before it:

The fold version had collided at 4: #2280 spent it on the tag-stop
parser and this branch had spent it on the token read. A cursor
persisted under #2280's reading predates this one and must still be
rebuilt, so the version moves to 5.

A bracketed impostor is dropped from the malformed-token list. On main
today "resolved [corr=deadbeef] [key=victim]:" already reads as the bare
verb, as does "resolved [anything at all] [key=victim]:", because the tag
stop ends the parse at the opening bracket regardless of content. That is
#2280's reviewed contract; asserting otherwise here would narrow it. The
unbracketed impostors it owns stay strict and still fold as prose.

Adds a consumer test for the two verb-string case arms that postdate this
branch: fm-supervise-daemon.sh's transient-stale arm and
fm-crew-state.sh's map_log_state.

* fix(review): Captain: Seed cursor migration fixture with version four

* fix(document): Clarify voice status normalization ownership

* fix(bin): Cursor-Park unter Pi-Host ohne Cursor-Identität stilllegen (#3115)

* fix(bin): Cursor-Park unter Pi-Host stilllegen.

pi-cursor-sdk lädt .cursor/hooks.json in die Pi-Sitzung und parkte einen zweiten Watcher; das erzeugte rearm-resurface und brach laufende Rückfragen ab. Bei PI_CODING_AGENT=true beendet der Park sofort, native cursor-agent Primaries bleiben unverändert.

* fix(bin): Cursor-Park trotz PI-Leak nur ohne Cursor-Identität stilllegen.

Stand-down gilt nur bei PI_CODING_AGENT=true ohne CURSOR_AGENT und ohne CURSOR_INVOKED_AS. Handgestartete cursor-agent Primaries mit geerbtem PI-Marker parken weiter.

* no-mistakes(document): Document Cursor park Pi-host stand-down

* fix(bin): no-mistakes-Mindestversion auf 1.46.0 anheben.

Die PR-Attestierung verlangt ab 1.46.0 strukturierte Pipeline-Schritte; der Bootstrap-Floor blieb bei 1.31.2 und ließ zu alte Builds zu.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(pi): make supervision model picker searchable and scrollable (#3099)

* fix(pi): make /supervision-model's model list bounded and searchable

Pi's generic extension selector renders every option at once with no
search box, so a real eligible catalog ran off the top of the terminal.
The model step now draws the same rows through Pi's own SelectList - the
bounded scrolling primitive behind Pi's /model picker - with Pi's own
Input and fuzzy filter above it for search, keeping 'Follow main' first,
the branch-runtime eligibility filter intact, and the pick branch-only.

Pi's ModelSelectorComponent is deliberately not reused: its selection
handler writes the captain's default model through Pi's settings manager,
which would move main's conversation as a side effect of pinning the
branch. The effort step's menu is a handful of levels and stays on Pi's
plain selector dialog.

* no-mistakes(document): Clarify supervision picker documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(document): Document searchable supervision model picker

* no-mistakes: apply CI fixes

* fix(bin): durably report merged pull requests (#3104)

* fix: make a landed merge leave a durable outcome

A merge was the one lifecycle event that left no record outside the
merging agent's memory. bin/fm-pr-merge.sh ended at the forge call, and a
home merging under standing authority never waits for the merge poll that
would otherwise confirm it, so three real merges reached the captain as
silence.

bin/fm-merge-outcome-lib.sh is the single owner of that record. A
secondmate home reports the landed PR upward on the same parent reply
channel its terminal-outcome backstop already uses; a main home records
it on the durable wake queue. The record is at most once per task and
canonical PR identity, and only a merge that actually landed produces one.

The merge poll feeds that same channel when it detects a merge this home
did not perform, so the captain's own forge merge and a merge firstmate
performed itself produce one consistent outcome instead of two reporting
paths. No new state file and no second watch path.

Two smaller gaps from the same failure:

- A mate charter listed its report triggers without naming a landed
  merge. Under standing merge authority nothing is ever "ready for
  review", so the enumerated list silently omitted the case that matters.
- A secondmate home seeded without its parent binding failed every
  terminal-outcome report for the same reason, and the diagnostic never
  named the binding. It does now.

* no-mistakes(review): Harden durable merge outcome reporting

* no-mistakes(review): Make merge race regression deterministic

* no-mistakes(review): Make merge outcomes retry-idempotent and forge-confirmed

* no-mistakes(review): Unify merge publication under canonical outcome marker

* no-mistakes(review): Publish merge outcomes before committing dedup markers

* no-mistakes(review): Document at-least-once merge outcome recovery

* no-mistakes(review): Use supported GitHub confirmation and update recovery docs

* no-mistakes(review): Preserve distinct merge wakes by PR identity

* no-mistakes(document): Document durable merge outcome semantics

* no-mistakes(test): Make merge outcome interleaving test deterministic

* no-mistakes(document): Clarify merge outcome documentation ownership

* fix(lint): keep the merge-outcome library an analysis boundary

bin/fm-watch.sh followed the new merge-outcome library's source graph,
which reaches the wake queue, PR identity, and secondmate parent
libraries. Expanding that inside an already-large lint root pushed
ShellCheck's external-source analysis past the bounded CI lint worker:
the Lint job was killed with SIGTERM after five silent minutes, twice,
having emitted no diagnostics at all.

Make it an analysis boundary, exactly as the transition and inbox owners
directly above and below it already are and for the same stated reason.
Coverage is unchanged because the library is a canonical lint root in its
own right and is still linted as one.

Measured locally: the watcher goes from not terminating within 120s to
9s clean, and the library alone lints in 1s clean.

* fix(bearings): preserve projections through inventory mismatches (#3129)

* fix(bearings): keep an inventory-mismatch home readable, and mark warnings as repairs

A backlog-vs-metadata inventory mismatch inside a secondmate home was being
reported as "we cannot read that home", which discarded that home's open
captain calls, queued work, landed work, and live workers from the whole
Bearings digest. The main home already treats the identical mismatch as a
harmless disclosure; this makes the secondmate path agree.

- fm-fleet-snapshot.sh: the invalidity gate now passes orphan_in_flight,
  unowned_current, and terminal_in_flight through the partial-structured
  carve-out alongside child_current_unavailable, so those homes keep their
  decisions, holds, queued, landed, and live work and leave unreadable[].
  missing_backlog and unstructured_current stay on the discard path, because
  there the backlog itself is untrustworthy.
- fm-fleet-snapshot.sh: the same three kinds no longer collapse the home's own
  classification to "unknown"; the real captain_decision / active_child_work /
  externally_held classification survives and invalidity carries the warning.
  An unavailable child state still collapses it, including when a mismatch
  masks it under strict-invalidity precedence.
- secondmate_landed.partial now keys on partial-structured trust rather than an
  unknown state, so an inventory-mismatch home is still disclosed as partial.

Ask the home that owns the wrong books to fix them:

- bin/fm-secondmate-reconcile.sh sends exactly one reconcile instruction per
  mismatch episode through the ordinary steering transport. A persistent
  mismatch keeps its episode identity and never re-nags; a changed mismatch
  earns one more ask; a repaired one is forgotten so a recurrence is asked
  about again. The parent never touches the mate's own files, and a failed send
  records nothing so the next run retries it.

Give integrity warnings their own look on the board:

- charted rows take an optional kind of "queued" (the default) or "warning".
  A warning badges "needs repair" instead of "waiting" and is excluded from the
  Charted Next count, so alarms stop reading as dispatchable queued work. No
  fifth board section, and every existing payload stays valid.

Tests pin the new policy behaviorally: the retained surfaces and classification
for all three mismatch kinds, the still-discarding unstructured_current and
missing_backlog cases, the once-per-episode reconcile ask through real durable
steering records, and the board rendering exercised through the shipped
template under a minimal DOM shim.

* no-mistakes(review): Make reconcile dedupe atomic and warnings non-dispatchable

* no-mistakes(review): Preserve reconcile identity and reject stale snapshots

* no-mistakes(review): Order snapshots uniquely and canonicalize episode identities

* no-mistakes(review): Add fire-and-forget reconcile and separate warning overflow

* no-mistakes(review): Exclude fire-and-forget from escalation and track reconcile background

* no-mistakes(review): Run reconcile enqueue inline across all adapters

* no-mistakes(review): Track reconcile clears across strict-invalidity homes

* no-mistakes(review): Persist reconcile transitions atomically

* no-mistakes(document): Document reconcile and fire-and-forget contracts

* refactor(bearings): replace the reconcile episode dedupe with a 4-hour cooldown

The reconcile ask needed to fire once per problem without nagging on every
recap. The episode-precise record that tried to do that had to be correct in
every direction at once - order two concurrent snapshots, tell a repair from a
new problem, and never lose a clear - and each direction it got wrong either
swallowed a nudge or sent a duplicate.

A per-home cooldown removes the whole class. One durable timestamp per home,
one nudge per four hours, and nothing to get stale, mis-order, or
mis-classify: a home in mismatch is asked once, later recaps stay silent, and
a mismatch still sitting there after the window earns one gentle re-nudge.

- bin/fm-secondmate-reconcile.sh: state/<id>.reconcile-nudged holds the epoch
  second of the last ask; FM_RECONCILE_COOLDOWN_SECONDS names the window. The
  episode identity, ordering generation, pending/clear transitions, and
  delivery-identity reuse are all gone. A known-undelivered send starts no
  cooldown so the next run retries it; an unconfirmed one does, because a
  duplicate ask is worse than one the mate may already hold.
- bin/fm-fleet-snapshot.sh, bin/fm-bearings-snapshot.sh: drop the snapshot
  `observation` monotonic identity, which existed only to order those records.
- bin/fm-teardown.sh: retire the cooldown record with the endpoint's other
  runtime artifacts, so reseeding a retired id is not silenced by its
  predecessor's window.

The inline durable fire-and-forget send is unchanged, and the projection fix
and the warning surface are untouched.

Tests follow the behavior: the cooldown suite now pins one ask per window, the
re-nudge after it, the four-hour boundary, per-home independence, and that the
ask stays out of a re-ring ladder that still rings an ordinary steer beside it.
The obsolete observation-ordering test is deleted with the machinery it covered.

* no-mistakes(review): Serialize reconcile cooldown commits with mate lifecycle

* no-mistakes(review): Reject stale reconcile snapshots across mate reincarnations

* no-mistakes(review): Start reconcile cooldown after delivery completes

* no-mistakes(review): Keep reconcile sends nonblocking and remove pending residue

* no-mistakes(document): Document reconcile skip and stale-endpoint behavior

* no-mistakes(lint): Fix reconcile test subshell lint warning

* no-mistakes: apply CI fixes

* fix(bin): reconcile markerless remote secondmates safely (#3140)

* fix(bin): stop dropping reconcile nudges for markerless remote secondmates

A persistent remote secondmate's parent-side state/<id>.meta never carries
spawn_gen: bin/fm-spawn.sh's spawn_remote_secondmate() is its sole writer and
never writes one, because that incarnation identity does not apply to a
remote route. fm-secondmate-reconcile.sh's row filter required a non-empty
spawn_gen matching an identifier regex, so every such row was silently
dropped before the per-row loop ever saw it: no sent/stale/failed line, no
cooldown record, nothing sent, and no trace of why.

Give a legitimately markerless persistent remote secondmate a safe substitute
identity - its recorded remote_host - instead of weakening the spawn_gen
check for rows that do have a generation:

- bin/fm-secondmate-reconcile.sh: carry host through the row projection for
  both fm-fleet-snapshot.v1 and fm-bearings.v1 documents, and admit an empty
  spawn_gen instead of filtering the row out. A new revalidate_identity()
  compares the sampled spawn_gen against current metadata when one was
  sampled (unchanged), or the sampled host against the metadata's
  remote_host when none was sampled and the metadata still carries no
  spawn_gen of its own. A row with neither a spawn_gen nor a host has no
  safe identity at all and fails loudly instead of vanishing, exactly the
  visibility the original bug lacked.
- Rows now join on the ASCII unit separator rather than @tsv: bash's
  IFS-whitespace read collapses consecutive tabs, which would have silently
  dropped a legitimately empty field again.
- bin/fm-bearings-snapshot.sh: thread host through the secondmate_reconcile
  projection so the fm-bearings.v1 path (the one bearings itself feeds to
  the reconcile hook) carries the same substitute identity.
- tests/fm-secondmate-reconcile.test.sh: end-to-end coverage through the real
  remote transport (fm-on.sh + fm-remote-secondmate-control.sh against a
  genuinely seeded remote home) for a markerless mate nudged once per
  cooldown window, a stale/replaced remote route refused exactly like the
  existing local spawn_gen case, and a row with no identity at all failing
  loudly rather than being swallowed.

* no-mistakes(review): Enforce markerless remote host identity during final delivery

* no-mistakes(document): Document markerless remote reconciliation safety

* fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated (#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.

* no-mistakes(review): key afk busy-pause handoff on declaration, clear wedge state

* no-mistakes(document): docs: scope away-mode busy-bound handoff to declared waits

* no-mistakes(document): docs: note afk busy-bound handoff in watcher header

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): name the stale submodule pin behind a pooled slot refusal (#3121)

* fix(bin): explain a pooled slot's stale submodule refusal

A pool slot whose submodule pin moved is refused with "is not clean; refusing
to discard uncommitted work", while the operator's own `git status` in that slot
reads clean. The message names no submodule, no pin, and no remedy, so the
refusal is unreadable and the slot looks wedged for no reason. That is the
failure that jammed three slots in a row when a submodule pin moved.

The refusal itself was never the bug and is unchanged: the gate still refuses,
and still touches nothing. It now distinguishes the one case it can prove and
says what it found - the submodule, the pin the slot has, the pin the base
records, and the command that clears it.

The diagnosis is deliberately conservative, because ` M <path>` alone cannot
tell a stale pin from real work. An entry is reported as stale only when every
reported entry is a gitlink whose submodule is internally clean and whose
recorded pin actually differs. A submodule holding uncommitted work, untracked
files, or an unpushed commit therefore keeps the original uncommitted-work
refusal, even when its pin is also stale - the remedy command would be wrong
there, and the conservative refusal is the safe answer.

Nothing is converged, synced, initialized, or deleted. There is no new failure
path: a slot that launched before still launches, a slot that refused before
still refuses, and projects that configure a submodule `ignore` are read exactly
as before. Paths are read with core.quotePath=false so a non-ASCII submodule is
named rather than falling back to the unreadable message.

Tests keep the reproductions that prove the message is accurate: the stale-pin
diagnosis (which fails against the previous refusal), work inside a submodule
still refused as uncommitted work, and a stale pin carrying real work refused
conservatively rather than called stale. Each asserts the slot is left untouched.

* no-mistakes(review): require remote containment before calling a submodule pin stale

* fix(bin): stop printing a remedy the containment check cannot stand behind

The stale-pin diagnosis printed `git submodule update --checkout` as the command
that clears the slot. The containment check behind it reads local refs only and
never fetches, because this gate has to stay usable offline. A remote-tracking
ref that has gone stale - its upstream branch deleted or force-pushed, and never
pruned - still reads as containment, so a commit that is really unpushed can look
contained and that command would move the submodule off it.

Naming the submodule and both pins is the whole point of the diagnosis: it turns
"is not clean", on a slot whose own `git status` reads clean, into a statement of
which submodule drifted and where it drifted from. The operator can choose the
remedy from that, seeing the whole picture. Printing an instruction that rests on
a judgement which can be fooled is worse than printing none, so it is dropped.

The limitation is now stated where it applies, in the script header and beside
the check itself, rather than left for a reader to discover.

No fetch is added: the gate stays offline-safe by design. Nothing else changes -
the same conditions are refused, the slot is still never touched, and a submodule
carrying real work or an unpushed commit still keeps the conservative
uncommitted-work refusal.

* no-mistakes(review): bound submodule containment probe to first commit

* fix(pi): prevent stale captain outcome re-emissions (#3154)

* fix(pi): type captain supervision outcomes so main relays them

A captain-relevant branch outcome reached main as a bare user message with
no marker of origin or required action, written in main's own captain-facing
voice, landing in a tail that often already held several such notes. Pi keeps
only a custom message's content when it builds the provider request, so
customType and display never reach the model and content was the only place
that identity could live.

Main could not tell an incoming outcome from its own earlier answer and
sometimes re-emitted that answer instead of relaying the outcome, losing it.
Measured against real Pi 0.84.1 on openai-codex/gpt-5.6-sol: 6 failures in 24
turns, rising to 3 in 6 once one stale answer was already in the tail, which
is how one captain conversation saw six identical messages in a row. The same
scenario with the outcome typed failed 0 times in 14 turns.

Wrap only the captain-verdict note in the branch-outcome operational kind
owned by bin/fm-operational-input.sh. Delivery is otherwise unchanged: still
display: false, still one triggerTurn follow-up, so the turn remains the
single captain-visible outcome and no hidden note is ever shown twice.
Routine notes stay plain because their renderer reads the glyph off the front
of that same string. An outcome that cannot be encoded degrades to the same
instruction as plain text rather than being lost, matching this file's stated
failure direction.

The existing assertions could not catch this: they pin the sendMessage
options and never look at what main receives. Add a portable regression that
classifies the delivered payload with the real protocol executable, and a
live guard that runs the real Pi SDK's own convertToLlm to prove content is
the entire model-visible payload.

* no-mistakes(document): Document typed Pi captain outcomes

* fix(bin): keep a declared wait on the pause cadence under a busy pane or enriched wedge (#3155)

* fix(bin): keep a busy pane from retiring a still-declared wait's window

The away-mode daemon's pause re-surface recheck (housekeeping step 2b) read a
busy pane as "the crew resumed" and dropped the declared-wait marker, without
re-reading that the crew's own latest status line still declared the wait.

That inference is not safe, because a declared wait can legitimately hold a pane
busy: a worker sitting on a long foreground call keeps that call live for as long
as the wait lasts. The marker is then cleared while the declaration still stands,
and migrate_watcher_pause_markers recreates it with a fresh timestamp on the very
next tick, so the window restarts forever and the wait never matures into its one
bounded recheck.

Away mode makes that terminal. Since the watcher half landed, a busy pane under a
declared wait is handed to the daemon exactly once per declaration and never woken
again while the declaration stands (bin/fm-watch.sh, busy_turn_bound_check), so
this recheck is the only thing left that can re-surface the pane at all. Measured
end to end on a throwaway state root, away mode active, a pi pane busy past
FM_BUSY_TURN_MAX_SECS, status still `paused:`, over six PAUSE_RESURFACE_SECS
windows: 0 captain-facing rechecks before this change, 6 after - one per window,
with the marker reset each time.

The fix drops only the busy arm of the 2b probe, leaving it an endpoint-readability
check: exit code 2 still means the capture failed, so the endpoint is gone and the
marker goes. The loop head above already drops the marker the moment the status
line stops declaring the wait, so nothing else is needed to end the routing, and
the reconcile path runs before the probe ever reads a pane.

tests/fm-daemon.test.sh: test_housekeeping_paused_resumed_cleared pinned the old
inference on purpose - its fixture's status line still read `paused:` while the
pane was busy, and its comment read "A pause whose pane became busy again (the
crew resumed)". Its fixture now resumes the way a crew actually resumes, by
appending a non-declaring status line, and it asserts its own busy verdict first
so it cannot silently decay into the idle-pane case that
test_housekeeping_paused_unpaused_cleared already covers. What it pins is now the
inverse guard: a busy pane must not GATE the clear either, so an over-correction
that kept the marker alive whenever the pane is busy would fail it.

test_housekeeping_busy_declared_wait_matures_its_window is the new regression, over
both declaration forms. It asserts the busy verdict, then that ticks inside the
window neither escalate nor let the marker be recreated with a fresh timestamp,
then exactly one recheck past the window named for the right human and never a
wedge, then silence on the next tick inside the reset window. It fails on
unmodified main with "produced 0 escalations past its window, expected exactly
one".

Refs #3149

* fix(bin): let a declared wait outrank an enriched wedge escalation

handle_wake classifies a stale wake through classify_stale, which returns a
`pause` verdict for a crew whose latest status line declares an external wait or
a verified captain-held transfer. It then threw that verdict away whenever the
wake reason matched `idle *s, possible wedge, escalation *`, so the watcher's
enriched wedge decoration outranked the crew's own declaration and a healthy
declared wait was escalated once per FM_STALE_ESCALATE_SECS for as long as the
wait lasted.

The enriched reason earns its precedence over the daemon's cheaper status-log
absorption honestly - it carries the watcher's escalation count and its explicit
"do not re-absorb on the run-step/pane state alone" demand. A `pause` verdict is
not run-step or pane state. It is the crew's own declaration that this pane waits
by design, which is precisely the question the wedge timer cannot answer for
itself, so it is the one verdict that decoration must not override.

The two classifications genuinely disagree in steady state rather than only in a
race: a crew that declares `paused:` while its no-mistakes run is still attributed
to its code reads `working` to the watcher's pause_state_class, so the watcher
takes the wedge timer while the daemon's classify_stale reads the status log and
correctly returns `pause`.

The wait stays bounded, not silenced. Absorbing to the pause action records the
declared-wait marker and drops wedge aging, and housekeeping (2b) then owns the
re-surface, so the pane still reaches the captain - once per PAUSE_RESURFACE_SECS
as an explicit "recheck whether the wait still holds", instead of once per
FM_STALE_ESCALATE_SECS as a possible wedge. Measured on a throwaway state root
over five wedge cadences for one declared wait: 5 escalations climbing to
demand-deep-inspection before this change, 0 after, with the one bounded recheck
still delivered.

tests/fm-daemon.test.sh: test_stale_diagnostic_wedge_survives_busy_housekeeping's
`paused` case pinned the old precedence on purpose, asserting exactly one
escalation carrying the demand-deep-inspection payload. That case now asserts the
pause cadence instead - no escalation inside the window, pause tracking recorded -
while the `working` and `prior-terminal` cases keep asserting the enriched wedge
verbatim, so the override itself is still pinned everywhere it is correct.

test_enriched_wedge_under_declared_wait_uses_pause_cadence is the new regression.
It asserts the fixture's own classifier verdict is a pause first, so the case
cannot go vacuous, then drives four consecutive wedge-cadence deliveries in both
the plain and demand-deep-inspection forms through the real handle_wake and
housekeeping pair, then matures the window for exactly one awaiting-external
recheck, then lifts the declaration and requires the same enriched wedge to
escalate again unchanged. It fails on unmodified main at the first delivery.

Refs #3149

* no-mistakes(review): align afk skill recheck wording with still-declared contract

* no-mistakes(document): daemon doc comments: pause window ages on declaration

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): recover Claude auto-arm from hung claims (#3156)

* fix(bin): make Claude auto-arm continuity self-heal past a hung claim

On a Claude primary, a Stop-hook auto-arm process that hung mid-arm held
the single-flight owner lock with its epoch ledger frozen at
outcome=arming, and the abandonment proof read any live lock holder in
arming as legitimately deciding forever. Every later Stop firing exited
0 at the lock, the turn-end guard kept deferring to the hung owner as
recovery under way, and the watcher was never auto-re-armed again for
the rest of the session - supervision survived only on manual arms and
lapsed between them (the 2026-08-26 watcher flap).

Corrections layered onto the lock-held-across-arm shape each reopened
the same concurrency class one level down, so this replaces the claim
machinery wholesale with a generation-based optimistic design:

- The epoch ledger's monotonic sequence IS the claim generation; the
  two-line entry (classic epoch record plus the claimant's MANDATORY
  pid-identity) is the claim. Every firing defers to a live OPEN claim:
  outcome arming, owner alive, identity recomputes and matches, and not
  stuck (entry and watcher beacon both older than the guard grace).
- A finished, dead, identity-mismatched, identityless, or stuck claim
  is superseded by simply taking the next generation - no signalling or
  revocation of a steady-state predecessor.
- No mutex is held across arming or output; the owner lock survives
  only as a micro-mutex around individual ledger writes. A superseded
  owner goes completely silent: ownership is re-verified before every
  arm invocation, episode-state mutation, ledger write, and
  continuation.
- The irrevocable commit point of a translation is the exit status
  (the harness delivers the collected stderr only on exit 2), so the
  owned terminal ledger write is the atomic commit: the winning
  generation exits 2 unconditionally after it, a refused one exits 0
  silently even after printing, and the once-per-episode failure notice
  commits in the same owned critical section as the winning failed
  write. Two bounded residuals are documented accepted intent: an owner
  dying between its owned write and its own exit, and a hung old-build
  owner resuming during the one legacy upgrade window.
- The pre-generation lock-holding claim shape keeps defer-or-reclaim
  behavior through a legacy shim: a live identity-verified stuck owner
  is retired via TERM (with a queued TERM sufficient when the owner is
  stopped) before its lock is removed, an unverified or identityless
  pid is never signalled but never blocks a proven-abandoned reclaim,
  and the lock's identity evidence is grafted into the ledger
  (mtime-preserving) so pid-reuse protection survives the lock.
- The guard reads the same predicates for recovery ownership and its
  terminal fail-open (which re-checks for a live open claim under the
  held locks before committing the attended alarm), with ledger reads
  anchored to line 1 so the identity line can never confuse them.

Behavioral regression coverage exercises all three edge classes through
the real hook and guard - a live open claim defers with no lock held, a
stuck claim is superseded and the home re-arms, and an end-to-end run
with a genuinely hung owner shows a concurrent firing deferring
promptly mid-arm, a later firing superseding the stuck owner, and the
superseded owner exiting silently without a second translation - plus
the identityless/reused-pid loopholes, the superseded-owner arm
boundary, and the legacy TERM, SIGSTOP, and signal-free reclaim paths.

* no-mistakes(review): Refuse auto-arm commits when notice marker creation fails

* no-mistakes(review): Make episode reset atomic with generation ownership

* no-mistakes(document): Update auto-arm generation and commit documentation

* fix(bin): verify the real GitHub merge outcome instead of reporting an unproved merge (#3064)

* fix(pr): verify GitHub merge outcome

* no-mistakes(review): Captain, fixed forge-only merge verification, queue guidance, metadata propagation

* no-mistakes(document): Correct forge-specific merge documentation

* no-mistakes(review): Captain: forge-only queue fix, focused tests pass

* no-mistakes(review): Captain: suppress closed-state guidance and prove parent regression

* no-mistakes(review): Captain: remove history proof; retain executable regressions

* no-mistakes(document): Clarify GitHub recording timing in architecture docs

* no-mistakes(document): Clarify outcome-aware PR merge recording documentation

* no-mistakes: apply CI fixes

* Revert "no-mistakes: apply CI fixes"

This reverts commit c326cfa9430c6173eedc8ff7f27d19d0552daf01.

The automatic CI repair round removed the up-front `gh` prerequisite check
while keeping the `gh` dependency: `bin/fm-pr-merge.sh` still calls
`gh api graphql` for the outcome read and `gh api` for the branch-rules read.
That left the same hard requirement without the clear named error, and review
immediately raised a new finding for exactly the failure the check prevents -
`gh-axi pr merge` landing the merge while the follow-up read fails, so the PR
metadata is never recorded.

The check is also symmetric with the GitLab arm directly above it, which
already refuses up front when `glab` or `jq` is missing, on the stated
principle that a missing tool should be a named prerequisite rather than a
merge that is armed and then refused for an unexplained reason.

The workflows this round was chasing sit at `action_required` because this is
a fork pull request; no code change can turn them green.

* fix(pr): keep PR bookkeeping when a merge outcome read fails

On the GitHub path a merge call that returned success was followed by
`github_read_outcome || exit 1`, so a transient API failure, rate limit,
or network blip during the read dropped out of the script before
`record_pr_metadata` ever ran. The merge could have landed while `pr=`
went unrecorded and the merge poll was never armed - bookkeeping lost on
a real merge. The failure path just above already recorded metadata
before exiting, so the error path was more careful than the success one.

Record the PR before that refusal. Recording arms the later merge poll
and is not a success claim, which is the same reasoning that keeps
`record_pr_metadata` on the gh-axi failure path. The refusal itself is
unchanged: exit stays non-zero and the message still names the concrete
observed state. Metadata is withheld only when the read succeeds and
proves the pull request neither merged nor queued.

Pin it with a case that stubs `gh api graphql` into failure after a
successful `gh-axi pr merge`, asserting both the non-zero exit and the
recorded metadata.

* no-mistakes(review): Aggregate queue rules and report conflicts explicitly

* fix(pr): keep the merge abstraction reachable and its bookkeeping intact

Two holes remained in the outcome-verified GitHub merge path, both on
installations where gh-axi is present but gh is not.

The verification preflight refused before bin/fm-pr-merge.sh ever reached
the configured gh-axi merge abstraction, so an installation without gh
could no longer merge at all. gh-axi now performs the merge unconditionally
and the queue-aware gh read became an optional enrichment: with gh on PATH
its GraphQL view still separates merged from queued, and without gh the
gh-axi view still proves a landed merge while every outcome it cannot prove
refuses.

The PR metadata recording sat behind the outcome read, so a merge that
landed before that read failed lost pr= and its merge poll. Recording now
happens once, before either forge call, which arms the poll without
claiming a landed outcome and leaves teardown a PR identity to verify
against no matter how the read ends.

Rebasing onto main also restored the durable merge-outcome reporting and
the GitLab landed-state confirmation that the conflict resolution dropped.

Tests pin each fix through the executable interface: the merge abstraction
is reached and verified with gh absent, a failed fallback read keeps its
bookkeeping, and a mock that snapshots the task meta during the forge call
proves pr= is recorded before the merge can land.

* no-mistakes(review): fix(pr): de-dup queue methods, fall back on failed gh read, refresh contracts

* no-mistakes(review): fix(pr): quote forge output and explain armed auto-merge on refusal

* no-mistakes(review): fix(pr): claim auto-merge armed only when the forge accepted it

* no-mistakes(review): fix(pr): tell the operator what each GitHub refusal could not observe

* no-mistakes(review): fix(pr): gate every forge-acceptance claim on a successful merge

* no-mistakes(document): align merge docs with verified GitHub outcome contract

* fix(pi): prevent duplicate captain outcome reports (#3184)

* fix(pi): stop reporting one merge to the captain twice

The supervision branch's captain-outcome note told main, unconditionally,
that the note "is not your own earlier output" and to relay it now. When
main had already reported the same event, that assertion was false and the
order turned the correct response - saying nothing new - into a mechanical
re-report, so the captain saw one merge reported twice in 16 seconds.

Two independent changes, both needed:

- The relay instruction is now conditional. It still names itself as a
  supervision outcome so main cannot mistake it for its own earlier answer
  (the silent loss that instruction exists to prevent), and it now lets
  main stay quiet about an outcome it has already given the captain.

- The merge case is closed at its source rather than left to that judgment.
  One merge reaches a home on two independent paths by design - main's own
  permanently main-owned merge poll, and the branch's task-local status
  wake - and main's captain-facing text only reaches the branch's mirror at
  main's turn end, so the branch can escalate before it could possibly see
  the captain was already told. bin/fm-pr-merge-notified.sh answers that
  question from bin/fm-pr-lib.sh's canonical merge-notification marker, so
  the answer holds regardless of mirror timing. A captain outcome naming an
  already-published merge is delivered as the ordinary rendered note
  instead of opening a follow-up turn: still appended, still visible, still
  recorded with the verdict the branch decided, minus the wasted turn.

Any error, timeout, or unreadable state relays the outcome. A duplicate
announces itself; a lost outcome does not.

Regression coverage drives the real delivery path in both directions: a new
outcome must still reach the captain in exactly one follow-up turn even
beside an unrelated published merge, and an already-published merge must
open no second turn while a different PR in the same task still does. The
merge path's real producer and this new consumer are exercised end to end
in tests/fm-pr-merge.test.sh.

Pi-only by construction: the delivery path lives in .pi/extensions, so no
other harness loads it, and the new script only reads existing markers.

* no-mistakes(review): Document accepted latest-marker suppression residual

* no-mistakes(review): Recheck ownership before merge outcome delivery

* no-mistakes(document): Document merge-outcome suppression exception

* refactor(pi): drop the source-level merge suppression, keep the envelope fix

The captain reviewed this branch and judged the source-level duplicate
suppression overly complicated for the problem it solved, and asked for
the change to be reduced to the envelope wording alone.

Remove the mergeIntoMain downgrade path, bin/fm-pr-merge-notified.sh, and
every test and document that existed only for it. What remains is the
conditional captain-outcome instruction: main is told to stay quiet about
an outcome it has already reported and to relay anything else, which
covers the duplicate without a second mechanism.

The silent-loss protection is untouched - the note is still typed,
self-describing, and delivered as one invisible follow-up turn - and the
behavioral tests still assert that, now requiring both halves of the
conditional instruction.

* no-mistakes(ci): Clarified in code comments and owned documentation that this is intentionally an M1-only, model-facing conditional relay fix—not source-level suppression—addressing Greptile’s mistaken scope expectation without changing runtime behavior. Net diff remains 3 files and 27 insertions. Verified with fm-pi-branch-extension tests, fm-lint, doc audience check, and git diff --check; all passed

* no-mistakes(ci): Strengthened the runtime delivery test to verify the captain outcome retains its required self-description and outcome text. Verified with `bash tests/fm-pi-branch-extension.test.sh`, `bin/fm-lint.sh`, `bin/fm-doc-audience-check.sh`, and `git diff --check`; all passed. The outer pipeline can now commit and attest the new head

* fix(bin): prioritize active pipeline-owned crew runs (#3194)

* fix(bin): bind the live pipeline-owned run instead of a superseded failed row

fm-crew-state.sh bound a superseded FAILED no-mistakes run to a task instead
of the LIVE replacement run: the live run's pipeline-owned lane head is not a
git object in the task worktree, so head-equality attribution rejected it and
the coarse runs-list fallback silently continued past the RUNNING row onto an
older failed row whose head equalled the stale worktree HEAD. The home summary
then flipped invalid and Bearings hid the home's live work (F10).

Attribution precedence now follows the daemon's own identity:
- An ACTIVE run for the task's branch binds without head equality while
  branch_sync.state is pipeline_owned (fm_nm_run_is_pipeline_owned_active);
  the pipeline owning the branch is itself the attribution.
- A genuinely failed run with no later run on the branch still reports failed
  through the unchanged head-equality path - real failures are not hidden.
- In the coarse runs scan, an unresolvable head is unknown attribution and
  stops the scan (fm_nm_head_resolvable) instead of falling through to an
  older row; a resolvable-but-mismatched head keeps the historical
  reused-branch skip.

The exemption never applies to a terminal run and requires pipeline_owned
specifically, both pinned by negative-control tests. Fixture shape verified
against the live incident run's real axi status output.

* no-mistakes(document): Updated run-attribution documentation ownership

* fix(pi): surface requested outcomes without replaying fleet events (#3211)

* fix(pi): surface requested supervision outcomes

* no-mistakes(review): Mirror in-flight captain requests before branch dispatch

* no-mistakes(review): Exercise real branch ownership and main outcome access

* no-mistakes(review): Preserve request tails and align verdict guidance

* no-mistakes(review): Preserve complete current captain requests

* no-mistakes(review): Require visible requested outcomes and realistic classification

* no-mistakes(document): Align supervision outcome documentation

* no-mistakes(ci): Fixed Greptile’s runtime-ordering finding. The extension now stages Pi’s authoritative `before_agent_start` prompt before SessionManager persistence and suppresses the later duplicate entry. Updated docs and behavioral regression to reproduce real Pi ordering and verify each prompt is mirrored exactly once. Passed branch-extension tests, supervision tests, strict Pi typecheck, full lint, and diff checks

* no-mistakes(review): Use canonical operational input classification

* no-mistakes(review): Filter legacy operational inputs canonically

* no-mistakes(document): Clarify captain request mirroring boundary

* no-mistakes(ci): Fixed the CI time-boundary failure in tests/fm-public-followup.test.sh by pinning its clock, including context-registry setup. This prevents follow-up fixtures from expiring based on wall time. Verified the full regression suite passes, project-owned lint passes, and git diff checks are clean

* no-mistakes(document): Clarify captain-visible supervision outcome documentation

* feat(bin): add concurrent bounded remote transport lanes (#3210)

* feat(bin): per-home remote transport lanes with cancellation, bounded send, and closed stdin

All remote commands for every home on one host used to serialize through one
single-job-at-a-time worker on one shared queue: a timed-out caller abandoned a
staged job that kept running, retries convoyed behind it, fm-send's remote leg
had no time bound, and staging captured the caller's stdin to EOF so any
fm-on.sh caller with an open stdin wedged staging indefinitely.

- The worker now serves one lane per staged home: same-home jobs run strictly
  FIFO in a new staging-sequence order while different homes run concurrently,
  each lane as its own top-level worker process (a backgrounded subshell does
  not reliably reap dead children, so a zombie group leader kept a finished
  command's process group signalable). Long-poll preemption is lane-scoped.
- A caller that disconnects or times out cancels its job: the entrypoint marks
  the record on any post-staging exit and probes its parent so a dead ssh
  channel cancels without a signal; the worker skips cancelled queued jobs,
  terminates a running cancelled job's process group, and reaps the record.
- fm-send's remote leg is bounded by FM_SEND_REMOTE_BUDGET (default 30s) and a
  bound hit exits through the existing unconfirmed-delivery contract, which
  stays idempotent because the remote enqueue deduplicates.
- fm-on.sh defaults the remote command's stdin to /dev/null; the three payload
  callers pass the new --stdin flag. Abandoned .stage.* litter is age-reaped.
- The job execution deadline no longer loses up to a second to clock
  truncation.

* no-mistakes(review): Protect live stages and validate send budgets early

* no-mistakes(review): Preserve sequence lock ownership during stale recovery

* no-mistakes(review): Allocate job sequences at publication boundary

* no-mistakes(review): Bound remote keys and extend stale lock recovery

* no-mistakes(document): Document bounded remote transport behavior

* no-mistakes(lint): Suppress intentional deferred-expansion lint warning

* no-mistakes(ci): Fixed stale sequence-lock recovery by reconciling the counter against published job records before allocating the next sequence, preventing duplicate sequences and same-home FIFO violations. Added a behavioral regression test reproducing displacement after publication and verifying execution order. Passed fm-remote-transport-lanes.test.sh, fm-remote-job.test.sh, fm-lint.sh, and git diff --check

* no-mistakes(review): Use atomic sequence claims and lossless lane keys

* no-mistakes(review): Recover regressed sequence hints and rate-limit claim reaping

* no-mistakes(review): Restrict worker heartbeats to serving loop

* no-mistakes(review): Verify supervisor identity before lane recovery signals

* no-mistakes(review): Verify tracked lane and claim owner identities

* no-mistakes(document): Clarify remote lane and transport contracts

* no-mistakes(ci): Fixed the CI time-boundary failure by pinning fm-public-followup tests to a deterministic clock, including context-registry setup. Verified tests/fm-public-followup.test.sh, tests/fm-remote-transport-lanes.test.sh, shellcheck, and git diff --check

* no-mistakes(review): Preserve assigned lane ownership of queued jobs

* no-mistakes(review): Reserve homes owned by foreign queued lanes

* no-mistakes(review): Preserve completed results during crash recovery

* no-mistakes(review): Harden claim cleanup, expiry, and cancellation races

* no-mistakes(review): Verify process groups and reap abandoned results

* no-mistakes(review): Stop leaderless groups and reap cancelled publications

* no-mistakes(document): Correct remote transport lifecycle documentation

* no-mistakes(lint): Quote done state comparisons for ShellCheck

* fix(bin): accelerate and bound changed test runs (#3250)

* fix(tests): make the changed-file map select per script and stabilize a budget flake

The changed-file map's bin/ fallback resolved a direct test reference to that
test's whole FAMILY. bin/fm-push-transition-lib.sh is named by exactly one
real-Herdr E2E, so a one-line change to it selected all 12 real-herdr-gated
scripts, including a 341s presentation E2E with no dependency on it.

Resolve direct test references per script, and keep resolving consumer bin/
scripts through the curated map so recorded family-level coupling survives.

Also fix a load-sensitive flake: the tool-update budget deadline is whole-second
granular, so a test budget of 1 left headroom anywhere in (0, 1] seconds and the
first budget check could already read as exhausted.

* feat(bin): make suite wall clock a result and let a family's concurrency be proven

--max-wall-ms fails a run whose wall clock exceeds the caller's budget, after
reporting the per-script results. A suite that stays green while outgrowing its
caller's invocation budget is the regression that got an agent killed mid-run
and retried invisibly, so duration has to be a result rather than a log note.

--pool on the isolation-proof harness runs the same concurrent proof over a
whole family, so 'is this family safe to parallelize?' is answered by a command
instead of a guess. Measured watcher-wake-lock and refused it: 3 of 18 scripts
fail under concurrency on wall-clock assertions about reaching the next poll.

* perf(bin): schedule the changed suite concurrently, longest first

The watcher-wake-lock family is proven concurrent-safe (two clean runs, 18
candidates, 0 failures at 4 workers; docs/fm-test-isolation-proof.md), so
--changed now schedules its proven-concurrent scripts with bounded parallelism
and runs any unproven remainder serially afterwards, never beside them.

Concurrent runs are ordered longest-hint-first. Workers are handed scripts in
order, so alphabetical order started the 193s fm-watch-triage last and stranded
it running alone: 395s wall against a 205s balanced four-worker sum.

An explicit --jobs keeps its strict refusal, so every CI lane is unchanged.

* fix(bin): bound a hung test instead of letting it hang the suite

tests/fm-calm-pi-extension.test.sh was observed running 17+ minutes against a
464ms recorded hint, and the suite had no per-script bound to stop it. An
unbounded suite is precisely what silently outruns a caller's invocation budget,
and --max-wall-ms is evaluated after the run so it cannot end one that never
finishes.

--per-script-timeout-secs terminates a script that outruns it and records exit
124, so the run still completes, accounts for the script, and fails. The
auto-concurrent --changed path applies 900s, far above the slowest real script
(the 341s Herdr presentation E2E), so it only ever converts a hang.

* no-mistakes(review): Enforce safe concurrency and descendant timeouts

* no-mistakes(review): Validate empty runs and isolation proof pools

* no-mistakes(review): Measure selection time in wall budget

* no-mistakes(review): Reap interrupted workers and bound finalization

* no-mistakes(review): Contain shutdown descendants and watchdog finalization

* no-mistakes(review): Honor remaining budget and close launch races

* no-mistakes(review): Restore timeout helper and simplify runner cleanup

* no-mistakes(review): Record isolation pool admission metadata

* no-mistakes(review): Bound Chrome reap and scope proof admission

* no-mistakes(review): Align proof scheduling and preserve budget summaries

* no-mistakes(review): Remove unreliable finalization watchdog

* no-mistakes(review): Freeze budget duration and enforce admission caps

* no-mistakes(document): Refresh test runner concurrency documentation

* no-mistakes(lint): Fix ShellCheck findings in test runner scripts

* no-mistakes(ci): Fixed Greptile’s concurrency-consent finding. `--changed` now remains serial by default; `--changed --jobs auto` explicitly opts into bounded concurrency and the automatic hang timeout. Updated documentation and added behavioral coverage proving serial default behavior, explicit concurrent scheduling, and refusal of `--jobs auto` outside `--changed`. Verified with `bash tests/fm-test-run.test.sh`, `bin/fm-lint.sh`, and `git diff --check`

* no-mistakes(review): Restore automatic changed-suite concurrency and timeout

* no-mistakes(review): Correct changed-suite contributor guidance

* no-mistakes(review): Reject gate-skipped isolation proofs

* no-mistakes(review): Correct automatic concurrency evidence

* no-mistakes(review): Isolate nested runner process groups

* no-mistakes(review): Remove unreliable signal cleanup machinery

* no-mistakes(test): Narrow changed-suite selection to executable contract owners

* no-mistakes(document): Document isolation proof skip and artifact semantics

* no-mistakes(ci): Fixed Greptile’s concurrency-consent finding. `--changed` now remains serial by default; bounded concurrency requires explicit `--jobs auto`. Updated behavioral coverage, contributor guidance, and isolation-proof commands accordingly. Verified with `tests/fm-test-run.test.sh`, `bin/fm-doc-audience-check.sh`, `bin/fm-lint.sh`, Bash syntax checks, and `git diff --check`; all passed

* no-mistakes(review): Restore plain changed-suite automatic concurrency

* no-mistakes(review): Record resolved changed-suite worker co…
peterOC26 added a commit to peterOC26/firstmate that referenced this pull request Sep 1, 2026
* fix: safely split supervision wake handling by actor (#2953)

* feat(bin,pi): per-actor wake consume, silent success gating, merge-poll dedup

Three related fixes to the shared wake-drain and Pi supervision-branch
dispatch machinery so a routine success is never main-blocking and a
mixed queue can safely split between actors.

1. Successful routine results no longer create main-blocking wake rows.
   fm-startup-network.sh only enqueues a check: startup-network wake when
   the deferred result is actionable (state is not "done", or the report
   carries a bootstrap-diagnostics actionable prefix); a clean success
   stays durable in the report file without ever waking the agent.

2. Per-actor wake-drain consume contract. bin/fm-wake-drain.sh now scopes
   presentation and --ack-through to the current actor
   (bin/fm-lease-lib.sh's fm_lease_actor): main keeps the original
   whole-queue cutoff behavior, unaffected. A branch actor
   (FM_SUPERVISION_ACTOR=branch, set only inside the Pi supervision
   branch's own bash tool calls) is scoped to an explicit eligible-row
   snapshot instead of a cutoff comparison, so it can never remove a row
   it was not granted - the fix for the swallow risk that used to force
   an all-or-nothing whole-queue fallback to main.
   .pi/extensions/lib/fm-branch-dispatch.ts's scopeForUnreadWake is the
   single owner of eligibility: a check-kind row (merge-confirmation
   polls, Relay mentions, credential/auth failures) is now excluded
   rather than vetoing the whole scan for a non-heartbeat wake, while a
   heartbeat review keeps its original all-or-nothing rule unchanged.
   writeEligibleRowsSnapshot publishes the exact eligible sequence
   numbers before every branch prompt; fm-primary-pi-watch.ts's offer
   still refuses a check-kind trigger outright so a main-only close is
   never itself routed to the branch.

3. A repeat identical merged-PR-poll result for an already-notified task
   is absorbed instead of enqueued again. A poll's own retirement state
   is scoped to one registration and cannot see a prior registration's
   outcome, so a task re-registered after its merge was already surfaced
   would otherwise wake main a second time for the same event.
   bin/fm-pr-lib.sh's new per-task pr-poll-merge-notified marker survives
   across re-registrations to catch that case; the first notification for
   a task still reaches main unchanged.

Regression tests colocated in tests/fm-startup-network.test.sh,
tests/fm-wake-queue.test.sh (including the mixed-queue no-swallow
property), tests/fm-pi-branch-extension.test.sh, and
tests/fm-pr-check-security.test.sh. docs/watcher-continuity.md and
docs/pi-supervision-branch.md updated for the new contracts.

* no-mistakes(review): Bind merge deduplication to canonical PR identity

* no-mistakes(review): Serialize wake row ownership across main and branch

* no-mistakes(review): Bind branch grants and deduplicate within actor claims

* no-mistakes(review): Fallback main-owned wake claims to main delivery

* no-mistakes(review): Clarify silent startup success guidance

* no-mistakes(review): Release residual branch grants after settled prompts

* no-mistakes(review): Reject truncated wake rows as corrupted

* no-mistakes(document): Document per-actor routing and silent startup success

* no-mistakes(lint): Fix ShellCheck findings in wake grant and startup test

* no-mistakes: apply CI fixes

* fix(pi): hide branch outcomes tool rows in Calm (#3024)

* Hide branch outcome tool in Pi Calm

* no-mistakes(review): Preserve stock outcomes rendering and document tool audit

* no-mistakes(review): Document branch read tool audit disposition

* no-mistakes(review): Match stock outcomes output sanitization

* no-mistakes(document): Document Calm custom-tool visibility

* fix: bind no-mistakes attestations to PR head (#3027)

* fix: delegate no-mistakes PR gate to pinned action

* no-mistakes(document): Document commit-bound no-mistakes attestations

* feat(pi): add persistent supervision branch model selection (#3028)

* feat(pi): let operators pin a cheaper supervision-branch model

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's model. A new /supervision-model
command opens Pi's own selector over Pi's own catalog of credentialed
models, plus a "Follow main" entry, and persists the pick as one
<provider>/<model-id> line in this home's gitignored
config/supervision-branch-model. Firstmate keeps no model catalog of its
own.

The branch resolves the pin at every branch build - the first wake of a
cold start and the reopen after /new, /resume, /fork, or reload - so the
choice survives all of them, and picking also releases the live branch so
the next wake reopens the same persistent branch conversation under the
new model. An absent, unreadable, or unparseable file means no pin and
keeps today's behavior byte for byte: no model option is passed and Pi
picks the branch's model exactly as before.

A pin naming a model Pi cannot hand back is never silently downgraded
onto main's model: the branch refuses to build and the wake falls back to
the captain-facing main path naming the unusable pin, which is the
extension's existing failure direction.

The choice is home-local and not part of secondmate inherited
configuration, matching the Pi Calm preference precedent.

docs/configuration.md owns the operator-facing schema. Portable
regressions cover pin-present on create and reopen, pin-absent default,
the command's persistence, cancellation, and live rebind, and both
unusable and unparseable pins. The opt-in real-SDK guard proves the
vendor surface the pin reads and that an explicit model wins over the
model a reopened session recorded.

* no-mistakes(review): Fix supervision model runtime and rebind races

* no-mistakes(review): Restrict supervision picker to isolated runtime models

* no-mistakes(document): Document supervision branch model selection

* fix(pi): make the supervision model pin authoritative on every reopen

Clearing the pin with "Follow main" removed the file but the next branch
build reopened the persistent branch session with no explicit model
override, so Pi restored the model that session had recorded - the old
pinned model - while the command reported that the branch now follows
main. The same gap meant an absent pin did not reliably mean
same-model-as-main once a home had pinned once.

The pin file's current state now decides the model on every branch build,
create and reopen alike, overriding Pi's session-state restore. With a
pin, that model. With no pin, main's own current model is applied
explicitly, tracked from the contexts Pi already hands the extension plus
its model_select event, since the branch is built at wake time with no
context of its own. Only when main's model is unknown, or this home's
stored credentials cannot run it in the isolated branch runtime, does a
build fall back to passing no override at all, which is the behavior from
before the pin existed; the branch is never refused over model choice.

The command's notification now reports the model actually applied, and
says plainly when clearing the pin could not apply main's model instead
of claiming a change that did not take effect.

No credential handling changes: the branch still relies entirely on the
stored credentials its own runtime already holds, and the picker stays
restricted to models that runtime can resolve.

Colocated regressions cover pin present on create and reopen, clearing
the pin returning a reopened branch to main's model and specifically not
the old pinned one, an unparseable pin behaving as no pin, and the
unknown-main-model fallback to no override.

* no-mistakes(review): Make unpinned supervision follow main model changes

* no-mistakes(document): Correct supervision model documentation

* feat(pi): let /supervision-model pick branch reasoning effort (#3079)

* feat(pi): let /supervision-model pick the branch's reasoning effort

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's reasoning effort any more than
it needs main's model. /supervision-model now settles both in one flow:
the existing model picker, then a follow-up effort picker built from Pi's
own supported thinking levels for the model just chosen. Firstmate keeps
no effort catalog of its own; the menu, the clamp, and the vocabulary all
come from Pi.

The pick persists as one line in this home's gitignored
config/supervision-branch-effort, independent of the model pin: a captain
may pin a model, an effort, both, or neither. The effort pin's current
state decides the branch effort on every branch build - the first wake of
a cold start and the reopen after /new, /resume, /fork, or reload - and
overrides Pi's restore of whatever level a reopened branch session
recorded, which is what keeps "Follow main" honest. With no pin, main's
own current effort is applied explicitly and followed live through Pi's
thinking_level_select event, the same way an unpinned branch already
follows main's model, and the two selections now share one build revision
so either change invalidates an in-flight build.

The branch is never refused over effort. Pi owns the clamp, so a pinned
level the branch's model cannot run becomes that model's nearest supported
level while the captain's raw pick is kept for a model that supports it,
and the command reports the level the branch will really run at rather
than the raw pin. A token Pi would not recognize at all is treated as no
pin rather than passed to that clamp, which would otherwise collapse a
typo into the model's lowest level. Only when main's effort cannot be read
either does a build pass no effort override at all, which is the behavior
from before this file existed.

Pi's own effort vocabulary is pinned by a bidirectional type assertion
against Pi's getThinkingLevel return type, so the tracked strict typecheck
against the installed package fails the moment Pi adds or removes a level.

docs/configuration.md owns the operator-facing schema for both pins.
Portable regressions cover the pin on create and reopen, model-only and
effort-only pins working independently, clearing a pin returning the
branch to main's effort, live-follow of a mid-session change, the clamp,
an unrecognized token, the unknown-main-effort fallback, and the command's
two-step flow, persistence, cancellation, and honest reporting. The opt-in
real-SDK guard proves the vendor surface all of that rests on, and also
repairs a pre-existing gap that left it unable to load the extension at
all.

* no-mistakes(review): Resolve effective branch effort honestly

* no-mistakes(document): Clarify Pi-owned effort picker behavior

* fix: keep routine supervision noise out of captain chat (#3093)

* fix(supervision): silence empty board closes and decouple the heartbeat

Two unrelated sources of noise put routine supervision events in the
captain's chat.

An empty Lavish board close - the captain reads a review surface, says
nothing, and closes it - became a check wake whose entire content was
that nothing happened. Suppress it at its source instead of routing it
anywhere: the generic runner gains a `silent` adapter seam mirroring the
existing `terminal` one, and the Lavish adapter answers it for exactly
one positively-determined shape, an `ended` session carrying no queued
content block. A silenced result is recorded durably handled so it does
not return on a later reconcile. Everything else announces unchanged - a
`Send & End` close carrying the captain's real answer, an `ended` result
still carrying content, a waiting or missing session, an unreadable
result, and every adapter that implements no `silent` command at all.
The keyed-answer feed is untouched, so suppressing an announcement never
suppresses the captain's own answer.

A fleet heartbeat was deferred to main merely because some unrelated
check row happened to be sitting unread, which put a routine fleet
review in the chat for a reason that had nothing to do with the fleet. A
check row is permanently main-owned, so it is now excluded from a
heartbeat claim rather than vetoing the scan, exactly as in every other
mode. What all-or-nothing guarantees is unchanged: the branch takes
every branch-ownable unread row or none of them, and an unresolvable
task-local row, an unknown row kind, or an unreadable queue still defers
the whole review to main. Main is still woken for the check on that
check's own triggering close, so nothing starves.

Main-only classes are unchanged and now each covered by a test: Relay
mentions, credential failures, merge confirmations, real board answers,
and watcher-failure repair. The per-actor acknowledgement and
no-cross-swallow properties are untouched.

* no-mistakes(review): Fail closed on all Lavish content headers

* no-mistakes(review): Suppress false unacknowledged status for silenced results

* fix(bin): stop a correlation token from hiding and stranding decisions (#1967)

* fix(classify): read the decision fold through a correlation token

status_line_verb stripped a trailing [key=...] from a status line's prefix
but left everything else glued to the verb, so a line carrying the
correlation token bin/fm-pending-reply-lib.sh embeds and a secondmate
echoes back matched no arm of _fm_decision_fold_line. Such a line folded as
ordinary status in both directions: a needs-decision or blocked opener never
opened its key, and a resolved or captain-held closer never closed one. The
same glued verb also hid correlated done and blocked lines from
status_is_captain_relevant and status_is_terminal_verb, and let correlated
working and resolved lines leak through the free-text fallback the
nonterminal guard was meant to stop.

The verb parse now walks whole words and drops only a token of the exact
shape a firstmate library writes - corr=<16 hex>, plus the bracketed form
bin/fm-secondmate-report.sh emits - before or after the key token, unkeyed,
or doubled. An arbitrary name=value word is deliberately NOT skipped:
skipping unknown tokens would let free text carrying an equals sign reduce
to a bare verb and impersonate a transition, which is the takeover the
strict parse and _fm_decision_key_transition_allowed exist to prevent. A
prefix with no corr= substring is returned byte-for-byte as before, so every
line without a token keeps its exact historical verb.

FM_OPEN_DECISIONS_FOLD_VERSION goes to 3, because every cursor persisted
under the previous reading carries an open set computed while correlated
lines were invisible and must be rebuilt from byte 0.

Measured over a real 383-line status log: 254 lines keep byte-identical
captain-relevance, pause, terminal-verb and captain-held verdicts, and all
129 changed lines carry a valid token - 14 correlated done/blocked/
needs-decision lines become captain-visible, and 20 correlated
working/resolved lines stop being escalated on prose alone.

* fix(review): Captain, block token-first decision impersonation

* fix(document): Clarify normalized status verb ownership

* fix(classify): reconcile the correlation-token read with the tag-stop parser

Rebasing onto main put this change beside #2280, which made verb parsing
stop at the first "[name=value]" tag. Both edit status_line_verb with
different intents, so the resolution keeps both rules rather than letting
one overwrite the other:

- #2280's tag stop is kept verbatim and now owns every BRACKETED tag,
  including the "[corr=...]" form fm-secondmate-report.sh writes. The
  bracket-unwrapping arm this branch had added to the token test is
  therefore removed as unreachable.
- This branch's token walk is kept and narrowed to the UNBRACKETED token
  fm-pending-reply-lib.sh writes, which the tag stop does not reach.

Two consequences of standing beside #2280 rather than before it:

The fold version had collided at 4: #2280 spent it on the tag-stop
parser and this branch had spent it on the token read. A cursor
persisted under #2280's reading predates this one and must still be
rebuilt, so the version moves to 5.

A bracketed impostor is dropped from the malformed-token list. On main
today "resolved [corr=deadbeef] [key=victim]:" already reads as the bare
verb, as does "resolved [anything at all] [key=victim]:", because the tag
stop ends the parse at the opening bracket regardless of content. That is
#2280's reviewed contract; asserting otherwise here would narrow it. The
unbracketed impostors it owns stay strict and still fold as prose.

Adds a consumer test for the two verb-string case arms that postdate this
branch: fm-supervise-daemon.sh's transient-stale arm and
fm-crew-state.sh's map_log_state.

* fix(review): Captain: Seed cursor migration fixture with version four

* fix(document): Clarify voice status normalization ownership

* fix(bin): Cursor-Park unter Pi-Host ohne Cursor-Identität stilllegen (#3115)

* fix(bin): Cursor-Park unter Pi-Host stilllegen.

pi-cursor-sdk lädt .cursor/hooks.json in die Pi-Sitzung und parkte einen zweiten Watcher; das erzeugte rearm-resurface und brach laufende Rückfragen ab. Bei PI_CODING_AGENT=true beendet der Park sofort, native cursor-agent Primaries bleiben unverändert.

* fix(bin): Cursor-Park trotz PI-Leak nur ohne Cursor-Identität stilllegen.

Stand-down gilt nur bei PI_CODING_AGENT=true ohne CURSOR_AGENT und ohne CURSOR_INVOKED_AS. Handgestartete cursor-agent Primaries mit geerbtem PI-Marker parken weiter.

* no-mistakes(document): Document Cursor park Pi-host stand-down

* fix(bin): no-mistakes-Mindestversion auf 1.46.0 anheben.

Die PR-Attestierung verlangt ab 1.46.0 strukturierte Pipeline-Schritte; der Bootstrap-Floor blieb bei 1.31.2 und ließ zu alte Builds zu.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(pi): make supervision model picker searchable and scrollable (#3099)

* fix(pi): make /supervision-model's model list bounded and searchable

Pi's generic extension selector renders every option at once with no
search box, so a real eligible catalog ran off the top of the terminal.
The model step now draws the same rows through Pi's own SelectList - the
bounded scrolling primitive behind Pi's /model picker - with Pi's own
Input and fuzzy filter above it for search, keeping 'Follow main' first,
the branch-runtime eligibility filter intact, and the pick branch-only.

Pi's ModelSelectorComponent is deliberately not reused: its selection
handler writes the captain's default model through Pi's settings manager,
which would move main's conversation as a side effect of pinning the
branch. The effort step's menu is a handful of levels and stays on Pi's
plain selector dialog.

* no-mistakes(document): Clarify supervision picker documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(document): Document searchable supervision model picker

* no-mistakes: apply CI fixes

* fix(bin): durably report merged pull requests (#3104)

* fix: make a landed merge leave a durable outcome

A merge was the one lifecycle event that left no record outside the
merging agent's memory. bin/fm-pr-merge.sh ended at the forge call, and a
home merging under standing authority never waits for the merge poll that
would otherwise confirm it, so three real merges reached the captain as
silence.

bin/fm-merge-outcome-lib.sh is the single owner of that record. A
secondmate home reports the landed PR upward on the same parent reply
channel its terminal-outcome backstop already uses; a main home records
it on the durable wake queue. The record is at most once per task and
canonical PR identity, and only a merge that actually landed produces one.

The merge poll feeds that same channel when it detects a merge this home
did not perform, so the captain's own forge merge and a merge firstmate
performed itself produce one consistent outcome instead of two reporting
paths. No new state file and no second watch path.

Two smaller gaps from the same failure:

- A mate charter listed its report triggers without naming a landed
  merge. Under standing merge authority nothing is ever "ready for
  review", so the enumerated list silently omitted the case that matters.
- A secondmate home seeded without its parent binding failed every
  terminal-outcome report for the same reason, and the diagnostic never
  named the binding. It does now.

* no-mistakes(review): Harden durable merge outcome reporting

* no-mistakes(review): Make merge race regression deterministic

* no-mistakes(review): Make merge outcomes retry-idempotent and forge-confirmed

* no-mistakes(review): Unify merge publication under canonical outcome marker

* no-mistakes(review): Publish merge outcomes before committing dedup markers

* no-mistakes(review): Document at-least-once merge outcome recovery

* no-mistakes(review): Use supported GitHub confirmation and update recovery docs

* no-mistakes(review): Preserve distinct merge wakes by PR identity

* no-mistakes(document): Document durable merge outcome semantics

* no-mistakes(test): Make merge outcome interleaving test deterministic

* no-mistakes(document): Clarify merge outcome documentation ownership

* fix(lint): keep the merge-outcome library an analysis boundary

bin/fm-watch.sh followed the new merge-outcome library's source graph,
which reaches the wake queue, PR identity, and secondmate parent
libraries. Expanding that inside an already-large lint root pushed
ShellCheck's external-source analysis past the bounded CI lint worker:
the Lint job was killed with SIGTERM after five silent minutes, twice,
having emitted no diagnostics at all.

Make it an analysis boundary, exactly as the transition and inbox owners
directly above and below it already are and for the same stated reason.
Coverage is unchanged because the library is a canonical lint root in its
own right and is still linted as one.

Measured locally: the watcher goes from not terminating within 120s to
9s clean, and the library alone lints in 1s clean.

* fix(bearings): preserve projections through inventory mismatches (#3129)

* fix(bearings): keep an inventory-mismatch home readable, and mark warnings as repairs

A backlog-vs-metadata inventory mismatch inside a secondmate home was being
reported as "we cannot read that home", which discarded that home's open
captain calls, queued work, landed work, and live workers from the whole
Bearings digest. The main home already treats the identical mismatch as a
harmless disclosure; this makes the secondmate path agree.

- fm-fleet-snapshot.sh: the invalidity gate now passes orphan_in_flight,
  unowned_current, and terminal_in_flight through the partial-structured
  carve-out alongside child_current_unavailable, so those homes keep their
  decisions, holds, queued, landed, and live work and leave unreadable[].
  missing_backlog and unstructured_current stay on the discard path, because
  there the backlog itself is untrustworthy.
- fm-fleet-snapshot.sh: the same three kinds no longer collapse the home's own
  classification to "unknown"; the real captain_decision / active_child_work /
  externally_held classification survives and invalidity carries the warning.
  An unavailable child state still collapses it, including when a mismatch
  masks it under strict-invalidity precedence.
- secondmate_landed.partial now keys on partial-structured trust rather than an
  unknown state, so an inventory-mismatch home is still disclosed as partial.

Ask the home that owns the wrong books to fix them:

- bin/fm-secondmate-reconcile.sh sends exactly one reconcile instruction per
  mismatch episode through the ordinary steering transport. A persistent
  mismatch keeps its episode identity and never re-nags; a changed mismatch
  earns one more ask; a repaired one is forgotten so a recurrence is asked
  about again. The parent never touches the mate's own files, and a failed send
  records nothing so the next run retries it.

Give integrity warnings their own look on the board:

- charted rows take an optional kind of "queued" (the default) or "warning".
  A warning badges "needs repair" instead of "waiting" and is excluded from the
  Charted Next count, so alarms stop reading as dispatchable queued work. No
  fifth board section, and every existing payload stays valid.

Tests pin the new policy behaviorally: the retained surfaces and classification
for all three mismatch kinds, the still-discarding unstructured_current and
missing_backlog cases, the once-per-episode reconcile ask through real durable
steering records, and the board rendering exercised through the shipped
template under a minimal DOM shim.

* no-mistakes(review): Make reconcile dedupe atomic and warnings non-dispatchable

* no-mistakes(review): Preserve reconcile identity and reject stale snapshots

* no-mistakes(review): Order snapshots uniquely and canonicalize episode identities

* no-mistakes(review): Add fire-and-forget reconcile and separate warning overflow

* no-mistakes(review): Exclude fire-and-forget from escalation and track reconcile background

* no-mistakes(review): Run reconcile enqueue inline across all adapters

* no-mistakes(review): Track reconcile clears across strict-invalidity homes

* no-mistakes(review): Persist reconcile transitions atomically

* no-mistakes(document): Document reconcile and fire-and-forget contracts

* refactor(bearings): replace the reconcile episode dedupe with a 4-hour cooldown

The reconcile ask needed to fire once per problem without nagging on every
recap. The episode-precise record that tried to do that had to be correct in
every direction at once - order two concurrent snapshots, tell a repair from a
new problem, and never lose a clear - and each direction it got wrong either
swallowed a nudge or sent a duplicate.

A per-home cooldown removes the whole class. One durable timestamp per home,
one nudge per four hours, and nothing to get stale, mis-order, or
mis-classify: a home in mismatch is asked once, later recaps stay silent, and
a mismatch still sitting there after the window earns one gentle re-nudge.

- bin/fm-secondmate-reconcile.sh: state/<id>.reconcile-nudged holds the epoch
  second of the last ask; FM_RECONCILE_COOLDOWN_SECONDS names the window. The
  episode identity, ordering generation, pending/clear transitions, and
  delivery-identity reuse are all gone. A known-undelivered send starts no
  cooldown so the next run retries it; an unconfirmed one does, because a
  duplicate ask is worse than one the mate may already hold.
- bin/fm-fleet-snapshot.sh, bin/fm-bearings-snapshot.sh: drop the snapshot
  `observation` monotonic identity, which existed only to order those records.
- bin/fm-teardown.sh: retire the cooldown record with the endpoint's other
  runtime artifacts, so reseeding a retired id is not silenced by its
  predecessor's window.

The inline durable fire-and-forget send is unchanged, and the projection fix
and the warning surface are untouched.

Tests follow the behavior: the cooldown suite now pins one ask per window, the
re-nudge after it, the four-hour boundary, per-home independence, and that the
ask stays out of a re-ring ladder that still rings an ordinary steer beside it.
The obsolete observation-ordering test is deleted with the machinery it covered.

* no-mistakes(review): Serialize reconcile cooldown commits with mate lifecycle

* no-mistakes(review): Reject stale reconcile snapshots across mate reincarnations

* no-mistakes(review): Start reconcile cooldown after delivery completes

* no-mistakes(review): Keep reconcile sends nonblocking and remove pending residue

* no-mistakes(document): Document reconcile skip and stale-endpoint behavior

* no-mistakes(lint): Fix reconcile test subshell lint warning

* no-mistakes: apply CI fixes

* fix(bin): reconcile markerless remote secondmates safely (#3140)

* fix(bin): stop dropping reconcile nudges for markerless remote secondmates

A persistent remote secondmate's parent-side state/<id>.meta never carries
spawn_gen: bin/fm-spawn.sh's spawn_remote_secondmate() is its sole writer and
never writes one, because that incarnation identity does not apply to a
remote route. fm-secondmate-reconcile.sh's row filter required a non-empty
spawn_gen matching an identifier regex, so every such row was silently
dropped before the per-row loop ever saw it: no sent/stale/failed line, no
cooldown record, nothing sent, and no trace of why.

Give a legitimately markerless persistent remote secondmate a safe substitute
identity - its recorded remote_host - instead of weakening the spawn_gen
check for rows that do have a generation:

- bin/fm-secondmate-reconcile.sh: carry host through the row projection for
  both fm-fleet-snapshot.v1 and fm-bearings.v1 documents, and admit an empty
  spawn_gen instead of filtering the row out. A new revalidate_identity()
  compares the sampled spawn_gen against current metadata when one was
  sampled (unchanged), or the sampled host against the metadata's
  remote_host when none was sampled and the metadata still carries no
  spawn_gen of its own. A row with neither a spawn_gen nor a host has no
  safe identity at all and fails loudly instead of vanishing, exactly the
  visibility the original bug lacked.
- Rows now join on the ASCII unit separator rather than @tsv: bash's
  IFS-whitespace read collapses consecutive tabs, which would have silently
  dropped a legitimately empty field again.
- bin/fm-bearings-snapshot.sh: thread host through the secondmate_reconcile
  projection so the fm-bearings.v1 path (the one bearings itself feeds to
  the reconcile hook) carries the same substitute identity.
- tests/fm-secondmate-reconcile.test.sh: end-to-end coverage through the real
  remote transport (fm-on.sh + fm-remote-secondmate-control.sh against a
  genuinely seeded remote home) for a markerless mate nudged once per
  cooldown window, a stale/replaced remote route refused exactly like the
  existing local spawn_gen case, and a row with no identity at all failing
  loudly rather than being swallowed.

* no-mistakes(review): Enforce markerless remote host identity during final delivery

* no-mistakes(document): Document markerless remote reconciliation safety

* fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated (#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.

* no-mistakes(review): key afk busy-pause handoff on declaration, clear wedge state

* no-mistakes(document): docs: scope away-mode busy-bound handoff to declared waits

* no-mistakes(document): docs: note afk busy-bound handoff in watcher header

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): name the stale submodule pin behind a pooled slot refusal (#3121)

* fix(bin): explain a pooled slot's stale submodule refusal

A pool slot whose submodule pin moved is refused with "is not clean; refusing
to discard uncommitted work", while the operator's own `git status` in that slot
reads clean. The message names no submodule, no pin, and no remedy, so the
refusal is unreadable and the slot looks wedged for no reason. That is the
failure that jammed three slots in a row when a submodule pin moved.

The refusal itself was never the bug and is unchanged: the gate still refuses,
and still touches nothing. It now distinguishes the one case it can prove and
says what it found - the submodule, the pin the slot has, the pin the base
records, and the command that clears it.

The diagnosis is deliberately conservative, because ` M <path>` alone cannot
tell a stale pin from real work. An entry is reported as stale only when every
reported entry is a gitlink whose submodule is internally clean and whose
recorded pin actually differs. A submodule holding uncommitted work, untracked
files, or an unpushed commit therefore keeps the original uncommitted-work
refusal, even when its pin is also stale - the remedy command would be wrong
there, and the conservative refusal is the safe answer.

Nothing is converged, synced, initialized, or deleted. There is no new failure
path: a slot that launched before still launches, a slot that refused before
still refuses, and projects that configure a submodule `ignore` are read exactly
as before. Paths are read with core.quotePath=false so a non-ASCII submodule is
named rather than falling back to the unreadable message.

Tests keep the reproductions that prove the message is accurate: the stale-pin
diagnosis (which fails against the previous refusal), work inside a submodule
still refused as uncommitted work, and a stale pin carrying real work refused
conservatively rather than called stale. Each asserts the slot is left untouched.

* no-mistakes(review): require remote containment before calling a submodule pin stale

* fix(bin): stop printing a remedy the containment check cannot stand behind

The stale-pin diagnosis printed `git submodule update --checkout` as the command
that clears the slot. The containment check behind it reads local refs only and
never fetches, because this gate has to stay usable offline. A remote-tracking
ref that has gone stale - its upstream branch deleted or force-pushed, and never
pruned - still reads as containment, so a commit that is really unpushed can look
contained and that command would move the submodule off it.

Naming the submodule and both pins is the whole point of the diagnosis: it turns
"is not clean", on a slot whose own `git status` reads clean, into a statement of
which submodule drifted and where it drifted from. The operator can choose the
remedy from that, seeing the whole picture. Printing an instruction that rests on
a judgement which can be fooled is worse than printing none, so it is dropped.

The limitation is now stated where it applies, in the script header and beside
the check itself, rather than left for a reader to discover.

No fetch is added: the gate stays offline-safe by design. Nothing else changes -
the same conditions are refused, the slot is still never touched, and a submodule
carrying real work or an unpushed commit still keeps the conservative
uncommitted-work refusal.

* no-mistakes(review): bound submodule containment probe to first commit

* fix(pi): prevent stale captain outcome re-emissions (#3154)

* fix(pi): type captain supervision outcomes so main relays them

A captain-relevant branch outcome reached main as a bare user message with
no marker of origin or required action, written in main's own captain-facing
voice, landing in a tail that often already held several such notes. Pi keeps
only a custom message's content when it builds the provider request, so
customType and display never reach the model and content was the only place
that identity could live.

Main could not tell an incoming outcome from its own earlier answer and
sometimes re-emitted that answer instead of relaying the outcome, losing it.
Measured against real Pi 0.84.1 on openai-codex/gpt-5.6-sol: 6 failures in 24
turns, rising to 3 in 6 once one stale answer was already in the tail, which
is how one captain conversation saw six identical messages in a row. The same
scenario with the outcome typed failed 0 times in 14 turns.

Wrap only the captain-verdict note in the branch-outcome operational kind
owned by bin/fm-operational-input.sh. Delivery is otherwise unchanged: still
display: false, still one triggerTurn follow-up, so the turn remains the
single captain-visible outcome and no hidden note is ever shown twice.
Routine notes stay plain because their renderer reads the glyph off the front
of that same string. An outcome that cannot be encoded degrades to the same
instruction as plain text rather than being lost, matching this file's stated
failure direction.

The existing assertions could not catch this: they pin the sendMessage
options and never look at what main receives. Add a portable regression that
classifies the delivered payload with the real protocol executable, and a
live guard that runs the real Pi SDK's own convertToLlm to prove content is
the entire model-visible payload.

* no-mistakes(document): Document typed Pi captain outcomes

* fix(bin): keep a declared wait on the pause cadence under a busy pane or enriched wedge (#3155)

* fix(bin): keep a busy pane from retiring a still-declared wait's window

The away-mode daemon's pause re-surface recheck (housekeeping step 2b) read a
busy pane as "the crew resumed" and dropped the declared-wait marker, without
re-reading that the crew's own latest status line still declared the wait.

That inference is not safe, because a declared wait can legitimately hold a pane
busy: a worker sitting on a long foreground call keeps that call live for as long
as the wait lasts. The marker is then cleared while the declaration still stands,
and migrate_watcher_pause_markers recreates it with a fresh timestamp on the very
next tick, so the window restarts forever and the wait never matures into its one
bounded recheck.

Away mode makes that terminal. Since the watcher half landed, a busy pane under a
declared wait is handed to the daemon exactly once per declaration and never woken
again while the declaration stands (bin/fm-watch.sh, busy_turn_bound_check), so
this recheck is the only thing left that can re-surface the pane at all. Measured
end to end on a throwaway state root, away mode active, a pi pane busy past
FM_BUSY_TURN_MAX_SECS, status still `paused:`, over six PAUSE_RESURFACE_SECS
windows: 0 captain-facing rechecks before this change, 6 after - one per window,
with the marker reset each time.

The fix drops only the busy arm of the 2b probe, leaving it an endpoint-readability
check: exit code 2 still means the capture failed, so the endpoint is gone and the
marker goes. The loop head above already drops the marker the moment the status
line stops declaring the wait, so nothing else is needed to end the routing, and
the reconcile path runs before the probe ever reads a pane.

tests/fm-daemon.test.sh: test_housekeeping_paused_resumed_cleared pinned the old
inference on purpose - its fixture's status line still read `paused:` while the
pane was busy, and its comment read "A pause whose pane became busy again (the
crew resumed)". Its fixture now resumes the way a crew actually resumes, by
appending a non-declaring status line, and it asserts its own busy verdict first
so it cannot silently decay into the idle-pane case that
test_housekeeping_paused_unpaused_cleared already covers. What it pins is now the
inverse guard: a busy pane must not GATE the clear either, so an over-correction
that kept the marker alive whenever the pane is busy would fail it.

test_housekeeping_busy_declared_wait_matures_its_window is the new regression, over
both declaration forms. It asserts the busy verdict, then that ticks inside the
window neither escalate nor let the marker be recreated with a fresh timestamp,
then exactly one recheck past the window named for the right human and never a
wedge, then silence on the next tick inside the reset window. It fails on
unmodified main with "produced 0 escalations past its window, expected exactly
one".

Refs #3149

* fix(bin): let a declared wait outrank an enriched wedge escalation

handle_wake classifies a stale wake through classify_stale, which returns a
`pause` verdict for a crew whose latest status line declares an external wait or
a verified captain-held transfer. It then threw that verdict away whenever the
wake reason matched `idle *s, possible wedge, escalation *`, so the watcher's
enriched wedge decoration outranked the crew's own declaration and a healthy
declared wait was escalated once per FM_STALE_ESCALATE_SECS for as long as the
wait lasted.

The enriched reason earns its precedence over the daemon's cheaper status-log
absorption honestly - it carries the watcher's escalation count and its explicit
"do not re-absorb on the run-step/pane state alone" demand. A `pause` verdict is
not run-step or pane state. It is the crew's own declaration that this pane waits
by design, which is precisely the question the wedge timer cannot answer for
itself, so it is the one verdict that decoration must not override.

The two classifications genuinely disagree in steady state rather than only in a
race: a crew that declares `paused:` while its no-mistakes run is still attributed
to its code reads `working` to the watcher's pause_state_class, so the watcher
takes the wedge timer while the daemon's classify_stale reads the status log and
correctly returns `pause`.

The wait stays bounded, not silenced. Absorbing to the pause action records the
declared-wait marker and drops wedge aging, and housekeeping (2b) then owns the
re-surface, so the pane still reaches the captain - once per PAUSE_RESURFACE_SECS
as an explicit "recheck whether the wait still holds", instead of once per
FM_STALE_ESCALATE_SECS as a possible wedge. Measured on a throwaway state root
over five wedge cadences for one declared wait: 5 escalations climbing to
demand-deep-inspection before this change, 0 after, with the one bounded recheck
still delivered.

tests/fm-daemon.test.sh: test_stale_diagnostic_wedge_survives_busy_housekeeping's
`paused` case pinned the old precedence on purpose, asserting exactly one
escalation carrying the demand-deep-inspection payload. That case now asserts the
pause cadence instead - no escalation inside the window, pause tracking recorded -
while the `working` and `prior-terminal` cases keep asserting the enriched wedge
verbatim, so the override itself is still pinned everywhere it is correct.

test_enriched_wedge_under_declared_wait_uses_pause_cadence is the new regression.
It asserts the fixture's own classifier verdict is a pause first, so the case
cannot go vacuous, then drives four consecutive wedge-cadence deliveries in both
the plain and demand-deep-inspection forms through the real handle_wake and
housekeeping pair, then matures the window for exactly one awaiting-external
recheck, then lifts the declaration and requires the same enriched wedge to
escalate again unchanged. It fails on unmodified main at the first delivery.

Refs #3149

* no-mistakes(review): align afk skill recheck wording with still-declared contract

* no-mistakes(document): daemon doc comments: pause window ages on declaration

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): recover Claude auto-arm from hung claims (#3156)

* fix(bin): make Claude auto-arm continuity self-heal past a hung claim

On a Claude primary, a Stop-hook auto-arm process that hung mid-arm held
the single-flight owner lock with its epoch ledger frozen at
outcome=arming, and the abandonment proof read any live lock holder in
arming as legitimately deciding forever. Every later Stop firing exited
0 at the lock, the turn-end guard kept deferring to the hung owner as
recovery under way, and the watcher was never auto-re-armed again for
the rest of the session - supervision survived only on manual arms and
lapsed between them (the 2026-08-26 watcher flap).

Corrections layered onto the lock-held-across-arm shape each reopened
the same concurrency class one level down, so this replaces the claim
machinery wholesale with a generation-based optimistic design:

- The epoch ledger's monotonic sequence IS the claim generation; the
  two-line entry (classic epoch record plus the claimant's MANDATORY
  pid-identity) is the claim. Every firing defers to a live OPEN claim:
  outcome arming, owner alive, identity recomputes and matches, and not
  stuck (entry and watcher beacon both older than the guard grace).
- A finished, dead, identity-mismatched, identityless, or stuck claim
  is superseded by simply taking the next generation - no signalling or
  revocation of a steady-state predecessor.
- No mutex is held across arming or output; the owner lock survives
  only as a micro-mutex around individual ledger writes. A superseded
  owner goes completely silent: ownership is re-verified before every
  arm invocation, episode-state mutation, ledger write, and
  continuation.
- The irrevocable commit point of a translation is the exit status
  (the harness delivers the collected stderr only on exit 2), so the
  owned terminal ledger write is the atomic commit: the winning
  generation exits 2 unconditionally after it, a refused one exits 0
  silently even after printing, and the once-per-episode failure notice
  commits in the same owned critical section as the winning failed
  write. Two bounded residuals are documented accepted intent: an owner
  dying between its owned write and its own exit, and a hung old-build
  owner resuming during the one legacy upgrade window.
- The pre-generation lock-holding claim shape keeps defer-or-reclaim
  behavior through a legacy shim: a live identity-verified stuck owner
  is retired via TERM (with a queued TERM sufficient when the owner is
  stopped) before its lock is removed, an unverified or identityless
  pid is never signalled but never blocks a proven-abandoned reclaim,
  and the lock's identity evidence is grafted into the ledger
  (mtime-preserving) so pid-reuse protection survives the lock.
- The guard reads the same predicates for recovery ownership and its
  terminal fail-open (which re-checks for a live open claim under the
  held locks before committing the attended alarm), with ledger reads
  anchored to line 1 so the identity line can never confuse them.

Behavioral regression coverage exercises all three edge classes through
the real hook and guard - a live open claim defers with no lock held, a
stuck claim is superseded and the home re-arms, and an end-to-end run
with a genuinely hung owner shows a concurrent firing deferring
promptly mid-arm, a later firing superseding the stuck owner, and the
superseded owner exiting silently without a second translation - plus
the identityless/reused-pid loopholes, the superseded-owner arm
boundary, and the legacy TERM, SIGSTOP, and signal-free reclaim paths.

* no-mistakes(review): Refuse auto-arm commits when notice marker creation fails

* no-mistakes(review): Make episode reset atomic with generation ownership

* no-mistakes(document): Update auto-arm generation and commit documentation

* fix(bin): verify the real GitHub merge outcome instead of reporting an unproved merge (#3064)

* fix(pr): verify GitHub merge outcome

* no-mistakes(review): Captain, fixed forge-only merge verification, queue guidance, metadata propagation

* no-mistakes(document): Correct forge-specific merge documentation

* no-mistakes(review): Captain: forge-only queue fix, focused tests pass

* no-mistakes(review): Captain: suppress closed-state guidance and prove parent regression

* no-mistakes(review): Captain: remove history proof; retain executable regressions

* no-mistakes(document): Clarify GitHub recording timing in architecture docs

* no-mistakes(document): Clarify outcome-aware PR merge recording documentation

* no-mistakes: apply CI fixes

* Revert "no-mistakes: apply CI fixes"

This reverts commit c326cfa9430c6173eedc8ff7f27d19d0552daf01.

The automatic CI repair round removed the up-front `gh` prerequisite check
while keeping the `gh` dependency: `bin/fm-pr-merge.sh` still calls
`gh api graphql` for the outcome read and `gh api` for the branch-rules read.
That left the same hard requirement without the clear named error, and review
immediately raised a new finding for exactly the failure the check prevents -
`gh-axi pr merge` landing the merge while the follow-up read fails, so the PR
metadata is never recorded.

The check is also symmetric with the GitLab arm directly above it, which
already refuses up front when `glab` or `jq` is missing, on the stated
principle that a missing tool should be a named prerequisite rather than a
merge that is armed and then refused for an unexplained reason.

The workflows this round was chasing sit at `action_required` because this is
a fork pull request; no code change can turn them green.

* fix(pr): keep PR bookkeeping when a merge outcome read fails

On the GitHub path a merge call that returned success was followed by
`github_read_outcome || exit 1`, so a transient API failure, rate limit,
or network blip during the read dropped out of the script before
`record_pr_metadata` ever ran. The merge could have landed while `pr=`
went unrecorded and the merge poll was never armed - bookkeeping lost on
a real merge. The failure path just above already recorded metadata
before exiting, so the error path was more careful than the success one.

Record the PR before that refusal. Recording arms the later merge poll
and is not a success claim, which is the same reasoning that keeps
`record_pr_metadata` on the gh-axi failure path. The refusal itself is
unchanged: exit stays non-zero and the message still names the concrete
observed state. Metadata is withheld only when the read succeeds and
proves the pull request neither merged nor queued.

Pin it with a case that stubs `gh api graphql` into failure after a
successful `gh-axi pr merge`, asserting both the non-zero exit and the
recorded metadata.

* no-mistakes(review): Aggregate queue rules and report conflicts explicitly

* fix(pr): keep the merge abstraction reachable and its bookkeeping intact

Two holes remained in the outcome-verified GitHub merge path, both on
installations where gh-axi is present but gh is not.

The verification preflight refused before bin/fm-pr-merge.sh ever reached
the configured gh-axi merge abstraction, so an installation without gh
could no longer merge at all. gh-axi now performs the merge unconditionally
and the queue-aware gh read became an optional enrichment: with gh on PATH
its GraphQL view still separates merged from queued, and without gh the
gh-axi view still proves a landed merge while every outcome it cannot prove
refuses.

The PR metadata recording sat behind the outcome read, so a merge that
landed before that read failed lost pr= and its merge poll. Recording now
happens once, before either forge call, which arms the poll without
claiming a landed outcome and leaves teardown a PR identity to verify
against no matter how the read ends.

Rebasing onto main also restored the durable merge-outcome reporting and
the GitLab landed-state confirmation that the conflict resolution dropped.

Tests pin each fix through the executable interface: the merge abstraction
is reached and verified with gh absent, a failed fallback read keeps its
bookkeeping, and a mock that snapshots the task meta during the forge call
proves pr= is recorded before the merge can land.

* no-mistakes(review): fix(pr): de-dup queue methods, fall back on failed gh read, refresh contracts

* no-mistakes(review): fix(pr): quote forge output and explain armed auto-merge on refusal

* no-mistakes(review): fix(pr): claim auto-merge armed only when the forge accepted it

* no-mistakes(review): fix(pr): tell the operator what each GitHub refusal could not observe

* no-mistakes(review): fix(pr): gate every forge-acceptance claim on a successful merge

* no-mistakes(document): align merge docs with verified GitHub outcome contract

* fix(pi): prevent duplicate captain outcome reports (#3184)

* fix(pi): stop reporting one merge to the captain twice

The supervision branch's captain-outcome note told main, unconditionally,
that the note "is not your own earlier output" and to relay it now. When
main had already reported the same event, that assertion was false and the
order turned the correct response - saying nothing new - into a mechanical
re-report, so the captain saw one merge reported twice in 16 seconds.

Two independent changes, both needed:

- The relay instruction is now conditional. It still names itself as a
  supervision outcome so main cannot mistake it for its own earlier answer
  (the silent loss that instruction exists to prevent), and it now lets
  main stay quiet about an outcome it has already given the captain.

- The merge case is closed at its source rather than left to that judgment.
  One merge reaches a home on two independent paths by design - main's own
  permanently main-owned merge poll, and the branch's task-local status
  wake - and main's captain-facing text only reaches the branch's mirror at
  main's turn end, so the branch can escalate before it could possibly see
  the captain was already told. bin/fm-pr-merge-notified.sh answers that
  question from bin/fm-pr-lib.sh's canonical merge-notification marker, so
  the answer holds regardless of mirror timing. A captain outcome naming an
  already-published merge is delivered as the ordinary rendered note
  instead of opening a follow-up turn: still appended, still visible, still
  recorded with the verdict the branch decided, minus the wasted turn.

Any error, timeout, or unreadable state relays the outcome. A duplicate
announces itself; a lost outcome does not.

Regression coverage drives the real delivery path in both directions: a new
outcome must still reach the captain in exactly one follow-up turn even
beside an unrelated published merge, and an already-published merge must
open no second turn while a different PR in the same task still does. The
merge path's real producer and this new consumer are exercised end to end
in tests/fm-pr-merge.test.sh.

Pi-only by construction: the delivery path lives in .pi/extensions, so no
other harness loads it, and the new script only reads existing markers.

* no-mistakes(review): Document accepted latest-marker suppression residual

* no-mistakes(review): Recheck ownership before merge outcome delivery

* no-mistakes(document): Document merge-outcome suppression exception

* refactor(pi): drop the source-level merge suppression, keep the envelope fix

The captain reviewed this branch and judged the source-level duplicate
suppression overly complicated for the problem it solved, and asked for
the change to be reduced to the envelope wording alone.

Remove the mergeIntoMain downgrade path, bin/fm-pr-merge-notified.sh, and
every test and document that existed only for it. What remains is the
conditional captain-outcome instruction: main is told to stay quiet about
an outcome it has already reported and to relay anything else, which
covers the duplicate without a second mechanism.

The silent-loss protection is untouched - the note is still typed,
self-describing, and delivered as one invisible follow-up turn - and the
behavioral tests still assert that, now requiring both halves of the
conditional instruction.

* no-mistakes(ci): Clarified in code comments and owned documentation that this is intentionally an M1-only, model-facing conditional relay fix—not source-level suppression—addressing Greptile’s mistaken scope expectation without changing runtime behavior. Net diff remains 3 files and 27 insertions. Verified with fm-pi-branch-extension tests, fm-lint, doc audience check, and git diff --check; all passed

* no-mistakes(ci): Strengthened the runtime delivery test to verify the captain outcome retains its required self-description and outcome text. Verified with `bash tests/fm-pi-branch-extension.test.sh`, `bin/fm-lint.sh`, `bin/fm-doc-audience-check.sh`, and `git diff --check`; all passed. The outer pipeline can now commit and attest the new head

* fix(bin): prioritize active pipeline-owned crew runs (#3194)

* fix(bin): bind the live pipeline-owned run instead of a superseded failed row

fm-crew-state.sh bound a superseded FAILED no-mistakes run to a task instead
of the LIVE replacement run: the live run's pipeline-owned lane head is not a
git object in the task worktree, so head-equality attribution rejected it and
the coarse runs-list fallback silently continued past the RUNNING row onto an
older failed row whose head equalled the stale worktree HEAD. The home summary
then flipped invalid and Bearings hid the home's live work (F10).

Attribution precedence now follows the daemon's own identity:
- An ACTIVE run for the task's branch binds without head equality while
  branch_sync.state is pipeline_owned (fm_nm_run_is_pipeline_owned_active);
  the pipeline owning the branch is itself the attribution.
- A genuinely failed run with no later run on the branch still reports failed
  through the unchanged head-equality path - real failures are not hidden.
- In the coarse runs scan, an unresolvable head is unknown attribution and
  stops the scan (fm_nm_head_resolvable) instead of falling through to an
  older row; a resolvable-but-mismatched head keeps the historical
  reused-branch skip.

The exemption never applies to a terminal run and requires pipeline_owned
specifically, both pinned by negative-control tests. Fixture shape verified
against the live incident run's real axi status output.

* no-mistakes(document): Updated run-attribution documentation ownership

* fix(pi): surface requested outcomes without replaying fleet events (#3211)

* fix(pi): surface requested supervision outcomes

* no-mistakes(review): Mirror in-flight captain requests before branch dispatch

* no-mistakes(review): Exercise real branch ownership and main outcome access

* no-mistakes(review): Preserve request tails and align verdict guidance

* no-mistakes(review): Preserve complete current captain requests

* no-mistakes(review): Require visible requested outcomes and realistic classification

* no-mistakes(document): Align supervision outcome documentation

* no-mistakes(ci): Fixed Greptile’s runtime-ordering finding. The extension now stages Pi’s authoritative `before_agent_start` prompt before SessionManager persistence and suppresses the later duplicate entry. Updated docs and behavioral regression to reproduce real Pi ordering and verify each prompt is mirrored exactly once. Passed branch-extension tests, supervision tests, strict Pi typecheck, full lint, and diff checks

* no-mistakes(review): Use canonical operational input classification

* no-mistakes(review): Filter legacy operational inputs canonically

* no-mistakes(document): Clarify captain request mirroring boundary

* no-mistakes(ci): Fixed the CI time-boundary failure in tests/fm-public-followup.test.sh by pinning its clock, including context-registry setup. This prevents follow-up fixtures from expiring based on wall time. Verified the full regression suite passes, project-owned lint passes, and git diff checks are clean

* no-mistakes(document): Clarify captain-visible supervision outcome documentation

* feat(bin): add concurrent bounded remote transport lanes (#3210)

* feat(bin): per-home remote transport lanes with cancellation, bounded send, and closed stdin

All remote commands for every home on one host used to serialize through one
single-job-at-a-time worker on one shared queue: a timed-out caller abandoned a
staged job that kept running, retries convoyed behind it, fm-send's remote leg
had no time bound, and staging captured the caller's stdin to EOF so any
fm-on.sh caller with an open stdin wedged staging indefinitely.

- The worker now serves one lane per staged home: same-home jobs run strictly
  FIFO in a new staging-sequence order while different homes run concurrently,
  each lane as its own top-level worker process (a backgrounded subshell does
  not reliably reap dead children, so a zombie group leader kept a finished
  command's process group signalable). Long-poll preemption is lane-scoped.
- A caller that disconnects or times out cancels its job: the entrypoint marks
  the record on any post-staging exit and probes its parent so a dead ssh
  channel cancels without a signal; the worker skips cancelled queued jobs,
  terminates a running cancelled job's process group, and reaps the record.
- fm-send's remote leg is bounded by FM_SEND_REMOTE_BUDGET (default 30s) and a
  bound hit exits through the existing unconfirmed-delivery contract, which
  stays idempotent because the remote enqueue deduplicates.
- fm-on.sh defaults the remote command's stdin to /dev/null; the three payload
  callers pass the new --stdin flag. Abandoned .stage.* litter is age-reaped.
- The job execution deadline no longer loses up to a second to clock
  truncation.

* no-mistakes(review): Protect live stages and validate send budgets early

* no-mistakes(review): Preserve sequence lock ownership during stale recovery

* no-mistakes(review): Allocate job sequences at publication boundary

* no-mistakes(review): Bound remote keys and extend stale lock recovery

* no-mistakes(document): Document bounded remote transport behavior

* no-mistakes(lint): Suppress intentional deferred-expansion lint warning

* no-mistakes(ci): Fixed stale sequence-lock recovery by reconciling the counter against published job records before allocating the next sequence, preventing duplicate sequences and same-home FIFO violations. Added a behavioral regression test reproducing displacement after publication and verifying execution order. Passed fm-remote-transport-lanes.test.sh, fm-remote-job.test.sh, fm-lint.sh, and git diff --check

* no-mistakes(review): Use atomic sequence claims and lossless lane keys

* no-mistakes(review): Recover regressed sequence hints and rate-limit claim reaping

* no-mistakes(review): Restrict worker heartbeats to serving loop

* no-mistakes(review): Verify supervisor identity before lane recovery signals

* no-mistakes(review): Verify tracked lane and claim owner identities

* no-mistakes(document): Clarify remote lane and transport contracts

* no-mistakes(ci): Fixed the CI time-boundary failure by pinning fm-public-followup tests to a deterministic clock, including context-registry setup. Verified tests/fm-public-followup.test.sh, tests/fm-remote-transport-lanes.test.sh, shellcheck, and git diff --check

* no-mistakes(review): Preserve assigned lane ownership of queued jobs

* no-mistakes(review): Reserve homes owned by foreign queued lanes

* no-mistakes(review): Preserve completed results during crash recovery

* no-mistakes(review): Harden claim cleanup, expiry, and cancellation races

* no-mistakes(review): Verify process groups and reap abandoned results

* no-mistakes(review): Stop leaderless groups and reap cancelled publications

* no-mistakes(document): Correct remote transport lifecycle documentation

* no-mistakes(lint): Quote done state comparisons for ShellCheck

* fix(bin): accelerate and bound changed test runs (#3250)

* fix(tests): make the changed-file map select per script and stabilize a budget flake

The changed-file map's bin/ fallback resolved a direct test reference to that
test's whole FAMILY. bin/fm-push-transition-lib.sh is named by exactly one
real-Herdr E2E, so a one-line change to it selected all 12 real-herdr-gated
scripts, including a 341s presentation E2E with no dependency on it.

Resolve direct test references per script, and keep resolving consumer bin/
scripts through the curated map so recorded family-level coupling survives.

Also fix a load-sensitive flake: the tool-update budget deadline is whole-second
granular, so a test budget of 1 left headroom anywhere in (0, 1] seconds and the
first budget check could already read as exhausted.

* feat(bin): make suite wall clock a result and let a family's concurrency be proven

--max-wall-ms fails a run whose wall clock exceeds the caller's budget, after
reporting the per-script results. A suite that stays green while outgrowing its
caller's invocation budget is the regression that got an agent killed mid-run
and retried invisibly, so duration has to be a result rather than a log note.

--pool on the isolation-proof harness runs the same concurrent proof over a
whole family, so 'is this family safe to parallelize?' is answered by a command
instead of a guess. Measured watcher-wake-lock and refused it: 3 of 18 scripts
fail under concurrency on wall-clock assertions about reaching the next poll.

* perf(bin): schedule the changed suite concurrently, longest first

The watcher-wake-lock family is proven concurrent-…
ameen-saeed added a commit to ameen-saeed/firstmate that referenced this pull request Sep 1, 2026
…t and SSF-style agent-cookbook (#1)

* feat(bin): deliver remote steers through durable inboxes (#2901)

* feat(bin): deliver remote secondmate steers through durable task inboxes

Stage 2 of the inbox+doorbell steer channel (stage 1: #2856). A remote
secondmate steer now crosses fm-on.sh as a durable record written
idempotently into the remote home's steering inbox plus a best-effort
remote doorbell, and the last typed-payload steer transport is deleted:

- fm-remote-secondmate-control.sh cmd_send writes the record via the new
  fm_task_inbox_write_idempotent and rings the doorbell; it no longer
  types the payload through an inner fm-send at an explicit pane target.
- fm-send.sh routes every remote text steer (harness-native included,
  which marking already reduced to chat) onto the remote inbox leg,
  retries the identical leg once on ssh 255, closes --resolve-key
  decisions at enqueue for remote too, and preserves a marked request's
  reply expectation when completion stays unknown. The exit-3-as-
  delivered remap, the 255 do-not-resend trap, and the remote typed
  submit block are removed.
- fm-task-inbox-lib.sh owns the idempotent enqueue: an exact-body re-run
  lands on the existing record, handled or not, so an ambiguous
  transport can always be safely re-run.
- Tests pin the new contract end to end (record + doorbell + no typed
  payload across ssh, one-record idempotence under an ambiguous
  transport, enqueue-time decision close, loud real failures, and the
  deleted typed-payload behaviors gone), and AGENTS.md plus
  docs/remote-secondmates.md describe the remote leg's new semantics.

* no-mistakes(review): Harden remote inbox delivery against lifecycle races

* no-mistakes(review): Enable correlation-preserving remote steer resends

* no-mistakes(review): Fail closed on stale correlation resends

* no-mistakes(review): Include home context in remote resend commands

* no-mistakes(review): Lock and revalidate remote parent routes

* no-mistakes(document): Clarify remote steer retry documentation

* no-mistakes: apply CI fixes

* feat: add persistent Pi supervision branch (#2858)

* wip: forked supervision on Pi (checkpoint before docs)

* fix(pi-branch): harden mirror delivery, fallback encoding, and session replacement

Peek-then-shift mirror flush so a failed append retries instead of dropping;
durable mirror cursor commits only after delivery into the branch;
the main fallback wake is operational-encoded like every watcher injection;
session_shutdown quiesces the generation and session_start re-arms, so /new
and /resume no longer kill the branch permanently. Registers the extension in
the strict typecheck, adds the dispatch handshake test, the branch extension
suite, the bash-level regression suite, the session-start replay test, and
the opt-in real-SDK live guard.

* test(fixtures): carry the branch-dispatch lib and lease lib into isolated fixtures

The watcher extension now imports lib/fm-branch-dispatch.ts and fm-teardown
sources fm-lease-lib.sh, so every fixture that copies or symlinks those
files in isolation gains the new sibling.

* no-mistakes(review): Prevent shutdown wake loss and serialize lease claims

* no-mistakes(review): Durably hand off wakes and retain portable leases

* no-mistakes(review): Require durable reports and clear disposed branch leases

* no-mistakes(review): Enforce per-wake outcomes and quiescent lease cleanup

* no-mistakes(review): Require wake acknowledgements and tighten branch lifecycle boundaries

* no-mistakes(review): Require complete acknowledgements and replay cleanup failures

* no-mistakes(review): Bind supervision to lock ownership and durable delivery

* no-mistakes(review): Activate branch lazily after session lock acquisition

* no-mistakes(review): Preserve undelivered mirror context across extension rebinds

* no-mistakes(review): Acknowledge startup replay only after main delivery

* no-mistakes(review): Isolate replay metadata from untrusted digest content

* no-mistakes(review): Reject duplicate reports for active wake sequences

* no-mistakes(review): Retain failed fallbacks and deduplicate outcome replay

* no-mistakes(review): Deduplicate durable outcomes and cache delivery receipts

* no-mistakes(review): Anchor wake sequence matching to outcome fields

* no-mistakes(document): Clarify Pi supervision durability contracts

* no-mistakes(lint): Fix ShellCheck issues in branch supervision scripts

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* refactor(pi-branch): collapse to confused-agent-grade guards per captain decision

Captain decision A: the lease/actor guards target the CONFUSED-AGENT threat
model bin/fm-gate-refuse-lib.sh already documents; adversarial-grade
separation is impossible in the shared-process design and is filed as
separate follow-up work. Rip out the machinery that chased it: the
generation fence and shell-provenance markers, the wrapper-tagged ancestry
walks, guard auto-claim with per-script release traps, the pending-wake
files and ack-receipt correlation (the durable wake queue already
re-presents anything unacknowledged), the delivery-receipt store with
contiguous cursor advancement, the session-start replay-metadata channel,
and the branch tool quiescence counters.

Keep the behaviors the board requires, each on its simplest implementation:
lazy per-action session-lock ownership (cold start activates after the lock
lands; a secondary session stays inert), mirror durability across extension
rebinds via the durable cursor, replay-exactly-once from the one read
cursor, the awaited operational-encoded fallback, per-generation stray-lease
cleanup, session-lock-bound lease liveness (a recycled pid or a non-Pi home
never honors a leftover lease), the loud accidental-override guards
(readonly actor prelude, cross-actor claim refusal), and the role-partition
refinements (no forced teardown, no direct relaunch for the branch).
Default-on-for-Pi is unchanged.

* no-mistakes(review): Enforce lock ownership and serialize lease mutations

* no-mistakes(review): Synchronize guard cleanup and bind leases to lock owner

* no-mistakes(review): Report outcomes before acknowledging durable wakes

* no-mistakes(review): Restrict leases to Pi and instruct main claims

* no-mistakes(review): Reject malformed lease locks and torn outcome tails

* no-mistakes(review): Validate complete outcome tails before appending

* no-mistakes(review): Guard branch side effects across session replacements

* no-mistakes(document): Update Pi supervision durability and lease documentation

* no-mistakes(lint): Suppress intentional nested-shell expansion warning

* no-mistakes: apply CI fixes

* fix(pi-branch): authorize lease releases by caller

* fix(lint): break redundant source-analysis path in fm-lease-lib.sh

fm-lease-lib.sh's lazy fallback source of fm-wake-lib.sh gave ShellCheck's
--external-sources traversal a second path into an already 1540-line file
that fm-send.sh and fm-teardown.sh also source directly, blowing up the
recursive analysis past CI's lint timeout. Mark it a source=/dev/null
analysis boundary, matching the existing fm-task-inbox-lib.sh convention.

Also restores bin/fm-lint.sh and tests/fm-lint.test.sh to the shared
serial-lint definition (dropping an unrelated parallel-sharding change
that was itself hanging and masked this root cause).

* no-mistakes(document): Correct lease caller-authorization documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* fix(bin): parallelize startup network sweeps (#2927)

* feat(bin): parallelize session-start remote secondmate network sweeps

Run per-secondmate liveness and convergence probes concurrently and overlap clone refresh, while replaying each mate's fail-closed diagnostic in original order. Ignore scratchpad* so untracked scratch no longer blocks remote sync.

Co-authored-by: Cursor <cursoragent@cursor.com>

* no-mistakes(document): Document parallel startup network sweeps

* no-mistakes(lint): Fix empty environment assignment lint warning

* no-mistakes: apply CI fixes

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: handle absent watcher wake queues (#2845)

* fix(tests): count declared-pause wakes without crashing on an absent queue

The exited-declared-pause case counts queued stale wakes by handing
state/.wake-queue straight to awk. A watcher that queues nothing never
creates that file, and awk aborts on a missing path before its END rule
runs, so the count collapses to the empty string. The next comparison
then fails as an integer-expression error and surfaces as a wake flood
with no number, hiding the real contract breach the following grep names.

Read the queue the way the drain-count assertion at the end of this file
already does: silence awk's open error and default an absent queue to
zero. Applied to all four counts in this case, including the live
external-decision gate pair whose queue an acknowledged drain can also
leave behind. An absent queue now reports "did not use the bounded
paused recheck", while a genuine flood still fails with its real count.

Fixes #2628

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* style(pi): distinguish routine and captain supervision merge notes by icon (#2934)

* style(pi): restyle supervision merge notes with a sailboat and matching pad

Secondary-session notes were flush against the TUI edge and fully tinted.
Use the sailboat prefix, Pi's default outputPad, boat-only color, and dim remainder so they sit like real messages.

* style(pi): distinguish routine and captain merge notes by icon only

Visible notes now lead with a sailboat or anchor, then only the dim outcome.
Drop the branch-merged wording and verdict brackets so the icon is the only kind signal.

* docs(pi): add the approved multi-brain architecture poster (#2938)

The markdown contract stays the owner; the still is only the visual of the idea.

* feat(pi): default branch supervision and route heartbeats (#2939)

* fix(bin): bound remote job worker supervisor restarts (#2942)

* fix(bin): bound remote worker supervisors

* no-mistakes(review): release incumbent supervisor before starting its replacement

* no-mistakes(review): wait out a healthy same-root supervisor instead of replacing it

* no-mistakes(review): narrow remote worker change to restart accounting only

* no-mistakes(document): clarify supervisor restart guard is a lifetime total

* fix: safely split supervision wake handling by actor (#2953)

* feat(bin,pi): per-actor wake consume, silent success gating, merge-poll dedup

Three related fixes to the shared wake-drain and Pi supervision-branch
dispatch machinery so a routine success is never main-blocking and a
mixed queue can safely split between actors.

1. Successful routine results no longer create main-blocking wake rows.
   fm-startup-network.sh only enqueues a check: startup-network wake when
   the deferred result is actionable (state is not "done", or the report
   carries a bootstrap-diagnostics actionable prefix); a clean success
   stays durable in the report file without ever waking the agent.

2. Per-actor wake-drain consume contract. bin/fm-wake-drain.sh now scopes
   presentation and --ack-through to the current actor
   (bin/fm-lease-lib.sh's fm_lease_actor): main keeps the original
   whole-queue cutoff behavior, unaffected. A branch actor
   (FM_SUPERVISION_ACTOR=branch, set only inside the Pi supervision
   branch's own bash tool calls) is scoped to an explicit eligible-row
   snapshot instead of a cutoff comparison, so it can never remove a row
   it was not granted - the fix for the swallow risk that used to force
   an all-or-nothing whole-queue fallback to main.
   .pi/extensions/lib/fm-branch-dispatch.ts's scopeForUnreadWake is the
   single owner of eligibility: a check-kind row (merge-confirmation
   polls, Relay mentions, credential/auth failures) is now excluded
   rather than vetoing the whole scan for a non-heartbeat wake, while a
   heartbeat review keeps its original all-or-nothing rule unchanged.
   writeEligibleRowsSnapshot publishes the exact eligible sequence
   numbers before every branch prompt; fm-primary-pi-watch.ts's offer
   still refuses a check-kind trigger outright so a main-only close is
   never itself routed to the branch.

3. A repeat identical merged-PR-poll result for an already-notified task
   is absorbed instead of enqueued again. A poll's own retirement state
   is scoped to one registration and cannot see a prior registration's
   outcome, so a task re-registered after its merge was already surfaced
   would otherwise wake main a second time for the same event.
   bin/fm-pr-lib.sh's new per-task pr-poll-merge-notified marker survives
   across re-registrations to catch that case; the first notification for
   a task still reaches main unchanged.

Regression tests colocated in tests/fm-startup-network.test.sh,
tests/fm-wake-queue.test.sh (including the mixed-queue no-swallow
property), tests/fm-pi-branch-extension.test.sh, and
tests/fm-pr-check-security.test.sh. docs/watcher-continuity.md and
docs/pi-supervision-branch.md updated for the new contracts.

* no-mistakes(review): Bind merge deduplication to canonical PR identity

* no-mistakes(review): Serialize wake row ownership across main and branch

* no-mistakes(review): Bind branch grants and deduplicate within actor claims

* no-mistakes(review): Fallback main-owned wake claims to main delivery

* no-mistakes(review): Clarify silent startup success guidance

* no-mistakes(review): Release residual branch grants after settled prompts

* no-mistakes(review): Reject truncated wake rows as corrupted

* no-mistakes(document): Document per-actor routing and silent startup success

* no-mistakes(lint): Fix ShellCheck findings in wake grant and startup test

* no-mistakes: apply CI fixes

* fix(pi): hide branch outcomes tool rows in Calm (#3024)

* Hide branch outcome tool in Pi Calm

* no-mistakes(review): Preserve stock outcomes rendering and document tool audit

* no-mistakes(review): Document branch read tool audit disposition

* no-mistakes(review): Match stock outcomes output sanitization

* no-mistakes(document): Document Calm custom-tool visibility

* fix: bind no-mistakes attestations to PR head (#3027)

* fix: delegate no-mistakes PR gate to pinned action

* no-mistakes(document): Document commit-bound no-mistakes attestations

* feat(pi): add persistent supervision branch model selection (#3028)

* feat(pi): let operators pin a cheaper supervision-branch model

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's model. A new /supervision-model
command opens Pi's own selector over Pi's own catalog of credentialed
models, plus a "Follow main" entry, and persists the pick as one
<provider>/<model-id> line in this home's gitignored
config/supervision-branch-model. Firstmate keeps no model catalog of its
own.

The branch resolves the pin at every branch build - the first wake of a
cold start and the reopen after /new, /resume, /fork, or reload - so the
choice survives all of them, and picking also releases the live branch so
the next wake reopens the same persistent branch conversation under the
new model. An absent, unreadable, or unparseable file means no pin and
keeps today's behavior byte for byte: no model option is passed and Pi
picks the branch's model exactly as before.

A pin naming a model Pi cannot hand back is never silently downgraded
onto main's model: the branch refuses to build and the wake falls back to
the captain-facing main path naming the unusable pin, which is the
extension's existing failure direction.

The choice is home-local and not part of secondmate inherited
configuration, matching the Pi Calm preference precedent.

docs/configuration.md owns the operator-facing schema. Portable
regressions cover pin-present on create and reopen, pin-absent default,
the command's persistence, cancellation, and live rebind, and both
unusable and unparseable pins. The opt-in real-SDK guard proves the
vendor surface the pin reads and that an explicit model wins over the
model a reopened session recorded.

* no-mistakes(review): Fix supervision model runtime and rebind races

* no-mistakes(review): Restrict supervision picker to isolated runtime models

* no-mistakes(document): Document supervision branch model selection

* fix(pi): make the supervision model pin authoritative on every reopen

Clearing the pin with "Follow main" removed the file but the next branch
build reopened the persistent branch session with no explicit model
override, so Pi restored the model that session had recorded - the old
pinned model - while the command reported that the branch now follows
main. The same gap meant an absent pin did not reliably mean
same-model-as-main once a home had pinned once.

The pin file's current state now decides the model on every branch build,
create and reopen alike, overriding Pi's session-state restore. With a
pin, that model. With no pin, main's own current model is applied
explicitly, tracked from the contexts Pi already hands the extension plus
its model_select event, since the branch is built at wake time with no
context of its own. Only when main's model is unknown, or this home's
stored credentials cannot run it in the isolated branch runtime, does a
build fall back to passing no override at all, which is the behavior from
before the pin existed; the branch is never refused over model choice.

The command's notification now reports the model actually applied, and
says plainly when clearing the pin could not apply main's model instead
of claiming a change that did not take effect.

No credential handling changes: the branch still relies entirely on the
stored credentials its own runtime already holds, and the picker stays
restricted to models that runtime can resolve.

Colocated regressions cover pin present on create and reopen, clearing
the pin returning a reopened branch to main's model and specifically not
the old pinned one, an unparseable pin behaving as no pin, and the
unknown-main-model fallback to no override.

* no-mistakes(review): Make unpinned supervision follow main model changes

* no-mistakes(document): Correct supervision model documentation

* feat(pi): let /supervision-model pick branch reasoning effort (#3079)

* feat(pi): let /supervision-model pick the branch's reasoning effort

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's reasoning effort any more than
it needs main's model. /supervision-model now settles both in one flow:
the existing model picker, then a follow-up effort picker built from Pi's
own supported thinking levels for the model just chosen. Firstmate keeps
no effort catalog of its own; the menu, the clamp, and the vocabulary all
come from Pi.

The pick persists as one line in this home's gitignored
config/supervision-branch-effort, independent of the model pin: a captain
may pin a model, an effort, both, or neither. The effort pin's current
state decides the branch effort on every branch build - the first wake of
a cold start and the reopen after /new, /resume, /fork, or reload - and
overrides Pi's restore of whatever level a reopened branch session
recorded, which is what keeps "Follow main" honest. With no pin, main's
own current effort is applied explicitly and followed live through Pi's
thinking_level_select event, the same way an unpinned branch already
follows main's model, and the two selections now share one build revision
so either change invalidates an in-flight build.

The branch is never refused over effort. Pi owns the clamp, so a pinned
level the branch's model cannot run becomes that model's nearest supported
level while the captain's raw pick is kept for a model that supports it,
and the command reports the level the branch will really run at rather
than the raw pin. A token Pi would not recognize at all is treated as no
pin rather than passed to that clamp, which would otherwise collapse a
typo into the model's lowest level. Only when main's effort cannot be read
either does a build pass no effort override at all, which is the behavior
from before this file existed.

Pi's own effort vocabulary is pinned by a bidirectional type assertion
against Pi's getThinkingLevel return type, so the tracked strict typecheck
against the installed package fails the moment Pi adds or removes a level.

docs/configuration.md owns the operator-facing schema for both pins.
Portable regressions cover the pin on create and reopen, model-only and
effort-only pins working independently, clearing a pin returning the
branch to main's effort, live-follow of a mid-session change, the clamp,
an unrecognized token, the unknown-main-effort fallback, and the command's
two-step flow, persistence, cancellation, and honest reporting. The opt-in
real-SDK guard proves the vendor surface all of that rests on, and also
repairs a pre-existing gap that left it unable to load the extension at
all.

* no-mistakes(review): Resolve effective branch effort honestly

* no-mistakes(document): Clarify Pi-owned effort picker behavior

* fix: keep routine supervision noise out of captain chat (#3093)

* fix(supervision): silence empty board closes and decouple the heartbeat

Two unrelated sources of noise put routine supervision events in the
captain's chat.

An empty Lavish board close - the captain reads a review surface, says
nothing, and closes it - became a check wake whose entire content was
that nothing happened. Suppress it at its source instead of routing it
anywhere: the generic runner gains a `silent` adapter seam mirroring the
existing `terminal` one, and the Lavish adapter answers it for exactly
one positively-determined shape, an `ended` session carrying no queued
content block. A silenced result is recorded durably handled so it does
not return on a later reconcile. Everything else announces unchanged - a
`Send & End` close carrying the captain's real answer, an `ended` result
still carrying content, a waiting or missing session, an unreadable
result, and every adapter that implements no `silent` command at all.
The keyed-answer feed is untouched, so suppressing an announcement never
suppresses the captain's own answer.

A fleet heartbeat was deferred to main merely because some unrelated
check row happened to be sitting unread, which put a routine fleet
review in the chat for a reason that had nothing to do with the fleet. A
check row is permanently main-owned, so it is now excluded from a
heartbeat claim rather than vetoing the scan, exactly as in every other
mode. What all-or-nothing guarantees is unchanged: the branch takes
every branch-ownable unread row or none of them, and an unresolvable
task-local row, an unknown row kind, or an unreadable queue still defers
the whole review to main. Main is still woken for the check on that
check's own triggering close, so nothing starves.

Main-only classes are unchanged and now each covered by a test: Relay
mentions, credential failures, merge confirmations, real board answers,
and watcher-failure repair. The per-actor acknowledgement and
no-cross-swallow properties are untouched.

* no-mistakes(review): Fail closed on all Lavish content headers

* no-mistakes(review): Suppress false unacknowledged status for silenced results

* fix(bin): stop a correlation token from hiding and stranding decisions (#1967)

* fix(classify): read the decision fold through a correlation token

status_line_verb stripped a trailing [key=...] from a status line's prefix
but left everything else glued to the verb, so a line carrying the
correlation token bin/fm-pending-reply-lib.sh embeds and a secondmate
echoes back matched no arm of _fm_decision_fold_line. Such a line folded as
ordinary status in both directions: a needs-decision or blocked opener never
opened its key, and a resolved or captain-held closer never closed one. The
same glued verb also hid correlated done and blocked lines from
status_is_captain_relevant and status_is_terminal_verb, and let correlated
working and resolved lines leak through the free-text fallback the
nonterminal guard was meant to stop.

The verb parse now walks whole words and drops only a token of the exact
shape a firstmate library writes - corr=<16 hex>, plus the bracketed form
bin/fm-secondmate-report.sh emits - before or after the key token, unkeyed,
or doubled. An arbitrary name=value word is deliberately NOT skipped:
skipping unknown tokens would let free text carrying an equals sign reduce
to a bare verb and impersonate a transition, which is the takeover the
strict parse and _fm_decision_key_transition_allowed exist to prevent. A
prefix with no corr= substring is returned byte-for-byte as before, so every
line without a token keeps its exact historical verb.

FM_OPEN_DECISIONS_FOLD_VERSION goes to 3, because every cursor persisted
under the previous reading carries an open set computed while correlated
lines were invisible and must be rebuilt from byte 0.

Measured over a real 383-line status log: 254 lines keep byte-identical
captain-relevance, pause, terminal-verb and captain-held verdicts, and all
129 changed lines carry a valid token - 14 correlated done/blocked/
needs-decision lines become captain-visible, and 20 correlated
working/resolved lines stop being escalated on prose alone.

* fix(review): Captain, block token-first decision impersonation

* fix(document): Clarify normalized status verb ownership

* fix(classify): reconcile the correlation-token read with the tag-stop parser

Rebasing onto main put this change beside #2280, which made verb parsing
stop at the first "[name=value]" tag. Both edit status_line_verb with
different intents, so the resolution keeps both rules rather than letting
one overwrite the other:

- #2280's tag stop is kept verbatim and now owns every BRACKETED tag,
  including the "[corr=...]" form fm-secondmate-report.sh writes. The
  bracket-unwrapping arm this branch had added to the token test is
  therefore removed as unreachable.
- This branch's token walk is kept and narrowed to the UNBRACKETED token
  fm-pending-reply-lib.sh writes, which the tag stop does not reach.

Two consequences of standing beside #2280 rather than before it:

The fold version had collided at 4: #2280 spent it on the tag-stop
parser and this branch had spent it on the token read. A cursor
persisted under #2280's reading predates this one and must still be
rebuilt, so the version moves to 5.

A bracketed impostor is dropped from the malformed-token list. On main
today "resolved [corr=deadbeef] [key=victim]:" already reads as the bare
verb, as does "resolved [anything at all] [key=victim]:", because the tag
stop ends the parse at the opening bracket regardless of content. That is
#2280's reviewed contract; asserting otherwise here would narrow it. The
unbracketed impostors it owns stay strict and still fold as prose.

Adds a consumer test for the two verb-string case arms that postdate this
branch: fm-supervise-daemon.sh's transient-stale arm and
fm-crew-state.sh's map_log_state.

* fix(review): Captain: Seed cursor migration fixture with version four

* fix(document): Clarify voice status normalization ownership

* fix(bin): Cursor-Park unter Pi-Host ohne Cursor-Identität stilllegen (#3115)

* fix(bin): Cursor-Park unter Pi-Host stilllegen.

pi-cursor-sdk lädt .cursor/hooks.json in die Pi-Sitzung und parkte einen zweiten Watcher; das erzeugte rearm-resurface und brach laufende Rückfragen ab. Bei PI_CODING_AGENT=true beendet der Park sofort, native cursor-agent Primaries bleiben unverändert.

* fix(bin): Cursor-Park trotz PI-Leak nur ohne Cursor-Identität stilllegen.

Stand-down gilt nur bei PI_CODING_AGENT=true ohne CURSOR_AGENT und ohne CURSOR_INVOKED_AS. Handgestartete cursor-agent Primaries mit geerbtem PI-Marker parken weiter.

* no-mistakes(document): Document Cursor park Pi-host stand-down

* fix(bin): no-mistakes-Mindestversion auf 1.46.0 anheben.

Die PR-Attestierung verlangt ab 1.46.0 strukturierte Pipeline-Schritte; der Bootstrap-Floor blieb bei 1.31.2 und ließ zu alte Builds zu.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(pi): make supervision model picker searchable and scrollable (#3099)

* fix(pi): make /supervision-model's model list bounded and searchable

Pi's generic extension selector renders every option at once with no
search box, so a real eligible catalog ran off the top of the terminal.
The model step now draws the same rows through Pi's own SelectList - the
bounded scrolling primitive behind Pi's /model picker - with Pi's own
Input and fuzzy filter above it for search, keeping 'Follow main' first,
the branch-runtime eligibility filter intact, and the pick branch-only.

Pi's ModelSelectorComponent is deliberately not reused: its selection
handler writes the captain's default model through Pi's settings manager,
which would move main's conversation as a side effect of pinning the
branch. The effort step's menu is a handful of levels and stays on Pi's
plain selector dialog.

* no-mistakes(document): Clarify supervision picker documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(document): Document searchable supervision model picker

* no-mistakes: apply CI fixes

* fix(bin): durably report merged pull requests (#3104)

* fix: make a landed merge leave a durable outcome

A merge was the one lifecycle event that left no record outside the
merging agent's memory. bin/fm-pr-merge.sh ended at the forge call, and a
home merging under standing authority never waits for the merge poll that
would otherwise confirm it, so three real merges reached the captain as
silence.

bin/fm-merge-outcome-lib.sh is the single owner of that record. A
secondmate home reports the landed PR upward on the same parent reply
channel its terminal-outcome backstop already uses; a main home records
it on the durable wake queue. The record is at most once per task and
canonical PR identity, and only a merge that actually landed produces one.

The merge poll feeds that same channel when it detects a merge this home
did not perform, so the captain's own forge merge and a merge firstmate
performed itself produce one consistent outcome instead of two reporting
paths. No new state file and no second watch path.

Two smaller gaps from the same failure:

- A mate charter listed its report triggers without naming a landed
  merge. Under standing merge authority nothing is ever "ready for
  review", so the enumerated list silently omitted the case that matters.
- A secondmate home seeded without its parent binding failed every
  terminal-outcome report for the same reason, and the diagnostic never
  named the binding. It does now.

* no-mistakes(review): Harden durable merge outcome reporting

* no-mistakes(review): Make merge race regression deterministic

* no-mistakes(review): Make merge outcomes retry-idempotent and forge-confirmed

* no-mistakes(review): Unify merge publication under canonical outcome marker

* no-mistakes(review): Publish merge outcomes before committing dedup markers

* no-mistakes(review): Document at-least-once merge outcome recovery

* no-mistakes(review): Use supported GitHub confirmation and update recovery docs

* no-mistakes(review): Preserve distinct merge wakes by PR identity

* no-mistakes(document): Document durable merge outcome semantics

* no-mistakes(test): Make merge outcome interleaving test deterministic

* no-mistakes(document): Clarify merge outcome documentation ownership

* fix(lint): keep the merge-outcome library an analysis boundary

bin/fm-watch.sh followed the new merge-outcome library's source graph,
which reaches the wake queue, PR identity, and secondmate parent
libraries. Expanding that inside an already-large lint root pushed
ShellCheck's external-source analysis past the bounded CI lint worker:
the Lint job was killed with SIGTERM after five silent minutes, twice,
having emitted no diagnostics at all.

Make it an analysis boundary, exactly as the transition and inbox owners
directly above and below it already are and for the same stated reason.
Coverage is unchanged because the library is a canonical lint root in its
own right and is still linted as one.

Measured locally: the watcher goes from not terminating within 120s to
9s clean, and the library alone lints in 1s clean.

* fix(bearings): preserve projections through inventory mismatches (#3129)

* fix(bearings): keep an inventory-mismatch home readable, and mark warnings as repairs

A backlog-vs-metadata inventory mismatch inside a secondmate home was being
reported as "we cannot read that home", which discarded that home's open
captain calls, queued work, landed work, and live workers from the whole
Bearings digest. The main home already treats the identical mismatch as a
harmless disclosure; this makes the secondmate path agree.

- fm-fleet-snapshot.sh: the invalidity gate now passes orphan_in_flight,
  unowned_current, and terminal_in_flight through the partial-structured
  carve-out alongside child_current_unavailable, so those homes keep their
  decisions, holds, queued, landed, and live work and leave unreadable[].
  missing_backlog and unstructured_current stay on the discard path, because
  there the backlog itself is untrustworthy.
- fm-fleet-snapshot.sh: the same three kinds no longer collapse the home's own
  classification to "unknown"; the real captain_decision / active_child_work /
  externally_held classification survives and invalidity carries the warning.
  An unavailable child state still collapses it, including when a mismatch
  masks it under strict-invalidity precedence.
- secondmate_landed.partial now keys on partial-structured trust rather than an
  unknown state, so an inventory-mismatch home is still disclosed as partial.

Ask the home that owns the wrong books to fix them:

- bin/fm-secondmate-reconcile.sh sends exactly one reconcile instruction per
  mismatch episode through the ordinary steering transport. A persistent
  mismatch keeps its episode identity and never re-nags; a changed mismatch
  earns one more ask; a repaired one is forgotten so a recurrence is asked
  about again. The parent never touches the mate's own files, and a failed send
  records nothing so the next run retries it.

Give integrity warnings their own look on the board:

- charted rows take an optional kind of "queued" (the default) or "warning".
  A warning badges "needs repair" instead of "waiting" and is excluded from the
  Charted Next count, so alarms stop reading as dispatchable queued work. No
  fifth board section, and every existing payload stays valid.

Tests pin the new policy behaviorally: the retained surfaces and classification
for all three mismatch kinds, the still-discarding unstructured_current and
missing_backlog cases, the once-per-episode reconcile ask through real durable
steering records, and the board rendering exercised through the shipped
template under a minimal DOM shim.

* no-mistakes(review): Make reconcile dedupe atomic and warnings non-dispatchable

* no-mistakes(review): Preserve reconcile identity and reject stale snapshots

* no-mistakes(review): Order snapshots uniquely and canonicalize episode identities

* no-mistakes(review): Add fire-and-forget reconcile and separate warning overflow

* no-mistakes(review): Exclude fire-and-forget from escalation and track reconcile background

* no-mistakes(review): Run reconcile enqueue inline across all adapters

* no-mistakes(review): Track reconcile clears across strict-invalidity homes

* no-mistakes(review): Persist reconcile transitions atomically

* no-mistakes(document): Document reconcile and fire-and-forget contracts

* refactor(bearings): replace the reconcile episode dedupe with a 4-hour cooldown

The reconcile ask needed to fire once per problem without nagging on every
recap. The episode-precise record that tried to do that had to be correct in
every direction at once - order two concurrent snapshots, tell a repair from a
new problem, and never lose a clear - and each direction it got wrong either
swallowed a nudge or sent a duplicate.

A per-home cooldown removes the whole class. One durable timestamp per home,
one nudge per four hours, and nothing to get stale, mis-order, or
mis-classify: a home in mismatch is asked once, later recaps stay silent, and
a mismatch still sitting there after the window earns one gentle re-nudge.

- bin/fm-secondmate-reconcile.sh: state/<id>.reconcile-nudged holds the epoch
  second of the last ask; FM_RECONCILE_COOLDOWN_SECONDS names the window. The
  episode identity, ordering generation, pending/clear transitions, and
  delivery-identity reuse are all gone. A known-undelivered send starts no
  cooldown so the next run retries it; an unconfirmed one does, because a
  duplicate ask is worse than one the mate may already hold.
- bin/fm-fleet-snapshot.sh, bin/fm-bearings-snapshot.sh: drop the snapshot
  `observation` monotonic identity, which existed only to order those records.
- bin/fm-teardown.sh: retire the cooldown record with the endpoint's other
  runtime artifacts, so reseeding a retired id is not silenced by its
  predecessor's window.

The inline durable fire-and-forget send is unchanged, and the projection fix
and the warning surface are untouched.

Tests follow the behavior: the cooldown suite now pins one ask per window, the
re-nudge after it, the four-hour boundary, per-home independence, and that the
ask stays out of a re-ring ladder that still rings an ordinary steer beside it.
The obsolete observation-ordering test is deleted with the machinery it covered.

* no-mistakes(review): Serialize reconcile cooldown commits with mate lifecycle

* no-mistakes(review): Reject stale reconcile snapshots across mate reincarnations

* no-mistakes(review): Start reconcile cooldown after delivery completes

* no-mistakes(review): Keep reconcile sends nonblocking and remove pending residue

* no-mistakes(document): Document reconcile skip and stale-endpoint behavior

* no-mistakes(lint): Fix reconcile test subshell lint warning

* no-mistakes: apply CI fixes

* fix(bin): reconcile markerless remote secondmates safely (#3140)

* fix(bin): stop dropping reconcile nudges for markerless remote secondmates

A persistent remote secondmate's parent-side state/<id>.meta never carries
spawn_gen: bin/fm-spawn.sh's spawn_remote_secondmate() is its sole writer and
never writes one, because that incarnation identity does not apply to a
remote route. fm-secondmate-reconcile.sh's row filter required a non-empty
spawn_gen matching an identifier regex, so every such row was silently
dropped before the per-row loop ever saw it: no sent/stale/failed line, no
cooldown record, nothing sent, and no trace of why.

Give a legitimately markerless persistent remote secondmate a safe substitute
identity - its recorded remote_host - instead of weakening the spawn_gen
check for rows that do have a generation:

- bin/fm-secondmate-reconcile.sh: carry host through the row projection for
  both fm-fleet-snapshot.v1 and fm-bearings.v1 documents, and admit an empty
  spawn_gen instead of filtering the row out. A new revalidate_identity()
  compares the sampled spawn_gen against current metadata when one was
  sampled (unchanged), or the sampled host against the metadata's
  remote_host when none was sampled and the metadata still carries no
  spawn_gen of its own. A row with neither a spawn_gen nor a host has no
  safe identity at all and fails loudly instead of vanishing, exactly the
  visibility the original bug lacked.
- Rows now join on the ASCII unit separator rather than @tsv: bash's
  IFS-whitespace read collapses consecutive tabs, which would have silently
  dropped a legitimately empty field again.
- bin/fm-bearings-snapshot.sh: thread host through the secondmate_reconcile
  projection so the fm-bearings.v1 path (the one bearings itself feeds to
  the reconcile hook) carries the same substitute identity.
- tests/fm-secondmate-reconcile.test.sh: end-to-end coverage through the real
  remote transport (fm-on.sh + fm-remote-secondmate-control.sh against a
  genuinely seeded remote home) for a markerless mate nudged once per
  cooldown window, a stale/replaced remote route refused exactly like the
  existing local spawn_gen case, and a row with no identity at all failing
  loudly rather than being swallowed.

* no-mistakes(review): Enforce markerless remote host identity during final delivery

* no-mistakes(document): Document markerless remote reconciliation safety

* fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated (#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.

* no-mistakes(review): key afk busy-pause handoff on declaration, clear wedge state

* no-mistakes(document): docs: scope away-mode busy-bound handoff to declared waits

* no-mistakes(document): docs: note afk busy-bound handoff in watcher header

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): name the stale submodule pin behind a pooled slot refusal (#3121)

* fix(bin): explain a pooled slot's stale submodule refusal

A pool slot whose submodule pin moved is refused with "is not clean; refusing
to discard uncommitted work", while the operator's own `git status` in that slot
reads clean. The message names no submodule, no pin, and no remedy, so the
refusal is unreadable and the slot looks wedged for no reason. That is the
failure that jammed three slots in a row when a submodule pin moved.

The refusal itself was never the bug and is unchanged: the gate still refuses,
and still touches nothing. It now distinguishes the one case it can prove and
says what it found - the submodule, the pin the slot has, the pin the base
records, and the command that clears it.

The diagnosis is deliberately conservative, because ` M <path>` alone cannot
tell a stale pin from real work. An entry is reported as stale only when every
reported entry is a gitlink whose submodule is internally clean and whose
recorded pin actually differs. A submodule holding uncommitted work, untracked
files, or an unpushed commit therefore keeps the original uncommitted-work
refusal, even when its pin is also stale - the remedy command would be wrong
there, and the conservative refusal is the safe answer.

Nothing is converged, synced, initialized, or deleted. There is no new failure
path: a slot that launched before still launches, a slot that refused before
still refuses, and projects that configure a submodule `ignore` are read exactly
as before. Paths are read with core.quotePath=false so a non-ASCII submodule is
named rather than falling back to the unreadable message.

Tests keep the reproductions that prove the message is accurate: the stale-pin
diagnosis (which fails against the previous refusal), work inside a submodule
still refused as uncommitted work, and a stale pin carrying real work refused
conservatively rather than called stale. Each asserts the slot is left untouched.

* no-mistakes(review): require remote containment before calling a submodule pin stale

* fix(bin): stop printing a remedy the containment check cannot stand behind

The stale-pin diagnosis printed `git submodule update --checkout` as the command
that clears the slot. The containment check behind it reads local refs only and
never fetches, because this gate has to stay usable offline. A remote-tracking
ref that has gone stale - its upstream branch deleted or force-pushed, and never
pruned - still reads as containment, so a commit that is really unpushed can look
contained and that command would move the submodule off it.

Naming the submodule and both pins is the whole point of the diagnosis: it turns
"is not clean", on a slot whose own `git status` reads clean, into a statement of
which submodule drifted and where it drifted from. The operator can choose the
remedy from that, seeing the whole picture. Printing an instruction that rests on
a judgement which can be fooled is worse than printing none, so it is dropped.

The limitation is now stated where it applies, in the script header and beside
the check itself, rather than left for a reader to discover.

No fetch is added: the gate stays offline-safe by design. Nothing else changes -
the same conditions are refused, the slot is still never touched, and a submodule
carrying real work or an unpushed commit still keeps the conservative
uncommitted-work refusal.

* no-mistakes(review): bound submodule containment probe to first commit

* fix(pi): prevent stale captain outcome re-emissions (#3154)

* fix(pi): type captain supervision outcomes so main relays them

A captain-relevant branch outcome reached main as a bare user message with
no marker of origin or required action, written in main's own captain-facing
voice, landing in a tail that often already held several such notes. Pi keeps
only a custom message's content when it builds the provider request, so
customType and display never reach the model and content was the only place
that identity could live.

Main could not tell an incoming outcome from its own earlier answer and
sometimes re-emitted that answer instead of relaying the outcome, losing it.
Measured against real Pi 0.84.1 on openai-codex/gpt-5.6-sol: 6 failures in 24
turns, rising to 3 in 6 once one stale answer was already in the tail, which
is how one captain conversation saw six identical messages in a row. The same
scenario with the outcome typed failed 0 times in 14 turns.

Wrap only the captain-verdict note in the branch-outcome operational kind
owned by bin/fm-operational-input.sh. Delivery is otherwise unchanged: still
display: false, still one triggerTurn follow-up, so the turn remains the
single captain-visible outcome and no hidden note is ever shown twice.
Routine notes stay plain because their renderer reads the glyph off the front
of that same string. An outcome that cannot be encoded degrades to the same
instruction as plain text rather than being lost, matching this file's stated
failure direction.

The existing assertions could not catch this: they pin the sendMessage
options and never look at what main receives. Add a portable regression that
classifies the delivered payload with the real protocol executable, and a
live guard that runs the real Pi SDK's own convertToLlm to prove content is
the entire model-visible payload.

* no-mistakes(document): Document typed Pi captain outcomes

* fix(bin): keep a declared wait on the pause cadence under a busy pane or enriched wedge (#3155)

* fix(bin): keep a busy pane from retiring a still-declared wait's window

The away-mode daemon's pause re-surface recheck (housekeeping step 2b) read a
busy pane as "the crew resumed" and dropped the declared-wait marker, without
re-reading that the crew's own latest status line still declared the wait.

That inference is not safe, because a declared wait can legitimately hold a pane
busy: a worker sitting on a long foreground call keeps that call live for as long
as the wait lasts. The marker is then cleared while the declaration still stands,
and migrate_watcher_pause_markers recreates it with a fresh timestamp on the very
next tick, so the window restarts forever and the wait never matures into its one
bounded recheck.

Away mode makes that terminal. Since the watcher half landed, a busy pane under a
declared wait is handed to the daemon exactly once per declaration and never woken
again while the declaration stands (bin/fm-watch.sh, busy_turn_bound_check), so
this recheck is the only thing left that can re-surface the pane at all. Measured
end to end on a throwaway state root, away mode active, a pi pane busy past
FM_BUSY_TURN_MAX_SECS, status still `paused:`, over six PAUSE_RESURFACE_SECS
windows: 0 captain-facing rechecks before this change, 6 after - one per window,
with the marker reset each time.

The fix drops only the busy arm of the 2b probe, leaving it an endpoint-readability
check: exit code 2 still means the capture failed, so the endpoint is gone and the
marker goes. The loop head above already drops the marker the moment the status
line stops declaring the wait, so nothing else is needed to end the routing, and
the reconcile path runs before the probe ever reads a pane.

tests/fm-daemon.test.sh: test_housekeeping_paused_resumed_cleared pinned the old
inference on purpose - its fixture's status line still read `paused:` while the
pane was busy, and its comment read "A pause whose pane became busy again (the
crew resumed)". Its fixture now resumes the way a crew actually resumes, by
appending a non-declaring status line, and it asserts its own busy verdict first
so it cannot silently decay into the idle-pane case that
test_housekeeping_paused_unpaused_cleared already covers. What it pins is now the
inverse guard: a busy pane must not GATE the clear either, so an over-correction
that kept the marker alive whenever the pane is busy would fail it.

test_housekeeping_busy_declared_wait_matures_its_window is the new regression, over
both declaration forms. It asserts the busy verdict, then that ticks inside the
window neither escalate nor let the marker be recreated with a fresh timestamp,
then exactly one recheck past the window named for the right human and never a
wedge, then silence on the next tick inside the reset window. It fails on
unmodified main with "produced 0 escalations past its window, expected exactly
one".

Refs #3149

* fix(bin): let a declared wait outrank an enriched wedge escalation

handle_wake classifies a stale wake through classify_stale, which returns a
`pause` verdict for a crew whose latest status line declares an external wait or
a verified captain-held transfer. It then threw that verdict away whenever the
wake reason matched `idle *s, possible wedge, escalation *`, so the watcher's
enriched wedge decoration outranked the crew's own declaration and a healthy
declared wait was escalated once per FM_STALE_ESCALATE_SECS for as long as the
wait lasted.

The enriched reason earns its precedence over the daemon's cheaper status-log
absorption honestly - it carries the watcher's escalation count and its explicit
"do not re-absorb on the run-step/pane state alone" demand. A `pause` verdict is
not run-step or pane state. It is the crew's own declaration that this pane waits
by design, which is precisely the question the wedge timer cannot answer for
itself, so it is the one verdict that decoration must not override.

The two classifications genuinely disagree in steady state rather than only in a
race: a crew that declares `paused:` while its no-mistakes run is still attributed
to its code reads `working` to the watcher's pause_state_class, so the watcher
takes the wedge timer while the daemon's classify_stale reads the status log and
correctly returns `pause`.

The wait stays bounded, not silenced. Absorbing to the pause action records the
declared-wait marker and drops wedge aging, and housekeeping (2b) then owns the
re-surface, so the pane still reaches the captain - once per PAUSE_RESURFACE_SECS
as an explicit "recheck whether the wait still holds", instead of once per
FM_STALE_ESCALATE_SECS as a possible wedge. Measured on a throwaway state root
over five wedge cadences for one declared wait: 5 escalations climbing to
demand-deep-inspection before this change, 0 after, with the one bounded recheck
still delivered.

tests/fm-daemon.test.sh: test_stale_diagnostic_wedge_survives_busy_housekeeping's
`paused` case pinned the old precedence on purpose, asserting exactly one
escalation carrying the demand-deep-inspection payload. That case now asserts the
pause cadence instead - no escalation inside the window, pause tracking recorded -
while the `working` and `prior-terminal` cases keep asserting the enriched wedge
verbatim, so the override itself is still pinned everywhere it is correct.

test_enriched_wedge_under_declared_wait_uses_pause_cadence is the new regression.
It asserts the fixture's own classifier verdict is a pause first, so the case
cannot go vacuous, then drives four consecutive wedge-cadence deliveries in both
the plain and demand-deep-inspection forms through the real handle_wake and
housekeeping pair, then matures the window for exactly one awaiting-external
recheck, then lifts the declaration and requires the same enriched wedge to
escalate again unchanged. It fails on unmodified main at the first delivery.

Refs #3149

* no-mistakes(review): align afk skill recheck wording with still-declared contract

* no-mistakes(document): daemon doc comments: pause window ages on declaration

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>

* fix(bin): recover Claude auto-arm from hung claims (#3156)

* fix(bin): make Claude auto-arm continuity self-heal past a hung claim

On a Claude primary, a Stop-hook auto-arm process that hung mid-arm held
the single-flight owner lock with its epoch ledger frozen at
outcome=arming, and the abandonment proof read any live lock holder in
arming as legitimately deciding forever. Every later Stop firing exited
0 at the lock, the turn-end guard kept deferring to the hung owner as
recovery under way, and the watcher was never auto-re-armed again for
the rest of the session - supervision survived only on manual arms and
lapsed between them (the 2026-08-26 watcher flap).

Corrections layered onto the lock-held-across-arm shape each reopened
the same concurrency class one level down, so this replaces the claim
machinery wholesale with a generation-based optimistic design:

- The epoch ledger's monotonic sequence IS the claim generation; the
  two-line entry (classic epoch record plus the claimant's MANDATORY
  pid-identity) is the claim. Every firing defers to a live OPEN claim:
  outcome arming, owner alive, identity recomputes and matches, and not
  stuck (entry and watcher beacon both older than the guard grace).
- A finished, dead, identity-mismatched, identityless, or stuck claim
  is superseded by simply taking the next generation - no signalling or
  revocation of a steady-state predecessor.
- No mutex is held across arming or output; the owner lock survives
  only as a micro-mutex around individual ledger writes. A superseded
  owner goes completely silent: ownership is re-verified before every
  arm invocation, episode-state mutation, ledger write, and
  continuation.
- The irrevocable commit point of a translation is the exit status
  (the harness delivers the collected stderr only on exit 2), so the
  owned terminal ledger write is the atomic commit: the winning
  generation exits 2 unconditionally after it, a refused one exits 0
  silently even after printing, and the once-per-episode failure notice
  commits in the same owned critical section as the winning failed
  write. Two bounded residuals are documented accepted intent: an owner
  dying between its owned write and its own exit, and a hung old-build
  owner resuming during the one legacy upgrade window.
- The pre-generation lock-holding claim shape keeps defer-or-reclaim
  behavior through a legacy shim: a live identity-verified stuck owner
  is retired via TERM (with a queued TERM sufficient when the owner is
  stopped) before its lock is removed, an unverified or identityless
  pid is never signalled but never blocks a proven-abandoned reclaim,
  and the lock's identity evidence is grafted into the ledger
  (mtime-preserving) so pid-reuse protection survives the lock.
- The guard reads the same predicates for recovery ownership and its
  terminal fail-open (which re-checks for a live open claim under the
  held locks before committing the attended alarm), with ledger reads
  anchored to line 1 so the identity line can never confuse them.

Behavioral regression coverage exercises all three edge classes through
the real hook and guard - a live open claim defers with no lock held, a
stuck claim is superseded and the home re-arms, and an end-to-end run
with a genuinely hung owner shows a concurrent firing deferring
promptly mid-arm, a later firing superseding the stuck owner, and the
superseded owner exiting silently without a second translation - plus
the identityless/reused-pid loopholes, the superseded-owner arm
boundary, and the legacy TERM, SIGSTOP, and signal-free reclaim paths.

* no-mistakes(review): Refuse auto-arm commits when notice marker creation fails

* no-mistakes(review): Make episode reset atomic with generation ownership

* no-mistakes(document): Update auto-arm generation and commit documentation

* fix(bin): verify the real GitHub merge outcome instead of reporting an unproved merge (#3064)

* fix(pr): verify GitHub merge outcome

* no-mistakes(review): Captain, fixed forge-only merge verification, queue guidance, metadata propagation

* no-mistakes(document): Correct forge-specific merge documentation

* no-mistakes(review): Captain: forge-only queue fix, focused tests pass

* no-mistakes(review): Captain: suppress closed-state guidance and prove parent regression

* no-mistakes(review): Captain: remove history proof; retain executable regressions

* no-mistakes(document): Clarify GitHub recording timing in architecture docs

* no-mistakes(document): Clarify outcome-aware PR merge recording documentation

* no-mistakes: apply CI fixes

* Revert "no-mistakes: apply CI fixes"

This reverts commit c326cfa9430c6173eedc8ff7f27d19d0552daf01.

The automatic CI repair round removed the up-front `gh` prerequisite check
while keeping the `gh` dependency: `bin/fm-pr-merge.sh` still calls
`gh api graphql` for the outcome read and `gh api` for the branch-rules read.
That left the same hard requirement without the clear named error, and review
immediately raised a new finding for exactly the failure the check prevents -
`gh-axi pr merge` landing the merge while the follow-up read fails, so the PR
metadata is never recorded.

The check is also symmetric with the GitLab arm directly above it, which
already refuses up front when `glab` or `jq` is missing, on the stated
principle that a missing tool should be a named prerequisite rather than a
merge that is armed and then refused for an unexplained reason.

The workflows this round was chasing sit at `action_required` because this is
a fork pull request; no code change can turn them green.

* fix(pr): keep PR bookkeeping when a merge outcome read fails

On the GitHub path a merge call that returned success was followed by
`github_read_outcome || exit 1`, so a transient API failure, rate limit,
or network blip during the read dropped out of the script before
`record_pr_metadata` ever ran. The merge could have landed while `pr=`
went unrecorded and the merge poll was never armed - bookkeeping lost on
a real merge. The failure path just above already recorded metadata
before exiting, so the error path was more careful than the success one.

Record the PR before that refusal. Recording arms the later merge poll
and is not a success claim, which is the same reasoning that keeps
`record_pr_metadata` on the gh-axi failure path. The refusal itself is
unchanged: exit stays non-zero and the message still names the concrete
observed state. Metadata is withheld only when the read succeeds and
proves the pull request neither merged nor queued.

Pin it with a case that stubs `gh api graphql` into failure after a
successful `gh-axi pr merge`, asserting both the non-zero exit and the
recorded metadata.

* no-mistakes(review): Aggregate queue rules and report conflicts explicitly

* fix(pr): keep the merge abstraction reachable and its bookkeeping intact

Two holes remained in the outcome-verified GitHub merge path, both on
installations where gh-axi is present but gh is not.

The verification preflight refused before bin/fm-pr-merge.sh ever reached
the configured gh-axi merge abstraction, so an installation without gh
could no longer merge at all. gh-axi now performs the merge unconditionally
and the queue-aware gh read became an optional enrichment: with gh on PATH
its GraphQL view still separates merged from queued, and without gh the
gh-axi view still proves a landed merge while every outcome it cannot prove
refuses.

The PR metadata recording sat behind the outcome read, so a merge that
landed before that read failed lost pr= and its merge poll. Recording now
happens once, before either forge call, which arms the poll without
claiming a landed outcome and leaves teardown a PR identity to verify
against no matter how the read ends.

Rebasing onto main also restored the durable merge-outcome reporting and
the GitLab landed-state confirmation that the conflict resolution dropped.

Tests pin each fix through the executable interface: the merge abstraction
is reached and verified with gh absent, a failed fallback read keeps its
bookkeeping, and a mock that snapshots the task meta during the forge call
proves pr= is recorded before the merge can land.

* no-mistakes(review): fix(pr): de-dup queue methods, fall back on failed gh read, refresh contracts

* no-mistakes(review): fix(pr): quote forge output and explain armed auto-merge on refusal

* no-mistakes(review): fix(pr): claim auto-merge armed only when the forge accepted it

* no-mistakes(review): fix(pr): tell the operator what each GitHub refusal could not observe

* no-mistakes(review): fix(pr): gate every forge-acceptance claim on a successful merge

* no-mistakes(document): align merge docs with verified GitHub outcome contract

* fix(pi): prevent duplicate captain outcome reports (#3184)

* fix(pi): stop reporting one merge to the captain twice

The supervision branch's captain-outcome note told main, unconditionally,
that the note "is not your own earlier output" and to relay it now. When
main had already reported the same event, that assertion was false and the
order turned the correct response - saying nothing new - into a mechanical
re-report, so the captain saw one merge reported twice in 16 seconds.

Two independent changes, both needed:

- The relay instruction is now conditional. It still names itself as a
  supervision outcome so main cannot mistake it for its own earlier answer
  (the silent loss that instruction exists to prevent), and it now lets
  main stay quiet about an outcome it has already given the captain.

- The merge case is closed at its source rather than left to that judgment.
  One merge reaches a home on two independent paths by design - main's own
  permanently main-owned merge poll, and the branch's task-local status
  wake - and main's captain-facing text only reaches the branch's mirror at
  main's turn end, so the branch can escalate before it could possibly see
  the captain was already told. bin/fm-pr-merge-notified.sh answers that
  question from bin/fm-pr-lib.sh's canonical merge-notification marker, so
  the answer holds regardless of mirror timing. A captain outcome naming an
  already-published merge is delivered as the ordinary rendered note
  instead of opening a follow-up turn: still appended, still visible, still
  recorded with the verdict the branch decided, minus the wasted turn.

Any error, timeout, or unreadable state relays the outcome. A duplicate
announces itself; a lost outcome does not.

Regression coverage drives the real delivery path in both directions: a new
outcome must still reach the captain in exactly one follow-up turn even
beside an unrelated published merge, and an already-published merge must
open no second turn while a different PR in the same task still does. The
merge path's real producer and this new consumer are exercised end to end
in tests/fm-pr-merge.test.sh.

Pi-only by construction: the delivery path lives in .pi/exte…
adonis-garcia-git added a commit to adonis-garcia-git/firstmate that referenced this pull request Sep 3, 2026
…iation (#12)

* feat(bin): merge GitLab merge requests through the guarded PR merge path (#2779)

* feat(bin): merge GitLab merge requests through the guarded PR merge path

bin/fm-pr-lib.sh already parses a GitLab merge request URL for the watcher,
but bin/fm-pr-merge.sh refused every non-github provider, so a merge request
had to be merged by hand and got none of the recording, guards, or audit
trail a pull request gets.

The merge path now dispatches on the parsed provider. A GitHub URL keeps its
exact previous behavior. A GitLab URL is addressed through glab by the project
URL rebuilt from the parsed host and path, so a merge request on any instance
resolves and no host is hardcoded, and no merge-method flag is added because
the project's own merge method is what should apply.

A GitLab merge happens only after one live read of the merge request confirms
it is open, detailed_merge_status is mergeable, has_conflicts is false,
blocking_discussions_resolved is true, and the head pipeline succeeded at the
exact current head. Every failing condition is reported, not just the first.
The verified head is bound to the merge with glab's --sha, so a push landing
between the read and the merge fails the merge instead of landing commits
nothing verified. Recorded metadata is never the authority for any of this: a
rebase moves the head and leaves a recorded value stale, so a recorded head
that disagrees with the live one is reported rather than trusted, and the
recorded value is read before the recording step because that step drops a
GitLab head it cannot resolve.

* no-mistakes(review): reject bundled -R clusters and make tool-absence cases host-independent

* no-mistakes(test): state authorised GitHub narrowing of bundled -R guard

This branch NARROWS GitHub behaviour. The narrowing was authorised
deliberately rather than slipping in by accident, and it applies to both
providers, GitHub and GitLab alike, because a script that guards one provider
and not the other is a trap for the next reader.

What bin/fm-pr-merge.sh now refuses is extra merge arguments containing a
bundled short-option cluster that includes R, for example "-dR other/repo".
The forge CLIs expand such a cluster one character at a time, so it carries
"--repo other/repo", and that later value wins over the repository the URL
named. Before this change, "fm-pr-merge.sh <task> <github-url> -- -dR
other/repo" reached "gh-axi pr merge 12 --repo example/repo --squash -dR
other/repo" and exited 0 with pr= recorded and the merge poll armed. It now
exits 1 with "extra merge arguments must not override the repository", records
nothing, and invokes no forge merge command. Every other GitHub invocation is
byte-identical to the base commit.

Closing that hole honours the existing rule rather than departing from it. The
file header already forbids --repo and -R because the repository must come
only from the URL, so a bundled cluster carrying a repository override was
never legitimate behaviour to preserve: it was that guard being evaded.
Redirecting a merge to a repository the URL does not name is exactly what the
guard exists to prevent.

The refusal is already pinned on both paths by the existing case
test_bundled_repo_override_args_refuse_before_recording in
tests/fm-pr-merge.test.sh. On GitHub ("-dR wrong/repo") and on GitLab ("-yR
https://other.example/g/p") it asserts exit 1, the refusal wording, no pr= in
the task meta, no armed merge poll, and no forge merge command invoked, with a
control case proving a cluster that carries no repository override still
reaches the forge. No duplicate assertion was added. Both assertions were
confirmed to have teeth by narrowing the guard back to a bare -R and watching
each path fail.

This commit carries no file change: the guard and its coverage landed in
614853d, and this message exists so the pull request description states the
narrowing.

* no-mistakes(document): fix README pointer for GitLab watch and merge doc

* no-mistakes: apply CI fixes

* fix(bin): record a lost relay connection instead of an unanswered turn (#2788)

* no-mistakes: apply CI fixes

* fix(bin): drop a private record citation and narrow the review rule

Three corrections to the spoken interface that landed in #2767, plus one
fix carried over from that branch after its pull request had already been
merged.

The confidentiality fix. The module docstring of bin/fm-voice-relay.py
cited a private, gitignored fleet record by exact path and section number.
That widens what this public repository points at, and it cannot resolve
for any reader here, because the path has never been in the repository.
Both traps it pointed at are already described in full in the list
immediately below it, and docs/voice-relay.md carries the same two for
operators with no citation at all, so the pointer is removed and no claim
is weakened by losing it. Two comments that referred to "the survey" as
though it were something a reader could open are reworded the same way.
Neither exposed a path, so that half is comprehensibility rather than
confidentiality.

The review rule. .greptile/rules.md is kept, because its conditions are
right and deleting it would leave the next reviewer to re-litigate a
decision already argued out. What was wrong with it is narrower than its
existence: it read as settled repository policy, when whether VISION.md
itself should be reconciled is an open question belonging to the captain.
One sentence now says so, and says that the conditions listed below it are
what the interpretation depends on. That narrows the claim rather than
widening it.

The carried-over fix. The first commit on this branch is 7f98e797 from
fm/voice-relay-build-v4, taken verbatim rather than rewritten. It closes
the window where a transport failure was recorded and then erased, so a
run could be emitted as answered false with relay_error null. That matters
more than it looks: relay_error is the field that keeps an infrastructure
failure from being averaged into a latency figure, so the failure mode is
a dead connection wearing the costume of a slow reply. It landed fifteen
minutes after #2767 merged and so never reached the default branch.

* no-mistakes(review): name a reason on every unanswered-turn close path

* no-mistakes(review): guard the downlink body and pin frames to their turn

* no-mistakes(review): attribute reply audio to its own turn and tell endings apart

* no-mistakes(review): tell a cut-short reply from an unanswered turn

* no-mistakes(review): discard reply audio arriving after the output closes

* no-mistakes(review): count discarded reply audio on the speaker path too

* no-mistakes(review): keep a reason off a turn already answered in full

* no-mistakes(review): say a reset cut a reply short, not that none arrived

* no-mistakes(review): read one turn's audio count once, and hush a tidy exit

* no-mistakes(document): fix stale session-end relay_error claim in voice-relay guide

* fix(composer): stop a blocked pi pane from proving an empty composer (#2811)

A pi worker parked on an interactive prompt - a permission dialog, a
question menu, a trust dialog - reports agent_status=blocked, because it
is waiting on a human keystroke. Pi draws that menu above its separator
pair, so the composer region between the rules is blank and structure
alone looks like a free composer. _fm_composer_pi_verdict admitted
blocked alongside idle and done, so the shared classifier reported an
affirmatively empty composer for exactly the pane where typing is unsafe.

Every "is it safe to type here?" consumer reads that verdict and proceeds
only on an affirmative empty, so both are told yes on a parked prompt:
the away-mode injection guard in bin/fm-supervise-daemon.sh, and fm-send's
pre-type refusal. The keys then answer the menu instead of composing a
message - the highlighted default is selected, the text is discarded, and
the record attributes a decision to a human who never made it.

blocked now defers to unknown, which every consumer already treats as
fail-closed. idle and done still prove an empty composer, so ordinary
steering is unchanged, and Cursor is unaffected because its always-blocked
panes never reach this pi-only branch.

Regression coverage lands first at both levels: the verdict owner
(a blocked pi defers) and the herdr adapter (a parked pi prompt is not an
empty composer).

* fix(bin): require project clone roots during fleet sync (#2849)

* fix(bin): require a clone root before fleet-sync touches a project

Git repository discovery walks upward, so `git -C projects/<dir>` on a plain
directory nested under projects/ resolves to the enclosing repository - in a
firstmate home, the firstmate checkout itself. fm-fleet-sync.sh guarded its
candidates with `rev-parse --is-inside-work-tree`, which such a directory
passes, so every later git call read, pruned and fast-forwarded firstmate's own
default branch and reported it under the project directory's label. A running
session's AGENTS.md changed underneath it, and the report named a project that
had nothing to do with the change.

Require each candidate to be the root of its own work tree before any other git
command: compare `rev-parse --show-toplevel` against the directory's own
physical path. Both sides are physical, so a symlinked clone still compares
equal. Anything else is skipped by name, naming the repository that would have
been touched, and bootstrap relays that as a FLEET_SYNC line.

Regression coverage reproduces the wrong-repo fast-forward against a home nested
inside another repository, in both the whole-fleet and single-project forms, and
pins that a symlinked clone dir still syncs.

* no-mistakes(review): Keep enclosing fixture clean during clone-root regression

* fix(bin): retry transient Lavish poll interruptions (#2846)

* fix(procevent): retry a transient Lavish poll interruption quietly

A live Lavish listener can be cut short by the server with exactly

    error: Lavish Editor poll response was interrupted
    code: SERVER_ERROR

while the session's marks remain available. Firstmate registered raw
`lavish-axi poll` output, so the generic process-event runner captured
that transient response as a result and woke the whole fleet over what is
really an internal retry.

The Lavish adapter now registers its own listener command, which reruns
the published blocking poll up to 12 times at 5 second intervals for that
one exact two-line response. The match is deliberately narrow: real
feedback, ended and missing sessions, any other SERVER_ERROR, and the same
interruption still standing once the bound is spent all pass straight
through and are captured and announced as before. The retry is a Lavish
fact, so the generic runner stays adapter-agnostic.

`FM_LAVISH_POLL_RETRY_DELAY` is a bounded 0 to 60 second override for the
interval only, refused rather than rounded when malformed, so a test can
exercise the real bound without waiting it out.

* no-mistakes(review): Harden Lavish retry matching, validation, and cleanup

* no-mistakes(review): Bound Lavish retry staging and stabilize regression

* no-mistakes(document): docs: explain Lavish retry adoption

* no-mistakes(lint): Restore Lavish trap ShellCheck suppression

* fix(brief): stop the documented {TASK} fill from corrupting the Herdr gate (#2838)

The unguarded Herdr declaration quoted `{TASK}` in its own prose while the
scaffold instructs firstmate to replace every `{TASK}` placeholder. The
documented global replace therefore spliced the whole task body into the
middle of the safety gate's sentence, silently destroying the one contract
that exists precisely because the scaffold cannot inspect the task text.

Reword the gate to refer to the task text filled in above, leaving the
placeholder only at its genuine fill site. Rewording rather than renaming the
token keeps the unfilled-charter guards in fm-home-seed.sh and
fm-remote-home-seed.sh working unchanged.

Add a regression test that performs the documented global fill on ship and
scout scaffolds and asserts the body lands once and the gate survives.

* fix(bin): resolve the busy-state lock mtime with the platform's own stat form (#2837)

The writer lock's stale-lock branch read the lock's mtime with
`stat -f %m ... || stat -c %Y ...`. On GNU coreutils `-f` is filesystem
stat, so it consumed the format string as a path, complained on stderr,
printed a partial filesystem dump ("  File: ...") on stdout, and still
exited 0. The GNU form in the fallback therefore never ran, and the
following arithmetic evaluated the word `File`, aborting the writer under
`set -u` with "File: unbound variable".

fm-teardown.sh died there after returning the worktree, leaving
state/<id>.meta, .status, .busy-gen, .busy-state, .busy-state.lock/ and
.turn-ended behind. The surviving metadata kept the watcher monitoring an
endpoint whose agent was gone, so a finished task produced stale wakes
forever, and every re-run died identically because the abandoned lock was
never broken.

Detect the platform once and pick the right stat form, the pattern
bin/fm-watch.sh already documents, and treat any non-numeric result as
"just created" so a future portability surprise degrades to a lock-timeout
refusal rather than killing teardown mid-way.

* fix(stow): add opt-in pass horizon for memory decay (#2850)

* fix(stow): give memory decay a per-pass horizon so the clock fires

The tiered decay clocks were wall-clock only, while admission is per-pass:
each /stow admits the findings that pass produced. In a home that stows
daily those two rates diverge by the stow cadence, an entry the fleet keeps
exercising never reaches 30 days unreinforced, and memory only grows while
the pass reports decay evaluated.

Give each dated marker an optional unreinforced-pass counter and make both
tiers stale at whichever horizon comes first: 10 passes or 30 days for
aging, 3 passes or 7 days for perishable. Reinforcement clears the counter
and nothing else does, so the existing evidence-based restamp rule stays
the only way an entry renews its lease. An absent /N means zero, so entries
that stay exercised carry no extra marker bytes, and a rarely stowed home
keeps its current behaviour through the unchanged date horizon.

* no-mistakes(document): Align stow workflow with dual decay clocks

* fix(stow): make the per-pass decay horizon opt-in

The unreinforced-pass horizon shipped as a new default archival cadence,
which is a product default rather than a restoration of the existing
wall-clock contract. Keep the 30-day and 7-day horizons as the only
default clock, and put the 10-pass and 3-pass horizons behind an explicit
opt-in: config/stow-pass-horizon for the firstmate home, and the file's
own header pointer for the public skill.

With the opt-in absent no counter is written and no counter is read, so a
home that does not ask for it decays exactly as it does today.

* no-mistakes(review): Preserve frozen counters and correct archive provenance

* test(watcher): stop fixture confirmation budgets racing real child startup (#2876)

tests/fm-watcher-lock.test.sh passed in isolation but failed intermittently
under full-suite and ambient concurrent load. bin/fm-watch-arm.sh computes its
confirmation deadline immediately after forking the real child watcher, so the
child's entire fork, exec, lock acquisition and beacon publication has to land
inside that wall clock. Two cases shrank that budget to one second, leaving a
two-second window for work measured at 3.1-4.9s under CPU oversubscription, so
the arm honestly reported "FAILED - no live watcher with a fresh beacon" and
their premises collapsed. A third case ran on the production budget, but its
child must also execute a registered check before exiting: measured at 1.9-2.3s
idle and 9.1-13.1s under load, against an 11s budget.

The two cases that must confirm a real child now hold the arm to production's
own budget instead of a shrunken fixture one, the immediate-wake case gets an
explicit budget with headroom over its measured loaded cost, and the two waits
for the arm's typed failure are sized off the largest production default rather
than a fixed eight seconds.

No bin/ change and no default behavior change: the lock's fail-closed semantics,
SIGSTOP handling, stale-heartbeat detection and the arm's typed failures are
untouched. Verified 4/4 green at 3x CPU oversubscription (loadavg 75-80) after
3/3 red before the change, and CONTRIBUTING.md records the convention.

* fix(bin): deterministically order remote tool paths (#2870)

* fix(bin): order discovered tool installs by the shell's own expansion

fm_remote_job_compose_operator_path built the asdf and mise install
directories with `compgen -G`, which does not sort. Bash sorts glob
matches in pathexp.c, on the shell's own pathname-expansion path only;
`compgen -G` reaches the same glob_filename through pcomplete.c, which
sorts nothing. On bash 3.2 (macOS /bin/bash) and every bash before 5.3
that handed the composition raw readdir order, so which install of a
multi-version tool a remote job resolved was decided by directory order
on disk rather than by this composition.

Expand the globs at the call sites and let the function take the matches,
so the composition and the documented portable-PATH contract are the same
operation. Quoting the account home at the call site also stops a home
whose name contains glob metacharacters from being reinterpreted.

The colocated regression pins both the order and the mechanism: bash 5.3
moved sorting into the glob library, so an order-only assertion cannot
see the defect there.

* no-mistakes(review): Remove source-reading PATH regression guard

* fix(bin): prevent routed secondmate work from stranding (#2848)

* fix: surface stalled secondmate queues and wake handoffs

* no-mistakes(review): Make handoff wakes retryable and stall alerts crash-safe

* no-mistakes(review): Prevent duplicate handoff wakes and cover remote delivery

* no-mistakes(review): Serialize local handoffs and preserve pre-move wake intent

* no-mistakes(review): Serialize teardown with handoffs and retain remote wake confirmation

* no-mistakes(review): Reconcile correlated handoff wake delivery after crashes

* no-mistakes(review): Keep failed wakes retryable and isolate stall receipts

* no-mistakes(review): Reset known-undelivered wake attempts for durable retries

* no-mistakes(review): Refuse duplicate sends for unresolved delivery attempts

* no-mistakes(review): Atomically restore retryability after reconciled send failures

* no-mistakes(review): Serialize delivery confirmation with reconciliation

* no-mistakes(document): Document routed wake and stall supervision

* no-mistakes(lint): Fix ShellCheck expansion and subshell warnings

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(review): Retire stale wake state and defer pre-move wakes

* no-mistakes(review): Secure markers, bind batches, and preserve teardown routes

* no-mistakes(review): Preserve unresolved prepared wakes across unrelated handoffs

* no-mistakes(review): Preserve prepared wakes before unrelated moving handoffs

* no-mistakes(document): Document prepared wake batch ownership

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(review): Make local wake retirement recoverable

* no-mistakes(document): Clarify handoff recovery and teardown documentation

* fix: make macOS inbox test path portable (#2857)

* feat(bin): deliver local steers through durable task inboxes (#2856)

* feat(bin): steer local tasks by durable inbox record plus constant doorbell

Stage 1 (local steers) of the captain-adopted reframe in
data/fm-send-reliability-reframe-s1/report.md: an ordinary fm-send text
steer to a task recorded in this home is appended as a sequenced durable
record under state/<id>.inbox/ and the terminal receives only one constant
self-describing doorbell line, best-effort. The worker acknowledges by
moving the record into handled/; the watcher re-rings an unacknowledged
message on an idle pane and escalates once as an ordinary stale wake.
--resolve-key closes decisions at enqueue time, because the durable
enqueue IS delivery to the task's record. bin/fm-task-inbox-lib.sh owns
the record format, doorbell line, and re-ring ladder.

The typed plane remains for what must reach the terminal itself:
lifecycle keys, harness-native slash and codex $-skill invocations,
explicit backend targets, and the remote secondmate leg (unchanged until
the remote inbox leg ships separately). The composer classifier is
demoted from delivery proof to an advisory ring guard that skips only on
a proven pending verdict.

Verified live against claude, codex, opencode, pi, grok, and muse: each
real worker read its record, acted, and acked with the mv
(docs/verification/runtime-backends.md "Steering-inbox doorbell").

* docs(verification): flag the grok 1.0.5 composer-matrix staleness observed by the doorbell run

* test(captain-hold): read the chat-channel answer from the durable inbox record

* test: migrate fm-control's marker contrast to the inbox record and fix macOS wc padding in the tool-update suite

* no-mistakes(review): Harden inbox locking, teardown races, and acknowledgements

* no-mistakes(review): Serialize watcher actions with inbox acknowledgements

* no-mistakes(review): Bound metadata locking and tighten acknowledgement rechecks

* no-mistakes(review): Preserve exact inbox bytes and harden delivery recovery

* no-mistakes(review): Harden watcher bookkeeping against concurrent inbox teardown

* no-mistakes(document): Update inbox and typed-plane documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* revert(pipeline): keep parser-native secondmate marking and the both-failed exit out of stage 1

The CI monitor's fix changed the secondmate marking contract for
parser-native invocations (appending the marker after the text) and
softened the both-commit-and-marker-failed branch to exit 0. The merge
authority ruled the marking question out of scope for this stage-1
transport PR (follow-up: fm-send-secondmate-harness-invocation-r1) and
ruled the both-failed case a loud nonzero local failure. Restore both,
keeping the monitor's legitimate migrations and hardening.

* no-mistakes(document): Document inbox and typed-plane boundaries

* no-mistakes(document): Scope backend transport docs to typed plane

* no-mistakes(document): Clarify inbox attempt-budget documentation

* no-mistakes: apply CI fixes

* fix(send): the durable record alone governs the inbox exit status

Captain-refined ruling on the F2/Greptile finding: the durable inbox
record is what delivers the steer, so pending-reply bookkeeping trouble
after a successful enqueue never exits nonzero - a resend-inviting status
would make automated callers enqueue the delivered instruction again
under a new sequence. With the recovery marker stored the watcher
reconciles silently; with the commit and marker both lost the send
surfaces a distinct reply-tracking-degraded do-not-resend warning and
still exits 0. Nonzero remains only where nothing was delivered (or a
decision close needs its manual command). Regression: record durable +
both bookkeeping writes lost -> exit 0, one record, no duplicate.

* no-mistakes(review): Preserve inbox ordering with drain-all doorbells

* no-mistakes(review): Surface unwritable inbox ladder bookkeeping

* no-mistakes(review): Silence ladder failures after inbox acknowledgement

* no-mistakes(document): Update steering inbox documentation

* no-mistakes: apply CI fixes

* feat(bin): add fast local lint mode (#2891)

* feat: add fast local lint mode

* fix: preserve complete fm-lint help

* fix: isolate fast lint mode

* no-mistakes(document): Clarify lint mode documentation ownership

* no-mistakes: apply CI fixes

* feat(bin): deliver remote steers through durable inboxes (#2901)

* feat(bin): deliver remote secondmate steers through durable task inboxes

Stage 2 of the inbox+doorbell steer channel (stage 1: #2856). A remote
secondmate steer now crosses fm-on.sh as a durable record written
idempotently into the remote home's steering inbox plus a best-effort
remote doorbell, and the last typed-payload steer transport is deleted:

- fm-remote-secondmate-control.sh cmd_send writes the record via the new
  fm_task_inbox_write_idempotent and rings the doorbell; it no longer
  types the payload through an inner fm-send at an explicit pane target.
- fm-send.sh routes every remote text steer (harness-native included,
  which marking already reduced to chat) onto the remote inbox leg,
  retries the identical leg once on ssh 255, closes --resolve-key
  decisions at enqueue for remote too, and preserves a marked request's
  reply expectation when completion stays unknown. The exit-3-as-
  delivered remap, the 255 do-not-resend trap, and the remote typed
  submit block are removed.
- fm-task-inbox-lib.sh owns the idempotent enqueue: an exact-body re-run
  lands on the existing record, handled or not, so an ambiguous
  transport can always be safely re-run.
- Tests pin the new contract end to end (record + doorbell + no typed
  payload across ssh, one-record idempotence under an ambiguous
  transport, enqueue-time decision close, loud real failures, and the
  deleted typed-payload behaviors gone), and AGENTS.md plus
  docs/remote-secondmates.md describe the remote leg's new semantics.

* no-mistakes(review): Harden remote inbox delivery against lifecycle races

* no-mistakes(review): Enable correlation-preserving remote steer resends

* no-mistakes(review): Fail closed on stale correlation resends

* no-mistakes(review): Include home context in remote resend commands

* no-mistakes(review): Lock and revalidate remote parent routes

* no-mistakes(document): Clarify remote steer retry documentation

* no-mistakes: apply CI fixes

* feat: add persistent Pi supervision branch (#2858)

* wip: forked supervision on Pi (checkpoint before docs)

* fix(pi-branch): harden mirror delivery, fallback encoding, and session replacement

Peek-then-shift mirror flush so a failed append retries instead of dropping;
durable mirror cursor commits only after delivery into the branch;
the main fallback wake is operational-encoded like every watcher injection;
session_shutdown quiesces the generation and session_start re-arms, so /new
and /resume no longer kill the branch permanently. Registers the extension in
the strict typecheck, adds the dispatch handshake test, the branch extension
suite, the bash-level regression suite, the session-start replay test, and
the opt-in real-SDK live guard.

* test(fixtures): carry the branch-dispatch lib and lease lib into isolated fixtures

The watcher extension now imports lib/fm-branch-dispatch.ts and fm-teardown
sources fm-lease-lib.sh, so every fixture that copies or symlinks those
files in isolation gains the new sibling.

* no-mistakes(review): Prevent shutdown wake loss and serialize lease claims

* no-mistakes(review): Durably hand off wakes and retain portable leases

* no-mistakes(review): Require durable reports and clear disposed branch leases

* no-mistakes(review): Enforce per-wake outcomes and quiescent lease cleanup

* no-mistakes(review): Require wake acknowledgements and tighten branch lifecycle boundaries

* no-mistakes(review): Require complete acknowledgements and replay cleanup failures

* no-mistakes(review): Bind supervision to lock ownership and durable delivery

* no-mistakes(review): Activate branch lazily after session lock acquisition

* no-mistakes(review): Preserve undelivered mirror context across extension rebinds

* no-mistakes(review): Acknowledge startup replay only after main delivery

* no-mistakes(review): Isolate replay metadata from untrusted digest content

* no-mistakes(review): Reject duplicate reports for active wake sequences

* no-mistakes(review): Retain failed fallbacks and deduplicate outcome replay

* no-mistakes(review): Deduplicate durable outcomes and cache delivery receipts

* no-mistakes(review): Anchor wake sequence matching to outcome fields

* no-mistakes(document): Clarify Pi supervision durability contracts

* no-mistakes(lint): Fix ShellCheck issues in branch supervision scripts

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* refactor(pi-branch): collapse to confused-agent-grade guards per captain decision

Captain decision A: the lease/actor guards target the CONFUSED-AGENT threat
model bin/fm-gate-refuse-lib.sh already documents; adversarial-grade
separation is impossible in the shared-process design and is filed as
separate follow-up work. Rip out the machinery that chased it: the
generation fence and shell-provenance markers, the wrapper-tagged ancestry
walks, guard auto-claim with per-script release traps, the pending-wake
files and ack-receipt correlation (the durable wake queue already
re-presents anything unacknowledged), the delivery-receipt store with
contiguous cursor advancement, the session-start replay-metadata channel,
and the branch tool quiescence counters.

Keep the behaviors the board requires, each on its simplest implementation:
lazy per-action session-lock ownership (cold start activates after the lock
lands; a secondary session stays inert), mirror durability across extension
rebinds via the durable cursor, replay-exactly-once from the one read
cursor, the awaited operational-encoded fallback, per-generation stray-lease
cleanup, session-lock-bound lease liveness (a recycled pid or a non-Pi home
never honors a leftover lease), the loud accidental-override guards
(readonly actor prelude, cross-actor claim refusal), and the role-partition
refinements (no forced teardown, no direct relaunch for the branch).
Default-on-for-Pi is unchanged.

* no-mistakes(review): Enforce lock ownership and serialize lease mutations

* no-mistakes(review): Synchronize guard cleanup and bind leases to lock owner

* no-mistakes(review): Report outcomes before acknowledging durable wakes

* no-mistakes(review): Restrict leases to Pi and instruct main claims

* no-mistakes(review): Reject malformed lease locks and torn outcome tails

* no-mistakes(review): Validate complete outcome tails before appending

* no-mistakes(review): Guard branch side effects across session replacements

* no-mistakes(document): Update Pi supervision durability and lease documentation

* no-mistakes(lint): Suppress intentional nested-shell expansion warning

* no-mistakes: apply CI fixes

* fix(pi-branch): authorize lease releases by caller

* fix(lint): break redundant source-analysis path in fm-lease-lib.sh

fm-lease-lib.sh's lazy fallback source of fm-wake-lib.sh gave ShellCheck's
--external-sources traversal a second path into an already 1540-line file
that fm-send.sh and fm-teardown.sh also source directly, blowing up the
recursive analysis past CI's lint timeout. Mark it a source=/dev/null
analysis boundary, matching the existing fm-task-inbox-lib.sh convention.

Also restores bin/fm-lint.sh and tests/fm-lint.test.sh to the shared
serial-lint definition (dropping an unrelated parallel-sharding change
that was itself hanging and masked this root cause).

* no-mistakes(document): Correct lease caller-authorization documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* fix(bin): parallelize startup network sweeps (#2927)

* feat(bin): parallelize session-start remote secondmate network sweeps

Run per-secondmate liveness and convergence probes concurrently and overlap clone refresh, while replaying each mate's fail-closed diagnostic in original order. Ignore scratchpad* so untracked scratch no longer blocks remote sync.

Co-authored-by: Cursor <cursoragent@cursor.com>

* no-mistakes(document): Document parallel startup network sweeps

* no-mistakes(lint): Fix empty environment assignment lint warning

* no-mistakes: apply CI fixes

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: handle absent watcher wake queues (#2845)

* fix(tests): count declared-pause wakes without crashing on an absent queue

The exited-declared-pause case counts queued stale wakes by handing
state/.wake-queue straight to awk. A watcher that queues nothing never
creates that file, and awk aborts on a missing path before its END rule
runs, so the count collapses to the empty string. The next comparison
then fails as an integer-expression error and surfaces as a wake flood
with no number, hiding the real contract breach the following grep names.

Read the queue the way the drain-count assertion at the end of this file
already does: silence awk's open error and default an absent queue to
zero. Applied to all four counts in this case, including the live
external-decision gate pair whose queue an acknowledged drain can also
leave behind. An absent queue now reports "did not use the bounded
paused recheck", while a genuine flood still fails with its real count.

Fixes #2628

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* style(pi): distinguish routine and captain supervision merge notes by icon (#2934)

* style(pi): restyle supervision merge notes with a sailboat and matching pad

Secondary-session notes were flush against the TUI edge and fully tinted.
Use the sailboat prefix, Pi's default outputPad, boat-only color, and dim remainder so they sit like real messages.

* style(pi): distinguish routine and captain merge notes by icon only

Visible notes now lead with a sailboat or anchor, then only the dim outcome.
Drop the branch-merged wording and verdict brackets so the icon is the only kind signal.

* docs(pi): add the approved multi-brain architecture poster (#2938)

The markdown contract stays the owner; the still is only the visual of the idea.

* feat(pi): default branch supervision and route heartbeats (#2939)

* fix(bin): bound remote job worker supervisor restarts (#2942)

* fix(bin): bound remote worker supervisors

* no-mistakes(review): release incumbent supervisor before starting its replacement

* no-mistakes(review): wait out a healthy same-root supervisor instead of replacing it

* no-mistakes(review): narrow remote worker change to restart accounting only

* no-mistakes(document): clarify supervisor restart guard is a lifetime total

* fix: safely split supervision wake handling by actor (#2953)

* feat(bin,pi): per-actor wake consume, silent success gating, merge-poll dedup

Three related fixes to the shared wake-drain and Pi supervision-branch
dispatch machinery so a routine success is never main-blocking and a
mixed queue can safely split between actors.

1. Successful routine results no longer create main-blocking wake rows.
   fm-startup-network.sh only enqueues a check: startup-network wake when
   the deferred result is actionable (state is not "done", or the report
   carries a bootstrap-diagnostics actionable prefix); a clean success
   stays durable in the report file without ever waking the agent.

2. Per-actor wake-drain consume contract. bin/fm-wake-drain.sh now scopes
   presentation and --ack-through to the current actor
   (bin/fm-lease-lib.sh's fm_lease_actor): main keeps the original
   whole-queue cutoff behavior, unaffected. A branch actor
   (FM_SUPERVISION_ACTOR=branch, set only inside the Pi supervision
   branch's own bash tool calls) is scoped to an explicit eligible-row
   snapshot instead of a cutoff comparison, so it can never remove a row
   it was not granted - the fix for the swallow risk that used to force
   an all-or-nothing whole-queue fallback to main.
   .pi/extensions/lib/fm-branch-dispatch.ts's scopeForUnreadWake is the
   single owner of eligibility: a check-kind row (merge-confirmation
   polls, Relay mentions, credential/auth failures) is now excluded
   rather than vetoing the whole scan for a non-heartbeat wake, while a
   heartbeat review keeps its original all-or-nothing rule unchanged.
   writeEligibleRowsSnapshot publishes the exact eligible sequence
   numbers before every branch prompt; fm-primary-pi-watch.ts's offer
   still refuses a check-kind trigger outright so a main-only close is
   never itself routed to the branch.

3. A repeat identical merged-PR-poll result for an already-notified task
   is absorbed instead of enqueued again. A poll's own retirement state
   is scoped to one registration and cannot see a prior registration's
   outcome, so a task re-registered after its merge was already surfaced
   would otherwise wake main a second time for the same event.
   bin/fm-pr-lib.sh's new per-task pr-poll-merge-notified marker survives
   across re-registrations to catch that case; the first notification for
   a task still reaches main unchanged.

Regression tests colocated in tests/fm-startup-network.test.sh,
tests/fm-wake-queue.test.sh (including the mixed-queue no-swallow
property), tests/fm-pi-branch-extension.test.sh, and
tests/fm-pr-check-security.test.sh. docs/watcher-continuity.md and
docs/pi-supervision-branch.md updated for the new contracts.

* no-mistakes(review): Bind merge deduplication to canonical PR identity

* no-mistakes(review): Serialize wake row ownership across main and branch

* no-mistakes(review): Bind branch grants and deduplicate within actor claims

* no-mistakes(review): Fallback main-owned wake claims to main delivery

* no-mistakes(review): Clarify silent startup success guidance

* no-mistakes(review): Release residual branch grants after settled prompts

* no-mistakes(review): Reject truncated wake rows as corrupted

* no-mistakes(document): Document per-actor routing and silent startup success

* no-mistakes(lint): Fix ShellCheck findings in wake grant and startup test

* no-mistakes: apply CI fixes

* fix(pi): hide branch outcomes tool rows in Calm (#3024)

* Hide branch outcome tool in Pi Calm

* no-mistakes(review): Preserve stock outcomes rendering and document tool audit

* no-mistakes(review): Document branch read tool audit disposition

* no-mistakes(review): Match stock outcomes output sanitization

* no-mistakes(document): Document Calm custom-tool visibility

* fix: bind no-mistakes attestations to PR head (#3027)

* fix: delegate no-mistakes PR gate to pinned action

* no-mistakes(document): Document commit-bound no-mistakes attestations

* feat(pi): add persistent supervision branch model selection (#3028)

* feat(pi): let operators pin a cheaper supervision-branch model

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's model. A new /supervision-model
command opens Pi's own selector over Pi's own catalog of credentialed
models, plus a "Follow main" entry, and persists the pick as one
<provider>/<model-id> line in this home's gitignored
config/supervision-branch-model. Firstmate keeps no model catalog of its
own.

The branch resolves the pin at every branch build - the first wake of a
cold start and the reopen after /new, /resume, /fork, or reload - so the
choice survives all of them, and picking also releases the live branch so
the next wake reopens the same persistent branch conversation under the
new model. An absent, unreadable, or unparseable file means no pin and
keeps today's behavior byte for byte: no model option is passed and Pi
picks the branch's model exactly as before.

A pin naming a model Pi cannot hand back is never silently downgraded
onto main's model: the branch refuses to build and the wake falls back to
the captain-facing main path naming the unusable pin, which is the
extension's existing failure direction.

The choice is home-local and not part of secondmate inherited
configuration, matching the Pi Calm preference precedent.

docs/configuration.md owns the operator-facing schema. Portable
regressions cover pin-present on create and reopen, pin-absent default,
the command's persistence, cancellation, and live rebind, and both
unusable and unparseable pins. The opt-in real-SDK guard proves the
vendor surface the pin reads and that an explicit model wins over the
model a reopened session recorded.

* no-mistakes(review): Fix supervision model runtime and rebind races

* no-mistakes(review): Restrict supervision picker to isolated runtime models

* no-mistakes(document): Document supervision branch model selection

* fix(pi): make the supervision model pin authoritative on every reopen

Clearing the pin with "Follow main" removed the file but the next branch
build reopened the persistent branch session with no explicit model
override, so Pi restored the model that session had recorded - the old
pinned model - while the command reported that the branch now follows
main. The same gap meant an absent pin did not reliably mean
same-model-as-main once a home had pinned once.

The pin file's current state now decides the model on every branch build,
create and reopen alike, overriding Pi's session-state restore. With a
pin, that model. With no pin, main's own current model is applied
explicitly, tracked from the contexts Pi already hands the extension plus
its model_select event, since the branch is built at wake time with no
context of its own. Only when main's model is unknown, or this home's
stored credentials cannot run it in the isolated branch runtime, does a
build fall back to passing no override at all, which is the behavior from
before the pin existed; the branch is never refused over model choice.

The command's notification now reports the model actually applied, and
says plainly when clearing the pin could not apply main's model instead
of claiming a change that did not take effect.

No credential handling changes: the branch still relies entirely on the
stored credentials its own runtime already holds, and the picker stays
restricted to models that runtime can resolve.

Colocated regressions cover pin present on create and reopen, clearing
the pin returning a reopened branch to main's model and specifically not
the old pinned one, an unparseable pin behaving as no pin, and the
unknown-main-model fallback to no override.

* no-mistakes(review): Make unpinned supervision follow main model changes

* no-mistakes(document): Correct supervision model documentation

* feat(pi): let /supervision-model pick branch reasoning effort (#3079)

* feat(pi): let /supervision-model pick the branch's reasoning effort

Supervision is an easier job than the captain's own conversation, so the
Pi supervision branch does not need main's reasoning effort any more than
it needs main's model. /supervision-model now settles both in one flow:
the existing model picker, then a follow-up effort picker built from Pi's
own supported thinking levels for the model just chosen. Firstmate keeps
no effort catalog of its own; the menu, the clamp, and the vocabulary all
come from Pi.

The pick persists as one line in this home's gitignored
config/supervision-branch-effort, independent of the model pin: a captain
may pin a model, an effort, both, or neither. The effort pin's current
state decides the branch effort on every branch build - the first wake of
a cold start and the reopen after /new, /resume, /fork, or reload - and
overrides Pi's restore of whatever level a reopened branch session
recorded, which is what keeps "Follow main" honest. With no pin, main's
own current effort is applied explicitly and followed live through Pi's
thinking_level_select event, the same way an unpinned branch already
follows main's model, and the two selections now share one build revision
so either change invalidates an in-flight build.

The branch is never refused over effort. Pi owns the clamp, so a pinned
level the branch's model cannot run becomes that model's nearest supported
level while the captain's raw pick is kept for a model that supports it,
and the command reports the level the branch will really run at rather
than the raw pin. A token Pi would not recognize at all is treated as no
pin rather than passed to that clamp, which would otherwise collapse a
typo into the model's lowest level. Only when main's effort cannot be read
either does a build pass no effort override at all, which is the behavior
from before this file existed.

Pi's own effort vocabulary is pinned by a bidirectional type assertion
against Pi's getThinkingLevel return type, so the tracked strict typecheck
against the installed package fails the moment Pi adds or removes a level.

docs/configuration.md owns the operator-facing schema for both pins.
Portable regressions cover the pin on create and reopen, model-only and
effort-only pins working independently, clearing a pin returning the
branch to main's effort, live-follow of a mid-session change, the clamp,
an unrecognized token, the unknown-main-effort fallback, and the command's
two-step flow, persistence, cancellation, and honest reporting. The opt-in
real-SDK guard proves the vendor surface all of that rests on, and also
repairs a pre-existing gap that left it unable to load the extension at
all.

* no-mistakes(review): Resolve effective branch effort honestly

* no-mistakes(document): Clarify Pi-owned effort picker behavior

* fix: keep routine supervision noise out of captain chat (#3093)

* fix(supervision): silence empty board closes and decouple the heartbeat

Two unrelated sources of noise put routine supervision events in the
captain's chat.

An empty Lavish board close - the captain reads a review surface, says
nothing, and closes it - became a check wake whose entire content was
that nothing happened. Suppress it at its source instead of routing it
anywhere: the generic runner gains a `silent` adapter seam mirroring the
existing `terminal` one, and the Lavish adapter answers it for exactly
one positively-determined shape, an `ended` session carrying no queued
content block. A silenced result is recorded durably handled so it does
not return on a later reconcile. Everything else announces unchanged - a
`Send & End` close carrying the captain's real answer, an `ended` result
still carrying content, a waiting or missing session, an unreadable
result, and every adapter that implements no `silent` command at all.
The keyed-answer feed is untouched, so suppressing an announcement never
suppresses the captain's own answer.

A fleet heartbeat was deferred to main merely because some unrelated
check row happened to be sitting unread, which put a routine fleet
review in the chat for a reason that had nothing to do with the fleet. A
check row is permanently main-owned, so it is now excluded from a
heartbeat claim rather than vetoing the scan, exactly as in every other
mode. What all-or-nothing guarantees is unchanged: the branch takes
every branch-ownable unread row or none of them, and an unresolvable
task-local row, an unknown row kind, or an unreadable queue still defers
the whole review to main. Main is still woken for the check on that
check's own triggering close, so nothing starves.

Main-only classes are unchanged and now each covered by a test: Relay
mentions, credential failures, merge confirmations, real board answers,
and watcher-failure repair. The per-actor acknowledgement and
no-cross-swallow properties are untouched.

* no-mistakes(review): Fail closed on all Lavish content headers

* no-mistakes(review): Suppress false unacknowledged status for silenced results

* fix(bin): stop a correlation token from hiding and stranding decisions (#1967)

* fix(classify): read the decision fold through a correlation token

status_line_verb stripped a trailing [key=...] from a status line's prefix
but left everything else glued to the verb, so a line carrying the
correlation token bin/fm-pending-reply-lib.sh embeds and a secondmate
echoes back matched no arm of _fm_decision_fold_line. Such a line folded as
ordinary status in both directions: a needs-decision or blocked opener never
opened its key, and a resolved or captain-held closer never closed one. The
same glued verb also hid correlated done and blocked lines from
status_is_captain_relevant and status_is_terminal_verb, and let correlated
working and resolved lines leak through the free-text fallback the
nonterminal guard was meant to stop.

The verb parse now walks whole words and drops only a token of the exact
shape a firstmate library writes - corr=<16 hex>, plus the bracketed form
bin/fm-secondmate-report.sh emits - before or after the key token, unkeyed,
or doubled. An arbitrary name=value word is deliberately NOT skipped:
skipping unknown tokens would let free text carrying an equals sign reduce
to a bare verb and impersonate a transition, which is the takeover the
strict parse and _fm_decision_key_transition_allowed exist to prevent. A
prefix with no corr= substring is returned byte-for-byte as before, so every
line without a token keeps its exact historical verb.

FM_OPEN_DECISIONS_FOLD_VERSION goes to 3, because every cursor persisted
under the previous reading carries an open set computed while correlated
lines were invisible and must be rebuilt from byte 0.

Measured over a real 383-line status log: 254 lines keep byte-identical
captain-relevance, pause, terminal-verb and captain-held verdicts, and all
129 changed lines carry a valid token - 14 correlated done/blocked/
needs-decision lines become captain-visible, and 20 correlated
working/resolved lines stop being escalated on prose alone.

* fix(review): Captain, block token-first decision impersonation

* fix(document): Clarify normalized status verb ownership

* fix(classify): reconcile the correlation-token read with the tag-stop parser

Rebasing onto main put this change beside #2280, which made verb parsing
stop at the first "[name=value]" tag. Both edit status_line_verb with
different intents, so the resolution keeps both rules rather than letting
one overwrite the other:

- #2280's tag stop is kept verbatim and now owns every BRACKETED tag,
  including the "[corr=...]" form fm-secondmate-report.sh writes. The
  bracket-unwrapping arm this branch had added to the token test is
  therefore removed as unreachable.
- This branch's token walk is kept and narrowed to the UNBRACKETED token
  fm-pending-reply-lib.sh writes, which the tag stop does not reach.

Two consequences of standing beside #2280 rather than before it:

The fold version had collided at 4: #2280 spent it on the tag-stop
parser and this branch had spent it on the token read. A cursor
persisted under #2280's reading predates this one and must still be
rebuilt, so the version moves to 5.

A bracketed impostor is dropped from the malformed-token list. On main
today "resolved [corr=deadbeef] [key=victim]:" already reads as the bare
verb, as does "resolved [anything at all] [key=victim]:", because the tag
stop ends the parse at the opening bracket regardless of content. That is
#2280's reviewed contract; asserting otherwise here would narrow it. The
unbracketed impostors it owns stay strict and still fold as prose.

Adds a consumer test for the two verb-string case arms that postdate this
branch: fm-supervise-daemon.sh's transient-stale arm and
fm-crew-state.sh's map_log_state.

* fix(review): Captain: Seed cursor migration fixture with version four

* fix(document): Clarify voice status normalization ownership

* fix(bin): Cursor-Park unter Pi-Host ohne Cursor-Identität stilllegen (#3115)

* fix(bin): Cursor-Park unter Pi-Host stilllegen.

pi-cursor-sdk lädt .cursor/hooks.json in die Pi-Sitzung und parkte einen zweiten Watcher; das erzeugte rearm-resurface und brach laufende Rückfragen ab. Bei PI_CODING_AGENT=true beendet der Park sofort, native cursor-agent Primaries bleiben unverändert.

* fix(bin): Cursor-Park trotz PI-Leak nur ohne Cursor-Identität stilllegen.

Stand-down gilt nur bei PI_CODING_AGENT=true ohne CURSOR_AGENT und ohne CURSOR_INVOKED_AS. Handgestartete cursor-agent Primaries mit geerbtem PI-Marker parken weiter.

* no-mistakes(document): Document Cursor park Pi-host stand-down

* fix(bin): no-mistakes-Mindestversion auf 1.46.0 anheben.

Die PR-Attestierung verlangt ab 1.46.0 strukturierte Pipeline-Schritte; der Bootstrap-Floor blieb bei 1.31.2 und ließ zu alte Builds zu.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(pi): make supervision model picker searchable and scrollable (#3099)

* fix(pi): make /supervision-model's model list bounded and searchable

Pi's generic extension selector renders every option at once with no
search box, so a real eligible catalog ran off the top of the terminal.
The model step now draws the same rows through Pi's own SelectList - the
bounded scrolling primitive behind Pi's /model picker - with Pi's own
Input and fuzzy filter above it for search, keeping 'Follow main' first,
the branch-runtime eligibility filter intact, and the pick branch-only.

Pi's ModelSelectorComponent is deliberately not reused: its selection
handler writes the captain's default model through Pi's settings manager,
which would move main's conversation as a side effect of pinning the
branch. The effort step's menu is a handful of levels and stays on Pi's
plain selector dialog.

* no-mistakes(document): Clarify supervision picker documentation

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes(document): Document searchable supervision model picker

* no-mistakes: apply CI fixes

* fix(bin): durably report merged pull requests (#3104)

* fix: make a landed merge leave a durable outcome

A merge was the one lifecycle event that left no record outside the
merging agent's memory. bin/fm-pr-merge.sh ended at the forge call, and a
home merging under standing authority never waits for the merge poll that
would otherwise confirm it, so three real merges reached the captain as
silence.

bin/fm-merge-outcome-lib.sh is the single owner of that record. A
secondmate home reports the landed PR upward on the same parent reply
channel its terminal-outcome backstop already uses; a main home records
it on the durable wake queue. The record is at most once per task and
canonical PR identity, and only a merge that actually landed produces one.

The merge poll feeds that same channel when it detects a merge this home
did not perform, so the captain's own forge merge and a merge firstmate
performed itself produce one consistent outcome instead of two reporting
paths. No new state file and no second watch path.

Two smaller gaps from the same failure:

- A mate charter listed its report triggers without naming a landed
  merge. Under standing merge authority nothing is ever "ready for
  review", so the enumerated list silently omitted the case that matters.
- A secondmate home seeded without its parent binding failed every
  terminal-outcome report for the same reason, and the diagnostic never
  named the binding. It does now.

* no-mistakes(review): Harden durable merge outcome reporting

* no-mistakes(review): Make merge race regression deterministic

* no-mistakes(review): Make merge outcomes retry-idempotent and forge-confirmed

* no-mistakes(review): Unify merge publication under canonical outcome marker

* no-mistakes(review): Publish merge outcomes before committing dedup markers

* no-mistakes(review): Document at-least-once merge outcome recovery

* no-mistakes(review): Use supported GitHub confirmation and update recovery docs

* no-mistakes(review): Preserve distinct merge wakes by PR identity

* no-mistakes(document): Document durable merge outcome semantics

* no-mistakes(test): Make merge outcome interleaving test deterministic

* no-mistakes(document): Clarify merge outcome documentation ownership

* fix(lint): keep the merge-outcome library an analysis boundary

bin/fm-watch.sh followed the new merge-outcome library's source graph,
which reaches the wake queue, PR identity, and secondmate parent
libraries. Expanding that inside an already-large lint root pushed
ShellCheck's external-source analysis past the bounded CI lint worker:
the Lint job was killed with SIGTERM after five silent minutes, twice,
having emitted no diagnostics at all.

Make it an analysis boundary, exactly as the transition and inbox owners
directly above and below it already are and for the same stated reason.
Coverage is unchanged because the library is a canonical lint root in its
own right and is still linted as one.

Measured locally: the watcher goes from not terminating within 120s to
9s clean, and the library alone lints in 1s clean.

* fix(bearings): preserve projections through inventory mismatches (#3129)

* fix(bearings): keep an inventory-mismatch home readable, and mark warnings as repairs

A backlog-vs-metadata inventory mismatch inside a secondmate home was being
reported as "we cannot read that home", which discarded that home's open
captain calls, queued work, landed work, and live workers from the whole
Bearings digest. The main home already treats the identical mismatch as a
harmless disclosure; this makes the secondmate path agree.

- fm-fleet-snapshot.sh: the invalidity gate now passes orphan_in_flight,
  unowned_current, and terminal_in_flight through the partial-structured
  carve-out alongside child_current_unavailable, so those homes keep their
  decisions, holds, queued, landed, and live work and leave unreadable[].
  missing_backlog and unstructured_current stay on the discard path, because
  there the backlog itself is untrustworthy.
- fm-fleet-snapshot.sh: the same three kinds no longer collapse the home's own
  classification to "unknown"; the real captain_decision / active_child_work /
  externally_held classification survives and invalidity carries the warning.
  An unavailable child state still collapses it, including when a mismatch
  masks it under strict-invalidity precedence.
- secondmate_landed.partial now keys on partial-structured trust rather than an
  unknown state, so an inventory-mismatch home is still disclosed as partial.

Ask the home that owns the wrong books to fix them:

- bin/fm-secondmate-reconcile.sh sends exactly one reconcile instruction per
  mismatch episode through the ordinary steering transport. A persistent
  mismatch keeps its episode identity and never re-nags; a changed mismatch
  earns one more ask; a repaired one is forgotten so a recurrence is asked
  about again. The parent never touches the mate's own files, and a failed send
  records nothing so the next run retries it.

Give integrity warnings their own look on the board:

- charted rows take an optional kind of "queued" (the default) or "warning".
  A warning badges "needs repair" instead of "waiting" and is excluded from the
  Charted Next count, so alarms stop reading as dispatchable queued work. No
  fifth board section, and every existing payload stays valid.

Tests pin the new policy behaviorally: the retained surfaces and classification
for all three mismatch kinds, the still-discarding unstructured_current and
missing_backlog cases, the once-per-episode reconcile ask through real durable
steering records, and the board rendering exercised through the shipped
template under a minimal DOM shim.

* no-mistakes(review): Make reconcile dedupe atomic and warnings non-dispatchable

* no-mistakes(review): Preserve reconcile identity and reject stale snapshots

* no-mistakes(review): Order snapshots uniquely and canonicalize episode identities

* no-mistakes(review): Add fire-and-forget reconcile and separate warning overflow

* no-mistakes(review): Exclude fire-and-forget from escalation and track reconcile background

* no-mistakes(review): Run reconcile enqueue inline across all adapters

* no-mistakes(review): Track reconcile clears across strict-invalidity homes

* no-mistakes(review): Persist reconcile transitions atomically

* no-mistakes(document): Document reconcile and fire-and-forget contracts

* refactor(bearings): replace the reconcile episode dedupe with a 4-hour cooldown

The reconcile ask needed to fire once per problem without nagging on every
recap. The episode-precise record that tried to do that had to be correct in
every direction at once - order two concurrent snapshots, tell a repair from a
new problem, and never lose a clear - and each direction it got wrong either
swallowed a nudge or sent a duplicate.

A per-home cooldown removes the whole class. One durable timestamp per home,
one nudge per four hours, and nothing to get stale, mis-order, or
mis-classify: a home in mismatch is asked once, later recaps stay silent, and
a mismatch still sitting there after the window earns one gentle re-nudge.

- bin/fm-secondmate-reconcile.sh: state/<id>.reconcile-nudged holds the epoch
  second of the last ask; FM_RECONCILE_COOLDOWN_SECONDS names the window. The
  episode identity, ordering generation, pending/clear transitions, and
  delivery-identity reuse are all gone. A known-undelivered send starts no
  cooldown so the next run retries it; an unconfirmed one does, because a
  duplicate ask is worse than one the mate may already hold.
- bin/fm-fleet-snapshot.sh, bin/fm-bearings-snapshot.sh: drop the snapshot
  `observation` monotonic identity, which existed only to order those records.
- bin/fm-teardown.sh: retire the cooldown record with the endpoint's other
  runtime artifacts, so reseeding a retired id is not silenced by its
  predecessor's window.

The inline durable fire-and-forget send is unchanged, and the projection fix
and the warning surface are untouched.

Tests follow the behavior: the cooldown suite now pins one ask per window, the
re-nudge after it, the four-hour boundary, per-home independence, and that the
ask stays out of a re-ring ladder that still rings an ordinary steer beside it.
The obsolete observation-ordering test is deleted with the machinery it covered.

* no-mistakes(review): Serialize reconcile cooldown commits with mate lifecycle

* no-mistakes(review): Reject stale reconcile snapshots across mate reincarnations

* no-mistakes(review): Start reconcile cooldown after delivery completes

* no-mistakes(review): Keep reconcile sends nonblocking and remove pending residue

* no-mistakes(document): Document reconcile skip and stale-endpoint behavior

* no-mistakes(lint): Fix reconcile test subshell lint warning

* no-mistakes: apply CI fixes

* fix(bin): reconcile markerless remote secondmates safely (#3140)

* fix(bin): stop dropping reconcile nudges for markerless remote secondmates

A persistent remote secondmate's parent-side state/<id>.meta never carries
spawn_gen: bin/fm-spawn.sh's spawn_remote_secondmate() is its sole writer and
never writes one, because that incarnation identity does not apply to a
remote route. fm-secondmate-reconcile.sh's row filter required a non-empty
spawn_gen matching an identifier regex, so every such row was silently
dropped before the per-row loop ever saw it: no sent/stale/failed line, no
cooldown record, nothing sent, and no trace of why.

Give a legitimately markerless persistent remote secondmate a safe substitute
identity - its recorded remote_host - instead of weakening the spawn_gen
check for rows that do have a generation:

- bin/fm-secondmate-reconcile.sh: carry host through the row projection for
  both fm-fleet-snapshot.v1 and fm-bearings.v1 documents, and admit an empty
  spawn_gen instead of filtering the row out. A new revalidate_identity()
  compares the sampled spawn_gen against current metadata when one was
  sampled (unchanged), or the sampled host against the metadata's
  remote_host when none was sampled and the metadata still carries no
  spawn_gen of its own. A row with neither a spawn_gen nor a host has no
  safe identity at all and fails loudly instead of vanishing, exactly the
  visibility the original bug lacked.
- Rows now join on the ASCII unit separator rather than @tsv: bash's
  IFS-whitespace read collapses consecutive tabs, which would have silently
  dropped a legitimately empty field again.
- bin/fm-bearings-snapshot.sh: thread host through the secondmate_reconcile
  projection so the fm-bearings.v1 path (the one bearings itself feeds to
  the reconcile hook) carries the same substitute identity.
- tests/fm-secondmate-reconcile.test.sh: end-to-end coverage through the real
  remote transport (fm-on.sh + fm-remote-secondmate-control.sh against a
  genuinely seeded remote home) for a markerless mate nudged once per
  cooldown window, a stale/replaced remote route refused exactly like the
  existing local spawn_gen case, and a row with no identity at all failing
  loudly rather than being swallowed.

* no-mistakes(review): Enforce markerless remote host identity during final delivery

* no-mistakes(document): Document markerless remote reconciliation safety

* fix(bin): hand a busy declared pause to the away-mode daemon once, undecorated (#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE…
lytv pushed a commit to lytv/mymate that referenced this pull request Sep 8, 2026
…decorated (kunchenguid#3147)

* fix(watch): hand a busy declared pause to the away-mode daemon undecorated

While away mode is active the daemon owns triage and the watcher reverts to
one-shot, handing over plain wake identities the daemon classifies itself. The
busy-turn bound was the one stale path that did not: with afk active it ran the
wedge timer, so the daemon received a wake already decorated as a possible wedge.

That decoration outranks the daemon's own verdict. handle_wake escalates an
enriched wedge reason before its pause classification can apply, so a crew that
declared the wait itself - a `paused:` external wait or a verified captain-held
transfer holding a live foreground call - was wedge-escalated once per
FM_STALE_ESCALATE_SECS for as long as the wait lasted, the escalation count
climbing into demand-deep-inspection on a pane nobody needed to inspect.
Measured on the pre-fix tree, five consecutive re-arms produced five escalations.

busy_turn_bound_check now reads the declaration before the afk branch: away mode
hands off the plain window identity, one-shot per distinct stale hash, leaving
normal-mode pause bookkeeping unwritten because the daemon owns it there. The
daemon then classifies the wait itself and self-handles it on the long cadence.
Normal-mode behavior is unchanged, and lifting the declaration still restores the
busy-pane wedge escalation on the same pane.

The regression covers all three: the undecorated handoff with no wedge timer or
escalation counter, the one-shot on re-arm that the escalation ladder used to
climb, and the restored wedge escalation once the declaration is lifted.

* no-mistakes(review): key afk busy-pause handoff on declaration, clear wedge state

* no-mistakes(document): docs: scope away-mode busy-bound handoff to declared waits

* no-mistakes(document): docs: note afk busy-bound handoff in watcher header

---------

Co-authored-by: Talon Stark <talonstark@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants