Skip to content

feat(bin): report watched tooling updates that are available or installed but inert - #2684

Merged
kunchenguid merged 12 commits into
kunchenguid:mainfrom
Inthuson:fm/tool-update-checks-u5
Aug 21, 2026
Merged

feat(bin): report watched tooling updates that are available or installed but inert#2684
kunchenguid merged 12 commits into
kunchenguid:mainfrom
Inthuson:fm/tool-update-checks-u5

Conversation

@Inthuson

Copy link
Copy Markdown
Contributor

Intent

Add a periodic check that tells firstmate when tooling this home depends on has an update available, and, just as importantly, when an update was installed but did not take effect.

The motivating incident: on 2026-08-20 the captain updated Herdr. It self-installed correctly to ~/.local/bin/herdr at 0.8.2, but mise also managed Herdr with its own 0.8.0 copy, and mise's install dir sits at PATH position 9 while ~/.local/bin sits at 18, so command -v herdr still resolved to 0.8.0. The running Herdr server spoke protocol 20 and the stale client spoke 19, so every Herdr command failed with protocol_mismatch, and firstmate could not read its own fleet and reported two live workers as stopped. mise could not even offer 0.8.2 because its minimum_release_age gate hid the release. The update was installed and inert. A check that only asks "is a newer version published" would have reported everything up to date and missed this completely. Detecting that second case is the point of this task, not a nice-to-have.

Acceptance criteria the captain set:

  1. A local registry of watched tools in config/, gitignored and local, never shipped to another home, firstmate-maintained but human-editable in the same spirit as config/crew-dispatch.json. Adding a tool must be a config edit, never a code change.
  2. One script under bin/ that performs the check and prints nothing when everything is current, so it composes with firstmate's existing check-and-wake mechanism instead of needing a new one.
  3. Report the two conditions distinctly: an update is available, and PATH skew where the version that actually resolves on PATH is not the newest copy installed on this host. Detect skew by asking every PATH hit for its own version and comparing them. Do not trust a single lookup, and do not infer a version from a directory name, because the incident's directory said "latest" while containing 0.8.0.
  4. Cover at least four tools with their real update sources: firstmate itself (commits behind origin default in its clone), AgentsOnTheGo (commits behind origin/mainline), Herdr (the self-updating binary in ~/.local/bin against any other copy on PATH including a mise-managed one, the PATH-skew case), and no-mistakes (it already announces "A new version of no-mistakes is available: vX -> vY", so read that rather than reimplementing its version lookup).
  5. Do not nag: the same pending update must not be reported on every poll. Reuse firstmate's existing state-check and wake contract, including its trust registration, rather than inventing a scheduler.
  6. Colocated tests, including a regression that fails if the PATH-skew case goes undetected. That test is the deliverable's whole justification, so it must prove the point rather than assert that a call happened. A test that passes against a build with the skew detection removed is worthless.
  7. shellcheck-clean at the pinned version.

Constraints the captain stated, which explain choices that would otherwise look surprising in the diff:

  • This is firstmate's own shared tracked material, so the firstmate-coding-guidelines contract applies.
  • Do not change the captain's mise configuration, PATH, or any installed tool. Detecting skew is this task's job. Fixing it is not, and a check that silently repaired the host would be worse than the problem. So the script reports and repairs nothing: it never installs, updates, uninstalls, reorders PATH, touches a version manager, or fetches into a watched repository, and every git probe is read-only.
  • Do not issue any Herdr lifecycle command. Reading a version is fine. A live Herdr daemon and the captain's live fleet run on this host and nothing may disturb them.
  • No em-dashes anywhere, including code comments, docs, and the commit message. Plain, simple, human-readable code and documentation, one sentence per line in docs, and no invented vocabulary.
  • Never add an agent name as a commit co-author.

Decisions and tradeoffs made while doing the work:

  • PATH skew is measured, never inferred. Every executable copy of a watched command on PATH is probed for its own version and those answers are compared. Copies are deduplicated by device and inode, so one install reached through a symlinked bin directory does not report skew against itself. A copy that will not report a version is a check failure rather than an assumed pass.
  • The check rides the existing watcher state-check contract: arm writes state/tool-updates.check.sh and binds its bytes with bin/fm-check-register.sh, so the existing watcher polls it on its normal cadence and turns its one line into a check wake. No new scheduler, and no separate daemon.
  • Deliberately not auto-armed from bin/fm-bootstrap.sh. Auto-arming would add a seventh documented mutating bootstrap sweep and would require editing the always-loaded AGENTS.md session-start contract, which is a much larger blast radius than this task needs. Firstmate arms once per home instead, and that is documented.
  • Deliberately not added to the inherited-config set, because criterion 1 requires the registry never be shipped to another home. config/ is already gitignored wholesale, so no gitignore change was needed.
  • Registry records are joined with the unit separator rather than a tab, because tab is IFS whitespace and read would collapse the empty fields an optional key leaves behind.
  • The one-line report is cut through the shared bounded-line owner (bin/fm-line-cap-lib.sh) so an over-long report carries the repo's standard visible truncation marker instead of ending mid-finding as if that were all of it. A wider max than the digest default is used on purpose, because a single skew finding names two absolute paths and two versions.
  • The finding is printed before the report record is written, so a record that cannot be written costs a repeated report rather than a lost one. A sweep killed part way through writes no record and is retried.
  • Optional announce_args was added after live probing showed a real gap: no-mistakes --version prints the version but not the update announcement, while its other commands carry it. Without a second probe command the no-mistakes source in criterion 4 would silently never fire. The announcement probe is asked only of the copy PATH resolves.
  • The git default-branch resolution gained a bounded read-only ls-remote --symref fallback after a live single-branch clone with no local record of origin HEAD produced a check failure the operator could not act on.
  • Environment override seams follow the repo's existing pattern (FM_HOME, FM_STATE_OVERRIDE, FM_CONFIG_OVERRIDE) and two unused seams were removed rather than left as dead configuration.
  • Tests are classified into the watcher-wake-lock family in bin/fm-test-run.sh because the suite drives the real watcher checkpoint end to end.
  • The PATH-skew regression was verified by mutation, not just by passing: replacing the skew report with a no-op, and stopping after the first PATH hit as a single lookup would, each make that test fail. Removing the second announcement probe likewise fails its test.
  • docs/configuration.md is declared the single owner of the registry schema, and the copyable example is registered in docs/documentation-audiences.json as an operator example.

Two pre-existing environment gaps were deliberately left alone, because fixing either would require installing a tool or changing the captain's mise configuration, which the captain forbade: actionlint is absent on this host (so the workflow lint tests cannot run), and tasks-axi resolves to a mise shim with no version set (so the decision-hold and bearings-board tests fail). Both reproduce identically from a pristine default-branch tree, so they are not caused by this change.

