Skip to content

feat(bin): sync upstream to #2850 with GitLab merge support - #40

Merged
zeeshaanahmad merged 19 commits into
mainfrom
fm/upstream-batch-8-lint-to-stow
Sep 3, 2026
Merged

zeeshaanahmad merged 19 commits into
mainfrom
fm/upstream-batch-8-lint-to-stow

Conversation

@zeeshaanahmad

Copy link
Copy Markdown
Owner

Intent

Sync the fork forward to upstream waypoint f170ced (upstream PR kunchenguid#2850, "add opt-in pass horizon for memory decay") as a single reviewed MERGE. This is batch 8 of eight planned batches; batches 1-7 have landed and batch 7's waypoint 3d125ad is already in origin/main's ancestry. The batch brings thirteen upstream commits, 738460d (kunchenguid#2758) through f170ced (kunchenguid#2850): lint prerequisites and harness tests (kunchenguid#2758), watched tooling updates report (kunchenguid#2684), ask-user decoupled from yolo (kunchenguid#2764), the spoken interface (kunchenguid#2767, large), Relay follow-up loops (kunchenguid#2763), GitLab MR merge path (kunchenguid#2779), lost relay connection (kunchenguid#2788), pi composer blocked-pane (kunchenguid#2811), fleet-sync clone roots (kunchenguid#2849), Lavish poll retries (kunchenguid#2846), brief {TASK} Herdr gate (kunchenguid#2838), busy-state lock mtime stat form (kunchenguid#2837), and the stow pass horizon (kunchenguid#2850).

REQUIRED SHAPE, deliberate and not to be "corrected": branch off origin/main, git fetch upstream, git merge f170ced producing a MERGE COMMIT. Never rebase, squash or cherry-pick this branch. Stop exactly at f170ced; the ~77 upstream commits beyond it are a separate re-plan and are deliberately excluded. The Rebase step is skipped for this run for that reason, because rewriting the branch would drop the upstream commit identities from ancestry - that is the exact defect this batch procedure exists to prevent (upstream 7f5255a was erased from main that way on 2026-08-25). The merge commit and the preserved upstream commit identities ARE the deliverable, so a large diff with 14802 insertions across 72 files is expected and correct.

Nine files conflicted across 21 hunks and every hunk was resolved by hand with a stated reason.

The collision that mattered is bin/fm-pr-merge.sh. The fork carries three guards upstream lacks: the merge-time static guard including its timeout refusal (fork PR 21), the refusal to squash or rebase a PR carrying upstream history (fork f290988), and the upstream-waypoint ancestry assertion (fork PR 32). Upstream kunchenguid#2779 adds a GitLab merge-request path through the same guarded script. The accepted requirement was that the merged script must carry ALL of the fork's guards AND upstream's GitLab path, with every guard applying to the GitLab path too where it makes sense, because a GitLab merge request can also carry upstream history. That required four deliberate provider-aware changes: the base branch now comes from glab's target_branch as well as gh's baseRefName; the request head is fetched from refs/merge_requests//head as well as refs/pull//head; the project identity both guards compare a remote against is the parsed forge-neutral FM_PR_PATH rather than an owner/repo pair GitLab has no equivalent of, with upstream_remote_slug returning the whole project path rather than its last two segments so a nested GitLab namespace compares equal (both forms are byte-identical for every GitHub URL); and resolved_merge_method returns empty for GitLab unconditionally, so even an explicit --merge does not excuse a merge request carrying upstream history, because GitLab applies the project's own merge method, takes no flag for it, and glab has no --merge flag at all. That asymmetry with GitHub is intentional and fail-closed, and the refusal names a GitLab-appropriate remedy instead of a flag that forge does not have. Upstream's broader short-option cluster rejection (-yR) replaces the fork's narrower -R form because it is strictly broader; upstream's GitLab head-override rejection and live pre-merge verification are taken verbatim.

Other conflict resolutions, all deliberate: tests/fm-muse-harness.test.sh keeps the fork's symlink construction for the renamed muse-bin process, which is required because macOS SIGKILLs a copied system bash, and adds upstream kunchenguid#2758's inherited-Cursor-marker scrubbing - both halves are needed. bin/fm-test-run.sh unions both portable_serial_weight_hints lists into one sorted 122-entry list, taking upstream's value where both name a file, and leaves the fork's FM_TEST_INHERITED_OVERRIDES scrub owner untouched. tests/fm-pi-watch-extension.test.sh takes upstream throughout: its session-lock case is a strict superset of the fork's, covering the same deterministic join through ensureArmed plus the hooks.event entry point the fork's version stopped exercising, re-expressed through the sessionID binding that auto-merged around the hunk. tests/fm-remote-job.test.sh keeps BOTH tail cases because neither contains the other, with the fork's no-leaked-worker proof deliberately placed last so it still measures everything before it. The four documentation surfaces are additive on both sides and were unioned, and docs/documentation-audiences.json stays valid for bin/fm-doc-audience-check.sh.

One deliberate wording change outside any conflict: the merge-time static guard's message and the one sentence in docs/merge-time-static-guard.md that owns it now say "merge result" and "the branch it targets" rather than "squash result" and "default-branch tip", because the guard now also runs on GitLab merge requests where the GitHub squash default does not apply.

Three regressions were added to tests/fm-merge-waypoint-guard.test.sh because nothing pinned the fork's guards on the new GitLab path: a merge request carrying upstream history is refused and names both the erased commits and a GitLab-appropriate remedy; --merge does not excuse such a merge request; and an ordinary merge request with a reachable waypoint passes both guards and merges through glab at its verified head. The fixture's GitLab variants deliberately delete refs/pull/7/head so the GitHub refspec cannot resolve the head and the GitLab one is actually proven. Each regression was demonstrated to fail when its half of the implementation is reverted.

Running the full portable lanes then surfaced six defects the upstream tail brings in that had never run on macOS or against this fork's stricter teardown gate. All six are upstream defects rather than merge damage, and all six are fixed on this branch: an empty-array expansion under set -u in bin/fm-public-followup.sh that bash 3.2 refuses; sed -i with no suffix in bin/fm-inbox.sh, which has no portable spelling and left fm-inbox.sh note entirely broken here, fixed by resolving the record id before the record is written rather than rewriting a placeholder; wc -l counts compared as strings in two new test files, which BSD wc pads with leading spaces, fixed the way this repo's other tests already do it; a path substitution written inline in a parameter-expansion pattern in tests/fm-public-followup.test.sh, which bash 3.2 splits on the quoted slash, fixed by binding both halves to variables; four ship-teardown fixtures that do not satisfy this fork's completion-report teardown gate, one of which was passing vacuously, fixed by calling the file's own write_completion_report helper; and a promised-final expiry hardcoded to a literal 2026-08-28 that was a future window when upstream wrote it and is now in the past, fixed by anchoring the window a week ahead of the run with the expiry-escalation case deriving its clock override from the same anchor.

Upstream kunchenguid#2764 rewrites AGENTS.md section 7 and the ask-user-authority skill so finding authority is that skill's criteria rather than the project's yolo posture. It merged cleanly and was deliberately taken exactly as upstream wrote it, not adapted; the policy consequences are reported to the captain separately rather than resolved here.

Repo constraints that apply: one sentence per line in tracked Markdown and plain dashes rather than em dashes, except that a file carried byte-identical from upstream is exempt; bin/.sh and bin/backends/.sh must pass bin/fm-lint.sh; no agent name as a commit co-author; and projects/, data/, state/ and config/ are not to be modified.

Validation already run locally and green: bin/fm-test-run.sh --check-coverage, bin/fm-doc-audience-check.sh, bin/fm-lint.sh, all of tests/fm-pr-merge.test.sh, tests/fm-merge-waypoint-guard.test.sh, tests/fm-static-guard.test.sh, tests/fm-muse-harness.test.sh, tests/fm-kimi-harness.test.sh, tests/fm-pi-watch-extension.test.sh, tests/fm-remote-job.test.sh, tests/fm-public-followup.test.sh, tests/fm-voice-relay.test.sh, tests/fm-tool-update-check.test.sh, tests/fm-backlog-handoff.test.sh, tests/fm-lint.test.sh and tests/fm-lint-workflows.test.sh, plus all six portable lanes. Two known lane failures remain and are NOT from this merge: tests/fm-backend-herdr-focus-flash-e2e.test.sh fails on the environmental Herdr fleet-state tripwire, and tests/fm-watcher-lock.test.sh is red on this machine while its test file and every script it exercises are byte-identical to origin/main on this branch (its filed guard-xmode failure, plus a load-sensitive fixed-wait HUP case that masks it under concurrent lanes).

What Changed

  • Merge upstream commits 738460d..f170ced (upstream PRs fix(bin): make lint prerequisites and harness tests reliable kunchenguid/firstmate#2758-fix(stow): add opt-in pass horizon for memory decay kunchenguid/firstmate#2850) via a single merge commit onto origin/main, preserving upstream commit identity (no rebase, squash, or cherry-pick).
  • Extend bin/fm-pr-merge.sh with upstream's GitLab merge-request path while keeping all of the fork's existing guards (merge-time static guard, squash/rebase-of-upstream-history refusal, waypoint-ancestry assertion) applying to GitLab requests too: provider-aware base-branch resolution (gh baseRefName / glab target_branch), head fetch from refs/merge_requests/<n>/head, forge-neutral project-identity comparisons, a GitLab-specific head-divergence TOCTOU check, and resolved_merge_method returning empty for GitLab so --merge cannot bypass the guard.
  • Add new scripts and docs from the upstream batch: bin/fm-inbox.sh, bin/fm-tool-update-check.sh, bin/fm-voice-client.py, bin/fm-voice-relay.py, bin/fm_voice_frame.py, bin/fm_voice_records.py, docs/voice-relay.md, .greptile/rules.md, plus matching test suites (tests/fm-voice-relay.test.sh, tests/fm-tool-update-check.test.sh, and expanded coverage in tests/fm-pr-merge.test.sh, tests/fm-merge-waypoint-guard.test.sh, tests/fm-public-followup.test.sh, and others).
  • Fix macOS/bash 3.2 portability regressions surfaced by running the full suite on this fork: an unguarded empty-array expansion in bin/fm-public-followup.sh, sed -i with no portable suffix in bin/fm-inbox.sh, wc -l string-vs-numeric comparisons in new tests, a quoted-slash parameter-expansion split in tests/fm-public-followup.test.sh, teardown-gate fixtures that didn't call the completion-report helper, and a hardcoded test expiry date that had moved into the past.
  • Update AGENTS.md and .agents/skills/ask-user-authority/SKILL.md per upstream fix: decouple ask-user decisions from yolo kunchenguid/firstmate#2764 so authority-finding is driven by the skill's own criteria rather than the project's yolo posture.

Risk Assessment

✅ Low: All nine prior findings, including the critical GitLab head-divergence TOCTOU bypass, were verified fixed by tracing the actual guard/merge control flow (merge_refs_ensure is unconditionally invoked for GitLab via upstream_guard_evaluate, populating MERGE_REFS_BASE/HEAD before the new post-verification equality check gates the merge), backed by a correctly constructed regression test; only a minor, non-functional documentation-wording gap remains.

Testing

Ran the four test suites most directly exercising this batch's collision area and macOS/bash-3.2 fixes (fm-merge-waypoint-guard, fm-pr-merge, fm-static-guard, fm-public-followup) plus fm-doc-audience-check — all green, no failures, matching the counts the author reported locally. Independently confirmed the merge commit has two parents (true merge, not rebase/squash) and manually exercised the fm-inbox.sh note fix end-to-end in an isolated temp home since no automated test covers it, producing a correctly formed note file. Spot-checked the wording, wc -l, and bash-3.2 array-guard fixes directly in source to corroborate the intent's specific claims. No regressions or flakiness found; working tree left clean.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped

Step was skipped.

⚠️ **Review** - 1 info
  • 🚨 bin/fm-pr-merge.sh:459 - On the GitLab merge-request path, every fork guard (upstream-waypoint ancestry, upstream-history refusal, and the merge-time static guard) evaluates MERGE_REFS_HEAD, but the actual merge is pinned to a separately-read FM_PR_MERGE_HEAD, and the two values are never compared anywhere in the script. This was demonstrated end-to-end in a sandbox: with the fetched refs/merge_requests/7/head pointing at a clean commit and the glab mr view JSON's .sha pointing at a commit carrying two upstream waypoints, the run printed 'merge-guard: UNGUARDED' and 'verified: ... at head 35d7071...', then executed glab mr merge 7 -R ... --sha 35d7071... --yes (rc=0) merging the upstream-carrying commit the guards never actually inspected. This is exactly the 'erases upstream history' outcome the fork's three guards exist to prevent, reachable whenever a merge request's head moves between the guard reads and the merge (the static guard alone runs a project linter in between, so the window is not merely theoretical). This directly undercuts the user intent's required property that all fork guards apply to the GitLab path the same way they do on GitHub.
  • ⚠️ bin/fm-pr-merge.sh:446 - When the refs/merge_requests/<n>/head fetch fails, the GitLab path falls back to reading pr_head from task meta - but bin/fm-pr-check.sh only ever records pr_head= for GitHub, never for GitLab. So an unfetchable GitLab head silently degrades the static guard to UNGUARDED (merges anyway), and if an operator has hand-written a pr_head= value (a supported shape per tests/fm-pr-merge.test.sh:659), the guards evaluate that stale commit while the merge is still pinned to the live head - a deterministic instance of the same head-divergence class as the HIGH finding above, rather than a racy one. The same fix that resolves the HIGH finding (verifying the guarded head equals the merged head before merging) would close this path too.
  • ⚠️ docs/gitlab-merge-watch.md:236 - The doc states 'Every output is reproduced exactly' (line 5), but the adopted transcripts for the merge path (lines 236-276) show only 'armed: ...' followed directly by 'error: refusing to merge ...', with no merge-guard line in between. Against the fork's actual guarded script, every one of those invocations emits a 'merge-guard: ...' line before the refusal and writes merge_guard= into the task meta (bin/fm-pr-merge.sh:938) - reproduced directly in the background review's sandbox. The evidence appears to have been carried from upstream's unguarded transcript rather than re-run against the fork's merged, guard-bearing script.
  • ⚠️ bin/fm-static-guard-lib.sh:7 - The user intent states the merge-time static guard's wording was updated from 'squash result'/'default-branch tip' to 'merge result'/'the branch it targets' because the guard now also runs on GitLab merge requests. That rewording only landed in bin/fm-pr-merge.sh's guard message and docs/merge-time-static-guard.md:13. This file's header comment (lines 7-8), which documents bin/fm-pr-merge.sh's contract and explicitly says the two 'must not drift', still reads 'checks the exact squash RESULT of a PR against the current default-branch tip', and lines 30-31's TRUST comment still says the check command is discovered 'ONLY from a trusted revision - the current default-branch tip', which no longer matches merge_guard_base_branch() resolving GitHub baseRefName / GitLab target_branch (not necessarily the default branch) before falling back to the default branch.
  • ⚠️ tests/fm-public-followup.test.sh:1440 - The a995859 commit message says one of four teardown fixtures 'asserted only the absence of a message and was passing vacuously' and claims this is fixed by calling write_completion_report. In test_dropped_baton_now_surfaces_open_loop (~lines 1440-1443) the teardown call's exit code is still discarded with || true, and the sole gating assertion checks for the absence of the string 'still owes a public reply'. A teardown refusal at an earlier, unrelated gate (e.g. a missing completion report) also would not contain that string, so this assertion would still pass even if write_completion_report were absent or ineffective for this fixture - unlike the other three fixtures, which gained a positive assertion (exit-code check or assert_contains on the expected message).
  • ℹ️ docs/merge-time-static-guard.md:34 - The same doc file the user intent says now 'owns' the updated sentence (line 13) still describes the guard as reading 'the current default-branch tip' at lines 34 and 69, inconsistent with the branch-it-targets wording introduced at line 13 for the GitLab-aware guard.
  • ℹ️ bin/fm-pr-merge.sh:31 - Within the same header comment block where line 22 was updated from 'default-branch tip' to 'base-branch tip', line 31 nine lines later ('THE GUARD DOES NOT SURVIVE AN AIRGAPPED SITE... the current default-branch tip') was left unupdated.
  • ℹ️ AGENTS.md:334 - Pre-existing fork-authored line ('That merge path re-checks the exact merge result against the current default branch and refuses a red one') is now stale for the GitLab/non-default-target case this batch's wording change was made for, while the equivalent sentences in docs/architecture.md and docs/scripts.md were updated to base-branch language.
  • ℹ️ bin/fm-public-followup.sh:160 - pf_registry_lock_release's for held in &#34;${PF_REGISTRY_LOCK_IDS[@]}&#34; remains the unguarded form (inconsistent with the sibling fix at line 144). Not currently reachable with an empty array because pf_registry_lock_held returns 1 (early return at line 158) whenever the array is empty, but it is a latent trap: a future edit that removes or reorders that early return would silently reintroduce the bash-3.2 'unbound variable' crash this same commit set out to eliminate.

🔧 Fix: Fix GitLab merge head-divergence TOCTOU, stale guard wording, and two weak fixtures
1 info still open:

  • ℹ️ bin/fm-static-guard-lib.sh:25 - The 'THIS DOES NOT SURVIVE AN AIRGAPPED SITE' paragraph (lines 25-29) still reads '...the guard also fetches the current default-branch tip from the forge.' The fix round updated the header contract (lines 7-8) and the TRUST paragraph (lines 31-36) to say the merge-time guard (bin/fm-pr-merge.sh) reads 'the current tip of the branch being checked against' rather than the default-branch tip, but left this paragraph, which describes both callers together, still asserting the default-branch-tip framing unconditionally. This is the same wording-drift class the fix round was meant to finish in this exact file, which its own header says the two callers 'must not drift' from.
✅ **Test** - passed

✅ No issues found.

  • git show --no-patch --format=&#34;%H %P&#34; b6cfd62 — verified two-parent merge commit (base + f170ced), confirming true merge rather than rebase/squash
  • bash tests/fm-merge-waypoint-guard.test.sh — 20/20 pass, incl. 3 new GitLab regression tests for upstream-history refusal, --merge non-excuse, and clean-MR pass-through
  • bash tests/fm-pr-merge.test.sh — 24/24 pass, covering fork guards + upstream GitLab MR merge path in bin/fm-pr-merge.sh
  • bash tests/fm-static-guard.test.sh — 17/17 pass
  • bash tests/fm-public-followup.test.sh — 52/52 pass on macOS bash 3.2
  • bash bin/fm-doc-audience-check.sh — 77 surfaces / 282 local links validated
  • Manual: FM_STATE_OVERRIDE=&lt;tmp&gt; bin/fm-inbox.sh note &#34;...&#34; in an isolated temp home — confirmed the id-resolved-before-write fix produces a correctly named/content-matching .note file (previously described as entirely broken on macOS)
  • Manual: grepped bin/fm-pr-merge.sh and docs/merge-time-static-guard.md for 'merge result'/'the branch it targets' vs stale 'squash result'/'default-branch tip' wording — confirmed consistent
  • Manual: confirmed wc -l | tr -d &#39; &#39;/[:space:] present in tests/fm-public-followup.test.sh and tests/fm-fleet-sync.test.sh to neutralize BSD wc padding
  • Manual: confirmed PF_REGISTRY_LOCK_IDS array expansions use the bash-3.2-safe ${arr[@]+&#34;${arr[@]}&#34;} guard pattern
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

kunchenguid and others added 19 commits August 21, 2026 13:28
…guid#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
…lled 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: 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
… work over (kunchenguid#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
…unchenguid#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
…ath (kunchenguid#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
kunchenguid#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 kunchenguid#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 7f98e79 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 kunchenguid#2767 merged and so never reached the default branch.

* no-mistakes(review): name a reason on every unanswered-turn close path

* no-mistakes(review): guard the downlink body and pin frames to their turn

* no-mistakes(review): attribute reply audio to its own turn and tell endings apart

* no-mistakes(review): tell a cut-short reply from an unanswered turn

* no-mistakes(review): discard reply audio arriving after the output closes

* no-mistakes(review): count discarded reply audio on the speaker path too

* no-mistakes(review): keep a reason off a turn already answered in full

* no-mistakes(review): say a reset cut a reply short, not that none arrived

* no-mistakes(review): read one turn's audio count once, and hush a tidy exit

* no-mistakes(document): fix stale session-end relay_error claim in voice-relay guide
…unchenguid#2811)

A pi worker parked on an interactive prompt - a permission dialog, a
question menu, a trust dialog - reports agent_status=blocked, because it
is waiting on a human keystroke. Pi draws that menu above its separator
pair, so the composer region between the rules is blank and structure
alone looks like a free composer. _fm_composer_pi_verdict admitted
blocked alongside idle and done, so the shared classifier reported an
affirmatively empty composer for exactly the pane where typing is unsafe.

Every "is it safe to type here?" consumer reads that verdict and proceeds
only on an affirmative empty, so both are told yes on a parked prompt:
the away-mode injection guard in bin/fm-supervise-daemon.sh, and fm-send's
pre-type refusal. The keys then answer the menu instead of composing a
message - the highlighted default is selected, the text is discarded, and
the record attributes a decision to a human who never made it.

blocked now defers to unknown, which every consumer already treats as
fail-closed. idle and done still prove an empty composer, so ordinary
steering is unchanged, and Cursor is unaffected because its always-blocked
panes never reach this pi-only branch.

Regression coverage lands first at both levels: the verdict owner
(a blocked pi defers) and the herdr adapter (a parked pi prompt is not an
empty composer).
…2849)

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

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

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

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

* no-mistakes(review): Keep enclosing fixture clean during clone-root regression
* fix(procevent): retry a transient Lavish poll interruption quietly

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

    error: Lavish Editor poll response was interrupted
    code: SERVER_ERROR

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

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

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

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

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

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

* no-mistakes(lint): Restore Lavish trap ShellCheck suppression
… gate (kunchenguid#2838)

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

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

Add a regression test that performs the documented global fill on ship and
scout scaffolds and asserts the body lands once and the gate survives.
…tat form (kunchenguid#2837)

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

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

Detect the platform once and pick the right stat form, the pattern
bin/fm-watch.sh already documents, and treat any non-numeric result as
"just created" so a future portability surprise degrades to a lock-timeout
refusal rather than killing teardown mid-way.
* fix(stow): give memory decay a per-pass horizon so the clock fires

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

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

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

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

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

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

* no-mistakes(review): Preserve frozen counters and correct archive provenance
… the stow pass horizon

Batch 8 of eight in the fork reconciliation, and the last one. Brings the
thirteen upstream commits from 738460d (kunchenguid#2758, make lint prerequisites and
harness tests reliable) through f170ced (kunchenguid#2850, opt-in pass horizon for
memory decay), including 59e7393 (kunchenguid#2684, watched tooling update reporting),
52d20f1 (kunchenguid#2764, decouple ask-user decisions from yolo), fbe37e9 (kunchenguid#2767, the
spoken interface), dc0172c (kunchenguid#2763, Relay follow-up loops), 5b6d0fb (kunchenguid#2779,
GitLab merge requests through the guarded merge path), 1231b6a (kunchenguid#2788, lost
relay connection), 8714c9a (kunchenguid#2811, blocked pi composer), 801c083 (kunchenguid#2849,
fleet-sync clone roots), 505c819 (kunchenguid#2846, Lavish poll retries), 86dd2f6
(kunchenguid#2838, the {TASK} fill and the Herdr gate) and 266fdb9 (kunchenguid#2837, the busy-state
lock mtime stat form).

Nine files conflicted. The one that matters is bin/fm-pr-merge.sh, where
upstream kunchenguid#2779 added a GitLab merge path to a script the fork had grown three
guards on. The merged script keeps all three - the merge-time static guard
with its timeout refusal, the upstream-history guard, and the upstream-sync
waypoint ancestry assertion - and runs every one of them on the GitLab path
too, because a merge request can carry shared upstream history exactly as a
pull request can. That took four provider-aware changes: the base branch now
comes from glab's target_branch as well as gh's baseRefName, the head is
fetched from refs/merge_requests/<n>/head as well as refs/pull/<n>/head, the
project identity both guards compare against is the parsed forge-neutral path
rather than an owner/repo pair GitLab has no equivalent of, and GitLab's merge
method reads as rewriting because the project's own setting decides it and
nothing on this command can prove or override that. Upstream's broader
short-option cluster rejection replaces the fork's narrower one, and upstream's
GitLab head-override rejection and live pre-merge verification are taken as
written. All 24 fm-pr-merge cases, all 16 upstream-history and waypoint cases,
and all 3 merge-time static guard cases pass on the merged script.

Other resolutions: tests/fm-muse-harness.test.sh keeps the fork's symlink
construction for the renamed muse-bin process, which macOS needs, and adds
upstream's inherited-Cursor-marker scrubbing; tests/fm-remote-job.test.sh
keeps both tail cases, with the fork's no-leaked-worker proof last so it still
measures everything before it; tests/fm-pi-watch-extension.test.sh takes
upstream throughout, whose session-lock case covers the fork's deterministic
join and also exercises the hook entry point the fork's skipped;
bin/fm-test-run.sh unions both weight-hint lists; docs/architecture.md,
docs/configuration.md, docs/scripts.md and docs/documentation-audiences.json
union both sides' additions.
Upstream kunchenguid#2779 added a GitLab merge path to bin/fm-pr-merge.sh, and this
batch's resolution runs the fork's upstream-history and waypoint-ancestry
guards on it. Nothing pinned that: every existing case addresses GitHub.

Three cases on the existing fixture, whose origin now also publishes the
request head where GitLab publishes it and whose GitLab variants drop
refs/pull/<n>/head so the GitHub refspec cannot resolve the head for them:

- a merge request carrying upstream history is refused, names the commits it
  would erase, and names a remedy that forge actually has
- --merge does not excuse such a merge request, because glab takes no merge
  method flag and the project's own setting decides the result
- an ordinary merge request with a reachable waypoint passes both guards and
  merges through glab at its verified head

Each fails when its half is reverted: dropping the refs/merge_requests
refspec leaves the first unable to name any erased commit, and restoring the
squash default for GitLab lets --merge merge a batch that would be flattened.
Upstream kunchenguid#2763 expands PF_REGISTRY_LOCK_IDS and the release helper's local
remaining array with the plain "${arr[@]}" form. Under set -u bash 3.2 treats
that as an unbound variable when the array is empty, and macOS ships 3.2, so
the first registry-lock check on a fresh run aborted:

  bin/fm-public-followup.sh: line 142: PF_REGISTRY_LOCK_IDS[@]: unbound variable
  not ok - could not register the public commitment

Both sites take the guarded ${arr[@]+"${arr[@]}"} form this repo already uses
in fm-test-run.sh, fm-spawn.sh and fm-pr-merge.sh. tests/fm-backlog-handoff.test.sh
goes from exit 1 to 13 ok.
The thirteen commits this batch brings in were written against GNU userland
and bash 4, and four of their surfaces do not work under macOS's bash 3.2 and
BSD coreutils. Each was found by running the portable lanes here.

- bin/fm-inbox.sh queued a note with a PENDING id and rewrote it via `sed -i`,
  whose in-place flag takes a mandatory suffix argument on BSD and none on
  GNU, so there is no portable spelling. The staging name is what makes the id
  unique, so the id is now resolved before the record is written and goes in
  directly; the placeholder and the rewrite are gone. `note` was completely
  broken here before this.
- tests/fm-tool-update-check.test.sh and tests/fm-voice-relay.test.sh compared
  `$(wc -l ...)` against a bare integer with `=`. BSD wc pads its count with
  leading spaces, so every such comparison was false here. They now strip the
  padding the way tests/fm-backend-herdr.test.sh and tests/fm-procevent.test.sh
  already do.
- tests/fm-public-followup.test.sh substituted an emit path with
  ${command/"$a/b"/"$c/d"}. bash 3.2 still splits on a slash written inside
  the pattern's own quotes, so the substitution landed in the wrong place and
  produced an unrunnable command. Both halves are bound to variables first.

Two fixture problems in the same new tests, neither platform-specific:

- Four teardown fixtures in tests/fm-public-followup.test.sh drive a kind=ship
  teardown without the completion report this fork's bin/fm-teardown.sh
  requires, so teardown refused at that gate before reaching the public-reply
  gate each case is about. One of them asserted only the absence of a message
  and was passing vacuously. They now call the file's own
  write_completion_report helper, which exists for exactly this reason.
- The promised-final fixtures pinned followup_expires_at to a literal
  2026-08-28, which was a future window when upstream wrote it and is not one
  now, so every rechain case refused on an unreachable thread. The window is
  anchored a week ahead of the run, and the expiry-escalation case derives its
  FMX_NOW_OVERRIDE clock from the same anchor rather than a second constant.

tests/fm-public-followup.test.sh goes from 33 ok / 1 not ok to 52 ok,
tests/fm-voice-relay.test.sh from 4 ok / 1 not ok to 40 ok, and
tests/fm-tool-update-check.test.sh from 0 ok / 1 not ok to 37 ok.
@zeeshaanahmad
zeeshaanahmad merged commit 8884885 into main Sep 3, 2026
13 checks passed
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.

4 participants