feat(bin): merge upstream's 76 commits and reconcile the fork's divergences - #29
Merged
Merged
Conversation
… asked, not declined (kunchenguid#4387) * fix(bin): read Claude Code's default external-imports flags as never asked, not declined (kunchenguid#4378) fm-claude-trust.sh refused the whole trust registration whenever the project-root entry carried hasClaudeMdExternalIncludesApproved === false, on the premise that Claude Code writes that value only on an explicit "No, disable". Claude Code's default project entry carries Approved and WarningShown both false before the dialog is ever shown, so every such project refused every spawn. Only Approved === false with WarningShown === true — the pair the dialog writes on a decline — now counts as a decline. false/false behaves like an absent flag: trust is registered and no import consent is manufactured. New case test_project_root_entry_default_import_flags_are_not_a_decline fails on b182d0f with the refusal and passes with the fix; tests/fm-claude-trust.test.sh 31/31, bin/fm-lint.sh clean with pinned ShellCheck 0.11.0 and actionlint 1.7.12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * no-mistakes(review): Correct harness doc's external-imports decline predicate --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…chenguid#4445) * fix(brief): keep operator address out of composed intent Teach raw-word authoring for intent sections and mid-task relays, with a neutral [captain] provenance marker for legacy mixed tasks. Keep headings and contract prose outside the serialized intent body. The legacy selector already excluded the old speaker labels from its output; preserve that read compatibility. The reproduced leak comes from adding labels inside a modern intent body, not from the legacy selector. Do not scrub actual request content. Add exact serialized-input and generated-contract regressions, retaining refusal of unmarked legacy tasks and coverage of scout promotion. Fixes kunchenguid#3882 * no-mistakes(review): Refuse operator-address lines in Captain's intent body * no-mistakes(document): Document operator-address refusal in intent contract comments
…as a proven empty composer (kunchenguid#4455) * fix(composer): accept Grok title overhang * no-mistakes(review): summary: named Grok overhang constant, doc caveat, restored tmux typed-title coverage
…ailure (kunchenguid#4474) * fix(bin): recover Claude auto-arm after timeout * no-mistakes(document): Add host-timeout signal coverage to autoarm test-coverage list
* fix(spawn): establish Claude task channel authority * no-mistakes(document): Document Claude task-worker control-channel trust in harness-adapters reference
…or pending text (kunchenguid#4458) * fix: guard relaunch exit against pending input * no-mistakes(review): Verifying test run in progress * no-mistakes(document): docs(agent-control): document exit's composer-empty fail-safe guard * no-mistakes(ci): fixed 2 tests broken by approved do_exit fail-safe change (empty-only composer gate). herdr-smoke test's sleep-stand-in never renders a real composer -> updated assertion to expect "not proven empty" refusal instead of stale "did not stop" msg. secondmate-restart fake tmux capture-pane returned bare '> ' glyph (never valid empty proof) -> changed to bordered empty box matching fm-control-relaunch fixture. all 4 related suites pass locally now
…unchenguid#4460) * fix: reconcile diverged secondmate updates * no-mistakes(document): Fix stale fm-update.sh/fm-ff-lib.sh purpose lines in docs/scripts.md * no-mistakes(document): docs: reflect secondmate divergence reconcile in README/SKILL.md
…d#4497) * fix(dispatch): support Codex Luna max effort * no-mistakes(review): use portable CODEX_HOME path in codex effort reference
kunchenguid#4498) * feat(calm): render smooth Unicode swell * feat(calm): make sails asymmetric * feat(calm): use quarter sail glyph * no-mistakes(review): docs: sync calm feasibility sprite passage with approved renderer * no-mistakes(document): docs: sync calm wave phase doc comment * no-mistakes(ci): CI の Lint 失敗は tests/fm-calm-pi-extension.test.sh の test_interactive_terminal_e2e 関数で `boat_narrow_sails` が local 宣言に残っていたことによる ShellCheck SC2034 でした。関数内での参照を確認したところ、狭幅端末の検査は boat_narrow_previous / boat_narrow_direction / boat_narrow_reversed に移行済みで、boat_narrow_sails は代入も参照も一切ありませんでした。そのため local 宣言からこの 1 語のみを削除しました(3315 行目)。Calm の描画実装、他のテストアサーション、ドキュメントは変更していません。検証: bin/fm-lint.sh(ローカル変更ファイルモード)exit 0、CI 相当の `shellcheck --norc --external-sources tests/fm-calm-pi-extension.test.sh` exit 0(SC2034 解消)、`bash -n` 構文チェック通過、actionlint 1.7.12 でワークフロー 3 件 valid。
kunchenguid#4491) * fix: supersede scout delivery brief on promotion * fix: preserve ship safety contract after promotion * no-mistakes(document): Document fm-promote.sh now supersedes brief.md on relaunch
…d stop cleanup dropping accents from a held body (kunchenguid#4471) * fix(bin): let captain holds work on hosts with an older JSON::PP Holding a task for the captain, and the cleanup that keeps a captain-held row open, both fail outright on any host whose JSON::PP defaults allow_nonref off - 2.27202 on a Linux desk is one. Both read a task's body back with `decode_json`, but tasks-axi shows a scalar field as a JSON-encoded bare string, and an older library rejects that whole value with "must be object or array". The consequence is fleet-wide on such a host, not one broken command: a worker there cannot formally record a decision for the captain at all. It can only mention the decision in passing in a status line, where it can be missed - which is how a real decision goes unrecorded. The hold reports that the task lost its hold-set stamp; the cleanup cannot return the row to Queued. Both call sites now ask for allow_nonref explicitly rather than inheriting whatever the installed library defaults to. The second one is worth naming: its `/\A"/` guard reads as deliberate, but a leading quote is exactly the bare-string case that fails, so the guard selects for the failing input rather than protecting against it. The regression case forces the older default back off for every perl the commands spawn, then drives both paths - holding a task that carries a body, and tearing down a captain-held row whose deliverable must still be appended. It also probes that the simulation genuinely rejects a bare scalar, so the case cannot pass vacuously on a lenient host. Each half was verified failing on its own unfixed call site with that site's real error message. Suites: fm-captain-hold-lifecycle 51 cases, fm-backlog-atomicity 99 cases, 0 failures. Verification limit: the mechanism is reproduced and tested, but neither fix is verified against a real JSON::PP 2.27202 host, because none is in the loop. This laptop runs 4.06, where the bug does not manifest. `bin/fm-procevent-lavish.sh:471` was checked and left alone - it matches a brace-delimited object before decoding, so allow_nonref never applies. * fix(bin): stop cleanup silently dropping accented characters from a held body Cleanup rewrites a captain-held row's body to append the finished work's deliverable, and the decoder it reads that body with printed decoded characters to a stream with no `:raw` layer. A character at or below U+00FF then came out as one latin-1 byte instead of two UTF-8 ones, so a body reading "café" lost the accent. `fm_backlog_retain` writes that body straight back through `--body-file`, and nothing reported an error - the character was simply gone from a row still waiting on the captain. The decoder now writes bytes, the same `binmode STDOUT, ":raw"` plus `utf8::encode` that the sibling decoder in `bin/fm-captain-hold.sh` already used. Review of the parent commit found this on one of the lines that commit already changed. It predates that change. The test asserts bytes rather than decoded strings, because comparing strings cannot tell latin-1 from UTF-8. It uses two separate rows on purpose: any character above U+00FF makes perl print the whole string as UTF-8, so one body carrying both an accent and an em dash passes even unfixed and proves nothing. Verified failing before the fix on the accented row, passing after. Suites: fm-captain-hold-lifecycle 52 cases, fm-backlog-atomicity 99 cases, 0 failures. * no-mistakes(document): record body-decode regression proofs in captain-hold lifecycle doc * no-mistakes(review): drop whole-file UTF-8 check from retained-body test * no-mistakes(review): correct stale JSON::PP fleet-host claim in lifecycle doc * no-mistakes(review): anchor native-reproduction claims per defect in lifecycle doc
…furniture (kunchenguid#4532) * fix(composer): read codex 0.154's idle starfield and status footer as furniture codex-cli 0.154.0 animates a braille "starfield" around its idle composer: on the row above the bold `›` prompt row, on the `›` row behind the SGR-2 dim `Ask Codex to do anything` placeholder, and on the row below it, then draws a bright status footer (`<model> <effort>[ fast] · <path> · <title>`). The cells are truecolor greys on both sides of the ghost luminance ceiling, so the brighter ones survive ghost stripping, and the rows below the glyph carry no structural edge. The shared classifier selected the bare `›` shape, extended its wrap region over the two rows beneath the glyph, read the survivors and the footer as wrapped typed input, and answered `pending`; the steering doorbell defers on exactly that verdict, so no doorbell ever reached an idle codex 0.154 pane. bin/fm-composer-lib.sh now recognises that furniture by shape, declared once next to the idle placeholders and reached from the two wrap-region boundary points: - a row whose non-whitespace content is entirely braille cells (U+2800..U+28FF, detected byte-exactly under LC_ALL=C) is furniture: it never counts as wrapped typed content and bounds a bare composer's wrap region; braille behind the glyph row's content is stripped before the emptiness decision when nothing else follows the glyph; a row mixing braille with other text stays typed content; - the codex status footer bounds the wrap region exactly as omp's status row does, anchored on the effort token, a spaced middle dot, and a `~` or `/` path cell, so a typed `fix · tests` stays composer input; - `^Ask Codex to do anything$` joins the verified idle-placeholder set; the ghost strip remains what proves that row empty, and the bare-row rule that bright placeholder text is real input is unchanged. Unchanged: the strict blank-row rule, the styled=0 degradation (a plain cmux/orca capture of this screen still reads `unknown`, never `pending`), FM_COMPOSER_GHOST_LUMA_MAX, and every other harness's shape. tests/fm-composer-lib.test.sh carries both live Herdr samples byte-for-byte with the divergence (letters in place of the starfield read `pending`) and the over-stripping negatives; tests/fm-composer-codex-idle-live-e2e.test.sh is the default-on live guard (token-free, skips explicitly without codex or tmux) that launches the installed codex idle and asserts `empty` through both the tmux and the cursorless styled reads, naming codex --version on failure. docs/verification/runtime-backends.md records the dated Herdr evidence: `pending` before, `empty` after, on the captured screen. * no-mistakes(review): drop unreachable codex footer rule and inert placeholder entry --------- Co-authored-by: Todd Billings <todd@usdvcapital.com>
* fix(bin): refuse empty text steers in fm-send A marked secondmate request sent with an empty message delivered only marker and correlation bytes and minted a pending-reply expectation the parent could never see resolved, stalling the fleet with no loud error (kunchenguid#4255). Fail closed on an empty or whitespace-only message on the text path, mirroring the existing --resolve-key refusal. * chore: retain ambient Pi-lens autoformat as its own commit Formatting-only edits produced by ambient Pi-lens autoformat during the msg-loss investigation, kept separate from the behavioural change in c23acba so the fix stays reviewable on its own. AGENTS.md is deliberately excluded: its only autoformat edit stripped the trailing space from the documented FM_OPERATIONAL_PREFIX value, which bin/fm-operational-input.sh:28 defines as "FIRSTMATE_OP: " and line 11 records as permanent compatibility. Documenting that constant without its trailing space makes the doc wrong about the contract, so that one line was restored rather than retained.
…chenguid#4554) On rose-pine-moon the two-color water (cyan crests over blue troughs) read as a pink stripe over aqua, the yellow left sail and mast clashed with the red right sail, and the hull carried a blue interior run. Every water cell is now blue so the swell reads through glyph height alone, and both sail halves, the mast, and the whole hull are one yellow run. Geometry, cadence, animation, direction flip, resize clamping, and the narrow fallback are unchanged. Update the unit and real-TUI color assertions to the new palette and the Calm docs that described the old one.
…chenguid#4270) * fix(watch): stop aging a second mate's active turn from its launch The parent watcher's second-mate wake-loop stall check exempts a mate that is demonstrably inside an active turn, but secondmate_in_active_turn asked busy_turn_over_age first and returned "not in a turn" whenever that said the bound was crossed. busy_turn_over_age ages from state/<task>.turn-ended, falling back to state/<task>.meta. A second mate's turns end in its own home, so the parent never gets a turn-ended mark for it and the fallback ages the mate's last launch. Every mate launched more than BUSY_TURN_MAX_SECS ago was therefore permanently "over age", the busy pane was never consulted, and any turn outstripping FM_SECONDMATE_WAKE_STALL_SECS raised a false wake-loop stall. The gate now bounds the busy exemption by <idle> - how long the queue's drain position has not moved - which is evidence this home actually holds. A busy mate stays exempt while the queue has been frozen for less than BUSY_TURN_MAX_SECS, and a mate stuck busy forever still alarms, so the bound that stops a busy pane from proving liveness forever is kept rather than removed. busy_turn_over_age is untouched; its remaining callers are the ordinary crew busy-pane bound. The regression pins the case that actually broke: a mate whose launch record predates BUSY_TURN_MAX_SECS and which is demonstrably mid-turn must not escalate, while the same mate with its queue frozen past the bound still publishes exactly one notification. The existing coverage only exercised a freshly launched mate, which passes either way. Reaching that alert now costs a pane capture inside the gate, so the three checkpoints in this suite that assert an alert move from a 1s to a 4s bound - the value the neighbouring active-turn cases already use. The bound is a ceiling, not a wait: the checkpoint returns on the first actionable wake. On a loaded machine a 1s bound missed the alert repeatedly; at 4s it did not miss in 20 runs under the same load. * no-mistakes(review): scope the second-mate active-turn regression test's coverage claim * no-mistakes(document): fix stale second-mate active-turn comments in fm-watch
…unchenguid#4278) * feat(bin): add read-only PR blocker and reviewer-discovery commands Two focused, opt-in commands that read GitHub and never write to it. fm-pr-state.sh reports what still blocks one pull request from the author's side: a closed or merged state, draft state, unknown or conflicting mergeability, absent or failing required checks, and a blocking CHANGES_REQUESTED decision explained by each reviewer's latest verdict, marked STALE when it was left at a superseded head. A pull request that only awaits an approval is not reported as blocked, and advisory checks are omitted. Every reading is taken against one exact head; a push that lands mid-read invalidates the whole result rather than mixing two snapshots. fm-pr-reviewers.sh suggests reviewers from the most recent commits to the pull request's exact changed paths, counting each commit once, resolving handles through GitHub's own commit author.login mapping, and excluding the author and Bot accounts. Both stay read-only: no review request, no approval, no merge. Unresolved review-thread state is left unreported because the REST API does not expose it and unattended commands may not use GraphQL. Closes kunchenguid#3731 * no-mistakes(review): accept only PR URLs and stop at terminal state * no-mistakes(review): report unconfirmed required checks; make URL-only guards discriminate * no-mistakes(review): stop attributing readings to unverified heads * no-mistakes(review): narrow readiness contract to checks that have reported * no-mistakes(review): read the pull request once, drop the head guard * no-mistakes(document): scope pr-forge isolation proof to its measured members * no-mistakes(document): record uncovered pr-forge members and their pending proof * docs(isolation-proof): re-prove pr-forge at its full membership tests/fm-pr-state.test.sh and tests/fm-pr-reviewers.test.sh joined the pr-forge family in this branch, and script_allows_concurrency grants four workers by family membership alone, so both ran concurrently on a proof measured before they existed. Re-proved the family at all eight members: two consecutive runs, 0 failures, each begun with the one-minute load average below 6.0 so the result measures isolation rather than contention. A third run taken between them is disclosed rather than recorded, because it started while the previous run's workers were still decaying. The new durations are not comparable with the six-member measurement above them, so they are not presented as evidence about the two new members, and that record's 1.72x four-worker figure is left as a statement about its own run rather than restated as current. * no-mistakes(review): disclose gh error-text coupling at its matching site and tests
…uid#2752) * fix(bin): teach validation-round pauses in briefs * no-mistakes(document): Point classifier comments to authoritative pause examples
…guid#4510) * fix(teardown): refuse a cleanup whose endpoint close failed bin/fm-teardown.sh discarded both the exit status and the stderr of every fm_backend_kill call, so a close that genuinely failed was indistinguishable from one that succeeded. Teardown continued past it, deleted the task's durable records, returned its worktree, and reported the cleanup as completed. The deleted metadata is the only record of which endpoint belongs to the task, so such a close did not merely leave a stray session behind, it stranded one: nothing was left on disk naming it. The adapters could not carry that signal either. Driven against the real code, every backend arm returned 0 for a genuine failure exactly as it did for an already-exited endpoint, so there was nothing for the four call sites to propagate even once they stopped swallowing it. The tmux arm now resolves a close that did not succeed against the window's exact recorded identity, since kill-window fails the same way for a window that is gone and one that is still there. The Orca arm reports a close its missing CLI never attempted. Both stay silent for an endpoint that is already legitimately gone, and the remaining arms are unchanged: their close-command timing cannot be established without the real Zellij, Orca, and cmux binaries, and a gate that refused ordinary cleanup of an already-exited session would be worse than the defect. docs/verification/runtime-backends.md records what each backend can prove. A reported close failure now reaches teardown's existing retain-and-stop refusal before the records naming the endpoint are removed, matching where the Herdr confirmed-gone gates already sit for the same hazard, and the retained records let a rerun finish once the close works. * no-mistakes(review): refuse unreadable tmux close re-read; honor --force override * no-mistakes(review): drop unreachable Orca force arm; prove CLI-absent close * no-mistakes(document): document endpoint-close refusal in its backend and retirement owners * no-mistakes(ci): The two reported failing checks are NOT code defects. Both "CI" (run 34935529184) and "Require no-mistakes" (run 34935529206) returned conclusion=action_required with zero jobs and 0s duration (run_started_at == updated_at), which is this repo's workflow-approval gate holding the run before any job starts. No job executed, so nothing in the diff could have caused them; two unrelated branches (fm/captain-hold-json-nonref, fm/presenter-core-l1) show the identical shape in the same time window. Verified the change locally instead: bin/fm-lint.sh clean, bin/fm-test-run.sh --check-coverage ok, and all suites the diff touches pass (fm-teardown-endpoint-safety 25/25 including the five new endpoint-close cases, fm-backend-orca, fm-backend, fm-backend-tmux-smoke, fm-backend-cmux, fm-backend-zellij, fm-backend-herdr). Separately, I found and fixed a genuinely flaky test that the phase rules require me to make deterministic: tests/fm-tmux-agent-liveness.test.sh intermittently failed "an idle shell pane must classify dead" (verdict ambiguous, comms=[bash sleep]). It is selected by --changed for this diff, so it would run against this PR once CI is approved. Root cause, established by instrumenting the pane's process group: the idle window was created by `new-session` with no command, so it inherited tmux's default-shell, i.e. whoever runs the suite. ps on the pane tty showed `-zsh` -> `bash` -> `sleep`, all sharing pgid==tpgid, i.e. the host operator's shell configuration spawning a periodic helper directly into the pane's FOREGROUND process group, which is the one surface the classifier reads. `sleep` classifies as `other`, so fg_other=1 and the verdict became `ambiguous` instead of `dead` whenever that helper overlapped the 10s poll window. Every other window in the suite runs an explicit command via new_window; the idle case was the only one whose process group the host defined. Fix (smallest root-cause, test-only, 1 line + explanatory comment): create the idle window with an explicit bare `/bin/sh` (`-- /bin/sh`), the same shell the neighbouring background case already execs. Its foreground group is now exactly one process (verified: `/bin/sh` alone), so no host configuration can inject into it. This flake is pre-existing and NOT caused by this PR: an interleaved A/B showed base commit da5e658 failing the identical case (2/6 runs) alongside head (3/7 runs), and the diff only extracted the tmux inventory read into a helper with identical semantics while never touching fm_backend_tmux_foreground_comms. After the fix: 8/8 consecutive passes, with lint and the coverage guard still clean. Change left uncommitted in the working tree
* feat(calm): ship the Claude Code Calm and sailboat mod behind the function-hooks flag Add .claude/mods/firstmate-calm, a Claude Code mod (function-hooks plugin) that brings Calm to Claude Code: the sailboat replaces the stock working row through a Raster repainted on the sprite's own tick, and tool, tool-group, mid-turn narration, and canonically classified operational user rows draw at zero height. /calm is registered by the hooks module itself and toggles the same per-home config/calm preference the Pi extension uses, so one choice applies on either harness; rows redraw retroactively on toggle and stay hidden across claude --continue. The mod loads only while Claude Code's default-off CLAUDE_CODE_ENABLE_FUNCTION_HOOKS flag is on. Nothing sets that flag in any settings file, and the plugin carries no command file, skill, agent, or classic hook, so it is a complete no-op while the flag is off. The trusted project auto-loads it through an .agents/skills symlink, the only path Claude Code scans for project plugins. Extract the working-ship geometry, bounce track, cadences, and freeze/resume state into a harness-neutral sprite core inside the mod (Claude Code refuses hooks-module imports from outside the plugin folder) and have the Pi widget paint that core's frames as standard ANSI, byte for byte as before; the Pi suite stays green. Classify operational rows through a port of bin/fm-operational-input.sh's classify command guarded by a corpus parity test against the shell owner. Tests: portable Node checks (plugin shape, sprite parity with Pi's rendering, Raster packing, policy, classifier parity), the mod's own claude plugin test suites behind a default-on wrapper, and an opt-in live TUI guard proving the flag-off no-op, the moving boat, hidden rows, the persisted toggle, and resume on Claude Code 2.1.272. Docs: record the version-scoped Claude Code evidence and the three bounded gaps in docs/calm-mode-feasibility.md, describe the Claude Code contract in docs/calm.md, and make the shared preference, layout, and contributor notes harness-neutral. * no-mistakes(review): Preserve colliding final replies and strengthen parser parity * no-mistakes(review): Preserve final replies and strengthen canonical parity checks * no-mistakes(review): Require exact function-hooks opt-in before Calm activation * no-mistakes(review): Clarify Calm module loading and activation boundaries * no-mistakes(review): Reset Calm presentation state across session starts * no-mistakes(document): Refresh Calm session lifecycle documentation * feat(calm): paint the Claude Code working ship in Claude's own theme colors The captain picked the "Claude native" palette for the Claude Code mod's Raster: every water cell takes the spinner blue of the active theme family (#93a5ff dark, #5769f7 light) and the whole boat takes the Claude orange of the stock spinner (#d77757), one water color and one boat color. The family follows the `theme` setting's prefix, read at load through $.config.list and re-read on a config.set of that row, with `auto` and custom themes falling back to the dark set. The Pi extension keeps its standard ANSI blue and yellow, byte for byte. Rename the shared sprite's color classes from hue names to `water` and `boat`, since each harness now maps them to its own colors; geometry, motion, cadence, and the activation gate are untouched. Tests cover both palettes' packing and the family rule under Node, and the plugin kit drives every theme value, a theme change mid-session, the Calm-off pass-through, and inertness of the menu read while the flag is off. The docs describe the Claude Code colors and record the guard passing on 2.1.273. * no-mistakes(review): Use light palette for unresolved Claude themes * no-mistakes(document): Refresh Claude Calm verification evidence
…kunchenguid#4586) * fix(watch): honour a declared wait before wedge-escalating a quiet pane wedge_timer_check escalated on elapsed idle time alone. Nothing asked whether the worker had already said why its pane was quiet, so a lane that declared a bounded external wait climbed the escalation ladder for as long as the wait lasted, and past FM_WEDGE_DEMAND_INSPECT_COUNT every repeat carried demand-deep-inspection - which by its own wording forbids re-absorbing on the run-step or pane state, so the supervisor could not use the evidence that was there either. The generated brief promises that declaring `paused:` buys the long recheck cadence instead of a wedge, but the timer was still reachable while that declaration stood: a crew that declares a wait and then has an active run or busy pane attributed to it is handed to the timer as provably-working. The declaration is what the worker said about its own silence, so it now outranks a liveness verdict that only says something is running. The consult runs in the at-threshold branch that was about to escalate, beside the worktree walk already there, and costs one status-line read. Either status-line record defers to the same FM_PAUSE_RESURFACE_SECS recheck the declared-wait absorber already uses, so the wait is still rechecked and cannot rot invisibly. Which verb declared it decides the wording, because the two block on different people: a `paused:` wait is owed by an external dependency and asks the reader to confirm it still holds, while a `captain-held:` transfer is owed by the captain reading the recheck and asks them to answer or release the hold. A hold is not rechecked at all while the away-posture record exists, as on every other captain-held path, and that absorb arms no throttle so the recheck is owed in full on return. A declared clearing time that has already passed stops counting, and a lane that never declared one keeps the identical escalation schedule, reason, count and demand-deep-inspection wording, so detection and its worst-case time are unchanged. The deferral restarts the idle timer rather than cancelling it, so a lane that stops waiting escalates again within one threshold. A lane quiet because its own validation run is parked at a gate awaiting a human decision is deliberately out of scope: reading that state needs a signal carrying who the wait is on and what clears it, rather than one inferred from a parked verdict that also covers gates awaiting the crewmate itself. Tests pin both directions for each case and were each confirmed to fail with the consult removed. * no-mistakes(document): docs: honour declared waits in stale-escalation docs
* fix(bin): derive passed PR state from PR record A completed no-mistakes run with outcome=passed does not prove the associated pull request merged or closed. A parked gate can be approved on other evidence, so the old crew-state label could report an open PR as merged and make teardown look safe when unlanded work still exists. For passed runs, derive the crew-state detail from the run or task PR identity, accept a matching merge-poll retirement receipt as local merged evidence, and otherwise perform a bounded forge read. If the identity is absent or unreadable, report the run as passed with unknown PR state instead of inventing a merged claim. Fixes kunchenguid#4607 * no-mistakes(review): Add bounded GitLab merge-request state reads * no-mistakes(review): Preserve network-free inactive crew-state scans * no-mistakes(document): Document PR record readers in shared library
kunchenguid#4627) * fix: restore published contribution follow-up (Fixes kunchenguid#4469) * fix(review): Fix contribution freshness and merge actor routing * fix(review): Restore issue triage and scope contribution follow-up * fix(test): test: assert one wake per contribution signal * fix(document): Document contribution follow-up * fix: restore truthful terminal delivery evidence * fix(review): Disclose unsupported contributions and deduplicate watcher wakes * fix(review): Preserve unmeasured unsupported contributions across Bearings * fix(review): Deduplicate shared contribution wakes and isolate diagnostics * fix(ci): Captain, fixed the CI failure by updating the PR-security fake GitHub interface to support the contribution observer’s API reads. Verified with shellcheck, git diff --check, the full contribution suite, and a focused merged-poll retirement reproduction. The full PR-security script was not allowed to complete locally after its expanded observer path made it substantially slower
…nguid#4658) * fix(bin): make a remote-reply document gap self-clearing and re-attemptable A remote mate's undelivered document raised a keyed `blocked` decision that nothing could ever resolve, and any `data/*.md` substring in any mirrored line was an unconditional fetch instruction. A mate announcing a report it had not written yet therefore manufactured a permanent, factually false blocker, and its own explanation of the false alarm manufactured more. The reader has no permanence vocabulary: a report still being written refuses exactly like a path that will never exist. So an undelivered document is now a durable, re-attemptable obligation under `state/remote-replies/<id>.pending-docs`, re-attempted on the next delta and on the channel's own quiet poll, and retired with a matching `resolved` line naming the local copy once it arrives. The cursor still advances and no delta stalls on one bad pointer. Only a structured `report=data/....md` pointer now offers a document, so a path merely mentioned in prose - including one under another home's mirror tree, which is provably not that mate's to serve - is never fetched. Offers are deduplicated across the whole delta, the escalation names each missing document once and carries the reader's own reason instead of discarding it, and a strictly increasing notice ordinal keeps a later escalation from being swallowed as duplicate bytes. A mirrored line still lands once whichever pointer form it was first written under. * no-mistakes(review): Require structured pointer token boundaries * no-mistakes(review): Unify boundary-safe pointer extraction and rewriting * fix(bin): identify a mirrored line independently of its delivery state Two defects in the boundary-safe pointer work. The at-most-once check compared only the all-remote and all-local renderings of a line, so it could not recognize a mixed one. A line offering two documents where only the first was deliverable mirrored as local-plus-remote; once the second arrived, a cursor-loss whole-log recapture rendered the same line all-local, matched neither alternate, and mirrored a second time. A line's identity is now the canonical form every boundary-valid pointer would take once delivered, derived by the same parser that does extraction and rewriting, so it no longer depends on which documents happened to be deliverable at the time. The pointer map was passed to awk through the process environment. A delta may carry up to the configured 1 MiB bound, and an expanded map of delivered pointers can exceed the platform's exec argument limit, so awk would fail to start; because no caller checked, the empty result would have been appended as blank lines while the cursor advanced past dropped status content. The map now travels in a file, and every call site checks the exit status and stops the ingest rather than committing a delta it could not render. Both passes now run once per stream instead of twice per line. * no-mistakes(review): Abort ingest when document pointer extraction fails * no-mistakes(review): Exclude structured cross-home pointers from document transfer * fix(bin): fail open on an undeliverable remote document instead of tracking it Narrow the remote-reply document fix to the scope the diagnosis actually requires, as decided after measuring a simpler alternative. A document the reader cannot deliver now fails open. The mate's line is mirrored with its own pointer, the cursor advances, and one unkeyed note carries the reader's reason. A note never enters the open-decision fold, so it cannot stand open the way the original keyed block did - which removes the never-clearing false blocker by construction rather than by resolving it. That makes the durable self-clearing obligation unnecessary, so it goes: the per-mate pending-documents record, its notice ordinal and resolved announcements, and the poll-side retry. Canonical line identity goes too, and with it a way to silently drop a genuine status line; mirroring is back to at-most-once on exact bytes. The cross-home exclusion goes as well: under fail-open a cross-home report= either fails harmlessly or is a nested remote report this mate genuinely holds, which is now relayed again. Kept: fetching only on a structured report= pointer, the boundary-correct parser, the file-based rewrite map, and checked extraction and rewrite exit status. The parser now scans behind a sentinel byte so a rejected candidate can no longer give the text right after it a false leading boundary. The reported incident is covered end to end: a report path announced in prose before it exists raises no decision, and the report still arrives through the ledger publisher's structured offer once written. * no-mistakes(review): Preserve source-line identity across remote reply replays * no-mistakes(document): Document remote reply transfer and replay semantics * no-mistakes(lint): Fix staging truncation lint checks
* Preserve substantive Calm mid-turn text * no-mistakes(review): Distinguish newline-preserved replies from short narration * no-mistakes(document): Document Calm mid-turn preservation boundaries * no-mistakes(ci): Fixed the flaky contribution watcher test by increasing its bounded checkpoint from 5 to 15 seconds, allowing diagnostics to surface under slower CI load. Verified with `bash tests/fm-contributions.test.sh` and `git diff --check`
…#4656) * fix(bin): re-record PR poll identity after a volume device renumber (Fixes kunchenguid#4260) A volume remount can renumber the state filesystem's st_dev while every inode and byte stays the same; APFS does this across a reboot. A poll registration records its sidecar and check as device:inode, so every poll armed before the remount failed strict validation and the watcher refused all of them as unauthenticated state checks until each was re-armed by hand. There are two device comparisons. fm_pr_private_file_valid compares a live file's device with the state directory's device read in the same invocation: it refuses a file that is not on the state directory's own filesystem and already survives a renumber, so it is unchanged. The registration's recorded identity versus the live identity (from kunchenguid#556, reused by the kunchenguid#932 retirement receipt) binds the registration to the exact files published in its own transaction; its device part is what breaks. When strict capture fails, the watcher now proves the device is the only difference: every other artifact check passes (template bytes, both hashes, private mode, single link, live device, metadata), both recorded identities name one device, and each recorded inode equals its live inode. Only then, under the task's control lock, does it rewrite the two identity lines, repeating the whole proof and comparing the registration's file identity and bytes just before the rename, and then capture strictly again. A swapped, altered, re-moded, relinked, split-device, or foreign-device artifact still fails a proof and is still refused, and a pending retirement receipt blocks the rewrite. Reproduction: on macOS a poll armed on an APFS disk image that was detached and re-attached behind another image moved st_dev 16777239 -> 16777243 with inodes, bytes, mode, and link count unchanged; the real watcher refused it on main and reports its merge with this change. The portable regression test rewrites a real registration's recorded device and drives the watcher. Not changed here: the status presentation cursor keys rows by its own device:inode identity in bin/fm-classify-lib.sh, a different helper that needs its own fix; a retirement receipt left by a reboot between its publication and removal still names the old device and stays refused; custom check trust binds only a content hash and is unaffected. * fix(review): Serialize PR poll publication writers * fix(review): Bound PR poll publication lock scope
…llow-up to kunchenguid#4627) (kunchenguid#4661) A budget that expires partway through an observation no longer records an error or prints the unavailable wake; the URL keeps its prior record and is observed first next poll. forge() flags budget exhaustion at the point it refuses, or when a read is killed at the budget's own deadline, so a genuine forge failure still records the error and wakes. Each distinct URL is now observed once per poll and applied to every owning task.
…kunchenguid#4680) * fix(bin): clear parent pending-replies on local secondmate retirement Local secondmate teardown left resolved parent pending-reply records behind after home removal (seen after papa-hdds / pxmx retirement). Refuse non-forced retirement while any reply for that id is still unresolved, and delete every matching record plus its delivery confirmation after a successful local or remote retirement, matching the remote cleanup path. * no-mistakes(document): Align secondmate retirement docs with pending-reply cleanup * no-mistakes(review): Lokale Pending-replies-Sicherheitsprüfung vor Home-Entfernung * no-mistakes(review): Pending-replies-corr_id auf 16-Hex absichern * no-mistakes(review): Pending-replies Basename und corr_id abgleichen * no-mistakes(document): Clarify forced retirement pending-reply cleanup --------- Co-authored-by: ladwein <ladwein@firstmate.bost8.thelad.loc>
…ile-fork-with-upstream # Conflicts: # .agents/skills/harness-adapters/references/harness/claude.md # .agents/skills/updatefirstmate/SKILL.md # AGENTS.md # bin/fm-brief.sh # bin/fm-classify-lib.sh # bin/fm-claude-trust.sh # bin/fm-crew-state.sh # bin/fm-ff-lib.sh # bin/fm-inactive-reconcile.sh # docs/architecture.md # docs/configuration.md # tests/fm-classify-decision-key.test.sh # tests/fm-claude-trust.test.sh # tests/fm-crew-state.test.sh # tests/fm-inactive-reconcile.test.sh
… open-decision fold Fixes gaps found reconciling upstream's status_current_line (fold-based current-state read) with the fork's status_current_state_line (backward-walk that survives a trailing note:/resolved: for a still-standing paused:/ captain-held: or open decision), surfaced by upstream's own new crew-state test suite once both were merged together: - A secondmate's own status log also carries done:/failed: child-outcome lines relayed upward by the ledger path, so those must not be read as the secondmate's own terminal state; ship/scout crews carry no such relayed lines, so done:/failed: there still win immediately. - A declared wait (paused:/captain-held:) must outrank even a still-open keyed decision positioned earlier in the log, not just an unrelated working:/done:/failed: - a worker that moves from an unresolved escalation to a declared pause is doing the pause now. - A ship/scout terminal declaration (done:/failed:) must be the log's literal newest recognized event to count as current: once anything - even a mere note: - trails it, something happened since, and it must not be resurrected as still-current over an even older decision beneath it. - A colonless continuation line has no verb at all (status_line_verb returns the whole line when there is no colon), so it must never be mistaken for a recognized event. All under fm-classify-lib.sh's status_current_line/status_current_state_line and fm-crew-state.sh's reuse of the resulting LOG_LINE, which now also skips emitting a status-log source when the mapped state is still unknown.
… unreachable unknown-state guards
…ip only relayed child outcomes
…lind terminal rule
…ne's real tier order
…allback tier order
…e-fork-with-upstream # Conflicts: # .agents/skills/afk/SKILL.md # AGENTS.md # bin/backends/herdr.sh # bin/fm-brief.sh # bin/fm-classify-lib.sh # bin/fm-control.sh # bin/fm-merge-authority-lib.sh # bin/fm-merge-outcome-lib.sh # bin/fm-pr-lib.sh # bin/fm-pr-merge.sh # bin/fm-send.sh # bin/fm-spawn.sh # bin/fm-supervise-daemon.sh # bin/fm-watch.sh # docs/agent-control.md # docs/architecture.md # docs/configuration.md # docs/scripts.md # tests/fm-captain-hold-lifecycle.test.sh # tests/fm-control-relaunch.test.sh # tests/fm-pr-check-security.test.sh # tests/fm-watch-triage.test.sh
…d merge-authority doc claims
Base advanced by three commits (#31, #30, #32) since this branch last took origin/main at 929a366. Only 4e5c8ee (#31, pin Codex workers to the standard service tier) overlapped the reconciliation, in three files: - .agents/skills/harness-adapters/references/harness/codex.md: keep upstream's Effort flag row, which now advertises `max` for gpt-5.6-luna, and add the base's new Service tier row alongside it. - bin/fm-spawn.sh: carry `-c 'service_tier="default"'` on both codex launch templates, keeping upstream's `--disable hooks` on the crewmate template, and take the base's service-tier rationale into the launch contract header. - tests/fm-spawn-dispatch-profile.test.sh: register the base's new test_codex_scout_uses_standard_service_tier next to upstream's restructured max-effort and hook-layer cases, and thread the service-tier flag through upstream's own Luna max-effort launch assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-fork-with-upstream # Conflicts: # docs/architecture.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Reconcile this fork with its upstream in BOTH directions. The captain's standing policy, in his own words on 2026-09-22: "the update process should be to incorporate upstream as well as my changes. Where my changes do/don't make sense, adjust/discard them. That's the standing way this should work."
So this is a reconciliation, not a one-way pull and not a refusal. Take upstream's work. Keep his changes where they still make sense against it. Adjust or discard his own where they do not - he authorised that explicitly and in advance.
CURRENT DIVERGENCE, measured 2026-09-22: origin/main (rub-a-dub-dub/firstmate) is 11 ahead and 76 behind upstream/main (kunchenguid/firstmate). The local checkout is a further 4 behind its own origin.
HIS 11 COMMITS UPSTREAM DOES NOT HAVE:
2e9903c derive fm-crew-state's passed-outcome PR detail from pr_state (#13)
7ce3cda report a retain replay's absent backlog row instead of silently retiring it (#9)
fc20562 refuse a merge when a PR's CI event may have been dropped (#12)
126eaec annotate pre-existing shellcheck findings that fail CI lint (#10)
6339e2d Merge the fork's landed fixes into the upstream line
00b1613 stop appended metadata from disarming the PR merge poll (#6)
3de1d01 fail the update when a remote secondmate's fork sync fails (#5)
7ac2246 treat a never-asked external-imports prompt as not a decline (#4)
6f4c112 synchronize GitHub forks before checking origin (#3)
d233f53 clarify acknowledgement means receipt, not completion (#2)
eb845e4 read current state from the newest state-bearing line, not the last line (#1)
FIRSTMATE RECOMMENDED THE OPPOSITE AND WAS OVERRULED - recorded so you do not re-litigate it. The recommendation was to take only his own four unpulled commits and leave upstream alone, on the grounds that 76 unreviewed commits land in the code that supervises the fleet, and that upstream carries open issue 5269 warning of a suspicious PR cluster targeting supervision code from external accounts. He overruled it in one line. Reconciliation is the standing way; do not propose isolation.
Later, when the worker escalated one upstream commit (69d660a, "feat(bin): add opt-in typed dispatch resolution") matching the pattern warned about in upstream issue 5269, with options to include it as-is with prominent disclosure, strip it, or hold the PR, the captain answered: "syncin with firstmate fork: approve including the commit".
When review repeatedly raised findings about telling a secondmate's own terminal line apart from relayed child lines, firstmate recommended one final bounded fix round with an automatic fallback to upstream's secondmate behavior if the theme recurs; the captain answered: "I agree with your rec".
What Changed
bin/fm-dispatch-resolve.sh(opt-in typed dispatch resolution, gated onTYPESAFE_API_KEY),bin/fm-pr-state.shandbin/fm-pr-reviewers.sh(read-only PR blocker and reviewer discovery),bin/fm-contributions.shplusbin/fm-contributions.jq,bin/fm-env-lib.sh, a rewrittenbin/fm-inbox.shcapture/reply/receipt surface, the flag-gated Claude Code Calm mod under.claude/mods/firstmate-calm/, and fixes across the supervision scripts, docs, CI workflows, and test suite.bin/fm-crew-state.shnow reports the current declared state from onestatus_current_lineread with a three-tier priority (declaredpaused:/captain-held:wait first, then the still-open decision fold, then the newest state-bearing line), and both the remote-secondmate branch and the log fallback reuse that single result instead of re-deriving it viastatus_current_state_line.bin/fm-classify-lib.sh: secondmate relay discrimination reverts to the kind-blind terminal rule, decision-origin preservation reverts to upstream's unconditional reset, and a plaindone:becomes the sole retirement route for the fold's default bucket while apaused:line no longer retires it (decision-fold version bumped to 11), so a worker that appendedblocked:thenpaused:stays visible to the watcher.Risk Assessment
✅ Low: This round's changes are a comment and documentation sweep plus one behavior alignment that I confirmed by direct library execution to make the whole-file fold, the cursor-backed fold, and the watcher's origins map mutually consistent - resolving a genuine reader divergence the merge had introduced - leaving only a cosmetic inaccuracy in a migration note.
Testing
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
⏭️ **Rebase** - skipped
.agents/skills/harness-adapters/references/harness/claude.md- merge conflict rebasing onto origin/mainbin/fm-claude-trust.sh- merge conflict rebasing onto origin/maintests/fm-claude-trust.test.sh- merge conflict rebasing onto origin/mainbin/fm-classify-lib.sh:2173- c5ce30a claims a durable fix ("keep paused decisions visible to watcher"), but the same failure is still reachable after one more appended line. The exemption only holds while thepaused:line is the log's LAST line.The guard is
if [ -z "$after" ] && [ "$verb" != "$pause" ]; then origins=''; fi.afteris empty on EVERY line once the fold has emptied - including lines_fm_decision_fold_lineignores outright (bare continuation prose,note:,working:), which return$openunchanged and therefore retire nothing. So the blanket reset re-fires on the first such line after the pause and discards the origin the pause just preserved.Traced by executing the real library (sourced bin/fm-classify-lib.sh at c5ce30a,
_fm_status_open_decision_origins+status_span_first_actionable_record <f> 0 rec needs):blocked: cannot reach the registry+paused: waiting for the window-> origins=[default\t1], rc=0, event=blocked: cannot reach the registry(the fixed case)note: still waiting-> origins=[], rc=1, needs=0, no eventStill waiting on the window.(no colon, not an event at all) -> origins=[], rc=1, needs=0, no eventneeds-decision: should I deploy to prod?+paused: waiting for the window+note: bumping-> origins=[], rc=1, needs=0That third line is the ordinary away-mode shape: a worker that declares a blocker, declares the wait, then keeps narrating. The consequence is exactly what the comment at lines 2145-2159 says must not happen - and it is total, because the other two surfaces are already silent by design: the OPEN DECISIONS fold retires the unkeyed bucket on the plain
paused:line (origin/main #19, now also on the whole-file path via this same commit), and fm-crew-state.sh reportsstate: pausedbecausestatus_current_state_linewalks back past thenote:to the pause. Once origins is cleared, the watcher's actionable-event classifier is the last surface and it reports rc=1 / needs=0, so the captain's question is invisible everywhere.The new test at tests/fm-classify-decision-key.test.sh:101 passes only because it writes exactly two lines and stops; it never appends anything after the pause, which is why the general case ships unnoticed.
Smallest honest remedy, inside the condition this commit just wrote and adding no new state: reset only when the line is one the fold actually acted on - i.e. gate the reset on the verb being in the fold's own vocabulary (
needs-decision|blocked|done|failed|$resolve|$held) rather than onaftermerely being empty, keeping the existing pause exclusion. A line the fold ignores cannot retire anything and must not clear the map. I verified that candidate against the same inputs: origins survivesnote:/prose (rc=0, event surfaced) and is still correctly dropped by a laterdone:(pause-then-done-> origins=[], event=done: shipped) and byresolved:(pause-then-resolved-> origins=[], rc=1). One judgment call is left for the author: under that candidate a laterworking:line also stops clearing the origin, so a worker that resumes keeps the blocked line actionable until a real terminal/resolve lands - pick whetherworkingbelongs in the resetting set.Not a regression against the merge parent (929a366 cleared origins in every pause case, including pause-as-last-line), so this is an incomplete fix rather than new breakage - but the commit's stated invariant does not hold as written.
bin/fm-classify-lib.sh:2159- The comment block that is now the authoritative statement of this commit's invariant points at coverage that does not exist: "see tests/fm-watch-triage.test.sh's 'hidden behind a current wait' case".grep -rn "hidden behind a current" tests/matches only tests/fm-classify-decision-key.test.sh:109-114 - the assertions c5ce30a itself just added. There is no such case in tests/fm-watch-triage.test.sh (the string was introduced by origin/main's 102d710 alongside code that did not yet implement the exemption, so the reference was never live).The stale pointer is pre-existing text, but c5ce30a is what makes the comment's claim true and adds the test that actually backs it, so the one-line correction belongs here: retarget the reference at tests/fm-classify-decision-key.test.sh's
test_plain_pause_retires_the_default_fold_without_hiding_its_origin. Worth fixing because this comment is the only place the paused-exclusion invariant is explained, and a maintainer following the pointer to verify it finds nothing.🔧 Fix: narrow origin reset to fold transitions after pause
2 issues (1 warning, 1 info) still open:
bin/fm-classify-lib.sh:2180- de9456c narrows the wholesaleorigins=''reset to the fold's transition vocabulary, but four of the six verbs it kept in that list can only ever fire when the fold is ALREADY empty - i.e. exactly the pause-preserved state c5ce30a exists to protect - so the same failure is still reachable. The listneeds-decision|blocked|done|failed|"$resolve"|"$held"is applied under[ -z "$after" ]only, which is NOT the "did THIS line empty the fold" test the new comment claims.Concrete production sequence, traced by sourcing bin/fm-classify-lib.sh at de9456c and calling
_fm_status_open_decision_origins+status_span_first_actionable_record <f> 0 rec needs(kind=code):needs-decision [key=deploy]: ship it?-> open={deploy}, origins=[deploy>1]blocked: cannot reach the registry-> open={deploy,default}, origins=[deploy>1, default>2]paused: waiting for the window-> fold retiresdefault; after={deploy} is non-empty so no reset; origins still holds default>2 (the c5ce30a exemption working as designed)resolved [key=deploy]: yes, ship-> fold drops deploy, after=''; verb=resolved is in the list ->origins='', wiping the default origin the pause preserved.Measured result at step 4: origins=[], rc=1, needs=0, no event - the worker's live blocker is invisible to the watcher. Drop the pause (same 3 lines without
paused:) and the blocker survives: origins=[default>2], rc=0, record=blocked: cannot reach the registry. So the regression is specific to the pause path the commit claims to have fixed.Same outcome, verified, for
captain-held [key=...]after a pause (origins=[], rc=1) and for a bareresolved [key=other]after a pause. Step 4's writers are ordinary production paths: bin/fm-send.sh:682 emitsresolved [key=$k]: ...when the captain answers a keyed decision, bin/fm-captain-hold.sh:1689 emitscaptain-held [key=$key]: ...on a verified transfer. The two tests de9456c added never open a second keyed decision, which is why this ships unnoticed.The defect lives inside the component the fix round introduced, and the smallest honest remedy is to REMOVE the part of it that is not required rather than harden it:
needs-decision|blockedcan reach the reset only when the fold ignored the line and the open set was already empty, and"$resolve"|"$held"are already handled correctly per-key by the explicit arm at line 2199 (_fm_open_set_has "$after" "$key" || _fm_decision_origin_drop). All four are no-ops except when they destroy a pause-preserved origin. Narrowing the arm todone|failed) origins='' ;;is a one-line edit that adds no state. I patched a copy of the library with exactly that and re-ran every input: the keyed-resolve, keyed-held and unrelated-key cases now keep origins=[default] and report the blocker, while all three assertions of the newtest_a_terminal_after_a_pause_still_retires_the_preserved_originstill hold (done: shipped it anyway,failed: gave up on the registryand unkeyedresolved: registry is backeach retire the origin, via the explicitdone)/"$resolve"arms and the ship/scout wholesale wipe), and the three narration cases plus a pause-then-resume-then-reblock sequence are unaffected.Not a regression against the merge parent (929a366 cleared origins on every pause), so this is an incomplete fix rather than new breakage - but the commit's stated invariant still does not hold.
bin/fm-classify-lib.sh:2163- The comment block de9456c added is now the authoritative statement of this invariant, and its central claim is false: "The wholesale reset below asks whether THIS line emptied the fold, not merely whether the fold is empty". The condition at line 2179 is still[ -z "$after" ]- it never inspects$open, so it cannot distinguish "this line emptied the fold" from "the fold was already empty". Verified: aresolved [key=other]line that folds to no change at all still triggers the reset, becauseafterwas already empty.That gap is precisely the mechanism of the finding above, and a maintainer trusting this comment would conclude the pause-preserved origin is safe when it is not. Correcting the arm to
done|failedalso requires rewriting this sentence to say what the code does test (the verb, not a fold transition); the two edits belong together.🔧 Fix: reset origins only on wholesale terminal fold wipe
2 issues (1 warning, 1 info) still open:
bin/fm-classify-lib.sh:2182- 03f2590's narrowed reset does close the round-2 defect I verified (keyed resolve/held after a pause now leave another key's blocker actionable), but the SAME failure class is still reachable, because the condition tests$verbalone and never$kind- which is exactly the gate its own commit message ("reset origins only on wholesale terminal fold wipe") and its new comment at line 2163 claim.Concrete production sequence, traced by sourcing bin/fm-classify-lib.sh at HEAD and calling
_fm_status_open_decision_origins+status_span_first_actionable_record <f> 0 rec needswith a sibling.metaholdingkind=secondmate:needs-decision: should I deploy to prod?-> open={default}, origins=[default>1]paused: waiting for the window-> fold retires the OPEN DECISIONS row, after=''; c5ce30a's exemption keeps origins (working as designed)done [key=child-outcome-w1-done-abcd1234]: child w1 done: landed->_fm_decision_fold_linedoes NOT wholesale-wipe (kind=secondmate fails itsdone:ship|done:scout|failed:ship|failed:scoutarm) and_fm_decision_line_retires_defaultis false (the key is notdefault), so it returns$openunchanged = '' -> after=''. verb=done hits line 2182 ->origins=''.Measured at step 3: origins=[], needs=0, and the span carries only the relayed child line - the captain's unanswered question is invisible to the watcher, which is precisely what the comment at lines 2145-2160 says must not happen. Drop the pause and it survives (origins=[default>1], the question in the span), so the loss is specific to the path c5ce30a claims to fix.
Step 3's writers are ordinary production paths, not hypotheticals: bin/fm-inactive-reconcile.sh:458-465 publishes
<state> [key=child-outcome-<child>-<state>-<fp8>]: child <id> <state>: <note>onto the secondmate's parent channel via fm_parent_channel_report (state is done or failed), and bin/fm-secondmate-report.sh:104 writesdone [corr=<16hex>]: <note> (via-helper)onto the same file. I measured both, plusfailed [key=child-outcome-...]: all three give origins=[] after a pause and origins=[default>1] without one. This also falsifies the standing promise at lines 2146-2151 that a correlation-marked done "never prunes an origin here either", and it re-opens the secondmate own-line-vs-relayed-child-line theme the round-6 decision settled with "treat every keyed secondmate terminal as a relay, not its own".Not a regression against upstream:
_fm_status_open_decision_originsat upstream 6f0f139, at the merge 650dfd1 and at the merge parent 929a366 all carry the unconditional[ -n "$after" ] || origins='', which wipes in strictly MORE cases. So this is an incomplete fix, not new breakage - but the commit's stated invariant does not hold.Two materially different remedies, which is why this is ask-user rather than a third mechanical repair:
(a) Gate the reset on the wholesale wipe the comment already describes - read the unstamped copy (as
_fm_decision_fold_lineitself does) and fire only fordone:ship|done:scout|failed:ship|failed:scoutwith a colon present, ideally via a shared predicate both readers call, the way_fm_decision_line_retires_defaultis already shared between them. I patched a copy with exactly that and re-measured every case: the three relay cases above now keep origins and surface the blocker/question; round 3's newresolved [key=deploy]/captain-held [key=deploy]assertions, round 2's three narration cases, round 1's pause-only case, anddone:/resolved:after a pause all keep their current asserted outcomes. One fixture needs adjusting: tests/fm-classify-decision-key.test.sh:165test_a_terminal_after_a_pause_still_retires_the_preserved_originwrites no.meta, so_fm_status_kindreturnsunknownand itsfailed: gave up on the registrycase currently passes only because the fold happened to be empty; give that fixture akind=shipmeta and all three of its trailing lines pass under the gate (verified). Note the same coincidence already makes today's behavior inconsistent: add one open keyed decision andfailed:stops retiring the origin at HEAD too.(b) Take the fallback the intent already authorizes. The User intent records: "firstmate recommended one final bounded fix round with an automatic fallback to upstream's secondmate behavior if the theme recurs; the captain answered: 'I agree with your rec'." This is the third round on this mechanism (c5ce30a, de9456c, 03f2590) and the theme has recurred, so reverting all three to upstream's unconditional
[ -n "$after" ] || origins=''is an authorized, in-scope option - and the round-6 decision established that "the reconciliation target is upstream's behavior, not this fork's pre-merge behavior". The cost is that a paused worker's blocker goes back to being invisible to the watcher, the harm c5ce30a was written to stop.My recommendation is (a): it is a one-line correction to the condition already written plus one test fixture, it adds no state, it makes the code match both comments, and it preserves the captain-facing behavior the user selected in rounds 1 and 2. ask-user because either direction changes user-visible watcher reporting and (b) would reverse two recorded decisions.
bin/fm-classify-lib.sh:2163- The comment block 03f2590 added is now the authoritative statement of this invariant, and its central claim is false: it says the reset "exists for the one retirement the per-key arms further down cannot model: _fm_decision_fold_line discards the WHOLE open set on a ship's or scout's done/failed declaration". The condition at lines 2181-2183 inspects$verbonly - it never reads$kind, and never checks that the line carries the colon the fold's wholesale arm requires - so it also fires for a secondmate's or unknown-kinddone:/failed:line, including the keyed and correlation-marked relays the fold deliberately refuses to let retire anything. Verified by execution:done [key=child-outcome-w1-done-abcd1234]: ...on a kind=secondmate log clears the whole map.The older comment directly above (lines 2146-2151) is falsified the same way - it promises "this origins map only drops on resolve/held and on a done line that _fm_decision_line_retires_default accepts - so a correlation-marked done ... never prunes an origin here either" - and a maintainer trusting either sentence would conclude the pause-preserved origin is safe when it is not.
This is the mechanism of the finding above, so the two belong together: taking remedy (a) there makes both sentences true as written and needs no comment edit; if that remedy is declined, both sentences must instead be rewritten to say what the code does test (the verb alone, for every kind).
🔧 Fix: revert origin preservation to upstream's unconditional reset
3 issues (1 error, 2 warnings) still open:
bin/fm-classify-lib.sh:2167- This round's revert (f177b1d) restores upstream's unconditional[ -n "$after" ] || origins=''while the fork'spausedretirement arm in_fm_decision_fold_line(line 786) stays in place. The combination loses an unkeyedblocked:followed by a plainpaused:from BOTH readers - a state neither merge input has.Measured by sourcing each library version and folding
blocked: cannot reach the release host+paused: waiting for release access:So HEAD is strictly worse than upstream, than the fork's base, and than the commit this round reverted. The concrete harm:
status_span_first_actionable_recordreturns needs=0 with no event for that span, so the watcher classifies a stuck-and-waiting worker as routine and never escalates it;paused:is deliberately not captain-relevant, so nothing is shown as the event that replaced the blocker.bin/fm-brief.sh:335andbin/fm-dod-lib.sh:260instruct workers to write exactly this plain unkeyedpaused [at=...]: {why}shape, so this is the documented away-mode path, not a hypothetical.The intent's authorized fallback is "an automatic fallback to upstream's secondmate behavior if the theme recurs". As executed this revert does not reach upstream's behavior - it reaches something worse than upstream's - because only one half of the collision was reverted.
Three remedies, which is why this is ask-user:
(a) Round 3's own recommended remedy, which the user selected: gate the wholesale reset on the same wholesale wipe
_fm_decision_fold_lineperforms (unstamped line, colon present,done:ship|done:scout|failed:ship|failed:scout), via a predicate both readers share the way_fm_decision_line_retires_defaultalready is. I patched a copy with exactly that and measured:blocked:+paused:-> origins=[default\t1], actionable=YES;needs-decision:+paused:+done [key=child-outcome-w1-done-abcd1234]: ...on a kind=secondmate log -> origins=[default\t1] (the captain's question survives the relay, closing round 3's finding too);needs-decision:+done:-> origins=[] unchanged. This fixes both the paused case and the secondmate-relay case, and needs no test rewrite.(b) Revert f177b1d, restoring c5ce30a's pause exemption. Restores base and upstream's watcher behavior, but leaves round 3's relay case open.
(c) Go fully to upstream by DELETING
pausedfrom_fm_decision_fold_line's retirement arm at line 786 (and then the"$pause"gate addition at line 825 becomes unnecessary - removal of that component rather than hardening it). This restores upstream behavior in both readers, but contradicts fork PR #19's accepted intent, which explicitly named "a later terminal or paused line" as the retirement trigger, and invalidates the assertion intest_plain_pause_retires_the_default_fold_in_both_foldsthat this same fix round added.I recommend (a): it is the remedy the user already chose in round 3, it is the only one that closes both failure paths, and it keeps every current assertion green.
docs/architecture.md:194- This line is the repository's authoritative statement of the invariant the finding above shows is violated, and it is now false: "the supervisors' actionable-event map (bin/fm-classify-lib.sh's_fm_status_open_decision_origins) retires that bucket on the crew's own later plain unkeyeddone:line but never on a paused one ... which would otherwise make a worker that appendedblocked:and then paused disappear from supervision the moment the wait was declared, while OPEN DECISIONS still retires the row."At HEAD the map does retire on a paused line (measured above), and the worker does disappear from supervision - the doc describes the harm as the reason for a guard that no longer exists. It was true one commit earlier at c5ce30a and false again after f177b1d. The doc also states the OPEN DECISIONS half correctly, so it is the clearest existing record of which of the two readers is meant to retire on pause.
Taking remedy (a) or (b) above makes this sentence true again with no doc edit. Taking remedy (c) requires rewriting it, because under (c) OPEN DECISIONS would no longer retire the row either. ask-user for the same reason as the finding above: the remedy depends on which behavior the author wants, not on a mechanical correction.
tests/fm-watch-triage.test.sh:235- The only assertion covering the invariant in the first finding was rewritten inside this branch's merge resolution, so nothing now fails when the paused blocker is dropped.Both inputs to merge 929a366 assert the
paused:form:printf 'blocked: cannot reach the release host\npaused: waiting for release access\n'...|| fail "a blocked: event hidden behind a current wait was classified routine"HEAD carries the resolution's rewrite instead - the input changed to
working: waiting for release accessand the failure message to "hidden behind routine work", with a new comment ("Use a working line: a plain paused declaration intentionally retires the shared default bucket") justifying it.git log -S'working: waiting for release access' -- tests/fm-watch-triage.test.shfinds no non-merge commit, so the rewrite exists only in 929a366's conflict resolution; it converted a failing behavior into a passing weaker test rather than resolving the collision between upstream's unconditional origins reset and the fork's pause retirement arm. Theworking:variant it was replaced with is already covered by theneeds-decision+working:case 18 lines above, so the rewrite is pure coverage loss.Remedy: restore the
paused:input and its original failure message once the first finding is resolved - under remedies (a), (b) or (c) it passes. ask-user because it reverses a deliberate resolution decision made in the merge and is coupled to which remedy is chosen above.🔧 Fix: drop paused retirement to match upstream in both readers
3 issues (1 error, 2 warnings) still open:
bin/fm-brief.sh:345- The worker-facing brief still states the rule this commit deleted. Lines 345, 454 and 549 all tell every dispatched worker: "An UNKEYED one folds under a shareddefaultbucket naming no particular decision, so it retires the moment you append any later plain unkeyeddone:or$PAUSED_VERB:line - your own later status supersedes it silently". After 1eb7076 a paused line retires nothing in either reader.Provenance:
git log -Sputs that sentence at 102d710, "fix(bin): give unkeyed open decisions a retirement route and a printed resolve key (#19)" - the same fork PR whose paused-retires behavior the round-4 decision dropped in favour of upstream. It is absent from upstream 6f0f139 and from merge 650dfd1, so it is fork text that the remedy missed. The fix round's own new test comment (tests/fm-classify-decision-key.test.sh, "bin/fm-brief.sh and bin/fm-dod-lib.sh both instruct a worker going away to append exactly this plain unkeyedpaused [at=...]: {why}line") cites fm-brief.sh as the authority for the paused shape while leaving fm-brief.sh asserting the opposite rule.Concrete reachable sequence, measured against HEAD by sourcing the library: a worker appends
blocked: cannot reach the release host; the blocker later clears on its own and the worker goes away withpaused [at=...]: waiting for release access, believing per line 454 that its unkeyed row has retired. At HEADstatus_open_decisionsstill returnsdefault\tblocked\tcannot reach the release hostandstatus_span_first_actionable_recordreturns the blocked record - so the captain's OPEN DECISIONS keeps the stale row and the watcher keeps escalating the worker as actionable, indefinitely, until someone appends aresolved:or a plain unkeyeddone:line that the brief no longer identifies as the only route out.Remedy: delete
or \$PAUSED_VERB`/or `$PAUSED_VERB:`from the three paragraphs (345, 454, 549) so the plain unkeyeddone:` line is the only retirement route named, matching the code. No test asserts the sentence, so nothing else moves. auto-fix: this corrects a statement the same change falsified, in the direction the round-4 decision already settled; it raises no new question about author intent.docs/architecture.md:107- The fix round rewrote line 194 exactly as instructed but left line 107, so docs/architecture.md now contradicts itself on the same rule. Line 107 still says the shared unkeyed bucket "names no specific decision, so it also retires when the crew's own later plain unkeyeddone:or paused line lands"; line 194 now says it is retired "by the crew's own later plain unkeyeddone:line in both readings ... and by apaused:line in neither". Line 194 is the one that matches the code I executed.Line 107 is the more load-bearing of the two for a reader: it documents the OPEN DECISIONS section that
fm-wake-drain.shprints on every presentation and the--resolve-key defaultfooter a captain uses to close such a row, i.e. exactly the surface whose behavior changed. Remedy: dropor pausedfrom line 107.bin/fm-classify-lib.sh:1205- Six comment sites in the changed file still state the deleted rule, two of them documented contracts rather than incidental prose:Line 1205 is the
FM_OPEN_DECISIONS_FOLD_VERSION=10rationale: "10: a plain (no correlation token) done/paused line now also retires the shared &fix(bin): report wake drain presentation failures on stdout #34;default&fix(bin): report wake drain presentation failures on stdout #34; bucket, so a cursor persisted under version 9 must be discarded". That numbered list is the documented meaning of the persisted cursor version, and item 10's stated rule is now half wrong. No cursor is actually mis-trusted - I checked the value at every relevant revision (upstream 6f0f139 = 9, fork base 6ad419d = 6, introduced as 10 by the merge 929a366 itself), so version 10 exists only on this unmerged branch and 10 invalidates both 6 and 9 - and the bump is still warranted, because HEAD's fold still retires on a plaindone:where upstream 9 does not (measured: kind=taskneeds-decision:+done:yields an empty set at HEAD, an opendefaultrow upstream). Only the stated reason is stale.Line 1032 is
status_key_closing_verb's own contract: "For the shared &fix(bin): report wake drain presentation failures on stdout #34;default&fix(bin): report wake drain presentation failures on stdout #34; key alone the closing verb may also be a plaindoneor the configured paused verb, because a plain unkeyed line of either retires that bucket". Paused no longer does.Lines 435, 438, 440 and 468 repeat "done/paused" in the key-grammar block that the code's own retirement arm at 787 now explicitly contradicts ("A paused line retires nothing").
Remedy: drop
/pausedandor the configured paused verbat 435, 438, 440, 468, 1032 and 1205, leaving the plaindoneline as the sole retirement route. Mechanical, no behavior change.🔧 Fix: name plain done as sole default-bucket retirement route
2 issues (1 warning, 1 info) still open:
bin/fm-classify-lib.sh:1207- The fix round changed_fm_decision_fold_linesemantics (1eb7076 removed the paused verb from both the accepted-verb gate at line 765 and the retirement arm at line 783) but leftFM_OPEN_DECISIONS_FOLD_VERSION=10. The block directly above states the rule this breaks: "FM_OPEN_DECISIONS_FOLD_VERSION must be bumped whenever _fm_decision_fold_line semantics change, so persisted state from an older interpretation is discarded and rebuilt from byte 0" (line 1139). Instead of bumping, 9c87acc rewrote item 10's prose to describe the NEW rule, which silently reinterprets cursors already persisted under version 10's OLD rule.Reproduced concretely, not inferred. In a temp state dir with
kind=secondmate, status logblocked: cannot reach the release host+paused: waiting for release access:git show 929a366:bin/fm-classify-lib.sh) and calledstatus_open_decisions_incremental. Under the old rule the paused line retired the default bucket, so it wrote.t.open-decisions-cursor=version=10:secondmate / offset=74 / ident=strong:...with an EMPTY open set.working: retrying the release hostand ran HEAD's library on the same file:status_open_decisions->default\tblocked\tcannot reach the release hoststatus_open_decisions_incremental-> `` (empty)The version matches, the ident matches, the file only grew, so no invalidation signal fires and the stale empty set is trusted forward forever; no later append can reopen the row because the blocked line is behind the cursor.
That is the exact surface the change is about:
bin/fm-wake-drain.sh:444builds the captain-facing OPEN DECISIONS section throughscan_open_decisions_snapshot->status_open_decisions_incremental(line 1841), so a real blocker stays invisible to the captain on every presentation while the whole-file fold says it is open. It also breaks the invariant the library documents at line 1129 ("the two strategies can never disagree on what is open") and thatassert_foldpins in tests/fm-classify-decision-key.test.sh.Reachability is narrow but real: it needs a state directory folded by this branch's code between merge 929a366 and 1eb7076. It is also a direct violation of the file's own stated invalidation contract, independent of who holds such a cursor today.
Remedy: set FM_OPEN_DECISIONS_FOLD_VERSION=11 and add an item-11 line recording that a paused line no longer retires the shared "default" bucket, so cursors folded under version 10's paused-retiring reading are discarded and rebuilt from byte 0. Revert item 10's prose to describe what version 10 actually meant. auto-fix: this corrects an omission in the fix round's own change, adds no new state or machinery (the versioning mechanism already exists), and is invisible to users beyond one extra full re-fold.
bin/fm-classify-lib.sh:1031- The contract comment 9c87acc rewrote at lines 1030-1032 now reads: "For the shared &fix(bin): report wake drain presentation failures on stdout #34;default&fix(bin): report wake drain presentation failures on stdout #34; key alone the closing verb may also be a plaindone, because a plain unkeyed line of that verb retires that bucket." The function contradicts it for every kind except ship/scout.status_key_closing_verb's per-line filter (lines 1062-1070, taken verbatim from upstream 6f0f139) lets a line reach the fold only whenevent:kindis one ofdone:ship|done:scout|failed:ship|failed:scout, or when the verb isneeds-decision|blocked|$resolve|$held. A plaindone:on any other kind hits*) continue, so the fold never sees it anddefaultnever closes in this reader.Measured on
needs-decision: ship without migration?+done: shipped, varying only the sibling .meta kind:ship -> status_open_decisions=[] status_key_closing_verb default=[done]
secondmate -> status_open_decisions=[] status_key_closing_verb default=[needs-decision]
task -> status_open_decisions=[] status_key_closing_verb default=[needs-decision]
Upstream's own test pins the secondmate answer: tests/fm-classify-decision-key.test.sh:693 asserts
expected=blockedfor kind=secondmate / want=default / terminal=done. And the one case that does reportdone(ship) is the ship/scout terminal rule closing EVERY key, not the "default key alone" exception the sentence credits.No behavior consequence today: the sole production caller, bin/fm-captain-hold.sh:1834, only tests
= "$resolve", and bothdoneandneeds-decisionfail that test identically. This is a stated function contract that is false, in the exact comment the last commit authored, so a later reader using it to decide whetherdefaultis still open would be misled.Remedy: replace the sentence with one that matches the code - the reported closing verb is
resolvedor the captain-held verb, plus a ship/scout terminaldone/failedwhich closes every key; the fold's plain-donedefault-bucket retirement is deliberately NOT reflected here, because the per-line filter drops plaindoneon other kinds.🔧 Fix: bump fold version 11 and correct closing-verb contract
4 issues (1 warning, 3 infos) still open:
.agents/skills/afk/SKILL.md:70- The round-5/6 sweep removed paused-retirement promises from the three bin/fm-brief.sh worker paragraphs and docs/architecture.md lines 107/194, but missed a fourth site, and this one is a live operational instruction rather than prose: "An UNKEYED blocker is retired by that fold on the worker's own later plain unkeyeddone:or paused line rather than by any proof it cleared ... so an away-window blocker that must hold the return needs its own[key=<slug>]."1eb7076 removed the paused verb from both the accepted-verb gate (bin/fm-classify-lib.sh:765) and the retirement arm (:783), so the paused half is now false. Verified by executing the real library:
blocked: cannot reach the release host+paused: waiting for release accessyieldsdefault\tblocked\tcannot reach the release hostfrom status_open_decisions AND status_open_decisions_incremental at HEAD.This is not a comment about internals. The sentence describes the exact gate it sits next to, and bin/fm-afk-return.sh:186 implements that gate by reading
status_open_decisionsand keeping every row whose verb isblocked(:191). So the away agent is told that a pause retires an unkeyed blocker and therefore that keying is needed to hold the return, when in fact the unkeyed blocker now survives a pause and holds the return by itself.Provenance: the sentence is the fork's own (absent from upstream 6f0f139, whose text reads "keeps every open
blocked:event until that blocker's own resolution is proven"), present at base 6ad419d:72 and carried through the merge at 929a366:70. Remedy: dropor pausedfrom the sentence, leaving the plain unkeyeddone:line as the sole retirement route, exactly as the three fm-brief.sh paragraphs were changed. Mechanical, no behavior change.bin/fm-classify-lib.sh:2152- The comment 1eb7076 rewrote opens with "Deliberately narrower than _fm_decision_fold_line's own retirement arm", but after paused was removed from the fold the two retirement conditions are equivalent, so the stated reason for the whole comment no longer exists, and the same comment's next paragraph now says the opposite ("Neither reader retires anything on a paused line").Traced both arms over every relevant case. The fold (:790) drops
defaultwhen_fm_decision_line_retires_default "$key" "$line". The origins map (:2194) drops the origin when that same predicate holds AND! _fm_open_set_has "$after" "$key". Case by case: keyed done -> predicate false in both, no drop; correlation-marked done -> predicate false in both, no drop; plain done withdefaultopen -> fold drops the row,aftertherefore lacksdefault, origins drops the entry; plain done withdefaultnot open -> fold no-ops, origins' extra guard is satisfied but there is no entry to drop. The extra_fm_open_set_hastest never changes an outcome; it only re-confirms what the fold just did on the same line. The asymmetry the word "narrower" named was the paused verb, which only the fold used to accept.No behavior consequence - this is comment text only. Remedy: replace the "deliberately narrower" framing with a statement that the two readers now retire the shared bucket on exactly the same predicate (a plain, correlation-token-free, unkeyed
doneline) and neither retires on a paused line, which is what the rest of the comment already says.bin/fm-classify-lib.sh:1210- The item-11 note 3c81e1c added reads "a paused line no longer retires that bucket - only a plain done one does, matching upstream". The paused clause is true, but "matching upstream" sits immediately after the plain-doneclause, and upstream does not retire the shared "default" bucket on a plain done line at all.Measured, not inferred. Sourced
git show 6f0f139:bin/fm-classify-lib.shand foldedneeds-decision: should I deploy?+done: shipped: upstream reportsdefault\tneeds-decision\tshould I deploy?for kind=task AND kind=secondmate, i.e. still open. HEAD reports an empty set for the same input. Structurally, upstream's_fm_decision_fold_lineaccepts onlyneeds-decision|blocked|$resolve|$held(its soledonehandling is the ship/scout terminal wipe, which closes every key rather than the default bucket); thedone)retirement arm at :783 and the|donein the verb gate at :765 exist only on the fork side, inherited from base 6ad419d.That matters for this file specifically: the numbered version list is the record a future reconciliation reads to decide which fold rules are the fork's own and which came from upstream, and this entry credits upstream with the one fold rule that is still fork-only. No behavior consequence. Remedy: attach "matching upstream" to the paused clause alone, and say that the plain-
doneretirement of the shared bucket remains the fork's own divergence from upstream's fold.tests/fm-classify-decision-key.test.sh:122-test_a_plain_pause_retires_nothing_in_either_reader(added by 1eb7076) is invoked on the line immediately after its own definition, while every other test in the file is invoked from the single list further down (:443 onward), which 1eb7076 did edit in the same hunk to renametest_unkeyed_blocked_retires_after_a_later_terminal_line.The test does run and its assertions are correct - I reproduced its expected outcome directly against the library (both folds report
default\tblocked\t…;status_span_first_actionable_recordreturns rc 0 with the needs-decision marker set). So this is wiring, not coverage: the file's convention is one invocation block, and the next person who notices the new test is missing from that block will add it there and silently run it twice. Remedy: delete the inline call at :122 and add the name to the invocation list beside the test it was written to complement.🔧 Fix: correct stale paused-retirement claims and test wiring
1 info still open:
bin/fm-classify-lib.sh:1211- The version-11 migration note states the stale-cursor direction backwards. It reads: "a cursor folded under version 10's paused-retiring reading holds a bucket this reading keeps open and must be discarded." A version-10 cursor cannot hold that bucket - version 10 is precisely the reading that RETIRED it on the paused line, so its persisted open-set has already dropped the row. Version 11 is the reading that keeps it open, so the stale cursor is MISSING a row, not holding an extra one.Measured, not inferred. Sourced
git show 929a366:bin/fm-classify-lib.sh(the version-10 reading) and foldedneeds-decision: should I deploy?+paused: waitingfor kind=task:status_open_decisions_incrementalreturned the empty set, i.e. the persisted cursor droppeddefault. HEAD returnsdefault\tneeds-decision\tshould I deploy?for the same input. So the discard is needed because the old cursor UNDER-reports, which is also the more consequential direction: an open captain-facing decision would stay invisible until the cursor is rebuilt, whereas the sentence describes a harmless extra row.No behavior consequence - the bump itself is correct and I confirmed both
status_open_decisions_incremental(line 1294) andstatus_open_decisions_cursor_offset(line 1892) compare againstFM_OPEN_DECISIONS_FOLD_VERSION:$kindand reset to offset 0 on mismatch. The defect is only in the record a future reconciliation reads to understand why each version was spent, which is the same record round 7 corrected for its upstream-attribution error. Remedy: state that a version-10 cursor has already dropped a bucket this reading keeps open, so it must be discarded and rebuilt from byte 0. The rest of the entry (the paused/upstream attribution and the fork-only plain-donedivergence) is accurate as written - I verified upstream 6f0f139's fold accepts nodoneverb outside its ship/scout terminal rule.⏭️ **Test** - skipped
Step was skipped.
docs/architecture.md:107- Judgment call left unchanged: the unkeyeddefault-bucket retirement rule now appears twice in this document - as a clause inside the wake-drain OPEN DECISIONS paragraph (:107) and as the full cross-reader rule in the shared-classification-library section (:194, the owner). This run's fix rounds had to update both copies when paused retirement was dropped, which is the duplication signal. I left :107 in place rather than reducing it to a pointer, because removing the clause would make its own sentence ("keeps surfacing until it is explicitly resolved") misleading for the captain-facing reader, and a pointer-plus-summary would restate nearly the same words. Both copies are correct against HEAD today. If a follow-up wants one owner, the right move is to trim :107 to the--resolve-key/defaultfact and let :194 own retirement, which is a small consolidation rather than anything this change requires.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.