What Changed

  • Added bin/fm-tool-update-check.sh, a read-only check that reads the tools this home depends on from local, gitignored config/watched-tools.json and prints one bounded line only when something needs attention. It reports two distinct conditions: <tool> update available from an update source (commits behind a remote branch for a local clone, or a tool's own update announcement matched by announce_pattern, optionally probed via a separate announce_args command), and <tool> update not in effect when a newer copy is installed but PATH still resolves an older one. Skew is measured by running every executable copy of the command found on PATH and comparing the versions each one reports, deduplicated by device and inode; a copy that will not report a version is a check failure rather than an assumed pass. The script never installs, updates, reorders PATH, touches a version manager, or fetches into a watched repo.
  • arm writes state/tool-updates.check.sh and binds its bytes through bin/fm-check-register.sh so the existing watcher polls it on its normal cadence, and disarm removes the shim, its trust binding, and the record. Findings are recorded uncut in state/.tool-updates after a sweep completes so a pending update is reported once instead of on every poll, while a changed or returning condition reports again. Probe sweeps are gated by FM_TOOL_UPDATE_INTERVAL and bounded by FM_TOOL_UPDATE_PROBE_SECS and FM_TOOL_UPDATE_BUDGET_SECS, with the sweep budget cut down to what FM_CHECK_TIMEOUT allows (and the cut named in the report) so a killed run cannot silently swallow a finding.
  • Added tests/fm-tool-update-check.test.sh (74 cases, classified into the watcher-wake-lock family in bin/fm-test-run.sh), including test_path_skew_is_reported_from_every_copy, which stages an older copy earlier on PATH than a newer one and asserts the exact skew line with both paths and versions, plus negative cases that a published update is not reported as skew, that two copies of the same version are not skew, and that one broken announce_pattern does not suppress another tool's skew report. Documented the registry schema in docs/configuration.md as its single owner, added the copyable docs/examples/watched-tools.json registered as an operator example, and listed the script and its config and state entries in docs/scripts.md and AGENTS.md.

Risk Assessment

✅ Low: The change is additive (one new script that only runs once armed, one new test file, docs, and two single-line registrations), every acceptance criterion I could verify from source holds, I reproduced the key behaviors myself including the fix round's newest claim, and the only remaining findings are documentation accuracy and one untested-but-non-silent safety branch.

Testing

Ran the colocated suite and the two suites owning the other changed files (59 cases, all passing), then proved the deliverable's justification by mutation: removing the skew report, reducing probing to a single PATH lookup, and removing the second announcement probe each make the relevant test fail, and the worktree was restored to HEAD after each. Product-level verification on this host reproduced the 2026-08-20 incident against the real herdr binary (the report names 0.8.0 at the version-manager copy and 0.8.2 at ~/.local/bin, reported once, then silent, then silent again once cleared), swept the four real update sources including the host's genuine no-mistakes v1.46.0 -> v1.53.0 announcement read from --help, and drove the armed check through the real watcher to a check wake. I also confirmed the sweep repairs nothing (one herdr --version invocation, watched clones byte-identical) and that the registry is gitignored and never inherited. No screenshots apply: this change has no rendered surface, so CLI transcripts of the check, the watcher wake, and the shim are the end-user surface. The only failing test I saw, fm-lint-workflows, fails because actionlint is absent on this host and is unrelated to this change.

Evidence: End-to-end CLI transcript: registry, four real sources, incident reproduction, no-nag, read-only proof, armed watcher wake

########## 1. the watched tool registry an operator edits (config/, local, gitignored)

$ cat config/watched-tools.json
{
  "tools": [
    { "name": "firstmate", "git": { "repo": "/tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/firstmate-clone", "remote": "origin" } },
    { "name": "agents-on-the-go", "git": { "repo": "/tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/agents-on-the-go", "remote": "origin", "branch": "mainline" } },
    { "name": "herdr", "command": "herdr", "version_args": ["--version"] },
    { "name": "no-mistakes", "command": "no-mistakes", "version_args": ["--version"],
      "announce_args": ["--help"],
      "announce_pattern": "A new version of no-mistakes is available: [^ ]+ -> [^ ]+" }
  ]
}
$ git check-ignore -v config/watched-tools.json
.gitignore:13:config/	config/watched-tools.json

########## 2. one sweep over the four real update sources

$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check
tool updates: firstmate update available: local main is 3 commits behind origin/main; agents-on-the-go update available: local mainline is 2 commits behind origin/mainline; no-mistakes update available: A new version of no-mistakes is available: v1.46.0 -> v1.53.0

(herdr is silent: this host has one copy on PATH and it is the newest one.
 no-mistakes announces its own update on --help, not on --version, so
 announce_args asks the command that carries it.)

########## 3. the 2026-08-20 incident: an update that installed but never took effect

A version-manager copy of herdr is put back at PATH position 1, in a
directory named "latest" that holds 0.8.0, while the real self-installed
0.8.2 stays where it is at ~/.local/bin.

$ command -v herdr; herdr --version   # everything a single lookup can know
/tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/incident/mise/installs/herdr/latest/herdr
herdr 0.8.0
$ /home/inthuson/.local/bin/herdr --version   # the newest copy installed here
herdr 0.8.2

A check that only asks "is a newer version published" sees 0.8.0 and the
newest release the version manager will offer, and reports nothing.

$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check   # poll 1
tool updates: herdr update not in effect: PATH resolves 0.8.0 at /tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/incident/mise/installs/herdr/latest/herdr but 0.8.2 is installed at /home/inthuson/.local/bin/herdr

$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check   # poll 2, same condition
(no output: the same pending update is not reported twice)

$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check   # stale copy removed
(no output: PATH resolves the newest copy again)

########## 4. it reports and repairs nothing

$ every herdr invocation the sweep makes, with the only herdr on PATH recorded
tool updates: firstmate update available: local main is 3 commits behind origin/main; agents-on-the-go update available: local mainline is 2 commits behind origin/mainline
herdr --version
(one version read, no Herdr lifecycle command)

$ the watched clones before vs after the sweep
identical: refs, HEAD, working tree, FETCH_HEAD, reflog, object count

########## 5. armed once per home, then polled by the existing watcher

$ bin/fm-tool-update-check.sh arm
armed: state/tool-updates.check.sh
$ cat state/tool-updates.check.sh
#!/usr/bin/env bash
# Auto-generated by fm-tool-update-check.sh - watched tool update poll shim.
# The watcher validates these bytes, then dispatches the trusted check script.
export FM_HOME=/tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/home-armed
exec /local/home/inthuson/.no-mistakes/worktrees/d4c7ad84348d/01M0FM08RBH7MKKX9N7ZZWF5GG/bin/fm-tool-update-check.sh check
$ head -1 state/tool-updates.check-trust
fm-custom-check-v1

$ bin/fm-watch-checkpoint.sh --seconds 10   # the real watcher, no new scheduler
check: /tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/home-armed/state/tool-updates.check.sh: tool updates: herdr update not in effect: PATH resolves 0.8.0 at /tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG/demo/incident/mise/installs/herdr/latest/herdr but 0.8.2 is installed at /home/inthuson/.local/bin/herdr

########## 6. the registry is never shipped to another home

$ . bin/fm-config-inherit-lib.sh; fm_config_inherit_items
config/crew-dispatch.json
config/crew-harness
config/backlog-backend
config/backend
config/herdr-presentation-spaces
config/startup-memory-budget
config/trace-context
data/captain-shared.md
(config/watched-tools.json is not in the inherited set)
Evidence: Mutation proof that the PATH-skew regression fails when skew detection is removed

===== mutation A: skew report replaced with a no-op ===== - emit "$name update not in effect: PATH resolves ... but ... is installed at ..." + : # mutation A: skew report removed not ok - PATH skew was not reported as an update that is not in effect --- test script exit=1 (non-zero means the mutation was caught) --- ===== mutation B: stop after the first PATH hit (single lookup) ===== + break # mutation B: stop after the first PATH hit not ok - PATH skew was not reported as an update that is not in effect --- test script exit=1 --- ===== mutation C: the second announcement probe is removed ===== - if [ "$announce_args" != "$args_joined" ]; then + if false; then not ok - the announcement was not read from the command that carries it --- test script exit=1 --- ===== worktree restored ===== bin/fm-tool-update-check.sh is back to its committed bytes


===== mutation A: skew report replaced with a no-op =====
 1 file changed, 1 insertion(+), 1 deletion(-)
-    emit "$name update not in effect: PATH resolves $resolved_version at $resolved_path but $best_version is installed at $best_path"
+    : # mutation A: skew report removed
--- test result ---
not ok - PATH skew was not reported as an update that is not in effect (missing: 'herdr update not in effect')
--- test script exit=1 (non-zero means the mutation was caught) ---

===== mutation B: stop after the first PATH hit (single lookup) =====
 1 file changed, 1 insertion(+)
+    break # mutation B: stop after the first PATH hit
--- test result ---
not ok - PATH skew was not reported as an update that is not in effect (missing: 'herdr update not in effect')
--- test script exit=1 (non-zero means the mutation was caught) ---

===== mutation C: the second announcement probe is removed =====
 1 file changed, 1 insertion(+), 1 deletion(-)
-    if [ "$announce_args" != "$args_joined" ]; then
+    if false; then # mutation C: second announcement probe removed
--- test result ---
ok - a copy that reports no version is a check failure, not a pass
ok - a watched command missing from PATH is reported
ok - a tool's own update announcement is read from its output
not ok - the announcement was not read from the command that carries it (missing: 'no-mistakes update available: A new version of no-mistakes is available: v1.46.0 -> v1.53.0')
--- test script exit=1 (non-zero means the mutation was caught) ---

===== worktree restored =====
bin/fm-tool-update-check.sh is back to its committed bytes
Evidence: The two conditions on real sources, and the incident reproduced against the real herdr binary
$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check
tool updates: firstmate update available: local main is 3 commits behind origin/main; agents-on-the-go update available: local mainline is 2 commits behind origin/mainline; no-mistakes update available: A new version of no-mistakes is available: v1.46.0 -> v1.53.0

$ command -v herdr; herdr --version # everything a single lookup can know
/tmp/.../demo/incident/mise/installs/herdr/latest/herdr
herdr 0.8.0
$ /home/inthuson/.local/bin/herdr --version # the newest copy installed here
herdr 0.8.2

$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check # poll 1
tool updates: herdr update not in effect: PATH resolves 0.8.0 at /tmp/.../demo/incident/mise/installs/herdr/latest/herdr but 0.8.2 is installed at /home/inthuson/.local/bin/herdr

$ FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check # poll 2, same condition
(no output: the same pending update is not reported twice)
Evidence: The armed check arriving at the real watcher as an ordinary check wake
$ bin/fm-tool-update-check.sh arm
armed: state/tool-updates.check.sh
$ head -1 state/tool-updates.check-trust
fm-custom-check-v1
$ bin/fm-watch-checkpoint.sh --seconds 10 # the real watcher, no new scheduler
check: /tmp/.../demo/home-armed/state/tool-updates.check.sh: tool updates: herdr update not in effect: PATH resolves 0.8.0 at /tmp/.../demo/incident/mise/installs/herdr/latest/herdr but 0.8.2 is installed at /home/inthuson/.local/bin/herdr
Evidence: Read-only proof: every herdr invocation the sweep made, and the watched clones before vs after
$ every herdr invocation the sweep makes, with the only herdr on PATH recorded
herdr --version
(one version read, no Herdr lifecycle command)

$ the watched clones before vs after the sweep
identical: refs, HEAD, working tree, FETCH_HEAD, reflog, object count
Evidence: Targeted test log (fm-tool-update-check, fm-documentation-audiences, fm-test-run)

FM_TEST_SUMMARY total=3 failed=0 skipped_gate=0 duration_ms=28185

FM_TEST_BEGIN 2026-08-20T15:30:08Z tests/fm-tool-update-check.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - PATH skew is reported by asking every copy on PATH for its own version
ok - no report when PATH already resolves the newest installed copy
ok - two copies of the same version are not skew
ok - one copy reached through two PATH entries is probed once as one install
ok - a copy that reports no version is a check failure, not a pass
ok - a watched command missing from PATH is reported
ok - a tool's own update announcement is read from its output
ok - an announcement carried by another command is read from that command
ok - an announce_pattern that cannot be used is reported instead of read as silence
ok - a broken pattern is reported for its own tool and the rest of the sweep still reports
ok - an announcement source the budget could not reach is reported, not read as current
ok - an announcement probe that does not answer is reported, not read as current
ok - a tool that announces nothing stays silent
ok - commits behind the origin branch are reported without touching the repository
ok - an omitted branch is detected from the remote's default branch
ok - the default branch is asked of the remote when the clone has no local record
ok - a repository that is current or ahead of its origin branch is silent
ok - an unusable git source is reported as a check failure
ok - a remote that cannot be read is reported as unreadable, not as a missing branch
ok - a branch a readable remote does not have is still reported as missing
ok - git probes stop and name their tool once the sweep budget is gone
ok - a git probe that does not answer is reported as a failure, never as an update
ok - a stalled repository probe is reported as no answer, not as not a repository
ok - no watched tool registry means no output at all
ok - a malformed registry is reported instead of quietly skipped
ok - the same pending update is reported once, and a change is reported again
ok - an over-long report is cut with the shared truncation marker
ok - a finding that lands past the cut is still reported as news
ok - probes run once per interval, not on every poll
ok - a budget that cannot fit the watcher bound is cut and reported, and the sweep keeps working
ok - an out of range bound or unknown action refuses instead of guessing
ok - arm registers a trusted check and disarm removes every trace
ok - a symlink at the shim path is refused instead of followed
ok - a failed registration never leaves a shim without a matching trust binding
ok - a re-arm that loses the trust binding leaves no shim behind
ok - a relative home is resolved before it is persisted into the shim
ok - the armed check reaches the watcher as an ordinary check wake
FM_TEST_END 2026-08-20T15:30:20Z tests/fm-tool-update-check.test.sh exit=0 duration_ms=12348 gate_skip=false
FM_TEST_BEGIN 2026-08-20T15:30:20Z tests/fm-documentation-audiences.test.sh family=pure-contract-unit expected_gate_skip=none
ok - documentation inventory classifies every maintained prose surface exactly once
ok - classification, setup routing, and maintained-prose scope fail safely
ok - required documentation owner pointers cannot silently disappear
ok - local links resolve while dates, versions, commands, and incident prose remain semantically reviewed
FM_TEST_END 2026-08-20T15:30:21Z tests/fm-documentation-audiences.test.sh exit=0 duration_ms=762 gate_skip=false
FM_TEST_BEGIN 2026-08-20T15:30:21Z tests/fm-test-run.test.sh family=pure-contract-unit expected_gate_skip=none
ok - exact suite coverage: --all lists every tests/*.test.sh once
ok - family selection returns a proper subset of the suite
ok - single-script selection lists exactly that path
ok - changed-file selection stays conservative (never silent full suite)
ok - changed selection covers dependents and fails closed for unmapped source
ok - empty changed selection emits deterministic text and JSON summaries
ok - timing markers and JSON artifact are valid
ok - aggregate exit reflects any script failure
ok - gate-skip accounting is honest and non-failing
ok - fail-on-gate-skip converts herdr-not-found into a hard failure
ok - exclude-family drops the named primary family after selection
ok - portable shard union, disjointness, and coverage guard hold
ok - portable serial shards are a deterministic disjoint cover of the serial lane
ok - portable serial shard lanes refuse mismatched, out-of-range, and countless names
ok - --jobs refuses non-proven / stateful selections
ok - jobs scheduler runs proven scripts; failure propagates; non-proven refused
ok - Herdr CI family-run step times out at 20 min under a 75 min job backstop
ok - aggregate-json merges lane timing artifacts
FM_TEST_END 2026-08-20T15:30:36Z tests/fm-test-run.test.sh exit=0 duration_ms=14958 gate_skip=false
FM_TEST_SUMMARY total=3 failed=0 skipped_gate=0 duration_ms=28185
FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=2 duration_ms=15720 failed=0
FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=1 duration_ms=12348 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-test-run.test.sh duration_ms=14958
FM_TEST_SLOWEST rank=2 script=tests/fm-tool-update-check.test.sh duration_ms=12348
FM_TEST_SLOWEST rank=3 script=tests/fm-documentation-audiences.test.sh duration_ms=762
Evidence: Reproduction script for the transcript above
#!/usr/bin/env bash
# Manual end-to-end demonstration of bin/fm-tool-update-check.sh on this host.
# Fixtures were built by the test phase; see tool-update-check-demo.txt for the
# captured transcript.
set -u
EV=/tmp/no-mistakes-evidence/01M0FM08RBH7MKKX9N7ZZWF5GG
D=$EV/demo
W=/local/home/inthuson/.no-mistakes/worktrees/d4c7ad84348d/01M0FM08RBH7MKKX9N7ZZWF5GG
CHECK=$W/bin/fm-tool-update-check.sh
STALE=$D/incident/mise/installs/herdr/latest

hdr() { printf '\n########## %s\n\n' "$*"; }
run() { printf '$ %s\n' "$*"; }

hdr '1. the watched tool registry an operator edits (config/, local, gitignored)'
run "cat config/watched-tools.json"
cat "$D/home-real/config/watched-tools.json"
run "git check-ignore -v config/watched-tools.json"
(cd "$W" && git check-ignore -v config/watched-tools.json)

hdr '2. one sweep over the four real update sources'
run "FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check"
rm -f "$D/home-real/state/.tool-updates"
env FM_HOME="$D/home-real" FM_TOOL_UPDATE_INTERVAL=0 "$CHECK" check
printf '\n(herdr is silent: this host has one copy on PATH and it is the newest one.\n'
printf ' no-mistakes announces its own update on --help, not on --version, so\n'
printf ' announce_args asks the command that carries it.)\n'

hdr '3. the 2026-08-20 incident: an update that installed but never took effect'
printf 'A version-manager copy of herdr is put back at PATH position 1, in a\n'
printf 'directory named "latest" that holds 0.8.0, while the real self-installed\n'
printf '0.8.2 stays where it is at ~/.local/bin.\n\n'
run 'command -v herdr; herdr --version   # everything a single lookup can know'
env PATH="$STALE:$PATH" bash -c 'command -v herdr; herdr --version'
run '/home/inthuson/.local/bin/herdr --version   # the newest copy installed here'
/home/inthuson/.local/bin/herdr --version
printf '\nA check that only asks "is a newer version published" sees 0.8.0 and the\n'
printf 'newest release the version manager will offer, and reports nothing.\n\n'
run "FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check   # poll 1"
rm -f "$D/home-incident/state/.tool-updates"
env FM_HOME="$D/home-incident" PATH="$STALE:$PATH" FM_TOOL_UPDATE_INTERVAL=0 "$CHECK" check
printf '\n'
run "FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check   # poll 2, same condition"
env FM_HOME="$D/home-incident" PATH="$STALE:$PATH" FM_TOOL_UPDATE_INTERVAL=0 "$CHECK" check
printf '(no output: the same pending update is not reported twice)\n\n'
run "FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check   # stale copy removed"
env FM_HOME="$D/home-incident" FM_TOOL_UPDATE_INTERVAL=0 "$CHECK" check
printf '(no output: PATH resolves the newest copy again)\n'

hdr '4. it reports and repairs nothing'
run 'every herdr invocation the sweep makes, with the only herdr on PATH recorded'
: > "$D/audit/herdr-calls.log"
rm -f "$D/home-audit/state/.tool-updates"
env -i FM_HOME="$D/home-audit" PATH="$D/audit/bin:/usr/bin:/bin" HOME="$HOME" \
  HERDR_CALL_LOG="$D/audit/herdr-calls.log" FM_TOOL_UPDATE_INTERVAL=0 "$CHECK" check
cat "$D/audit/herdr-calls.log"
printf '(one version read, no Herdr lifecycle command)\n\n'
run 'the watched clones before vs after the sweep'
diff -u "$EV/repo-before.txt" "$EV/repo-after.txt" \
  && printf 'identical: refs, HEAD, working tree, FETCH_HEAD, reflog, object count\n'

hdr '5. armed once per home, then polled by the existing watcher'
run "bin/fm-tool-update-check.sh arm"
# a fresh home, so the one wake the checkpoint reports is this check's own
rm -rf "$D/home-armed"; mkdir -p "$D/home-armed/state" "$D/home-armed/config"
printf '%s\n' '{ "tools": [ { "name": "herdr", "command": "herdr", "version_args": ["--version"] } ] }' \
  > "$D/home-armed/config/watched-tools.json"
printf '%s\n' fm-pr-check-migration-scan-v1 > "$D/home-armed/state/.pr-check-migration-scan-v1"
printf '%s\n' fm-pr-check-migration-v1 > "$D/home-armed/state/.pr-check-migration-v1"
chmod 0600 "$D/home-armed/state/.pr-check-migration-scan-v1" "$D/home-armed/state/.pr-check-migration-v1"
FM_HOME="$D/home-armed" "$CHECK" arm
run "cat state/tool-updates.check.sh"
cat "$D/home-armed/state/tool-updates.check.sh"
run "head -1 state/tool-updates.check-trust"
head -1 "$D/home-armed/state/tool-updates.check-trust"
printf '\n'
run "bin/fm-watch-checkpoint.sh --seconds 10   # the real watcher, no new scheduler"
rm -f "$D/home-armed/state/.tool-updates"
env FM_HOME="$D/home-armed" PATH="$STALE:$PATH" FM_CHECK_TIMEOUT=30 FM_TOOL_UPDATE_INTERVAL=0 \
  FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=1 "$W/bin/fm-watch-checkpoint.sh" --seconds 10

hdr '6. the registry is never shipped to another home'
run '. bin/fm-config-inherit-lib.sh; fm_config_inherit_items'
(cd "$W" && bash -c '. bin/fm-config-inherit-lib.sh; fm_config_inherit_items')
printf '(config/watched-tools.json is not in the inherited set)\n'
Evidence: Mutation harness used for the skew-regression proof
#!/usr/bin/env bash
# Mutation check: each mutation below removes one part of the detection the
# regression tests exist to protect. A test that only asserted "a call happened"
# would keep passing. Each run must fail.
set -u
W=/local/home/inthuson/.no-mistakes/worktrees/d4c7ad84348d/01M0FM08RBH7MKKX9N7ZZWF5GG
cd "$W" || exit 1
SRC=bin/fm-tool-update-check.sh

mutate() { python3 - "$SRC" "$1" "$2" <<'PY'
import sys
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(path).read()
if text.count(old) != 1:
    sys.exit("mutation anchor not unique: %d" % text.count(old))
open(path, "w").write(text.replace(old, new))
PY
}

run_case() {
  local label=$1
  printf '\n===== %s =====\n' "$label"
  git diff --stat -- "$SRC" | tail -1
  git diff -U0 -- "$SRC" | grep -E '^[-+][^-+]' 
  printf -- '--- test result ---\n'
  local status=0
  bash tests/fm-tool-update-check.test.sh 2>&1 | grep -E '^(ok|not ok)' | tail -4
  status=${PIPESTATUS[0]}
  printf -- '--- test script exit=%s (non-zero means the mutation was caught) ---\n' "$status"
  git checkout -- "$SRC"
}

# A: the PATH-skew report itself is removed.
mutate '    emit "$name update not in effect: PATH resolves $resolved_version at $resolved_path but $best_version is installed at $best_path"' \
       '    : # mutation A: skew report removed'
run_case 'mutation A: skew report replaced with a no-op'

# B: only the first PATH hit is probed, the way a single `command -v` lookup would.
mutate '      best_path=$hit
    fi
  done <<EOF' \
       '      best_path=$hit
    fi
    break # mutation B: stop after the first PATH hit
  done <<EOF'
run_case 'mutation B: stop after the first PATH hit (single lookup)'

# C: the separate announcement command is never asked.
mutate '    if [ "$announce_args" != "$args_joined" ]; then' \
       '    if false; then # mutation C: second announcement probe removed'
run_case 'mutation C: the second announcement probe is removed'

printf '\n===== worktree restored =====\n'
git status --porcelain -- "$SRC" | sed 's/^/dirty: /'
git diff --quiet -- "$SRC" && echo "bin/fm-tool-update-check.sh is back to its committed bytes"
- Outcome: ⚠️ 1 info across 1 run (12m20s)

Pipeline

Updates from git push no-mistakes

... (4 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

⚠️ **Review** - 2 infos

🔧 Fix: keep sweeps alive on broken patterns and oversized budgets
5 issues (3 warnings, 2 infos) still open:

  • ⚠️ bin/fm-tool-update-check.sh:782 - A failed registration leaves a written, unregistered check shim behind, which turns into a recurring false security wake. shim_write succeeds, then FM_HOME=&#34;$home&#34; &#34;$REGISTER_BIN&#34; &#34;$CHECK_ID&#34; fails and action_arm returns 1 without removing state/tool-updates.check.sh. Verified by running arm with a symlink planted at the trust path: ln -s .../elsewhere.txt state/tool-updates.check-trust makes fm-check-register.sh:29 (fm_pr_regular_destination_on_device_or_absent &#34;$TRUST&#34;) exit 1 with error: custom check trust path is unavailable, arm exits 1, and state/ is left holding -rwx------ tool-updates.check.sh with no valid .check-trust. The symlink's target is correctly untouched, so the round-2 shim guard works; the leftover shim is the problem. The watcher then takes the else branch at bin/fm-watch.sh:944-947, appends the shim to rejected_checks, and wakes firstmate with check: rejected unauthenticated state checks: .../tool-updates.check.sh (bin/fm-watch.sh:962-966) on every FM_CHECK_INTERVAL until someone deletes the file by hand, while fm-pr-check-migrate.sh separately treats it as a noncanonical artifact to quarantine (migration_needed at bin/fm-pr-check-migrate.sh:384-398). Other reachable register failures are the same shape: a host with neither shasum nor sha256sum makes fm_custom_check_sha256 fail, and the post-write fm_custom_check_registered re-check can fail on a race. The repo's own arm pattern is to roll back: fmx_arm_failed calls x_mode_remove_artifacts before reporting (bin/fm-bootstrap.sh:955-961). Fix: remove $CHECK_SHIM when registration fails, before returning 1.
  • ⚠️ tests/fm-tool-update-check.test.sh:96 - The suite now depends on an unpinned ambient environment variable, so a developer with FM_CHECK_TIMEOUT set sees spurious failures across the file. run_check passes FM_TOOL_UPDATE_INTERVAL=0 but not FM_CHECK_TIMEOUT, and tests/lib.sh does not sanitize the environment (its only export is FM_GATE_REFUSE_BYPASS=1). Because round 2 made the check read ${FM_CHECK_TIMEOUT:-30} and clamp the default 20s budget against CHECK_TIMEOUT - 1, any ambient value of 20 or less adds a report line to a sweep that should print nothing. Verified directly against the script under review with two copies on PATH, newest first: with env -u FM_CHECK_TIMEOUT the check is silent, with FM_CHECK_TIMEOUT=20 it prints tool updates: sweep budget 20s cut to 19s to stay inside the watcher check timeout of 20s, and with FM_CHECK_TIMEOUT=21 it is silent again. FM_CHECK_TIMEOUT is a documented operator tunable (docs/configuration.md:612), so 20 or 10 is a plausible ambient value. That breaks at least test_newest_copy_first_on_path_is_silent, test_identical_versions_are_silent, test_one_copy_reached_twice_is_probed_once, test_quiet_tool_with_announce_pattern_is_silent, test_current_and_ahead_repositories_are_silent, the control home in test_announcement_is_read_from_a_second_command, and the cleared-finding step of test_findings_are_reported_once_until_they_change. Fix: pin FM_CHECK_TIMEOUT in run_check (and in the cases that build their own env line, including the shim run at line 785 and the checkpoint run at line 810) so the clamp is exercised only by the case that means to exercise it.
  • ⚠️ bin/fm-tool-update-check.sh:158 - The new clamp does not actually leave enough room, so a clamped budget can still be killed by the watcher and reproduce the exact silence the clamp was added to prevent. BUDGET_MAX=$((CHECK_TIMEOUT - PROBE_MIN_SECS)) reserves one second, and the comment at lines 145-149 justifies that as "the margin a sweep can still need after its deadline". Two effects make the real overshoot larger. First, budget_exhausted and probe_bound compare whole seconds from date +%s, so a probe can be started when the integer arithmetic says one second remains while real remaining time is near zero, and it is then given a full one-second bound, ending up to a second past DEADLINE. Second, fm_run_external_timeout runs &#34;$runner&#34; -k 1 &#34;$seconds&#34; bash -c ... (bin/fm-timeout-lib.sh), so a probe that does not die on TERM is only KILLed a further second later. Concrete sequence: an operator sets FM_TOOL_UPDATE_BUDGET_SECS=60 on a home with the default FM_CHECK_TIMEOUT=30, so the budget is cut to 29 (the value test_an_oversized_budget_is_cut_to_fit_and_reported asserts). On an offline laptop the four watched tools spend the budget in 5s network probe bounds, the last probe starts just inside the deadline, and the run crosses 30s. run_check_process kills it, so nothing is printed and record_write never runs; because no record is written the cadence gate never engages and every subsequent poll repeats the same silence with no failure line. The default 20s budget has 9s of slack and is unaffected. Fix: derive BUDGET_MAX with room for the second-boundary rounding plus the kill grace (for example CHECK_TIMEOUT - PROBE_MIN_SECS - 2, still floored at 1), which leaves the default 20s uncut at FM_CHECK_TIMEOUT=30.
  • ℹ️ bin/fm-tool-update-check.sh:512 - Round 2 taught three of the bounded git probes to keep "no answer" apart from "the answer no", but left the first two reading a hit bound as a semantic answer. At line 512 if ! git_probe &#34;$repo&#34; rev-parse --git-dir &gt;/dev/null 2&gt;&amp;1; then emit &#34;$name check failed: $repo is not a git repository&#34;, and git_probe returns 124 when the bound is hit, so a clone on a stalled network mount is reported as not being a git repository at all: a diagnosis the probe never established, and one the operator cannot act on, which is the same class the review-1 and review-14 fixes closed for ls-remote, cat-file, merge-base, and rev-list. Line 519's symbolic-ref has the quieter version: its status is discarded, so a timeout looks identical to a clone with no local record of the remote's default branch and silently costs an extra network ls-remote --symref probe out of the same budget. Fix: capture both statuses and report 124 as a probe that did not answer, naming the repository, the way the other five probes now do.
  • ℹ️ tests/fm-tool-update-check.test.sh:533 - test_a_git_probe_that_does_not_answer_is_not_an_update can pass without exercising the fix it names. Its only assertions are assert_not_contains &#34;$report&#34; &#34;update available&#34; and assert_contains &#34;$report&#34; &#34;firstmate check failed&#34;, and it runs with FM_TOOL_UPDATE_PROBE_SECS=1. The fixture git wrapper only stalls on cat-file, but three earlier probes run under the same 1s bound first (rev-parse --git-dir, ls-remote, rev-parse --verify refs/heads/main), each through an extra bash wrapper layer. If any of those hits its bound on a loaded machine, git_findings returns early with origin did not answer within 1s or did not answer where main points, both of which satisfy both assertions, and both of which would also satisfy them against the pre-fix code that read a hit bound as "this clone does not have that commit". So the test's mutation proof depends on timing rather than on its assertions. Fix: assert the object-query message specifically (did not answer whether it already has), so an unrelated earlier timeout cannot make the case pass for the wrong reason.

🔧 Fix: roll back failed arm, widen budget clamp, bound repo probe
4 issues (3 warnings, 1 info) still open:

  • ⚠️ bin/fm-tool-update-check.sh:614 - The round-3 clamp still does not leave enough room, because git_findings issues up to three bounded probes after a single budget_allows. budget_allows is called at line 604, then cat-file -e (605), merge-base --is-ancestor (614) and rev-list --count (621) all run unguarded, and probe_bound floors at 1s once the budget is spent, so each can add ~1s and the last can add another 1s of kill grace. BUDGET_MAX=$((CHECK_TIMEOUT - PROBE_MIN_SECS - CLOCK_ROUNDING_SECS - KILL_GRACE_SECS)) reserves exactly 3s, which models only ONE probe running past the deadline. Reproduced locally with a logging git wrapper against the script under review: with FM_TOOL_UPDATE_BUDGET_SECS=4 and probes sleeping 0.9s each, rev-list was STARTED 1.3s past DEADLINE with no budget check and the sweep ended 2.23s past DEADLINE; with each probe consuming its full 1s floor bound and the last one timing out, the overshoot reaches 3-4s. Failure sequence: an operator sets FM_TOOL_UPDATE_BUDGET_SECS=60 on a default home, it is clamped to 27, a slow sweep crosses 30s, run_check_process kills the check (bin/fm-watch.sh:553, plain timeout with no -k), nothing is printed, record_write never runs, the cadence gate never engages, and every subsequent poll repeats the same silence with no failure line. That is exactly the failure review-18 was accepted to close. Lowering FM_CHECK_TIMEOUT, a documented operator tunable (docs/configuration.md:612), reaches the same state with the DEFAULT 20s budget, since 20 is then clamped to CHECK_TIMEOUT - 3. The file's own comment at lines 504-505, "Every probe consults the sweep budget first", is false for merge-base, rev-list, and the second rev-parse --verify HEAD at line 587. Earliest shared boundary: fold the budget check into git_probe, which already owns bound derivation, so no probe can be issued past the deadline and the one-probe reserve becomes true; that also removes the seven repeated status=$?; if [ &#34;$status&#34; -eq 124 ] blocks.
  • ⚠️ bin/fm-tool-update-check.sh:459 - The separate announcement probe's exit status is discarded, and empty output falls through the [ -n &#34;$announce_out&#34; ] guard at line 462, so an announce command that hangs or fails silently kills that tool's update source while the sweep reports everything current. Reproduced against the script under review with the documented no-mistakes shape (version_args [&#34;--version&#34;], announce_args [&#34;--help&#34;], announce_pattern present): a fixture whose --version answers instantly and whose --help hangs, run with FM_TOOL_UPDATE_PROBE_SECS=2 and the default budget, prints nothing at all and stamps reported= into state/.tool-updates. Trace: resolved_version is set from the fast --version probe, the budget is not exhausted so the review-12 branch at line 452 does not fire, probe_output at line 459 hits its bound and returns empty with status 124 discarded, the guard at 462 is false so no grep runs and nothing is emitted, and best_path == resolved_path so no skew either. This contradicts the file's own invariant at lines 31-32 that a probe which will not answer is a check failure rather than an assumed pass, and it is the same silently dead source announce_args was added to close. It is realistic because no-mistakes' announcement comes from its own network version check, which is exactly the call that stalls on a flaky link. Fix: capture probe_output's status for the announcement probe and report a check failure when the probe did not answer, in the same shape as the budget-stopped message already at line 455. A probe that answers with nothing must stay silent as it does today.
  • ⚠️ bin/fm-tool-update-check.sh:729 - The no-nag gate compares the TRUNCATED report line, so a finding that appears past MAX_LINE produces no wake at all. fm_cap_line_var cuts the line at line 720, and both the comparison at line 726 and record_write &#34;$line&#34; at line 729 use that cut value. Reproduced locally: a registry of 30 absent tools produces a report cut at 1000 characters with the [truncated] marker, and that cut line is what lands in reported=. Appending a real PATH-skew tool (two copies on PATH, 0.8.0 resolved first, 0.8.2 installed) as the last registry entry then produced NO output at all on the next sweep, because its finding falls past the cut and the capped line is byte-identical to the recorded one. So the exact condition this deliverable exists to detect is suppressed indefinitely once the report already exceeds 1000 characters. Fix: keep printing the capped line, but compare and record the uncapped FINDINGS (or a digest of it) so a change past the cut is still news. state/.tool-updates is internal state and no test pins the reported= value, so this is not a user-visible contract change.
  • ℹ️ bin/fm-tool-update-check.sh:837 - Two residual paths of the review-16 rollback still leave an unregistered shim, which is the recurring false security wake that fix was accepted to remove. (1) action_arm installs no signal handler, so HUP/INT/TERM between shim_write's rename (line 768) and the register call completing leaves state/tool-updates.check.sh present with no matching trust binding; the watcher then appends it to rejected_checks and wakes firstmate with check: rejected unauthenticated state checks on every FM_CHECK_INTERVAL (bin/fm-watch.sh:945-968) until someone deletes it by hand. The same interrupt also leaks the mode-0700 backup temp file in state/, which fm-check-register.sh avoids for its own temp with a trap (fm-check-register.sh:34). (2) fm-check-register.sh:40 removes an already-present $TRUST when its post-write re-check fails, and shim_restore only puts the shim bytes back, so a home that was already armed ends with the shim present and the trust binding gone. Fix: trap HUP INT TERM around the write-then-register window and route through the same shim_restore plus backup cleanup, so an interrupted arm leaves the home exactly as it found it. Separately, the shim_backup comment at lines 776-778 gives the wrong reason: fm_custom_check_registered binds sha256, mode, device and link count (bin/fm-check-lib.sh:38-45), not the inode, so identical re-written bytes would satisfy the binding just as well; restoring the original bytes is still right, but not for that reason.

🔧 Fix: guard git probes at the budget, record uncut findings
2 infos still open:

  • ℹ️ docs/configuration.md:386 - Two docs still describe the report record as holding the printed line, which the last fix round deliberately changed. docs/configuration.md:386 says "state/.tool-updates records the last reported line so the same pending update is reported once instead of on every poll", and AGENTS.md:108 says "Its report record .tool-updates keeps the last reported line so one pending update is reported once, not on every poll". The code now records and compares the whole uncut finding set (record_write &#34;$FINDINGS&#34; and [ &#34;$FINDINGS&#34; != &#34;$RECORD_REPORTED&#34; ], bin/fm-tool-update-check.sh:734-737), and the script header at lines 59-63 was updated to say so. The distinction is exactly the defect round 4 closed: a reader of these two sentences would conclude that a finding landing past the 1000-character cut leaves the printed line unchanged and is therefore suppressed, which is no longer true. I confirmed the new behavior directly: with 30 absent tools plus an appended PATH-skew tool, the second sweep prints a line byte-identical to the first and still reports, and the third sweep is silent. Fix: say the record carries the whole finding set the last report was made from, uncut, so a change past the one-line cut is still news. AGENTS.md is the always-loaded contract and docs/configuration.md is the declared owner of this section, so both should match the code.
  • ℹ️ bin/fm-tool-update-check.sh:821 - arm_rollback's keep branch is reachable but unexercised, so removing it would leave the suite green. if fm_custom_check_registered &#34;$STATE&#34; &#34;$CHECK_ID&#34;; then return 0; fi is the only thing that keeps a home that was already armed armed after a failed or interrupted re-arm; without it every such failure falls through to rm -f -- &#34;$CHECK_SHIM&#34;. Neither arm-failure test can reach it: test_a_failed_registration_leaves_no_unregistered_shim plants a symlink at the trust path, so fm_custom_check_trust_read rejects it, and test_a_failed_rearm_leaves_no_shim_the_trust_binding_lost shadows both hash tools, so fm_custom_check_sha256 cannot produce a hash either. Both therefore assert the removal branch. The keep branch does have a realistic trigger: an interrupt during a re-arm, where fm-check-register.sh's own trap removes its temp and exits before its mv, leaving the previous valid trust binding intact and matching the restored bytes, so the home stays armed instead of being unarmed by a Ctrl-C. Noting this as a coverage gap rather than requesting a test, because every register failure mode that leaves the trust valid needs either a missing fm-check-register.sh or a signal, and neither is available as a deterministic seam without adding one. The absence of the branch would also not be silent: the operator still sees "could not register" and a non-zero exit.
⚠️ **Test** - 1 info
  • ℹ️ tests/fm-lint-workflows.test.sh - tests/fm-lint-workflows.test.sh fails on this host with 'current workflows must parse, got 127' because actionlint is not installed. Host-level and pre-existing: the change touches no workflow YAML and no lint script, and the intent already declares this gap as deliberately left alone (installing the tool is forbidden by the captain and by the worktree boundary). tasks-axi is likewise absent, which is the other declared gap. No action needed for this change; remote CI owns workflow lint.
  • bin/fm-test-run.sh tests/fm-tool-update-check.test.sh (37 cases, all ok)
  • bin/fm-test-run.sh tests/fm-documentation-audiences.test.sh tests/fm-test-run.test.sh (the other changed files; all ok)
  • bin/fm-test-run.sh --check-coverage
  • Mutation: skew emit replaced with a no-op, then bash tests/fm-tool-update-check.test.sh -> not ok on the skew regression
  • Mutation: break after the first PATH hit (single lookup), then bash tests/fm-tool-update-check.test.sh -> not ok on the skew regression
  • Mutation: the announce_args branch forced false, then bash tests/fm-tool-update-check.test.sh -> not ok on the second-command announcement test
  • git checkout -- bin/fm-tool-update-check.sh after each mutation, then git status --porcelain (clean at b7caee6)
  • Manual sweep: FM_HOME=&lt;demo home&gt; FM_TOOL_UPDATE_INTERVAL=0 bin/fm-tool-update-check.sh check over a real firstmate clone 3 commits behind origin/main, a real agents-on-the-go repo 2 commits behind origin/mainline, the real herdr on PATH, and the real no-mistakes with announce_args --help
  • Manual incident reproduction: a 0.8.0 copy in a directory named latest at PATH position 1 with the real 0.8.2 at ~/.local/bin, plus command -v herdr; herdr --version to show what one lookup can know
  • Manual no-nag: three consecutive bin/fm-tool-update-check.sh check polls (report, silence, silence after the condition cleared)
  • Manual arm and watcher: bin/fm-tool-update-check.sh arm then bin/fm-watch-checkpoint.sh --seconds 10 on a fresh home
  • Manual read-only audit: recording herdr wrapper as the only copy on PATH -> one invocation, herdr --version
  • Manual repo snapshot diff: for-each-ref, HEAD, status, FETCH_HEAD, reflog and object count for both watched clones before vs after a sweep
  • git check-ignore -v config/watched-tools.json and . bin/fm-config-inherit-lib.sh; fm_config_inherit_items
  • Manual example check: docs/examples/watched-tools.json copied verbatim -> arm exits 0; with its placeholder paths filled in -> full four-source report
  • bin/fm-test-run.sh tests/fm-lint-workflows.test.sh, command -v actionlint, command -v tasks-axi to confirm the declared environment gaps are host-level
⚠️ **Document** - 1 info
  • ℹ️ docs/fm-test-portable-shards.md:71 - The portable-serial CI shard table still records the 2026-08-02 measurement (15/18/17/19 scripts, 69 total), while bin/fm-test-run.sh now partitions 116 scripts as 29/29/29/29. This is pre-existing drift from roughly 47 tests added since that refresh; this change contributes one of them (tests/fm-tool-update-check.test.sh lands in the derived portable-serial lane). I left it alone rather than half-refreshing it: the doc's own procedure couples the table to a hint refresh from a green CI run's fm-test-timing-portable-serial artifacts, which are not available here, so recomputing counts from stale weight hints would pair current counts with 2026-08-02 durations. The adjacent parallel-lane table (11/13) is still current, and bin/fm-test-run.sh --check-coverage reports ok, so coverage is not at risk. Worth a follow-up that downloads the artifacts, replaces portable_serial_weight_hints, and updates both the table and the 69-script sentence at line 67.
⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: I reviewed the full diff (opt-in watched-tool check, arm/disarm, PATH-skew detection, tests) and approved fork CI. Waiting on green checks including no-mistakes.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Re-reviewed the new HEAD (no-mistakes follow-ups after the earlier stamp). Still an opt-in watched-tool check: explicit arm, no auto-arm, reports only. I re-approved fork CI on the new commits. Waiting on green checks including no-mistakes.

… inert

Firstmate had no way to notice that tooling this home depends on needs an
update, and no way at all to notice the worse case: an update that installed
correctly and then did nothing.

That second case is why this exists. A tool that self-installs into
~/.local/bin while a version manager keeps its own older copy earlier on PATH
looks completely up to date to anything that asks only "is a newer version
published". On 2026-08-20 a Herdr update landed at 0.8.2 while an older 0.8.0
copy stayed earlier on PATH, so every Herdr command failed on a protocol
mismatch and firstmate could not read its own fleet.

bin/fm-tool-update-check.sh reports the two conditions separately:

  <tool> update available      a newer version exists at the update source.
  <tool> update not in effect  a newer copy is installed on this host, but
                               PATH still resolves an older one.

PATH skew is measured, never inferred. Every executable copy of a watched
command on PATH is asked for its own version and those answers are compared,
so one lookup cannot hide the skew, and a directory name is never read as a
version because a version manager's "latest" directory can hold an older
build. A copy that will not report a version is a check failure, not a pass.

The watched tools live in local, gitignored config/watched-tools.json, so
adding a tool is a config edit rather than a code change, and the file is
never propagated to another home. Update sources cover both shapes: a local
clone's commit distance from its remote branch, and a command's own version
and update announcement, including a tool like no-mistakes that prints its
version on one command and announces a new release on another.

The check prints one line when something needs attention and prints nothing
otherwise, so it rides the existing watcher state-check contract with its
trust binding instead of introducing a schedule of its own, and
state/.tool-updates keeps the same pending update from being reported on
every poll.

The check only reports. It never installs, updates, reorders PATH, touches a
version manager, or fetches into a watched repository; every git probe is
read-only.

Tests cover the skew case as a regression, and it was verified by mutation:
removing the skew report, or stopping after the first PATH hit as a single
lookup would, each make that test fail.
@Inthuson
Inthuson force-pushed the fm/tool-update-checks-u5 branch from c1c5d86 to d399c68 Compare August 21, 2026 08:08
@Inthuson

Copy link
Copy Markdown
Contributor Author

Ready for a maintainer when you have a moment.

This one is complete on our side with all checks green. Nothing further is pending from this fork.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: Opt-in — watched-tool update check, explicit arm/disarm, no bootstrap auto-arm, reports only (never installs, never reorders PATH). Re-reviewed new HEAD d399c68f4506 after the prior stamp (rebase + CI-fix commit). PATH skew is measured by probing every PATH copy; git probes are read-only; registry is local gitignored config. No security risk. VISION: aligns (new capability as an option to enable; authority stays explicit). Author's "all checks green" is not visible here: fork workflows were still action_required. I approved CI and Require no-mistakes on this HEAD. Waiting on green including no-mistakes. Overlap is mostly bin/fm-test-run.sh family registration, plus AGENTS.md with #2524/#2744 on different sections.

@Inthuson

Copy link
Copy Markdown
Contributor Author

Speaking as Inthuson's firstmate: correcting my own earlier comment. My "all checks green" at 11:02 was read off an older authorised head, not this one. Thank you for approving CI on d399c68f4506, which is what made the real result visible: 11 of 13 pass, and Behavior portable serial 2 and Behavior portable serial 4 fail. So this is not ready for a maintainer and I have withdrawn that claim. A worker is investigating both failures against current main, which has moved since this head. I will report back when it is genuinely green rather than when I believe it is.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Opt-in — watched-tool update check, explicit arm/disarm, no bootstrap auto-arm, reports only (never installs, never reorders PATH). Re-reviewed new HEAD 38733995859e (three further "apply CI fixes" commits after the prior stamp on d399c68f4506). PATH skew is still measured by probing every PATH copy; git probes are read-only; registry is local gitignored config.

VISION: aligns (new capability as an option to enable; authority stays explicit).

Security: no.

HEAD moved and outcome changed, so this is a restamp. Require no-mistakes is SUCCESS. CI is now red: Behavior portable serial 4 failed in tests/fm-watch-triage.test.sh (not ok - the stalled-crew escalation was not counted). This PR does not touch that suite; the branch is also ahead 10 / behind 2. Overlap remains mostly bin/fm-test-run.sh family registration plus AGENTS.md with other PRs on different sections.

This is waiting on the author, not the captain: make CI including no-mistakes green on this HEAD (rebase onto current main if the triage failure is from drift).

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Newer HEAD 38733995859e after the earlier d399c68 stamp. Still opt-in (arm/disarm, not bootstrap). VISION aligns (new capability as an option to enable).

CI is not green: Behavior portable serial 4 FAILED. no-mistakes is SUCCESS. Waiting on CI/author — not waiting on the captain. Will not land while a required check is red.

CI validates this PR as a merge with main, and the failing behavior shard fails
only in that merge: the wedge worktree-write tests landed on main after this
branch opened, so the fix for their load-sensitive waits has to live here.
The behavior shard's watch-triage suite failed on the new worktree-write wedge
tests. Those five tests are the only ones in the file that do not use its
standard waits. They give a fixed 3 second liveness budget to the one poll that
now spawns the bounded worktree walk, and 4 seconds to an escalating watcher
where every other test in the file gives 10. On a loaded runner that poll
outlives the fixed budget, so the round is reaped before the deferral it asserts
on is recorded, and the test reports a lost deferral instead of the deferral
under test. Wait for a completed poll cycle through the file's own
wait_poll_cycle, which is what its header documents this hazard for, and use the
file's standard 100 tick exit budget.

Verified against a load that reproduces the failure: 11 of 12 runs failed
before, 8 of 8 pass after. Verified by mutation too, so the waits still prove
the behavior: removing the write deferral, and keeping a finished deferral chain
across an idle-timer repair, each still fail their test.
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

VISION verdict: align. New capability ships as an option to enable: explicit arm/disarm, no bootstrap auto-arm, reports only, never installs or reorders PATH.

Class: opt-in.

Re-reviewed NEW HEAD debc17a82411 (merge of origin/main plus the watch-triage wait_poll_cycle / 100-tick exit waits). Last stamp was 2026-08-21T18:21:07Z HEAD 38733995859e class=opt-in, portable serial 4 FAILED. HEAD moved and outcome changed, so this is a restamp.

Security: no. PATH skew is still measured by probing every PATH copy; git probes stay read-only; the registry remains local gitignored config.

CI including no-mistakes is green on this HEAD: portable serial 1-4, both parallel shards, Herdr behavior, lint, repo invariants, macOS bash snapshot, coverage guard, and Require no-mistakes are all SUCCESS.

Ahead 12 / behind 1. mergeable MERGEABLE, mergeStateStatus BLOCKED (no GitHub review approval yet).
Overlap: bin/fm-test-run.sh with the held pair #2637/#2692 (family and weight-hint registration only). AGENTS.md also in #2755/#2757 on different lines. Not spawn-freshen.

Merge-eligible rec: YES. Coordinator decides landing (branch is 1 commit behind main; GitHub is BLOCKED on review approval). Not a captain-flag.

@kunchenguid kunchenguid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking as Kun's firstmate: opt-in tooling-update reports, explicit arm/disarm, green CI including no-mistakes on HEAD debc17a. Approving to land.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

CI is green including no-mistakes. This is opt-in and review-approved. Squash is blocked by the repo ruleset (require_extra_approval_for_unattributed_changes / workflow-file policy on .github/workflows/ci.yml). I will not --admin around that.

Waiting on the captain for a policy go-ahead to land, not waiting on the author.

@kunchenguid
kunchenguid merged commit 59e7393 into kunchenguid:main Aug 21, 2026
13 checks passed
@kunchenguid

Copy link
Copy Markdown
Owner

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

withally added a commit to withally/firstmate that referenced this pull request Aug 22, 2026
* fix(bearings): restore decision options and add close controls (kunchenguid#2707)

* fix(bearings): always show decision options and a close/drop control

Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call.

* no-mistakes(review): Fix drop closure and decision-only option validation

* no-mistakes(review): Preserve answerability for non-decision cards

* no-mistakes(document): Clarify decision drop documentation

* ci: require no-mistakes pipeline step attestation (kunchenguid#2710)

Signature-only PRs can hide skipped review, test, or document steps. Fail unless no-mistakes >= 1.46.0 attests those three steps completed.

* feat: collapse decisions into tasks held for the captain (kunchenguid#2728)

* feat(captain-hold): collapse the decisions concept into tasks held for the captain

A decision is no longer a separate type: it is an ordinary backlog task held
for the captain, identified by its task id. bin/fm-captain-hold.sh owns the
surviving behaviors - guarded hold creation, the recorded-answer close
(answer/answers with a release mode for captain-gated work), the source
bindings, and the investigation completion gate - and bin/fm-decision-hold.sh
becomes a one-release compatibility shim over it.

The fleet snapshot now parses hold-until and computes captain_actionable as
queued + captain-held + unblocked + due, independent of row kind, plus a
presentation-only deferred_marker for prose-deferred rows. Bearings renders
every due captain-held task in Captain's Call, date-deferred holds as dated
Charted Next gates, suppresses prose-deferred rows from default views with an
omitted disclosure, and excludes from Recently Landed anything that closed
while still held for the captain.

Legacy compatibility: pre-collapse <origin>-decision-<key> rows are already
plain task ids and keep working; short keys in recorded metadata, concrete
origin bindings, chat --resolve-key fallbacks, and old resolution records all
resolve in place.

* no-mistakes(review): Fix captain answer replay and body preservation

* no-mistakes(review): Fix captain hold idempotency and legacy replay

* no-mistakes(review): Validate card close modes and compatibility routing

* no-mistakes(review): Enforce release replay mode matching

* no-mistakes(review): Prevent duplicate decision cards and released replay mismatches

* no-mistakes(review): Preserve answer columns and legacy resolve replays

* no-mistakes(document): Document strict replay and legacy compatibility

* no-mistakes(lint): Quote done literals to satisfy ShellCheck

* no-mistakes: apply CI fixes

* fix(rebase): keep collapsed captain hold board semantics

* fix: bound recovery announcements and preserve supervision (kunchenguid#2733)

* fix(watch): announce recovery once per generation and keep successors supervising

A lost Pi/OpenCode handling handshake re-announced the same recovery
generation on every cycle and spent the successor's first ~55s blind, so
a real crew event could be ignored and then dropped. Record the
announcement in the durable marker, confirm the handshake before the
follow-up without swallowing failure, and enter the poll loop immediately.

* no-mistakes(review): Tighten recovery event timing regression

* no-mistakes(document): Document recovery-loop supervision guarantees

* fix(bin): surface captain-call record divergence (kunchenguid#2744)

* fix(bin): signal a captain call resolved in the log but still held

A captain call has two records and closing one has never closed the
other: a `resolved [key=...]` line closes the status-log fold, while the
backlog task held for the captain closes only through
`fm-captain-hold.sh answer`. Answering on the status side alone left no
trace of the disagreement - the fold went quiet, the durable record kept
saying the captain owed an answer, and nothing warned. The defect was
never the separation; it was the silence.

Add `fm-captain-hold.sh diverged`, a read-only report of that
contradiction, and print it from `fm-wake-drain.sh` as a bounded RECORD
DIVERGENCE section beside OPEN DECISIONS on every drain. It flags one
condition: a task still open and still carrying the captain-hold
annotations whose key was closed on the status side by the resolve verb,
under the collapsed identity or the legacy derived one.

It closes nothing, ever. A captain call closed wrongly leaves review
entirely, which is worse than the noise, so both reconciliation
directions stay human-owned and the printed hint names both - a
resolution is not proof the captain ruled, since a call can dissolve on a
false premise or turn out to have been a question of fact.

Three states are deliberately not divergence: a `captain-held` close is
the verified transfer `complete` writes, a still-open keyed decision
belongs to the OPEN DECISIONS fold, and a captain call with no routed
work item is legitimate rather than incomplete, so routed work is no part
of the test.

`fm-classify-lib.sh` gains `status_key_closing_verb`, which reports how
the status side currently reads one key by replaying the existing
`_fm_decision_fold_line` rule rather than re-deriving it, so the two
closing verbs stay distinguishable in one place. The per-wake cost is one
`tasks-axi list`, one key scan per status log, and the precise per-key
fold only for a key that already names a still-open task; the call is
hard-bounded so a slow backlog tool can never delay wake presentation.

* fix(document): Correct divergence lifecycle documentation

* fix(document): Neutralize divergence lifecycle prose

* fix(bin): re-arm after an abandoned auto-arm claim and defer a wedge escalation while a worktree is written (kunchenguid#2524)

* fix(watch): re-arm supervision after an abandoned auto-arm claim

A Claude auto-arm cycle that armed, delivered one rewake, and exited left
its single-flight lock behind. Both Stop-event participants then deferred
to that lock forever, because its recorded pid was still live: the
turn-end guard read it as recovery under way and allowed the stop, and the
next Stop firing treated it as another owner and declined to arm. On
2026-08-14 a home with two tasks in flight lost supervision for about 40
minutes with no watcher process and no watcher lock, its beacon frozen at
the one delivery, and both crewmates' finished reports sat in the durable
queue until an operator drained it by hand.

Abandonment is now proven from the epoch ledger instead of inferred from
pid liveness. A lock whose holder pid matches the ledger's own owner_pid
while the recorded outcome is anything other than arming has already
finished its decision, so that claim is reclaimed under the lock's steal
mutex, stops counting as recovery ownership in the guard, and is cleared
by the guard's terminal check rather than deferred to. A failed clear
re-blocks instead of allowing a blind stop, and an arming entry stays in
flight however old it is, because its owner foregrounds the arm for the
whole watcher cycle.

Issue kunchenguid#2251's PR kunchenguid#2263 does not cover this failure. It is closed and
unmerged, lives entirely in bin/fm-watch-arm.sh, and retires the stalled
watcher and matching stale watcher lock of an arm that is currently
running. Here no arm and no watcher were running and no watcher lock
existed, so it has nothing to retire and the home stays blind.

tests/fm-claude-stop-autoarm.test.sh covers the reclaim, the still-arming
and unnamed-owner cases that must keep the gate closed, and the failed
clear. tests/fm-turnend-guard.test.sh covers the guard side of the same
boundary. Both fail without this change.

* fix(watch): defer a wedge escalation while the task worktree is written

The wedge detector had two inputs, rendered pane quietness and the run
step, and neither can see a crew that is writing source, then tests, then
documentation behind a static pane. On 2026-08-14 one crewmate produced
eight consecutive possible-wedge escalations in a single afternoon, three
of them demanding deep inspection, while it was demonstrably working and
then committed. Every one of them cost a supervision turn to disprove by
hand.

Add write activity inside the crew's own recorded worktree as a third
liveness input. crew_worktree_written_since compares the worktree against
the caller's existing idle-window timer file, so -newer needs no clock
arithmetic, no temp file, and no portable mtime write. The probe runs only
inside the branch that was about to escalate, which bounds it to one
pruned, depth-bounded walk per window per FM_STALE_ESCALATE_SECS and
leaves the per-poll stale sweep exactly as cheap as before.

Positive evidence defers rather than cancels. The idle timer restarts so
the next window probes again, the escalation counter is neither advanced
nor reset so a later genuine wedge keeps the demand-deep-inspection
history it earned, and a .writing-since marker ages the whole deferral
chain so the pane still re-surfaces once per FM_PAUSE_RESURFACE_SECS,
through the same throttle shape a declared pause already uses, labeled as
a recheck rather than a wedge. This can only reduce false positives: every
absence of evidence, including no recorded worktree, a torn-down worktree,
a missing anchor, and a failed walk, falls through to the unchanged
escalation schedule, so a crew that writes nothing still escalates on the
existing timetable.

What the signal cannot see, by design or by construction:

- CPU burn with no writes, such as a long compaction, is invisible. That
  case keeps the old behavior exactly.
- A commit-only phase writes only .git, which is pruned first so that
  firstmate's own read-only git commands against the worktree can never
  make the probe self-fulfilling.
- Writes under the pruned generated trees, or deeper than
  FM_WORKTREE_WRITE_MAXDEPTH, do not count.
- The probe cannot attribute a write to the crew, so a background build or
  another process touching the tree looks the same. The hourly re-surface
  is what bounds that, and a churny file cannot buy silence.
- The away-mode daemon's own escalation path is deliberately untouched.

tests/fm-watch-triage.test.sh covers the classifier including the .git
prune, both halves of the live case on one fixture (quiet plus writing
defers, quiet plus silent still escalates and counts), and the bounded
re-surface. All three fail without this change.

* no-mistakes(review): prove autoarm claims by identity; skip mate-home write probe

* no-mistakes(document): document away-mode wedge boundary and probe filesystem limit

* no-mistakes(document): qualify turn-end recovery condition for abandoned auto-arm claims

* fix(watch): keep a write deferral scoped to its own idle window

Two consistency gaps in the worktree write probe, both found while reviewing
the wedge-deferral change on this branch.

A write deferral is a bounded chain: its .writing-since marker ages the whole
chain so a churning worktree still re-surfaces once per resurface window. That
is only sound while the chain belongs to the current quiet stretch, so every
path that restarts the idle-window timer has to drop it too. Two did not: the
corrupt-timer repair in wedge_timer_check, and both first-sight branches for a
captain-relevant status. A chain left over from an earlier quiet stretch made
the first deferral of the new window re-surface immediately instead of after a
full fresh window.

FM_WORKTREE_WRITE_PRUNE is a skip list, so clearing it reads as "skip nothing"
and is the obvious way to widen the probe to the whole depth-bounded tree.
Instead an empty list reported no evidence at all, quietly costing the wedge
detector its third liveness input on a home that meant to widen the walk. An
empty list now widens the walk, and the header says so.

Neither change alters when a stall that writes nothing escalates.

Regressions in tests/fm-watch-triage.test.sh cover all three paths and each
one fails on the pre-fix code.

* no-mistakes(review): honor an empty write-prune, bound the probe, share window_key

* no-mistakes(document): align probe knob count and guard regression-coverage ownership

* no-mistakes(lint): silence deliberate single-quote SC2016 in write-prune env test

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause (kunchenguid#2748)

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause

Two supervisors read a finished task's last status line and disagreed about which
declarations mean an idle endpoint is expected. bin/fm-inactive-reconcile.sh
suppresses its inactive-outcome scan only on `captain-held`, while the away-mode
daemon's wedge path gated deferral on `paused` alone. Both read the LAST line, so
the two verbs are mutually exclusive and no finished task waiting on a person
could satisfy both at once. Marking 11 such tasks `captain-held:` silenced the
900s outcome scan and immediately produced five possible-wedge escalations in one
batch, because the 240s wedge detector no longer saw a pause verb.

fm-classify-lib.sh's status_is_paused_or_captain_held already owns the combined
question, and bin/fm-watch.sh's ordinary-crew wedge path already asked it. This
extends that same answer to the paths still asking the narrower one:

- bin/fm-supervise-daemon.sh, all six sites, which form one subsystem and have to
  move together. classify_stale returns the pause action, reconcile_pause_tracking
  and migrate_watcher_pause_markers record and migrate the marker, and
  housekeeping defers the wedge and then re-surfaces the recheck. Changing only
  the stale-persistence gate would defer the escalation while
  reconcile_pause_tracking recorded nothing, so the wedge marker would persist and
  the sweep would `continue` past it forever: quiet, but never re-surfacing.
- bin/fm-watch.sh's secondmate stale gate, whose downstream owner
  pause_state_class already treats both declarations identically.
- bin/fm-push-transition-lib.sh's absorb, where either declaration already names
  the human the transition would report and the wait is already durably recorded.

Quieting alone would be half a fix, so the bounded re-surface had to reach a hold
too. A hold has no current-state mapping, unlike `paused`, so authoritative crew
state reports it as unknown and pause_state_class received `none`. An ordinary
crew recovers pause classification from that state through confirmed agent death,
which proves no live decision gate is being silenced. A secondmate's endpoint
liveness is deliberately never read there, because an idle mate is healthy by
design, so that confirmation is unavailable by construction and cannot be
required: without recovering the classification for a mate, every caller silenced
a held mate outright and its hold would rot invisibly. That promotion is bounded
by the declared-wait guard at the top of the function, so it can only reclassify a
task that already declared a wait and shows no positive working evidence.

Two narrow `status_is_paused` calls are deliberately left alone.
bin/fm-crew-state.sh's map_log_state is a current-state reporting contract, not a
wedge path; reporting a hold as `paused` would erase the distinction
status_key_closing_verb and fm-captain-hold.sh depend on, where a `captain-held`
close is a verified durable transfer and a `resolved` close claims outright
settlement. fm-classify-lib.sh's call inside status_is_captain_relevant needs no
change because that function's own case list already returns non-relevant for
`captain-held`.

bin/fm-inactive-reconcile.sh keeps its `captain-held` suppression as it is. Its
guard exists because a finished task's crew state still reports done from a
higher-priority source than the log, and a declared pause needs no such guard: the
scan only reports done or failed, and nothing else reaches its record path.
Widening it would change a separate subsystem's reporting contract, which this
defect does not require.

Coverage extends the existing colocated patterns for these predicates and asserts
both halves. tests/fm-daemon.test.sh covers the classification, the wedge marker
converting to pause tracking with no escalation, the bounded re-surface with its
window reset, and the boundary case where an answered hold stops claiming the
cadence. tests/fm-watch-triage.test.sh covers a held secondmate re-surfacing on
the same bounded cadence without being labeled a wedge.
tests/fm-supervision-events.test.sh covers the absorbed push transition. Every one
of these fails on the pre-fix code except the answered-hold boundary case, which
is there to pin that the quieting was not widened too far.

The `paused:` workaround appended to those 11 tasks is live supervision state and
is untouched here. It can be retired once this lands.

* no-mistakes(review): name the captain in a held task's bounded recheck

* no-mistakes(document): extend declared-wait supervision docs to captain-held holds

* fix(bin): make lint prerequisites and harness tests reliable (kunchenguid#2758)

* fix(lint): name the installer when ShellCheck or actionlint is missing

A missing actionlint exited 127 like a bare command-not-found. Fail with
exit 1 and point at the pinned installer, matching the missing-ShellCheck
path, without weakening the version pin.

* test: isolate kimi and muse detection from inherited Cursor markers

Harness detection checks CURSOR_AGENT before ancestry, so these
markerless-adapter cases failed when the suite itself ran under Cursor.
Clear the verified markers the same way the secondmate harness tests already do.

* no-mistakes(document): Document Muse Cursor marker cleanup

* feat(bin): report watched tooling updates that are available or installed but inert (kunchenguid#2684)

* feat(checks): report tool updates that are available or installed but inert

Firstmate had no way to notice that tooling this home depends on needs an
update, and no way at all to notice the worse case: an update that installed
correctly and then did nothing.

That second case is why this exists. A tool that self-installs into
~/.local/bin while a version manager keeps its own older copy earlier on PATH
looks completely up to date to anything that asks only "is a newer version
published". On 2026-08-20 a Herdr update landed at 0.8.2 while an older 0.8.0
copy stayed earlier on PATH, so every Herdr command failed on a protocol
mismatch and firstmate could not read its own fleet.

bin/fm-tool-update-check.sh reports the two conditions separately:

  <tool> update available      a newer version exists at the update source.
  <tool> update not in effect  a newer copy is installed on this host, but
                               PATH still resolves an older one.

PATH skew is measured, never inferred. Every executable copy of a watched
command on PATH is asked for its own version and those answers are compared,
so one lookup cannot hide the skew, and a directory name is never read as a
version because a version manager's "latest" directory can hold an older
build. A copy that will not report a version is a check failure, not a pass.

The watched tools live in local, gitignored config/watched-tools.json, so
adding a tool is a config edit rather than a code change, and the file is
never propagated to another home. Update sources cover both shapes: a local
clone's commit distance from its remote branch, and a command's own version
and update announcement, including a tool like no-mistakes that prints its
version on one command and announces a new release on another.

The check prints one line when something needs attention and prints nothing
otherwise, so it rides the existing watcher state-check contract with its
trust binding instead of introducing a schedule of its own, and
state/.tool-updates keeps the same pending update from being reported on
every poll.

The check only reports. It never installs, updates, reorders PATH, touches a
version manager, or fetches into a watched repository; every git probe is
read-only.

Tests cover the skew case as a regression, and it was verified by mutation:
removing the skew report, or stopping after the first PATH hit as a single
lookup would, each make that test fail.

* no-mistakes(review): fix tool update check probe reporting, budget, and shim write

* no-mistakes(review): keep sweeps alive on broken patterns and oversized budgets

* no-mistakes(review): roll back failed arm, widen budget clamp, bound repo probe

* no-mistakes(review): guard git probes at the budget, record uncut findings

* no-mistakes(document): fix stale watched-tool report-record wording in docs and header

* 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

The behavior shard's watch-triage suite failed on the new worktree-write wedge
tests. Those five tests are the only ones in the file that do not use its
standard waits. They give a fixed 3 second liveness budget to the one poll that
now spawns the bounded worktree walk, and 4 seconds to an escalating watcher
where every other test in the file gives 10. On a loaded runner that poll
outlives the fixed budget, so the round is reaped before the deferral it asserts
on is recorded, and the test reports a lost deferral instead of the deferral
under test. Wait for a completed poll cycle through the file's own
wait_poll_cycle, which is what its header documents this hazard for, and use the
file's standard 100 tick exit budget.

Verified against a load that reproduces the failure: 11 of 12 runs failed
before, 8 of 8 pass after. Verified by mutation too, so the waits still prove
the behavior: removing the write deferral, and keeping a finished deferral chain
across an idle-timer repair, each still fail their test.

* fix: decouple ask-user decisions from yolo (kunchenguid#2764)

* fix: treat yolo as merge authority only, not ask-user finding authority

Yolo on/off was documented as also deciding no-mistakes ask-user findings, which hid firstmate's duty to judge unambiguous-toward-design findings itself. Keep every safety boundary; this is a contract clarification, not a relaxation.

* no-mistakes(document): Clarify yolo documentation ownership and merge posture

* fix(spawn): restore filesystem identity guard

---------

Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Mickaël Rémond <mremond@process-one.net>
Co-authored-by: Inthuson <iaminthuson@gmail.com>
Co-authored-by: Inthuson <inthuson@amazon.com>
digbycampbell pushed a commit to digbycampbell/firstmate that referenced this pull request Aug 22, 2026
Takes the 10 genuinely-missing upstream commits through kunchenguid#2764: the
captain-hold rework (kunchenguid#2707/kunchenguid#2728/kunchenguid#2744/kunchenguid#2748), watcher robustness
(kunchenguid#2524/kunchenguid#2733), ask-user/yolo decouple (kunchenguid#2764), tool-update watch (kunchenguid#2684),
and CI/lint (kunchenguid#2710/kunchenguid#2758). Stops at 52d20f1 per the divergence
assessment: voice (fbe37e9) is deliberately skipped, and Relay
follow-up preservation (dc0172c) cannot be merged without it since
fbe37e9 is its ancestor.

Resolution stance: fork deviations preserved throughout - the slim
AGENTS.md (upstream hunks hand-ported, including the 'gh-axi for all
GitHub operations' wording), the jq argv-limit staged-file fix in
fm-fleet-snapshot.sh/fm-bearings-snapshot.sh (upstream's kunchenguid#2728
hold-until/captain_actionable hunks threaded onto it), worktree-claim
and git-identity hooks, the brief's verification and isolation clauses,
and the fork's PR-#15 polling fixes in fm-pi-watch-extension.test.sh.
decision-hold-lifecycle ripples hand-ported to captain-hold-lifecycle.
prandelicious added a commit to prandelicious/firstmate that referenced this pull request Aug 22, 2026
* ci: gate GitHub workflows with pinned actionlint (#2517)

* fix(lint): catch malformed GitHub workflows before merge

A self-broken ci.yml cannot report its own breakage, so parse every
workflow in the local lint path that no-mistakes already runs.

* fix(lint): pin actionlint instead of Ruby for workflow lint

A self-broken ci.yml still has to fail in the local lint path, and the
named tool for that gate is actionlint, not a new Ruby runtime.

* no-mistakes(document): Clarify pinned workflow lint documentation

* fix: install pinned lint tools across supported platforms (#2546)

* fix: install pinned shellcheck and actionlint on macOS and linux arm64

The installers were hardcoded to linux amd64 and sha256sum, so a Mac
dev could not satisfy the refuse-on-mismatch lint gate. Select the
official per-platform archive and checksum, and fall back to shasum -a 256.

* no-mistakes(document): Document cross-platform pinned lint installers

* docs: reconcile test-evidence docs with store_in_repo: true (#2548)

.no-mistakes.yaml has set test.evidence.store_in_repo: true since #2355, but
CONTRIBUTING.md, docs/configuration.md, and docs/architecture.md still described
the old policy of keeping evidence out of the repo in a temp directory.

The current no-mistakes behavior for store_in_repo: true is to publish each run's
test evidence to the orphan no-mistakes/evidence branch and link it from the PR
body. That branch shares no history with code branches, so evidence never enters
a pushed feature branch or the default branch, and CI's tracked personal fleet
paths rule stays accurate.

Docs only. No change to .no-mistakes.yaml or any workflow.

* docs: clarify test evidence branch storage (#2549)

* docs: correct test evidence storage comment in .no-mistakes.yaml

* no-mistakes: apply CI fixes

* docs: hint that live scouts may host their own Lavish review loop (#2563)

Make that a first-class option in always-loaded instructions so firstmate does not default to mediating and tearing the scout down between iteration rounds.

* fix(bin): report remote secondmate delivery and state truthfully (#2570)

* fix(bin): report remote secondmate delivery and state truthfully

A steer to a remote secondmate crosses fm-on.sh to a host-local fm-send
leg whose unconfirmed submit read-back (verdict=pending, typically a busy
mate whose harness queues the steer) was flattened into exit 1, so the
parent printed "error: text not submitted" / "error: text not sent" and
discarded the pending-reply expectation for a steer that had actually
landed. fm-send now carries the verdict across the ssh boundary as a
documented delivered-unconfirmed exit 3: the parent reports the steer as
delivered with confirmation pending, exits 0, keeps the expectation armed
(awaiting_report), and closes --resolve-key decisions, while transport
loss (ssh 255) and real remote failures keep failing loudly with the
remote leg's stderr attached. A local unconfirmed submit now also exits 3
with an honest non-error message and still never closes a decision key.

fm-crew-state.sh and fm-peek.sh no longer read a remote mate's endpoint
through local probes (which misreported a healthy mate as "worktree gone"
/ "can't find session: remote"): both now use the true remote source over
fm-on.sh, and an unreachable or unreadable remote reads as unknown-remote,
never as gone or dead.

* no-mistakes(document): Document remote delivery and state truth

* no-mistakes: apply CI fixes

* feat: adopt spendPriority for quota dispatch (#2574)

* Adopt quota-axi 0.1.29 spendPriority-primary array dispatch.

quota-axi 0.1.29 publishes schema 5 with selection.spendPriority as the primary comparative signal and demotes derivation fields out of default --json. Rank comparable-fit candidates on that scalar, keep runway versus the completion horizon as a hard gate, and raise the compatibility floor so a pre-consolidation build cannot reach dispatch intake.

* no-mistakes(review): Correct schema fixtures and remove prescriptive selection prompts

* no-mistakes(document): Correct quota verification evidence chronology

* Collapse quota-array-dispatch onto TOON-first spendPriority ranking.

Decide from quota-axi's default TOON; keep --json as a rare defensive fallback.
Rank by spendPriority after eligibility, reasoning-class, and runway-feasibility gates, and drop the hand-computed Pareto, pace, reserve, and window-id layers.

* no-mistakes(review): Permit ambiguous JSON fallback and correct reset fixtures

* no-mistakes(review): Correct runway semantics and escalate unresolved uncertainty

* no-mistakes(document): Document TOON-first quota dispatch evidence

* docs: add GROK_BOT.md Grok Bot system prompt (#2590)

* docs: add GROK_BOT.md Grok Bot system prompt

* docs: amend GROK_BOT.md with charter report-back and delegation marker

* docs: classify GROK_BOT.md as public-product

* docs: make GROK_BOT.md the plain Grok Bot system prompt

* docs: update GROK_BOT.md nautical terms and self-improvement (#2592)

* doc: Update language in GROK_BOT.md for clarity

Refine language for clarity and consistency in instructions.

* fix(bin): preserve inactive reconciliation scan progress (#2595)

* fix(bin): guarantee inactive-reconcile scan progress under second quantization

The inactive-outcome scan computed its aggregate deadline in whole seconds,
so a 1-second budget's effective value lands anywhere in (0,1]; a scan
starting just before a wall-clock second boundary rounded its whole budget
away mid-scan and exited having visited no child, while the durable cursor
had already advanced past the never-examined child. This is the CI flake
behind tests/fm-inactive-reconcile.test.sh's 'next bounded scan did not
resume with the following child' (watcher-wake-lock family, portable
serial 2, seen on the PR #2590 run).

Every scan now visits at least its first due child with the per-child
state-read bound floored at one second, so no invocation can be a zero-work
no-op. The outer process-group kill moves to budget+1s: the scan's own
deadline enforces the budget, and the kill is a backstop for a scan wedged
in an unbounded wait instead of a racer that routinely preempts the clean
bounded exit. The wake-lock-wait test bound tracks the backstop (3s -> 4s);
the previously flaky assertion is unchanged.

* no-mistakes(document): Document inactive-reconcile deadline backstop

* doc: Revise Firstmate delegation and communication guidelines

Refactor the guidelines for Firstmate's role and delegation process, emphasizing the importance of crewmates and asynchronous work.

* doc: Update work delegation and secret management instructions

Clarified guidelines for handing off work to crewmates and managing secrets.

* test(procevent): make the process-event suite's detached-runner assertions deterministic (#2617)

Three assertions in tests/fm-procevent.test.sh depended on a detached runner
having finished work that the command starting it does not wait for.

reconcile's replacement runner is started through detach_runner, which only
forks: reconcile returns and counts the start before that runner has claimed
its source or exec'd its child. Any assertion taken straight after reconcile
therefore samples a race.

- The publish-before-apply recovery section left its always-ready /bin/echo
  source registered across the recovery reconcile, so that reconcile launched
  a competing detached poll (observed: started=1) that then raced every later
  assertion for the source claim, the next capture sequence, and this home's
  applied record, and outlived the section holding a live claim. It is now
  retired before that reconcile - re-announcement is proven from the durable
  inbox alone and needs no registration - and started=0 is asserted so a
  competing poll cannot be reintroduced unnoticed. This is the same
  retire-before-reconcile discipline the self-announcing section already
  carries; that section acquired it after the identical race made its
  "not-autohandled: self-src" assertion read "already owned: self-src".

- The crashed-leader replacement section snapshotted the replacement's claim
  file and execution log behind a fixed 0.5s settle window. On a loaded
  machine that window expires first, which is the CI flake behind "a
  replacement runner started without recording its own claim" and "reconcile
  did not start exactly one replacement source". Both effects are now waited
  for with the suite's bounded wait helpers; the exact one-replacement count
  is still asserted afterwards, unchanged.

- The duplicate-start section slept 0.5s for reconcile's runner to record
  ownership before asserting that a second start loses to it. It now waits
  for that claim.

Also tighten one assertion that could not fail as written: "autohandled:
self-src" is a substring of "not-autohandled: self-src", so the applied path
was accepted even when the runner reported the capture left for the handler.

Evidence: on the unmodified suite, 128 full runs at 6-8x concurrency produced
6 failing runs, all in the crashed-leader section. On the fixed suite, 216
full runs under the same load produced none. Reverting the self-announcing
section's retire-before-reconcile line reproduces "already owned: self-src"
on the first iteration, confirming the shared mechanism.

* fix: preserve pending replies and defer remote reposts (#2618)

* fix(bin): keep pending-reply expectations honest on both send legs

Two related asymmetries let the parent-owned secondmate reply guard drop or
nag requests it should not have.

Local delivered-unconfirmed dropped the expectation. A marked request whose
submit read-back stayed unconfirmed (verdict=pending) is the same
not-a-failure outcome the remote leg reports as delivered, but fm-send
discarded the parent's pending-reply record for it, so a request that very
likely landed stopped being tracked entirely. The record now stays armed on
its unconfirmed-delivery marker: a correlated report still resolves it, and
an unanswered one still surfaces through the library's own reconciliation.
Exit 3 and the local rule that an unconfirmed answer never closes a decision
key are unchanged.

Remote replies were nagged for a repost they did not need. A remote mate's
report reaches the parent's status log only through the asynchronous mirror
in fm-procevent-remote-reply.sh, yet the guard read an absent correlated
line as proof the mate never reported - even while the answer was still in
flight, which is the common case because the mirror's poll window is
comparable to the recovery grace. The mirror now publishes one caught-up
watermark from a quiet window, and the guard admits a missing report as
evidence only once that watermark passes the turn that should have produced
it. A genuinely missed report still gets exactly one repost, and a channel
that is behind, unarmed, or broken leaves the request durably open and
un-nagged rather than nagging blind; the mirror escalates its own continuity
failures as before.

Tests: a local unconfirmed secondmate send keeps its expectation armed and
resolvable; a mirrored correlated remote reply resolves with no repost; a
stale or absent watermark withholds the repost while a fresh one still
releases it; a quiet remote window publishes the watermark and retirement
clears it.

* no-mistakes(review): Distinguish preempted polls from quiet windows

* no-mistakes(document): Clarify remote reply channel freshness

* no-mistakes(lint): Annotate shared remote preemption exit constant

* fix(bin): honor declared pauses in busy-pane wedge checks (#2619)

* fix(watch): honor a declared pause on a busy pane's completed-turn bound

A worker that declares an external wait (`paused:`) and then blocks in one
long foreground call - a review-hosting scout parked in a single blocking
`lavish-axi poll`, a bounded watch loop, a rate-limit sleep - keeps its pane
BUSY, so the stale path that already honors declared pauses never ran for it.
The busy-pane completed-turn bound instead routed it straight into
wedge_timer_check, which re-escalated "possible wedge, escalation N" (and, past
the threshold, demand-deep-inspection) every FM_STALE_ESCALATE_SECS for as long
as the review stayed open.

busy_turn_bound_check now owns which absorber takes a crossed bound: a crew
whose own last status line declares an external wait or a verified captain-held
transfer takes the bounded FM_PAUSE_RESURFACE_SECS recheck, and everything else
keeps the unchanged wedge timer. The discriminator is the declaration together
with liveness (the caller has already confirmed the pane is busy), never a
blanket silencing - a crew that declared nothing, or whose pane is not live,
escalates exactly as before, and a declared pause still re-surfaces once per
long cadence so a forgotten wait cannot rot invisibly. Away mode is untouched:
the daemon owns pause triage there and already reads the same vocabulary.

The two call sites also no longer clear pause bookkeeping in the same poll the
pause cadence recorded it, which would have erased the re-surface throttle and
turned the long cadence back into a per-poll re-surface.

Tests: a three-phase regression fixture pins the absorbed pause, its long-cadence
recheck, and the restored wedge escalation once the declaration is lifted on the
same busy over-age pane.

Also de-flakes tests/fm-watch-triage.test.sh, which failed spuriously on a loaded
machine: fixed liveness budgets were reaping watchers mid-startup, so assertions
on post-poll state passed vacuously or failed spuriously. Waits that describe a
poll's outcome now wait for a completed poll cycle via the liveness beacon, the
heartbeat test waits for the heartbeat it asserts on, and every wait_for_exit
budget is the uniform 10s already used elsewhere in the file.

* no-mistakes(review): Fail poll-cycle waits on timeout

* no-mistakes(review): Prevent poll timeout test hangs

* no-mistakes(document): Clarify paused busy-pane supervision

* doc: Enhance communication guidelines for decision-making

Added guidelines for decision communication to the captain.

* doc: Update task delegation and communication guidelines

Clarify communication protocols with crewmates regarding task delegation and reporting.

* fix(bin): reliably confirm herdr steer submission (#2647)

* fix(herdr): confirm local steers that native agent-state misses

Herdr can leave agent_status idle for a landed Claude turn and can keep
queued Enter text visible while busy, so fm-send was reporting false
swallows. Confirm those cases through the shared queued-Enter verdict
and a cleared composer, and keep a genuine idle pending composer as
unconfirmed.

* no-mistakes(review): Stop Herdr Enter retries on unreadable composers

* no-mistakes(review): Reject queued delivery when all Herdr Enter sends fail

* no-mistakes(review): Prevent confirmation after failed Herdr Enter

* no-mistakes(review): Pace Herdr retries and clarify submit fallback

* no-mistakes(review): Align Herdr submit docs with idle fallback

* no-mistakes(document): Correct Herdr submit-confirmation documentation

* feat(bearings): add interactive Lavish fleet board (#2659)

* feat(bin): accept any-origin decision bindings with full-identity keys

An aggregation surface (the bearings board) carries captain answers for holds
across origins, but a binding was one-origin-per-source and the Lavish adapter
capped question keys at 64 chars while real full hold identities measure 69-81.

- fm-decision-hold.sh: bind <source-id> --any-origin records the (any) marker;
  binding prints it verbatim and answers accepts it, so the runner's feed seam
  carries an any-origin source with no runner change. In any-origin mode each
  key is a full hold identity <origin>-decision-<key>, split at its first
  -decision-; a key with no separator (merge/dispatch instructions) is skipped
  and feeds nothing, keeping non-decision answers out of the hold ledger by
  construction. Every existing close guard applies unchanged.
- fm-procevent-lavish.sh: raise the question-key cap 64 -> 128 so a full hold
  identity fits; the slug-shape security property is unchanged.
- tests: cross-origin closure through the real runner seam, an 81-char
  identity through the adapter, cap and shape refusals, routed-work skips,
  nonexistent-identity skips, and idempotent replay.

* feat(bearings): add the /bearings lavish interactive fleet board

/bearings lavish renders the bearings snapshot onto a shipped, reusable board
template and arms it as a Lavish process-event source, so the captain answers
Captain's Call items on the board and firstmate is woken by an ordinary check
wake - no conversational turn ever blocks on a poll.

- .agents/skills/bearings/assets/board-template.html: the shipped template
  (myfirstmate design system inlined, one fm-bearings-board.v1 JSON slot,
  fail-closed schema guard that renders an error card instead of an empty
  fleet). Per-invocation agent work is composing the payload only.
- bin/fm-bearings-board.sh: build/refresh owner - fail-closed payload
  validation, slot injection with a round-trip check and \u003c escaping,
  stable board path, any-origin bind ALWAYS before arm, arm-if-absent.
- bearings SKILL.md: the lavish invocation option, board composition rules,
  board-wake handling, and the captain-ruled merge-click authorization with
  its mandatory safeguards (PR resolved from the task's own meta record,
  wake-time green re-verification, never a red or changed PR, merges only
  through bin/fm-pr-merge.sh, chat echo with the full PR URL).
- process-event-sources SKILL.md: one-line board-wake routing trigger.
- tests: payload refusals, injection round-trip, bind-before-arm, idempotent
  re-arm, and template slot integrity.

Fleet pickup: homes receive this after merge plus a firstmate self-update;
landing timing is coordinated with the main firstmate.

* no-mistakes(review): Harden bearings board validation and wake handling

* no-mistakes(review): Require HTTPS for bearings board PR links

* no-mistakes(review): Fail closed and bound bearings board answers

* no-mistakes(review): Enforce UTF-8 byte limits for board answers

* no-mistakes(review): Serve bearings board before arming and reject empty actions

* no-mistakes(review): Prove bind-before-arm ordering through live answer consumption

* no-mistakes(document): Document bearings board and cross-origin answers

* fix(bearings): restore decision options and add close controls (#2707)

* fix(bearings): always show decision options and a close/drop control

Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call.

* no-mistakes(review): Fix drop closure and decision-only option validation

* no-mistakes(review): Preserve answerability for non-decision cards

* no-mistakes(document): Clarify decision drop documentation

* ci: require no-mistakes pipeline step attestation (#2710)

Signature-only PRs can hide skipped review, test, or document steps. Fail unless no-mistakes >= 1.46.0 attests those three steps completed.

* feat: collapse decisions into tasks held for the captain (#2728)

* feat(captain-hold): collapse the decisions concept into tasks held for the captain

A decision is no longer a separate type: it is an ordinary backlog task held
for the captain, identified by its task id. bin/fm-captain-hold.sh owns the
surviving behaviors - guarded hold creation, the recorded-answer close
(answer/answers with a release mode for captain-gated work), the source
bindings, and the investigation completion gate - and bin/fm-decision-hold.sh
becomes a one-release compatibility shim over it.

The fleet snapshot now parses hold-until and computes captain_actionable as
queued + captain-held + unblocked + due, independent of row kind, plus a
presentation-only deferred_marker for prose-deferred rows. Bearings renders
every due captain-held task in Captain's Call, date-deferred holds as dated
Charted Next gates, suppresses prose-deferred rows from default views with an
omitted disclosure, and excludes from Recently Landed anything that closed
while still held for the captain.

Legacy compatibility: pre-collapse <origin>-decision-<key> rows are already
plain task ids and keep working; short keys in recorded metadata, concrete
origin bindings, chat --resolve-key fallbacks, and old resolution records all
resolve in place.

* no-mistakes(review): Fix captain answer replay and body preservation

* no-mistakes(review): Fix captain hold idempotency and legacy replay

* no-mistakes(review): Validate card close modes and compatibility routing

* no-mistakes(review): Enforce release replay mode matching

* no-mistakes(review): Prevent duplicate decision cards and released replay mismatches

* no-mistakes(review): Preserve answer columns and legacy resolve replays

* no-mistakes(document): Document strict replay and legacy compatibility

* no-mistakes(lint): Quote done literals to satisfy ShellCheck

* no-mistakes: apply CI fixes

* fix(rebase): keep collapsed captain hold board semantics

* fix: bound recovery announcements and preserve supervision (#2733)

* fix(watch): announce recovery once per generation and keep successors supervising

A lost Pi/OpenCode handling handshake re-announced the same recovery
generation on every cycle and spent the successor's first ~55s blind, so
a real crew event could be ignored and then dropped. Record the
announcement in the durable marker, confirm the handshake before the
follow-up without swallowing failure, and enter the poll loop immediately.

* no-mistakes(review): Tighten recovery event timing regression

* no-mistakes(document): Document recovery-loop supervision guarantees

* fix(bin): surface captain-call record divergence (#2744)

* fix(bin): signal a captain call resolved in the log but still held

A captain call has two records and closing one has never closed the
other: a `resolved [key=...]` line closes the status-log fold, while the
backlog task held for the captain closes only through
`fm-captain-hold.sh answer`. Answering on the status side alone left no
trace of the disagreement - the fold went quiet, the durable record kept
saying the captain owed an answer, and nothing warned. The defect was
never the separation; it was the silence.

Add `fm-captain-hold.sh diverged`, a read-only report of that
contradiction, and print it from `fm-wake-drain.sh` as a bounded RECORD
DIVERGENCE section beside OPEN DECISIONS on every drain. It flags one
condition: a task still open and still carrying the captain-hold
annotations whose key was closed on the status side by the resolve verb,
under the collapsed identity or the legacy derived one.

It closes nothing, ever. A captain call closed wrongly leaves review
entirely, which is worse than the noise, so both reconciliation
directions stay human-owned and the printed hint names both - a
resolution is not proof the captain ruled, since a call can dissolve on a
false premise or turn out to have been a question of fact.

Three states are deliberately not divergence: a `captain-held` close is
the verified transfer `complete` writes, a still-open keyed decision
belongs to the OPEN DECISIONS fold, and a captain call with no routed
work item is legitimate rather than incomplete, so routed work is no part
of the test.

`fm-classify-lib.sh` gains `status_key_closing_verb`, which reports how
the status side currently reads one key by replaying the existing
`_fm_decision_fold_line` rule rather than re-deriving it, so the two
closing verbs stay distinguishable in one place. The per-wake cost is one
`tasks-axi list`, one key scan per status log, and the precise per-key
fold only for a key that already names a still-open task; the call is
hard-bounded so a slow backlog tool can never delay wake presentation.

* fix(document): Correct divergence lifecycle documentation

* fix(document): Neutralize divergence lifecycle prose

* fix(bin): re-arm after an abandoned auto-arm claim and defer a wedge escalation while a worktree is written (#2524)

* fix(watch): re-arm supervision after an abandoned auto-arm claim

A Claude auto-arm cycle that armed, delivered one rewake, and exited left
its single-flight lock behind. Both Stop-event participants then deferred
to that lock forever, because its recorded pid was still live: the
turn-end guard read it as recovery under way and allowed the stop, and the
next Stop firing treated it as another owner and declined to arm. On
2026-08-14 a home with two tasks in flight lost supervision for about 40
minutes with no watcher process and no watcher lock, its beacon frozen at
the one delivery, and both crewmates' finished reports sat in the durable
queue until an operator drained it by hand.

Abandonment is now proven from the epoch ledger instead of inferred from
pid liveness. A lock whose holder pid matches the ledger's own owner_pid
while the recorded outcome is anything other than arming has already
finished its decision, so that claim is reclaimed under the lock's steal
mutex, stops counting as recovery ownership in the guard, and is cleared
by the guard's terminal check rather than deferred to. A failed clear
re-blocks instead of allowing a blind stop, and an arming entry stays in
flight however old it is, because its owner foregrounds the arm for the
whole watcher cycle.

Issue #2251's PR #2263 does not cover this failure. It is closed and
unmerged, lives entirely in bin/fm-watch-arm.sh, and retires the stalled
watcher and matching stale watcher lock of an arm that is currently
running. Here no arm and no watcher were running and no watcher lock
existed, so it has nothing to retire and the home stays blind.

tests/fm-claude-stop-autoarm.test.sh covers the reclaim, the still-arming
and unnamed-owner cases that must keep the gate closed, and the failed
clear. tests/fm-turnend-guard.test.sh covers the guard side of the same
boundary. Both fail without this change.

* fix(watch): defer a wedge escalation while the task worktree is written

The wedge detector had two inputs, rendered pane quietness and the run
step, and neither can see a crew that is writing source, then tests, then
documentation behind a static pane. On 2026-08-14 one crewmate produced
eight consecutive possible-wedge escalations in a single afternoon, three
of them demanding deep inspection, while it was demonstrably working and
then committed. Every one of them cost a supervision turn to disprove by
hand.

Add write activity inside the crew's own recorded worktree as a third
liveness input. crew_worktree_written_since compares the worktree against
the caller's existing idle-window timer file, so -newer needs no clock
arithmetic, no temp file, and no portable mtime write. The probe runs only
inside the branch that was about to escalate, which bounds it to one
pruned, depth-bounded walk per window per FM_STALE_ESCALATE_SECS and
leaves the per-poll stale sweep exactly as cheap as before.

Positive evidence defers rather than cancels. The idle timer restarts so
the next window probes again, the escalation counter is neither advanced
nor reset so a later genuine wedge keeps the demand-deep-inspection
history it earned, and a .writing-since marker ages the whole deferral
chain so the pane still re-surfaces once per FM_PAUSE_RESURFACE_SECS,
through the same throttle shape a declared pause already uses, labeled as
a recheck rather than a wedge. This can only reduce false positives: every
absence of evidence, including no recorded worktree, a torn-down worktree,
a missing anchor, and a failed walk, falls through to the unchanged
escalation schedule, so a crew that writes nothing still escalates on the
existing timetable.

What the signal cannot see, by design or by construction:

- CPU burn with no writes, such as a long compaction, is invisible. That
  case keeps the old behavior exactly.
- A commit-only phase writes only .git, which is pruned first so that
  firstmate's own read-only git commands against the worktree can never
  make the probe self-fulfilling.
- Writes under the pruned generated trees, or deeper than
  FM_WORKTREE_WRITE_MAXDEPTH, do not count.
- The probe cannot attribute a write to the crew, so a background build or
  another process touching the tree looks the same. The hourly re-surface
  is what bounds that, and a churny file cannot buy silence.
- The away-mode daemon's own escalation path is deliberately untouched.

tests/fm-watch-triage.test.sh covers the classifier including the .git
prune, both halves of the live case on one fixture (quiet plus writing
defers, quiet plus silent still escalates and counts), and the bounded
re-surface. All three fail without this change.

* no-mistakes(review): prove autoarm claims by identity; skip mate-home write probe

* no-mistakes(document): document away-mode wedge boundary and probe filesystem limit

* no-mistakes(document): qualify turn-end recovery condition for abandoned auto-arm claims

* fix(watch): keep a write deferral scoped to its own idle window

Two consistency gaps in the worktree write probe, both found while reviewing
the wedge-deferral change on this branch.

A write deferral is a bounded chain: its .writing-since marker ages the whole
chain so a churning worktree still re-surfaces once per resurface window. That
is only sound while the chain belongs to the current quiet stretch, so every
path that restarts the idle-window timer has to drop it too. Two did not: the
corrupt-timer repair in wedge_timer_check, and both first-sight branches for a
captain-relevant status. A chain left over from an earlier quiet stretch made
the first deferral of the new window re-surface immediately instead of after a
full fresh window.

FM_WORKTREE_WRITE_PRUNE is a skip list, so clearing it reads as "skip nothing"
and is the obvious way to widen the probe to the whole depth-bounded tree.
Instead an empty list reported no evidence at all, quietly costing the wedge
detector its third liveness input on a home that meant to widen the walk. An
empty list now widens the walk, and the header says so.

Neither change alters when a stall that writes nothing escalates.

Regressions in tests/fm-watch-triage.test.sh cover all three paths and each
one fails on the pre-fix code.

* no-mistakes(review): honor an empty write-prune, bound the probe, share window_key

* no-mistakes(document): align probe knob count and guard regression-coverage ownership

* no-mistakes(lint): silence deliberate single-quote SC2016 in write-prune env test

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause (#2748)

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause

Two supervisors read a finished task's last status line and disagreed about which
declarations mean an idle endpoint is expected. bin/fm-inactive-reconcile.sh
suppresses its inactive-outcome scan only on `captain-held`, while the away-mode
daemon's wedge path gated deferral on `paused` alone. Both read the LAST line, so
the two verbs are mutually exclusive and no finished task waiting on a person
could satisfy both at once. Marking 11 such tasks `captain-held:` silenced the
900s outcome scan and immediately produced five possible-wedge escalations in one
batch, because the 240s wedge detector no longer saw a pause verb.

fm-classify-lib.sh's status_is_paused_or_captain_held already owns the combined
question, and bin/fm-watch.sh's ordinary-crew wedge path already asked it. This
extends that same answer to the paths still asking the narrower one:

- bin/fm-supervise-daemon.sh, all six sites, which form one subsystem and have to
  move together. classify_stale returns the pause action, reconcile_pause_tracking
  and migrate_watcher_pause_markers record and migrate the marker, and
  housekeeping defers the wedge and then re-surfaces the recheck. Changing only
  the stale-persistence gate would defer the escalation while
  reconcile_pause_tracking recorded nothing, so the wedge marker would persist and
  the sweep would `continue` past it forever: quiet, but never re-surfacing.
- bin/fm-watch.sh's secondmate stale gate, whose downstream owner
  pause_state_class already treats both declarations identically.
- bin/fm-push-transition-lib.sh's absorb, where either declaration already names
  the human the transition would report and the wait is already durably recorded.

Quieting alone would be half a fix, so the bounded re-surface had to reach a hold
too. A hold has no current-state mapping, unlike `paused`, so authoritative crew
state reports it as unknown and pause_state_class received `none`. An ordinary
crew recovers pause classification from that state through confirmed agent death,
which proves no live decision gate is being silenced. A secondmate's endpoint
liveness is deliberately never read there, because an idle mate is healthy by
design, so that confirmation is unavailable by construction and cannot be
required: without recovering the classification for a mate, every caller silenced
a held mate outright and its hold would rot invisibly. That promotion is bounded
by the declared-wait guard at the top of the function, so it can only reclassify a
task that already declared a wait and shows no positive working evidence.

Two narrow `status_is_paused` calls are deliberately left alone.
bin/fm-crew-state.sh's map_log_state is a current-state reporting contract, not a
wedge path; reporting a hold as `paused` would erase the distinction
status_key_closing_verb and fm-captain-hold.sh depend on, where a `captain-held`
close is a verified durable transfer and a `resolved` close claims outright
settlement. fm-classify-lib.sh's call inside status_is_captain_relevant needs no
change because that function's own case list already returns non-relevant for
`captain-held`.

bin/fm-inactive-reconcile.sh keeps its `captain-held` suppression as it is. Its
guard exists because a finished task's crew state still reports done from a
higher-priority source than the log, and a declared pause needs no such guard: the
scan only reports done or failed, and nothing else reaches its record path.
Widening it would change a separate subsystem's reporting contract, which this
defect does not require.

Coverage extends the existing colocated patterns for these predicates and asserts
both halves. tests/fm-daemon.test.sh covers the classification, the wedge marker
converting to pause tracking with no escalation, the bounded re-surface with its
window reset, and the boundary case where an answered hold stops claiming the
cadence. tests/fm-watch-triage.test.sh covers a held secondmate re-surfacing on
the same bounded cadence without being labeled a wedge.
tests/fm-supervision-events.test.sh covers the absorbed push transition. Every one
of these fails on the pre-fix code except the answered-hold boundary case, which
is there to pin that the quieting was not widened too far.

The `paused:` workaround appended to those 11 tasks is live supervision state and
is untouched here. It can be retired once this lands.

* no-mistakes(review): name the captain in a held task's bounded recheck

* no-mistakes(document): extend declared-wait supervision docs to captain-held holds

* fix(bin): make lint prerequisites and harness tests reliable (#2758)

* fix(lint): name the installer when ShellCheck or actionlint is missing

A missing actionlint exited 127 like a bare command-not-found. Fail with
exit 1 and point at the pinned installer, matching the missing-ShellCheck
path, without weakening the version pin.

* test: isolate kimi and muse detection from inherited Cursor markers

Harness detection checks CURSOR_AGENT before ancestry, so these
markerless-adapter cases failed when the suite itself ran under Cursor.
Clear the verified markers the same way the secondmate harness tests already do.

* no-mistakes(document): Document Muse Cursor marker cleanup

* feat(bin): report watched tooling updates that are available or installed but inert (#2684)

* feat(checks): report tool updates that are available or installed but inert

Firstmate had no way to notice that tooling this home depends on needs an
update, and no way at all to notice the worse case: an update that installed
correctly and then did nothing.

That second case is why this exists. A tool that self-installs into
~/.local/bin while a version manager keeps its own older copy earlier on PATH
looks completely up to date to anything that asks only "is a newer version
published". On 2026-08-20 a Herdr update landed at 0.8.2 while an older 0.8.0
copy stayed earlier on PATH, so every Herdr command failed on a protocol
mismatch and firstmate could not read its own fleet.

bin/fm-tool-update-check.sh reports the two conditions separately:

  <tool> update available      a newer version exists at the update source.
  <tool> update not in effect  a newer copy is installed on this host, but
                               PATH still resolves an older one.

PATH skew is measured, never inferred. Every executable copy of a watched
command on PATH is asked for its own version and those answers are compared,
so one lookup cannot hide the skew, and a directory name is never read as a
version because a version manager's "latest" directory can hold an older
build. A copy that will not report a version is a check failure, not a pass.

The watched tools live in local, gitignored config/watched-tools.json, so
adding a tool is a config edit rather than a code change, and the file is
never propagated to another home. Update sources cover both shapes: a local
clone's commit distance from its remote branch, and a command's own version
and update announcement, including a tool like no-mistakes that prints its
version on one command and announces a new release on another.

The check prints one line when something needs attention and prints nothing
otherwise, so it rides the existing watcher state-check contract with its
trust binding instead of introducing a schedule of its own, and
state/.tool-updates keeps the same pending update from being reported on
every poll.

The check only reports. It never installs, updates, reorders PATH, touches a
version manager, or fetches into a watched repository; every git probe is
read-only.

Tests cover the skew case as a regression, and it was verified by mutation:
removing the skew report, or stopping after the first PATH hit as a single
lookup would, each make that test fail.

* no-mistakes(review): fix tool update check probe reporting, budget, and shim write

* no-mistakes(review): keep sweeps alive on broken patterns and oversized budgets

* no-mistakes(review): roll back failed arm, widen budget clamp, bound repo probe

* no-mistakes(review): guard git probes at the budget, record uncut findings

* no-mistakes(document): fix stale watched-tool report-record wording in docs and header

* 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

The behavior shard's watch-triage suite failed on the new worktree-write wedge
tests. Those five tests are the only ones in the file that do not use its
standard waits. They give a fixed 3 second liveness budget to the one poll that
now spawns the bounded worktree walk, and 4 seconds to an escalating watcher
where every other test in the file gives 10. On a loaded runner that poll
outlives the fixed budget, so the round is reaped before the deferral it asserts
on is recorded, and the test reports a lost deferral instead of the deferral
under test. Wait for a completed poll cycle through the file's own
wait_poll_cycle, which is what its header documents this hazard for, and use the
file's standard 100 tick exit budget.

Verified against a load that reproduces the failure: 11 of 12 runs failed
before, 8 of 8 pass after. Verified by mutation too, so the waits still prove
the behavior: removing the write deferral, and keeping a finished deferral chain
across an idle-timer repair, each still fail their test.

* fix: decouple ask-user decisions from yolo (#2764)

* fix: treat yolo as merge authority only, not ask-user finding authority

Yolo on/off was documented as also deciding no-mistakes ask-user findings, which hid firstmate's duty to judge unambiguous-toward-design findings itself. Keep every safety boundary; this is a contract clarification, not a relaxation.

* no-mistakes(document): Clarify yolo documentation ownership and merge posture

* feat(bin): add a spoken interface that answers from records and hands work over (#2767)

* feat(voice): spoken round trip on Nova Sonic 2 with a measured relay cost

Step one of the spoken interface: the laptop captures and plays audio, this
desktop holds the model session, and no AWS credential leaves the desktop.

Measured, amazon.nova-2-sonic-v1:0 in eu-north-1, end of speech to first byte
of reply audio, 6 runs each, all answered, on a question that forces a records
read:

  relay path   1.229 1.379 1.428 1.447 1.481 1.516  median 1.438
  direct       1.147 1.179 1.203 1.237 1.244 1.317  median 1.220

The relay costs about 0.22s of the median. The direct figure reproduces the
earlier survey, which is what makes it a usable control. Excluded: the
captain's own ssh round trip, microphone capture, and speaker output. This
desktop has no microphone and no speaker, so every run used audio files.

Three pieces:

  bin/fm-voice-relay.py    holds the conversation on this host
  bin/fm_voice_records.py  what a spoken answer may read, and the handover
  bin/fm-voice-client.py   the laptop end; audio devices UNVERIFIED
  bin/fm_voice_frame.py    the wire format both machines share

Real work is handed to the existing bin/fm-inbox.sh rather than a second
queueing surface, and the agent says it is handing over rather than answering
as firstmate.

Read scope: Done history and free-form note bodies are never assembled at any
scope, so the wide default cannot reach the places commercial detail
accumulates. config/voice-read-scope narrows it to counts only, and
config/voice-read-deny excludes a named item in one line. The boundary is an
executable test that widening the reader fails.

Push to talk is the default because it is cheaper and the choice is still open;
--listen open-mic is the single flip.

Two traps worth knowing: a clip with no trailing silence is never answered, and
the end of a reply is contentEnd with stopReason END_TURN, not completionEnd.
A second user turn in one session is treated as barge-in unconditionally, and
an interrupted turn that calls a tool is lost, so the session reconnects per
turn and gives up conversational memory. That is the concrete thing step three
has to solve.

* no-mistakes(review): fix voice relay credential reuse, frame validation and record parsing

* no-mistakes(review): test uplink header guard, bound unknown expiry, align state dir

* no-mistakes(review): decide deny per item, guard turn failures, bound ambient credentials

* no-mistakes(review): read account config from home, harden deny and turn failures

* no-mistakes(review): close status verb set, fix inbox help, pair data override

* no-mistakes(review): keep profile-free relay alive, unblock loop, fix dead assertion

* no-mistakes(review): hide finished pull requests, refuse open mic, keep suite offline

* no-mistakes(review): survive reader failures, release devices, fix claims

A failure while handling a model event, or while sending a tool result,
left the reader task dead with ended and turn_done clear, and close()
re-raised the stored failure on every await. One dropped stream became a
relay that could never build another session. The reader now reports the
session over in a finally whatever killed it, and close() absorbs the
task the same way it already absorbed its sends.

The laptop client releases what it already started when a later startup
step refuses, SystemExit from the handshake wait included, and names a
device refusal instead of leaking a raw PortAudio error. Whether it
releases correctly against a real device is still unverified here.

The records docstring claimed every reading was filtered to open ids.
Only the pull request count and list are; the worker count and the state
histogram cover every live runtime record, finished ids included,
because a meta file still on disk still needs tearing down.

The finished-work deny half of the suite asserted things that held with
the deny list absent. It is replaced by a deny on an open title, which
removes the row and says so while the count stays honest.

* no-mistakes(review): name reader failures, split file and device refusals

A failure inside the model reader released the waiting turn and told
nobody. The session was not marked spent, no notice reached the client,
and the client waits for a reply end or a notice, so the captain got
their whole timeout of silence and then a record saying the turn went
unanswered with nothing about why. Both ends of the relay now name a
failed turn through one function, once per turn, and --self-test carries
the cause in relay_error the way the client's own record does.

Two things that are not failures stay that way. A stream that simply
ends is the end of a session, which serve still reads on its own terms.
A stream that goes away because close() asked it to is an ordinary
renew, and announcing it would have put a failure notice in front of the
captain on every turn.

On the laptop end, the refusal that became a device error covered the
file-backed playback and capture too, so a mistyped --in-file was
reported as an audio device failure and the advice named the flag that
had just failed. The file ends now report the path and the flag that
chose it and stay an OSError; the device ends keep the device advice and
name the flag for that end. The device paths remain unrun here, so only
the file halves are covered by a test.

* no-mistakes(test): survive model session end, order client turn frames

* no-mistakes(document): sync voice relay docs with reviewed relay behavior

* no-mistakes(document): re-measure relay latency and correct its cause

* no-mistakes(document): correct measurement date and name the unmeasured SSH hop

* no-mistakes(document): describe the unpublished control measurement, fix list formatting

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* fix(bin): preserve Relay follow-up loops until explicit disposition (#2763)

* fix: keep Relay public loops open until retire

Delivering a promised-final reply was deleting the only record that tied a public thread to later work, so a follow-on ship silently owed no closing reply. Retain the registration after delivery, rechain follow-on work onto the same thread, and make retire --reason the only close.

* no-mistakes(review): Propagate public follow-up registration removal failures

* no-mistakes(review): Persist retire receipts and align parent resolution

* no-mistakes(review): Make rechain resumable after partial obligation creation

* no-mistakes(review): Repair follow-up state, briefs, and expiry escalation

* no-mistakes(review): Serialize follow-up delivery stamps with retirement

* no-mistakes(review): Serialize rechain claims and protect registration terminal states

* no-mistakes(review): Avoid reporting retired delivery loops as open

* no-mistakes(document): Refresh public-loop documentation and verification evidence

* no-mistakes: apply CI fixes

* no-mistakes(review): Preserve delivered follow-up bindings during registration replay

* no-mistakes(review): Harden public follow-up retirement and rechain races

* no-mistakes(review): Fail closed on unresolved secondmate retirement

* no-mistakes(review): Bind secondmate cleanup to its recorded canonical home

* no-mistakes(review): Fix rechain command output and expiry validation

* no-mistakes(review): Validate brief keys and warn on remote promotion

* no-mistakes(document): Document retained public follow-up loops

* no-mistakes(lint): Remove unused bounded-wait loop variable

* 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

---------

Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Mickaël Rémond <mremond@process-one.net>
Co-authored-by: Inthuson <iaminthuson@gmail.com>
Co-authored-by: Inthuson <inthuson@amazon.com>
Bre77 added a commit to Bre77/firstmate that referenced this pull request Aug 22, 2026
…s) (#70)

* fix(ci): fail hung Herdr behavior runs in 20 minutes (#2413)

A wedged family-run step was occupying the runner until the 75-minute
job cap; bound that step so cleanup and timing artifacts still upload.

* fix: keep the public promise reachable when work is routed to a second mate (#2457)

The lightweight Relay follow-up link lives in the answering home's own
state/<task-id>.meta, so it can only bind work that home owns. When a
Relay-linked request is routed to a second mate, the task record lives in the
second mate's home, fm-x-link.sh failed with a bare "no such task ...meta", and
nothing else picked the promise up: only the soft acknowledgement was ever
posted. The typed promised-final path already supports --work-home
secondmate:<id>; the playbook simply never chose it.

- fmx-respond now states the routing rule crisply: a task in this home takes the
  lightweight link, and second-mate-routed work takes a promised-final
  commitment bound to that home, registered up front with the brief command
  carried into the routed worker's instructions.
- fm-x-link.sh refuses a task with no local record by naming the registered
  second mate whose home actually holds it and printing the promised-final
  registration command, with the exact --work-home when the match is
  unambiguous. A home with no registered second mates keeps the plain error.
- fm-backlog-handoff.sh reports, after a successful move, any moved key that
  still owes a public reply bound to main/<key>, since that binding no longer
  names the home owning the work. The move itself is never blocked.

Docs and the secondmate handoff prose follow the same rule. Tests cover the
refusal, its scoping, the unchanged local-link path, and both handoff outcomes
at the script boundary.

* docs(skills): add remote-secondmate recovery hint for false-negative verdicts (#2456)

* fix(skills): hint that remote secondmate liveness verdicts false-negative

fm-crew-state and fm-send routinely misreport a live remote secondmate
as dead; confirm against the pane before relaunching, and relaunch only
through fm-spawn.sh, never raw herdr pane surgery.

* no-mistakes: apply CI fixes

* fix(calm): keep Pi's export confirmation visible (#2461)

Pi 0.83.0 added a status line to every tool-expansion change, and Pi
updates the previous status line in place when two status messages
arrive back to back. Calm's post-export redraw cycled tool expansion on
the macrotask right after Pi printed "Session exported to: <path>", so
both expansion status lines coalesced over that confirmation and the
captain was left with no record of where their export landed.

Calm now repaints only the tool rows it presents, by invalidating each
row through the render context Pi hands its render slots, and requests
the surrounding redraw through setStatus. Neither appends to the
transcript. The repaint is still needed because Pi can re-render a row
asynchronously - the built-in edit row invalidates itself once its diff
is ready - and that re-render can land inside the window where /export
forces stock rendering.

The real-terminal /export case now asserts the confirmation is still on
screen after the redraw has settled, and that the redraw restored every
Calm-hidden row, instead of only racing the moment the confirmation
first appeared.

* feat(stow): add open-record persistence to /stow before reset (#2488)

* feat(stow): persist the open records a session is holding

/stow curated memory and captured session knowledge, but never touched
record state, while AGENTS.md called it an "unfinished-work sweep" and the
receipt declared the session "safe to reset" - wording that implied a
record-correctness guarantee stow does not make. A shipped PR with no
backlog item, a queued umbrella whose phases had merged, and four decision
holds left open after their answers shipped all survived repeated stows.

Add a bounded pass that files record state from the same volatile input the
rest of stow already uses: the open threads in context, minutes before the
reset destroys them. It creates a record for an unfiled thread and corrects
one the session knows is wrong, through the owning path, and states its
boundary as part of the contract - it never enumerates the backlog, lists
holds, or queries a forge, because it cannot be a reconciliation and must
not be read as one.

Correct the wording in AGENTS.md and the completion receipt so reset-safe
means what it actually guarantees: nothing this session knew was lost.

* no-mistakes(review): correct stow decision-hold inspection to read hold via tasks-axi

* no-mistakes(document): note /stow open-record persistence in README command catalog

* refactor(stow): state open-record persistence as principle, not procedure

The first version enumerated triggers, named commands, and prescribed an
ordered procedure. That is too rigid for an agent skill: it invites literal
execution of a checklist instead of judgment, and every enumerated example
is a way for the guidance to go stale.

Reduce it to the intent - before a reset, the important open work you are
holding in context must end up durably recorded rather than dying with the
session, filing what is unfiled and correcting what is stale - and let the
agent judge importance, the record, and the owning write path.

Keep the scope bound, since it is a decided contract and not a mechanic:
this covers the open work the session is holding, never a reconciliation of
durable records against repository or forge reality. The wording
corrections in AGENTS.md and the completion receipt are unchanged.

* fix(decisions): close decision holds at answer time via one general keyed-answer path (#2490)

* fix(decisions): close captain holds at answer time

Firstmate had two "a decision is open" ledgers with asymmetric closing
mechanics. The live status-log ledger closes atomically at answer time,
because bin/fm-send.sh --resolve-key makes answering a decision be the
act that closes it. The durable backlog hold ledger had no such coupling:
answering and recording were two separate acts, and only the first was
forced by the workflow.

That asymmetry lost four real captain decisions. Their answers were
captured durably to disk, keyed character for character by the hold
decision keys, acknowledged, and even implemented and shipped, yet the
holds stayed open for two days and the captain was asked to re-answer
decisions already on his own disk.

Give the hold ledger the same answer-time-closure property:

- bin/fm-decision-hold.sh gains an `answer` subcommand, the hold ledger's
  counterpart to --resolve-key. It shares one unrouted close
  implementation with `decline`, so it carries every existing guard - the
  captain decision file, the active-hold requirement, retry identity, and
  the refusal to release still-routed work - and differs only in the
  resolution mode it records. `decline` keeps its stronger meaning that
  the answer routes no follow-up work at all.
- bin/fm-procevent-lavish.sh wires the channel that actually carried the
  lost answers. `arm --decisions-origin` binds a deck to the origin whose
  holds it carries, `answers` reads the structured choices out of a
  captured poll result, `close-decisions` maps each key to its hold and
  closes it through the command above, and `autohandle` lets the runner
  apply that at capture time.

Safety is preserved rather than traded away. Only rows tagged `choice`
are read, so freeform captain prose cannot forge a decision key. Closure
is confined to the one bound origin. The decision text is a pure function
of the captured result, so a replayed capture is idempotent. A hold that
is absent, already closed, or still blocking routed work is skipped and
left for `resolve`, never forced. A deck armed without the binding
touches no hold at all. And autohandle deliberately never reports full
handling, because recording an answer is transcription while acting on it
is firstmate's judgement - so the check wake still reaches the handler.

fm-send --resolve-key is untouched.

* no-mistakes(document): document state/lavish-decisions binding dir in AGENTS.md state inventory

* refactor(decisions): make keyed-answer closure one general capability

The previous pass gave holds answer-time closure but built it as bespoke
Lavish wiring: the review adapter carried the source-to-origin binding,
mapped keys to hold identities, wrote decision records, decided what to
skip, and closed holds itself. That treated a review deck as a special
decision source. It is not - it is an ephemeral discussion format that
happens to carry answers.

Collapse it into ONE general capability with one owner.

bin/fm-decision-hold.sh now owns the whole of "a keyed answer closes its
matching hold":
- `answers <origin> --source <provenance>` is the channel-agnostic
  intake. It reads key/answer/label lines on stdin, maps each key to its
  hold, and closes it through the same `answer` path, so every guard
  applies identically whatever channel the answer came from. --source is
  provenance recorded in the decision, never a behavior switch; there is
  no per-channel branch and no knowledge of chat, decks, or transports.
- `bind`/`unbind`/`binding` own the source-to-origin binding for any
  channel whose answers arrive detached from their origin.

Every channel is now an ordinary caller that only turns what it received
into keyed lines:
- bin/fm-send.sh (chat) feeds the intake for a key that names an active
  hold. This also fixes a real gap: once `complete` transfers a decision
  to its hold it closes the live status copy, so --resolve-key alone
  could never answer a transferred decision.
- bin/fm-procevent.sh feeds it generically. A bound source's captured
  result goes to `<adapter> answers <result-file>` and whatever that
  prints is piped into the intake. The runner names no adapter, parses
  no result, and carries no decision rule, so any future adapter with an
  `answers` command works with no change here.
- bin/fm-procevent-lavish.sh keeps only `answers`, which reports the
  structured choices a review captured and stops. It maps nothing to a
  hold and closes nothing; it lost ~160 lines of decision logic.

Feeding is independent of handling, so it never acknowledges a result
and never suppresses a wake - recording an answer is transcription,
acting on it stays firstmate's judgement.

The regression that proves closure now drives a FIXTURE adapter that is
not the review adapter, so what is proven is that any bound channel
reaches the intake rather than that one channel is wired specially. A
new regression drives the real fm-send over a stubbed transport for the
chat side. Every prior guarantee still holds, and fm-send's status-log
behavior is unchanged.

* no-mistakes(review): test(decisions): drop source-content grep from hold-closure regression

* fix(memory): emit a real @AGENTS.md pointer instead of a CLAUDE.md symlink (#2512)

A Write aimed at CLAUDE.md followed the symlink and destroyed AGENTS.md.
The installer now creates and migrates to a recoverable two-line pointer file.

* fix(ci): keep CLAUDE.md pointer check valid (#2515)

* ci: gate GitHub workflows with pinned actionlint (#2517)

* fix(lint): catch malformed GitHub workflows before merge

A self-broken ci.yml cannot report its own breakage, so parse every
workflow in the local lint path that no-mistakes already runs.

* fix(lint): pin actionlint instead of Ruby for workflow lint

A self-broken ci.yml still has to fail in the local lint path, and the
named tool for that gate is actionlint, not a new Ruby runtime.

* no-mistakes(document): Clarify pinned workflow lint documentation

* fix: install pinned lint tools across supported platforms (#2546)

* fix: install pinned shellcheck and actionlint on macOS and linux arm64

The installers were hardcoded to linux amd64 and sha256sum, so a Mac
dev could not satisfy the refuse-on-mismatch lint gate. Select the
official per-platform archive and checksum, and fall back to shasum -a 256.

* no-mistakes(document): Document cross-platform pinned lint installers

* docs: reconcile test-evidence docs with store_in_repo: true (#2548)

.no-mistakes.yaml has set test.evidence.store_in_repo: true since #2355, but
CONTRIBUTING.md, docs/configuration.md, and docs/architecture.md still described
the old policy of keeping evidence out of the repo in a temp directory.

The current no-mistakes behavior for store_in_repo: true is to publish each run's
test evidence to the orphan no-mistakes/evidence branch and link it from the PR
body. That branch shares no history with code branches, so evidence never enters
a pushed feature branch or the default branch, and CI's tracked personal fleet
paths rule stays accurate.

Docs only. No change to .no-mistakes.yaml or any workflow.

* docs: clarify test evidence branch storage (#2549)

* docs: correct test evidence storage comment in .no-mistakes.yaml

* no-mistakes: apply CI fixes

* docs: hint that live scouts may host their own Lavish review loop (#2563)

Make that a first-class option in always-loaded instructions so firstmate does not default to mediating and tearing the scout down between iteration rounds.

* fix(bin): report remote secondmate delivery and state truthfully (#2570)

* fix(bin): report remote secondmate delivery and state truthfully

A steer to a remote secondmate crosses fm-on.sh to a host-local fm-send
leg whose unconfirmed submit read-back (verdict=pending, typically a busy
mate whose harness queues the steer) was flattened into exit 1, so the
parent printed "error: text not submitted" / "error: text not sent" and
discarded the pending-reply expectation for a steer that had actually
landed. fm-send now carries the verdict across the ssh boundary as a
documented delivered-unconfirmed exit 3: the parent reports the steer as
delivered with confirmation pending, exits 0, keeps the expectation armed
(awaiting_report), and closes --resolve-key decisions, while transport
loss (ssh 255) and real remote failures keep failing loudly with the
remote leg's stderr attached. A local unconfirmed submit now also exits 3
with an honest non-error message and still never closes a decision key.

fm-crew-state.sh and fm-peek.sh no longer read a remote mate's endpoint
through local probes (which misreported a healthy mate as "worktree gone"
/ "can't find session: remote"): both now use the true remote source over
fm-on.sh, and an unreachable or unreadable remote reads as unknown-remote,
never as gone or dead.

* no-mistakes(document): Document remote delivery and state truth

* no-mistakes: apply CI fixes

* feat: adopt spendPriority for quota dispatch (#2574)

* Adopt quota-axi 0.1.29 spendPriority-primary array dispatch.

quota-axi 0.1.29 publishes schema 5 with selection.spendPriority as the primary comparative signal and demotes derivation fields out of default --json. Rank comparable-fit candidates on that scalar, keep runway versus the completion horizon as a hard gate, and raise the compatibility floor so a pre-consolidation build cannot reach dispatch intake.

* no-mistakes(review): Correct schema fixtures and remove prescriptive selection prompts

* no-mistakes(document): Correct quota verification evidence chronology

* Collapse quota-array-dispatch onto TOON-first spendPriority ranking.

Decide from quota-axi's default TOON; keep --json as a rare defensive fallback.
Rank by spendPriority after eligibility, reasoning-class, and runway-feasibility gates, and drop the hand-computed Pareto, pace, reserve, and window-id layers.

* no-mistakes(review): Permit ambiguous JSON fallback and correct reset fixtures

* no-mistakes(review): Correct runway semantics and escalate unresolved uncertainty

* no-mistakes(document): Document TOON-first quota dispatch evidence

* docs: add GROK_BOT.md Grok Bot system prompt (#2590)

* docs: add GROK_BOT.md Grok Bot system prompt

* docs: amend GROK_BOT.md with charter report-back and delegation marker

* docs: classify GROK_BOT.md as public-product

* docs: make GROK_BOT.md the plain Grok Bot system prompt

* docs: update GROK_BOT.md nautical terms and self-improvement (#2592)

* doc: Update language in GROK_BOT.md for clarity

Refine language for clarity and consistency in instructions.

* fix(bin): preserve inactive reconciliation scan progress (#2595)

* fix(bin): guarantee inactive-reconcile scan progress under second quantization

The inactive-outcome scan computed its aggregate deadline in whole seconds,
so a 1-second budget's effective value lands anywhere in (0,1]; a scan
starting just before a wall-clock second boundary rounded its whole budget
away mid-scan and exited having visited no child, while the durable cursor
had already advanced past the never-examined child. This is the CI flake
behind tests/fm-inactive-reconcile.test.sh's 'next bounded scan did not
resume with the following child' (watcher-wake-lock family, portable
serial 2, seen on the PR #2590 run).

Every scan now visits at least its first due child with the per-child
state-read bound floored at one second, so no invocation can be a zero-work
no-op. The outer process-group kill moves to budget+1s: the scan's own
deadline enforces the budget, and the kill is a backstop for a scan wedged
in an unbounded wait instead of a racer that routinely preempts the clean
bounded exit. The wake-lock-wait test bound tracks the backstop (3s -> 4s);
the previously flaky assertion is unchanged.

* no-mistakes(document): Document inactive-reconcile deadline backstop

* doc: Revise Firstmate delegation and communication guidelines

Refactor the guidelines for Firstmate's role and delegation process, emphasizing the importance of crewmates and asynchronous work.

* doc: Update work delegation and secret management instructions

Clarified guidelines for handing off work to crewmates and managing secrets.

* test(procevent): make the process-event suite's detached-runner assertions deterministic (#2617)

Three assertions in tests/fm-procevent.test.sh depended on a detached runner
having finished work that the command starting it does not wait for.

reconcile's replacement runner is started through detach_runner, which only
forks: reconcile returns and counts the start before that runner has claimed
its source or exec'd its child. Any assertion taken straight after reconcile
therefore samples a race.

- The publish-before-apply recovery section left its always-ready /bin/echo
  source registered across the recovery reconcile, so that reconcile launched
  a competing detached poll (observed: started=1) that then raced every later
  assertion for the source claim, the next capture sequence, and this home's
  applied record, and outlived the section holding a live claim. It is now
  retired before that reconcile - re-announcement is proven from the durable
  inbox alone and needs no registration - and started=0 is asserted so a
  competing poll cannot be reintroduced unnoticed. This is the same
  retire-before-reconcile discipline the self-announcing section already
  carries; that section acquired it after the identical race made its
  "not-autohandled: self-src" assertion read "already owned: self-src".

- The crashed-leader replacement section snapshotted the replacement's claim
  file and execution log behind a fixed 0.5s settle window. On a loaded
  machine that window expires first, which is the CI flake behind "a
  replacement runner started without recording its own claim" and "reconcile
  did not start exactly one replacement source". Both effects are now waited
  for with the suite's bounded wait helpers; the exact one-replacement count
  is still asserted afterwards, unchanged.

- The duplicate-start section slept 0.5s for reconcile's runner to record
  ownership before asserting that a second start loses to it. It now waits
  for that claim.

Also tighten one assertion that could not fail as written: "autohandled:
self-src" is a substring of "not-autohandled: self-src", so the applied path
was accepted even when the runner reported the capture left for the handler.

Evidence: on the unmodified suite, 128 full runs at 6-8x concurrency produced
6 failing runs, all in the crashed-leader section. On the fixed suite, 216
full runs under the same load produced none. Reverting the self-announcing
section's retire-before-reconcile line reproduces "already owned: self-src"
on the first iteration, confirming the shared mechanism.

* fix: preserve pending replies and defer remote reposts (#2618)

* fix(bin): keep pending-reply expectations honest on both send legs

Two related asymmetries let the parent-owned secondmate reply guard drop or
nag requests it should not have.

Local delivered-unconfirmed dropped the expectation. A marked request whose
submit read-back stayed unconfirmed (verdict=pending) is the same
not-a-failure outcome the remote leg reports as delivered, but fm-send
discarded the parent's pending-reply record for it, so a request that very
likely landed stopped being tracked entirely. The record now stays armed on
its unconfirmed-delivery marker: a correlated report still resolves it, and
an unanswered one still surfaces through the library's own reconciliation.
Exit 3 and the local rule that an unconfirmed answer never closes a decision
key are unchanged.

Remote replies were nagged for a repost they did not need. A remote mate's
report reaches the parent's status log only through the asynchronous mirror
in fm-procevent-remote-reply.sh, yet the guard read an absent correlated
line as proof the mate never reported - even while the answer was still in
flight, which is the common case because the mirror's poll window is
comparable to the recovery grace. The mirror now publishes one caught-up
watermark from a quiet window, and the guard admits a missing report as
evidence only once that watermark passes the turn that should have produced
it. A genuinely missed report still gets exactly one repost, and a channel
that is behind, unarmed, or broken leaves the request durably open and
un-nagged rather than nagging blind; the mirror escalates its own continuity
failures as before.

Tests: a local unconfirmed secondmate send keeps its expectation armed and
resolvable; a mirrored correlated remote reply resolves with no repost; a
stale or absent watermark withholds the repost while a fresh one still
releases it; a quiet remote window publishes the watermark and retirement
clears it.

* no-mistakes(review): Distinguish preempted polls from quiet windows

* no-mistakes(document): Clarify remote reply channel freshness

* no-mistakes(lint): Annotate shared remote preemption exit constant

* fix(bin): honor declared pauses in busy-pane wedge checks (#2619)

* fix(watch): honor a declared pause on a busy pane's completed-turn bound

A worker that declares an external wait (`paused:`) and then blocks in one
long foreground call - a review-hosting scout parked in a single blocking
`lavish-axi poll`, a bounded watch loop, a rate-limit sleep - keeps its pane
BUSY, so the stale path that already honors declared pauses never ran for it.
The busy-pane completed-turn bound instead routed it straight into
wedge_timer_check, which re-escalated "possible wedge, escalation N" (and, past
the threshold, demand-deep-inspection) every FM_STALE_ESCALATE_SECS for as long
as the review stayed open.

busy_turn_bound_check now owns which absorber takes a crossed bound: a crew
whose own last status line declares an external wait or a verified captain-held
transfer takes the bounded FM_PAUSE_RESURFACE_SECS recheck, and everything else
keeps the unchanged wedge timer. The discriminator is the declaration together
with liveness (the caller has already confirmed the pane is busy), never a
blanket silencing - a crew that declared nothing, or whose pane is not live,
escalates exactly as before, and a declared pause still re-surfaces once per
long cadence so a forgotten wait cannot rot invisibly. Away mode is untouched:
the daemon owns pause triage there and already reads the same vocabulary.

The two call sites also no longer clear pause bookkeeping in the same poll the
pause cadence recorded it, which would have erased the re-surface throttle and
turned the long cadence back into a per-poll re-surface.

Tests: a three-phase regression fixture pins the absorbed pause, its long-cadence
recheck, and the restored wedge escalation once the declaration is lifted on the
same busy over-age pane.

Also de-flakes tests/fm-watch-triage.test.sh, which failed spuriously on a loaded
machine: fixed liveness budgets were reaping watchers mid-startup, so assertions
on post-poll state passed vacuously or failed spuriously. Waits that describe a
poll's outcome now wait for a completed poll cycle via the liveness beacon, the
heartbeat test waits for the heartbeat it asserts on, and every wait_for_exit
budget is the uniform 10s already used elsewhere in the file.

* no-mistakes(review): Fail poll-cycle waits on timeout

* no-mistakes(review): Prevent poll timeout test hangs

* no-mistakes(document): Clarify paused busy-pane supervision

* doc: Enhance communication guidelines for decision-making

Added guidelines for decision communication to the captain.

* doc: Update task delegation and communication guidelines

Clarify communication protocols with crewmates regarding task delegation and reporting.

* fix(bin): reliably confirm herdr steer submission (#2647)

* fix(herdr): confirm local steers that native agent-state misses

Herdr can leave agent_status idle for a landed Claude turn and can keep
queued Enter text visible while busy, so fm-send was reporting false
swallows. Confirm those cases through the shared queued-Enter verdict
and a cleared composer, and keep a genuine idle pending composer as
unconfirmed.

* no-mistakes(review): Stop Herdr Enter retries on unreadable composers

* no-mistakes(review): Reject queued delivery when all Herdr Enter sends fail

* no-mistakes(review): Prevent confirmation after failed Herdr Enter

* no-mistakes(review): Pace Herdr retries and clarify submit fallback

* no-mistakes(review): Align Herdr submit docs with idle fallback

* no-mistakes(document): Correct Herdr submit-confirmation documentation

* feat(bearings): add interactive Lavish fleet board (#2659)

* feat(bin): accept any-origin decision bindings with full-identity keys

An aggregation surface (the bearings board) carries captain answers for holds
across origins, but a binding was one-origin-per-source and the Lavish adapter
capped question keys at 64 chars while real full hold identities measure 69-81.

- fm-decision-hold.sh: bind <source-id> --any-origin records the (any) marker;
  binding prints it verbatim and answers accepts it, so the runner's feed seam
  carries an any-origin source with no runner change. In any-origin mode each
  key is a full hold identity <origin>-decision-<key>, split at its first
  -decision-; a key with no separator (merge/dispatch instructions) is skipped
  and feeds nothing, keeping non-decision answers out of the hold ledger by
  construction. Every existing close guard applies unchanged.
- fm-procevent-lavish.sh: raise the question-key cap 64 -> 128 so a full hold
  identity fits; the slug-shape security property is unchanged.
- tests: cross-origin closure through the real runner seam, an 81-char
  identity through the adapter, cap and shape refusals, routed-work skips,
  nonexistent-identity skips, and idempotent replay.

* feat(bearings): add the /bearings lavish interactive fleet board

/bearings lavish renders the bearings snapshot onto a shipped, reusable board
template and arms it as a Lavish process-event source, so the captain answers
Captain's Call items on the board and firstmate is woken by an ordinary check
wake - no conversational turn ever blocks on a poll.

- .agents/skills/bearings/assets/board-template.html: the shipped template
  (myfirstmate design system inlined, one fm-bearings-board.v1 JSON slot,
  fail-closed schema guard that renders an error card instead of an empty
  fleet). Per-invocation agent work is composing the payload only.
- bin/fm-bearings-board.sh: build/refresh owner - fail-closed payload
  validation, slot injection with a round-trip check and \u003c escaping,
  stable board path, any-origin bind ALWAYS before arm, arm-if-absent.
- bearings SKILL.md: the lavish invocation option, board composition rules,
  board-wake handling, and the captain-ruled merge-click authorization with
  its mandatory safeguards (PR resolved from the task's own meta record,
  wake-time green re-verification, never a red or changed PR, merges only
  through bin/fm-pr-merge.sh, chat echo with the full PR URL).
- process-event-sources SKILL.md: one-line board-wake routing trigger.
- tests: payload refusals, injection round-trip, bind-before-arm, idempotent
  re-arm, and template slot integrity.

Fleet pickup: homes receive this after merge plus a firstmate self-update;
landing timing is coordinated with the main firstmate.

* no-mistakes(review): Harden bearings board validation and wake handling

* no-mistakes(review): Require HTTPS for bearings board PR links

* no-mistakes(review): Fail closed and bound bearings board answers

* no-mistakes(review): Enforce UTF-8 byte limits for board answers

* no-mistakes(review): Serve bearings board before arming and reject empty actions

* no-mistakes(review): Prove bind-before-arm ordering through live answer consumption

* no-mistakes(document): Document bearings board and cross-origin answers

* fix(bearings): restore decision options and add close controls (#2707)

* fix(bearings): always show decision options and a close/drop control

Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call.

* no-mistakes(review): Fix drop closure and decision-only option validation

* no-mistakes(review): Preserve answerability for non-decision cards

* no-mistakes(document): Clarify decision drop documentation

* ci: require no-mistakes pipeline step attestation (#2710)

Signature-only PRs can hide skipped review, test, or document steps. Fail unless no-mistakes >= 1.46.0 attests those three steps completed.

* feat: collapse decisions into tasks held for the captain (#2728)

* feat(captain-hold): collapse the decisions concept into tasks held for the captain

A decision is no longer a separate type: it is an ordinary backlog task held
for the captain, identified by its task id. bin/fm-captain-hold.sh owns the
surviving behaviors - guarded hold creation, the recorded-answer close
(answer/answers with a release mode for captain-gated work), the source
bindings, and the investigation completion gate - and bin/fm-decision-hold.sh
becomes a one-release compatibility shim over it.

The fleet snapshot now parses hold-until and computes captain_actionable as
queued + captain-held + unblocked + due, independent of row kind, plus a
presentation-only deferred_marker for prose-deferred rows. Bearings renders
every due captain-held task in Captain's Call, date-deferred holds as dated
Charted Next gates, suppresses prose-deferred rows from default views with an
omitted disclosure, and excludes from Recently Landed anything that closed
while still held for the captain.

Legacy compatibility: pre-collapse <origin>-decision-<key> rows are already
plain task ids and keep working; short keys in recorded metadata, concrete
origin bindings, chat --resolve-key fallbacks, and old resolution records all
resolve in place.

* no-mistakes(review): Fix captain answer replay and body preservation

* no-mistakes(review): Fix captain hold idempotency and legacy replay

* no-mistakes(review): Validate card close modes and compatibility routing

* no-mistakes(review): Enforce release replay mode matching

* no-mistakes(review): Prevent duplicate decision cards and released replay mismatches

* no-mistakes(review): Preserve answer columns and legacy resolve replays

* no-mistakes(document): Document strict replay and legacy compatibility

* no-mistakes(lint): Quote done literals to satisfy ShellCheck

* no-mistakes: apply CI fixes

* fix(rebase): keep collapsed captain hold board semantics

* fix: bound recovery announcements and preserve supervision (#2733)

* fix(watch): announce recovery once per generation and keep successors supervising

A lost Pi/OpenCode handling handshake re-announced the same recovery
generation on every cycle and spent the successor's first ~55s blind, so
a real crew event could be ignored and then dropped. Record the
announcement in the durable marker, confirm the handshake before the
follow-up without swallowing failure, and enter the poll loop immediately.

* no-mistakes(review): Tighten recovery event timing regression

* no-mistakes(document): Document recovery-loop supervision guarantees

* fix(bin): surface captain-call record divergence (#2744)

* fix(bin): signal a captain call resolved in the log but still held

A captain call has two records and closing one has never closed the
other: a `resolved [key=...]` line closes the status-log fold, while the
backlog task held for the captain closes only through
`fm-captain-hold.sh answer`. Answering on the status side alone left no
trace of the disagreement - the fold went quiet, the durable record kept
saying the captain owed an answer, and nothing warned. The defect was
never the separation; it was the silence.

Add `fm-captain-hold.sh diverged`, a read-only report of that
contradiction, and print it from `fm-wake-drain.sh` as a bounded RECORD
DIVERGENCE section beside OPEN DECISIONS on every drain. It flags one
condition: a task still open and still carrying the captain-hold
annotations whose key was closed on the status side by the resolve verb,
under the collapsed identity or the legacy derived one.

It closes nothing, ever. A captain call closed wrongly leaves review
entirely, which is worse than the noise, so both reconciliation
directions stay human-owned and the printed hint names both - a
resolution is not proof the captain ruled, since a call can dissolve on a
false premise or turn out to have been a question of fact.

Three states are deliberately not divergence: a `captain-held` close is
the verified transfer `complete` writes, a still-open keyed decision
belongs to the OPEN DECISIONS fold, and a captain call with no routed
work item is legitimate rather than incomplete, so routed work is no part
of the test.

`fm-classify-lib.sh` gains `status_key_closing_verb`, which reports how
the status side currently reads one key by replaying the existing
`_fm_decision_fold_line` rule rather than re-deriving it, so the two
closing verbs stay distinguishable in one place. The per-wake cost is one
`tasks-axi list`, one key scan per status log, and the precise per-key
fold only for a key that already names a still-open task; the call is
hard-bounded so a slow backlog tool can never delay wake presentation.

* fix(document): Correct divergence lifecycle documentation

* fix(document): Neutralize divergence lifecycle prose

* fix(bin): re-arm after an abandoned auto-arm claim and defer a wedge escalation while a worktree is written (#2524)

* fix(watch): re-arm supervision after an abandoned auto-arm claim

A Claude auto-arm cycle that armed, delivered one rewake, and exited left
its single-flight lock behind. Both Stop-event participants then deferred
to that lock forever, because its recorded pid was still live: the
turn-end guard read it as recovery under way and allowed the stop, and the
next Stop firing treated it as another owner and declined to arm. On
2026-08-14 a home with two tasks in flight lost supervision for about 40
minutes with no watcher process and no watcher lock, its beacon frozen at
the one delivery, and both crewmates' finished reports sat in the durable
queue until an operator drained it by hand.

Abandonment is now proven from the epoch ledger instead of inferred from
pid liveness. A lock whose holder pid matches the ledger's own owner_pid
while the recorded outcome is anything other than arming has already
finished its decision, so that claim is reclaimed under the lock's steal
mutex, stops counting as recovery ownership in the guard, and is cleared
by the guard's terminal check rather than deferred to. A failed clear
re-blocks instead of allowing a blind stop, and an arming entry stays in
flight however old it is, because its owner foregrounds the arm for the
whole watcher cycle.

Issue #2251's PR #2263 does not cover this failure. It is closed and
unmerged, lives entirely in bin/fm-watch-arm.sh, and retires the stalled
watcher and matching stale watcher lock of an arm that is currently
running. Here no arm and no watcher were running and no watcher lock
existed, so it has nothing to retire and the home stays blind.

tests/fm-claude-stop-autoarm.test.sh covers the reclaim, the still-arming
and unnamed-owner cases that must keep the gate closed, and the failed
clear. tests/fm-turnend-guard.test.sh covers the guard side of the same
boundary. Both fail without this change.

* fix(watch): defer a wedge escalation while the task worktree is written

The wedge detector had two inputs, rendered pane quietness and the run
step, and neither can see a crew that is writing source, then tests, then
documentation behind a static pane. On 2026-08-14 one crewmate produced
eight consecutive possible-wedge escalations in a single afternoon, three
of them demanding deep inspection, while it was demonstrably working and
then committed. Every one of them cost a supervision turn to disprove by
hand.

Add write activity inside the crew's own recorded worktree as a third
liveness input. crew_worktree_written_since compares the worktree against
the caller's existing idle-window timer file, so -newer needs no clock
arithmetic, no temp file, and no portable mtime write. The probe runs only
inside the branch that was about to escalate, which bounds it to one
pruned, depth-bounded walk per window per FM_STALE_ESCALATE_SECS and
leaves the per-poll stale sweep exactly as cheap as before.

Positive evidence defers rather than cancels. The idle timer restarts so
the next window probes again, the escalation counter is neither advanced
nor reset so a later genuine wedge keeps the demand-deep-inspection
history it earned, and a .writing-since marker ages the whole deferral
chain so the pane still re-surfaces once per FM_PAUSE_RESURFACE_SECS,
through the same throttle shape a declared pause already uses, labeled as
a recheck rather than a wedge. This can only reduce false positives: every
absence of evidence, including no recorded worktree, a torn-down worktree,
a missing anchor, and a failed walk, falls through to the unchanged
escalation schedule, so a crew that writes nothing still escalates on the
existing timetable.

What the signal cannot see, by design or by construction:

- CPU burn with no writes, such as a long compaction, is invisible. That
  case keeps the old behavior exactly.
- A commit-only phase writes only .git, which is pruned first so that
  firstmate's own read-only git commands against the worktree can never
  make the probe self-fulfilling.
- Writes under the pruned generated trees, or deeper than
  FM_WORKTREE_WRITE_MAXDEPTH, do not count.
- The probe cannot attribute a write to the crew, so a background build or
  another process touching the tree looks the same. The hourly re-surface
  is what bounds that, and a churny file cannot buy silence.
- The away-mode daemon's own escalation path is deliberately untouched.

tests/fm-watch-triage.test.sh covers the classifier including the .git
prune, both halves of the live case on one fixture (quiet plus writing
defers, quiet plus silent still escalates and counts), and the bounded
re-surface. All three fail without this change.

* no-mistakes(review): prove autoarm claims by identity; skip mate-home write probe

* no-mistakes(document): document away-mode wedge boundary and probe filesystem limit

* no-mistakes(document): qualify turn-end recovery condition for abandoned auto-arm claims

* fix(watch): keep a write deferral scoped to its own idle window

Two consistency gaps in the worktree write probe, both found while reviewing
the wedge-deferral change on this branch.

A write deferral is a bounded chain: its .writing-since marker ages the whole
chain so a churning worktree still re-surfaces once per resurface window. That
is only sound while the chain belongs to the current quiet stretch, so every
path that restarts the idle-window timer has to drop it too. Two did not: the
corrupt-timer repair in wedge_timer_check, and both first-sight branches for a
captain-relevant status. A chain left over from an earlier quiet stretch made
the first deferral of the new window re-surface immediately instead of after a
full fresh window.

FM_WORKTREE_WRITE_PRUNE is a skip list, so clearing it reads as "skip nothing"
and is the obvious way to widen the probe to the whole depth-bounded tree.
Instead an empty list reported no evidence at all, quietly costing the wedge
detector its third liveness input on a home that meant to widen the walk. An
empty list now widens the walk, and the header says so.

Neither change alters when a stall that writes nothing escalates.

Regressions in tests/fm-watch-triage.test.sh cover all three paths and each
one fails on the pre-fix code.

* no-mistakes(review): honor an empty write-prune, bound the probe, share window_key

* no-mistakes(document): align probe knob count and guard regression-coverage ownership

* no-mistakes(lint): silence deliberate single-quote SC2016 in write-prune env test

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause (#2748)

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause

Two supervisors read a finished task's last status line and disagreed about which
declarations mean an idle endpoint is expected. bin/fm-inactive-reconcile.sh
suppresses its inactive-outcome scan only on `captain-held`, while the away-mode
daemon's wedge path gated deferral on `paused` alone. Both read the LAST line, so
the two verbs are mutually exclusive and no finished task waiting on a person
could satisfy both at once. Marking 11 such tasks `captain-held:` silenced the
900s outcome scan and immediately produced five possible-wedge escalations in one
batch, because the 240s wedge detector no longer saw a pause verb.

fm-classify-lib.sh's status_is_paused_or_captain_held already owns the combined
question, and bin/fm-watch.sh's ordinary-crew wedge path already asked it. This
extends that same answer to the paths still asking the narrower one:

- bin/fm-supervise-daemon.sh, all six sites, which form one subsystem and have to
  move together. classify_stale returns the pause action, reconcile_pause_tracking
  and migrate_watcher_pause_markers record and migrate the marker, and
  housekeeping defers the wedge and then re-surfaces the recheck. Changing only
  the stale-persistence gate would defer the escalation while
  reconcile_pause_tracking recorded nothing, so the wedge marker would persist and
  the sweep would `continue` past it forever: quiet, but never re-surfacing.
- bin/fm-watch.sh's secondmate stale gate, whose downstream owner
  pause_state_class already treats both declarations identically.
- bin/fm-push-transition-lib.sh's absorb, where either declaration already names
  the human the transition would report and the wait is already durably recorded.

Quieting alone would be half a fix, so the bounded re-surface had to reach a hold
too. A hold has no current-state mapping, unlike `paused`, so authoritative crew
state reports it as unknown and pause_state_class received `none`. An ordinary
crew recovers pause classification from that state through confirmed agent death,
which proves no live decision gate is being silenced. A secondmate's endpoint
liveness is deliberately never read there, because an idle mate is healthy by
design, so that confirmation is unavailable by construction and cannot be
required: without recovering the classification for a mate, every caller silenced
a held mate outright and its hold would rot invisibly. That promotion is bounded
by the declared-wait guard at the top of the function, so it can only reclassify a
task that already declared a wait and shows no positive working evidence.

Two narrow `status_is_paused` calls are deliberately left alone.
bin/fm-crew-state.sh's map_log_state is a current-state reporting contract, not a
wedge path; reporting a hold as `paused` would erase the distinction
status_key_closing_verb and fm-captain-hold.sh depend on, where a `captain-held`
close is a verified durable transfer and a `resolved` close claims outright
settlement. fm-classify-lib.sh's call inside status_is_captain_relevant needs no
change because that function's own case list already returns non-relevant for
`captain-held`.

bin/fm-inactive-reconcile.sh keeps its `captain-held` suppression as it is. Its
guard exists because a finished task's crew state still reports done from a
higher-priority source than the log, and a declared pause needs no such guard: the
scan only reports done or failed, and nothing else reaches its record path.
Widening it would change a separate subsystem's reporting contract, which this
defect does not require.

Coverage extends the existing colocated patterns for these predicates and asserts
both halves. tests/fm-daemon.test.sh covers the classification, the wedge marker
converting to pause tracking with no escalation, the bounded re-surface with its
window reset, and the boundary case where an answered hold stops claiming the
cadence. tests/fm-watch-triage.test.sh covers a held secondmate re-surfacing on
the same bounded cadence without being labeled a wedge.
tests/fm-supervision-events.test.sh covers the absorbed push transition. Every one
of these fails on the pre-fix code except the answered-hold boundary case, which
is there to pin that the quieting was not widened too far.

The `paused:` workaround appended to those 11 tasks is live supervision state and
is untouched here. It can be retired once this lands.

* no-mistakes(review): name the captain in a held task's bounded recheck

* no-mistakes(document): extend declared-wait supervision docs to captain-held holds

* fix(bin): make lint prerequisites and harness tests reliable (#2758)

* fix(lint): name the installer when ShellCheck or actionlint is missing

A missing actionlint exited 127 like a bare command-not-found. Fail with
exit 1 and point at the pinned installer, matching the missing-ShellCheck
path, without weakening the version pin.

* test: isolate kimi and muse detection from inherited Cursor markers

Harness detection checks CURSOR_AGENT before ancestry, so these
markerless-adapter cases failed when the suite itself ran under Cursor.
Clear the verified markers the same way the secondmate harness tests already do.

* no-mistakes(document): Document Muse Cursor marker cleanup

* feat(bin): report watched tooling updates that are available or installed but inert (#2684)

* feat(checks): report tool updates that are available or installed but inert

Firstmate had no way to notice that tooling this home depends on needs an
update, and no way at all to notice the worse case: an update that installed
correctly and then did nothing.

That second case is why this exists. A tool that self-installs into
~/.local/bin while a version manager keeps its own older copy earlier on PATH
looks completely up to date to anything that asks only "is a newer version
published". On 2026-08-20 a Herdr update landed at 0.8.2 while an older 0.8.0
copy stayed earlier on PATH, so every Herdr command failed on a protocol
mismatch and firstmate could not read its own fleet.

bin/fm-tool-update-check.sh reports the two conditions separately:

  <tool> update available      a newer version exists at the update source.
  <tool> update not in effect  a newer copy is installed on this host, but
                               PATH still resolves an older one.

PATH skew is measured, never inferred. Every executable copy of a watched
command on PATH is asked for its own version and those answers are compared,
so one lookup cannot hide the skew, and a directory name is never read as a
version because a version manager's "latest" directory can hold an older
build. A copy that will not report a version is a check failure, not a pass.

The watched tools live in local, gitignored config/watched-tools.json, so
adding a tool is a config edit rather than a code change, and the file is
never propagated to another home. Update sources cover both shapes: a local
clone's commit distance from its remote branch, and a command's own version
and update announcement, including a tool like no-mistakes that prints its
version on one command and announces a new release on another.

The check prints one line when something needs attention and prints nothing
otherwise, so it rides the existing watcher state-check contract with its
trust binding instead of introducing a schedule of its own, and
state/.tool-updates keeps the same pending update from being reported on
every poll.

The check only reports. It never installs, updates, reorders PATH, touches a
version manager, or fetches into a watched repository; every git probe is
read-only.

Tests cover the skew case as a regression, and it was verified by mutation:
removing the skew report, or stopping after the first PATH hit as a single
lookup would, each make that test fail.

* no-mistakes(review): fix tool update check probe reporting, budget, and shim write

* no-mistakes(review): keep sweeps alive on broken patterns and oversized budgets

* no-mistakes(review): roll back failed arm, widen budget clamp, bound repo probe

* no-mistakes(review): guard git probes at the budget, record uncut findings

* no-mistakes(document): fix stale watched-tool report-record wording in docs and header

* 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

The behavior shard's watch-triage suite failed on the new worktree-write wedge
tests. Those five tests are the only ones in the file that do not use its
standard waits. They give a fixed 3 second liveness budget to the one poll that
now spawns the bounded worktree walk, and 4 seconds to an escalating watcher
where every other test in the file gives 10. On a loaded runner that poll
outlives the fixed budget, so the round is reaped before the deferral it asserts
on is recorded, and the test reports a lost deferral instead of the deferral
under test. Wait for a completed poll cycle through the file's own
wait_poll_cycle, which is what its header documents this hazard for, and use the
file's standard 100 tick exit budget.

Verified against a load that reproduces the failure: 11 of 12 runs failed
before, 8 of 8 pass after. Verified by mutation too, so the waits still prove
the behavior: removing the write deferral, and keeping a finished deferral chain
across an idle-timer repair, each still fail their test.

* fix: decouple ask-user decisions from yolo (#2764)

* fix: treat yolo as merge authority only, not ask-user finding authority

Yolo on/off was documented as also deciding no-mistakes ask-user findings, which hid firstmate's duty to judge unambiguous-toward-design findings itself. Keep every safety boundary; this is a contract clarification, not a relaxation.

* no-mistakes(document): Clarify yolo documentation ownership and merge posture

* feat(bin): add a spoken interface that answers from records and hands work over (#2767)

* feat(voice): spoken round trip on Nova Sonic 2 with a measured relay cost

Step one of the spoken interface: the laptop captures and plays audio, this
desktop holds the model session, and no AWS credential leaves the desktop.

Measured, amazon.nova-2-sonic-v1:0 in eu-north-1, end of speech to first byte
of reply audio, 6 runs each, all answered, on a question that forces a records
read:

  relay path   1.229 1.379 1.428 1.447 1.481 1.516  median 1.438
  direct       1.147 1.179 1.203 1.237 1.244 1.317  median 1.220

The relay costs about 0.22s of the median. The direct figure reproduces the
earlier survey, which is what makes it a usable control. Excluded: the
captain's own ssh round trip, microphone capture, and speaker output. This
desktop has no microphone and no speaker, so every run used audio files.

Three pieces:

  bin/fm-voice-relay.py    holds the conversation on this host
  bin/fm_voice_records.py  what a spoken answer may read, and the handover
  bin/fm-voice-client.py   the laptop end; audio devices UNVERIFIED
  bin/fm_voice_frame.py    the wire format both machines share

Real work is handed to the existing bin/fm-inbox.sh rather than a second
queueing surface, and the agent says it is handing over rather than answering
as firstmate.

Read scope: Done history and free-form note bodies are never assembled at any
scope, so the wide default cannot reach the places commercial detail
accumulates. config/voice-read-scope narrows it to counts only, and
config/voice-read-deny excludes a named item in one line. The boundary is an
executable test that widening the reader fails.

Push to talk is the default because it is cheaper and the choice is still open;
--listen open-mic is the single flip.

Two traps worth knowing: a clip with no trailing silence is never answered, and
the end of a reply is contentEnd with stopReason END_TURN, not completionEnd.
A second user turn in one session is treated as barge-in unconditionally, and
an interrupted turn that calls a tool is lost, so the session reconnects per
turn and gives up conversational memory. That is the concrete thing step three
has to solve.

* no-mistakes(review): fix voice relay credential reuse, frame validation and record parsing

* no-mistakes(review): test uplink header guard, bound unknown expiry, align state dir

* no-mistakes(review): decide deny per item, guard turn failures, bound ambient credentials

* no-mistakes(review): read account config from home, harden deny and turn failures

* no-mistakes(review): close status verb set, fix inbox help, pair data override

* no-mistakes(review): keep profile-free relay alive, unblock loop, fix dead assertion

* no-mistakes(review): hide finished pull requests, refuse open mic, keep suite offline

* no-mistakes(review): survive reader failures, release devices, fix claims

A failure while handling a model event, or while sending a tool result,
left the reader task dead with ended and turn_done clear, and close()
re-raised the stored failure on every await. One dropped stream became a
relay that could never build another session. The reader now reports the
session over in a finally whatever killed it, and close() absorbs the
task the same way it already absorbed its sends.

The laptop client releases what it already started when a later startup
step refuses, SystemExit from the handshake wait included, and names a
device refusal instead of leaking a raw PortAudio error. Whether it
releases correctly against a real device is still unverified here.

The records docstring claimed every reading was filtered to open ids.
Only the pull request count and list are; the worker count and the state
histogram cover every live runtime record, finished ids included,
because a meta file still on disk still needs tearing down.

The finished-work deny half of the suite asserted things that held with
the deny list absent. It is replaced by a deny on an open title, which
removes the row and says so while the count stays honest.

* no-mistakes(review): name reader failures, split file and device refusals

A failure inside the model reader released the waiting turn and told
nobody. The session was not marked spent, no notice reached the client,
and the client waits for a reply end or a notice, so the captain got
their whole timeout of silence and then a record saying the turn went
unanswered with nothing about why. Both ends of the relay now name a
failed turn through one function, once per turn, and --self-test carries
the cause in relay_error the way the client's own record does.

Two things that are not failures stay that way. A stream that simply
ends is the end of a session, which serve still reads on its own terms.
A stream that goes away because close() asked it to is an ordinary
renew, and announcing it would have put a failure notice in front of the
captain on every turn.

On the laptop end, the refusal that became a device error covered the
file-backed playback and capture too, so a mistyped --in-file was
reported as an audio device failure and the advice named the flag that
had just failed. The file ends now report the path and the flag that
chose it and stay an OSError; the device ends keep the device advice and
name the flag for that end. The device paths remain unrun here, so only
the file halves are covered by a test.

* no-mistakes(test): survive model session end, order client turn frames

* no-mistakes(document): sync voice relay docs with reviewed relay behavior

* no-mistakes(document): re-measure relay latency and correct its cause

* no-mistakes(document): correct measurement date and name the unmeasured SSH hop

* no-mistakes(document): describe the unpublished control measurement, fix list formatting

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* fix(bin): preserve Relay follow-up loops until explicit disposition (#2763)

* fix: keep Relay public loops open until retire

Delivering a promised-final reply was deleting the only record that tied a public thread to later work, so a follow-on ship silently owed no closing reply. Retain the registration after delivery, rechain follow-on work onto the same thread, and make retire --reason the only close.

* no-mistakes(review): Propagate public follow-up registration removal failures

* no-mistakes(review): Persist retire receipts and align parent resolution

* no-mistakes(review): Make rechain resumable after partial obligation creation

* no-mistakes(review): Repair follow-up state, briefs, and expiry escalation

* no-mistakes(review): Serialize follow-up delivery stamps with retirement

* no-mistakes(review): Serialize rechain claims and protect registration terminal states

* no-mistakes(review): Avoid reporting retired delivery loops as open

* no-mistakes(document): Refresh public-loop documentation and verification evidence

* no-mistakes: apply CI fixes

* no-mistakes(review): Preserve delivered follow-up bindings during registration replay

* no-mistakes(review): Harden public follow-up retirement and rechain races

* no-mistakes(review): Fail closed on unresolved secondmate retirement

* no-mistakes(review): Bind secondmate cleanup to its recorded canonical home

* no-mistakes(review): Fix rechain command output and expiry validation

* no-mistakes(review): Validate brief keys and warn on remote promotion

* no-mistakes(document): Document retained public follow-up loops

* no-mistakes(lint): Remove unused bounded-wait loop variable

* 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…
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.

2 participants