feat(bin): add decision-hold answer-time closure and workflow lint gate - #22
Merged
Merged
Conversation
…eyed-answer path (kunchenguid#2490) * fix(decisions): close captain holds at answer time Firstmate had two "a decision is open" ledgers with asymmetric closing mechanics. The live status-log ledger closes atomically at answer time, because bin/fm-send.sh --resolve-key makes answering a decision be the act that closes it. The durable backlog hold ledger had no such coupling: answering and recording were two separate acts, and only the first was forced by the workflow. That asymmetry lost four real captain decisions. Their answers were captured durably to disk, keyed character for character by the hold decision keys, acknowledged, and even implemented and shipped, yet the holds stayed open for two days and the captain was asked to re-answer decisions already on his own disk. Give the hold ledger the same answer-time-closure property: - bin/fm-decision-hold.sh gains an `answer` subcommand, the hold ledger's counterpart to --resolve-key. It shares one unrouted close implementation with `decline`, so it carries every existing guard - the captain decision file, the active-hold requirement, retry identity, and the refusal to release still-routed work - and differs only in the resolution mode it records. `decline` keeps its stronger meaning that the answer routes no follow-up work at all. - bin/fm-procevent-lavish.sh wires the channel that actually carried the lost answers. `arm --decisions-origin` binds a deck to the origin whose holds it carries, `answers` reads the structured choices out of a captured poll result, `close-decisions` maps each key to its hold and closes it through the command above, and `autohandle` lets the runner apply that at capture time. Safety is preserved rather than traded away. Only rows tagged `choice` are read, so freeform captain prose cannot forge a decision key. Closure is confined to the one bound origin. The decision text is a pure function of the captured result, so a replayed capture is idempotent. A hold that is absent, already closed, or still blocking routed work is skipped and left for `resolve`, never forced. A deck armed without the binding touches no hold at all. And autohandle deliberately never reports full handling, because recording an answer is transcription while acting on it is firstmate's judgement - so the check wake still reaches the handler. fm-send --resolve-key is untouched. * no-mistakes(document): document state/lavish-decisions binding dir in AGENTS.md state inventory * refactor(decisions): make keyed-answer closure one general capability The previous pass gave holds answer-time closure but built it as bespoke Lavish wiring: the review adapter carried the source-to-origin binding, mapped keys to hold identities, wrote decision records, decided what to skip, and closed holds itself. That treated a review deck as a special decision source. It is not - it is an ephemeral discussion format that happens to carry answers. Collapse it into ONE general capability with one owner. bin/fm-decision-hold.sh now owns the whole of "a keyed answer closes its matching hold": - `answers <origin> --source <provenance>` is the channel-agnostic intake. It reads key/answer/label lines on stdin, maps each key to its hold, and closes it through the same `answer` path, so every guard applies identically whatever channel the answer came from. --source is provenance recorded in the decision, never a behavior switch; there is no per-channel branch and no knowledge of chat, decks, or transports. - `bind`/`unbind`/`binding` own the source-to-origin binding for any channel whose answers arrive detached from their origin. Every channel is now an ordinary caller that only turns what it received into keyed lines: - bin/fm-send.sh (chat) feeds the intake for a key that names an active hold. This also fixes a real gap: once `complete` transfers a decision to its hold it closes the live status copy, so --resolve-key alone could never answer a transferred decision. - bin/fm-procevent.sh feeds it generically. A bound source's captured result goes to `<adapter> answers <result-file>` and whatever that prints is piped into the intake. The runner names no adapter, parses no result, and carries no decision rule, so any future adapter with an `answers` command works with no change here. - bin/fm-procevent-lavish.sh keeps only `answers`, which reports the structured choices a review captured and stops. It maps nothing to a hold and closes nothing; it lost ~160 lines of decision logic. Feeding is independent of handling, so it never acknowledges a result and never suppresses a wake - recording an answer is transcription, acting on it stays firstmate's judgement. The regression that proves closure now drives a FIXTURE adapter that is not the review adapter, so what is proven is that any bound channel reaches the intake rather than that one channel is wired specially. A new regression drives the real fm-send over a stubbed transport for the chat side. Every prior guarantee still holds, and fm-send's status-log behavior is unchanged. * no-mistakes(review): test(decisions): drop source-content grep from hold-closure regression
…mlink (kunchenguid#2512) A Write aimed at CLAUDE.md followed the symlink and destroyed AGENTS.md. The installer now creates and migrates to a recoverable two-line pointer file.
* fix(lint): catch malformed GitHub workflows before merge A self-broken ci.yml cannot report its own breakage, so parse every workflow in the local lint path that no-mistakes already runs. * fix(lint): pin actionlint instead of Ruby for workflow lint A self-broken ci.yml still has to fail in the local lint path, and the named tool for that gate is actionlint, not a new Ruby runtime. * no-mistakes(document): Clarify pinned workflow lint documentation
…d#2546) * fix: install pinned shellcheck and actionlint on macOS and linux arm64 The installers were hardcoded to linux amd64 and sha256sum, so a Mac dev could not satisfy the refuse-on-mismatch lint gate. Select the official per-platform archive and checksum, and fall back to shasum -a 256. * no-mistakes(document): Document cross-platform pinned lint installers
…uid#2548) .no-mistakes.yaml has set test.evidence.store_in_repo: true since kunchenguid#2355, but CONTRIBUTING.md, docs/configuration.md, and docs/architecture.md still described the old policy of keeping evidence out of the repo in a temp directory. The current no-mistakes behavior for store_in_repo: true is to publish each run's test evidence to the orphan no-mistakes/evidence branch and link it from the PR body. That branch shares no history with code branches, so evidence never enters a pushed feature branch or the default branch, and CI's tracked personal fleet paths rule stays accurate. Docs only. No change to .no-mistakes.yaml or any workflow.
* docs: correct test evidence storage comment in .no-mistakes.yaml * no-mistakes: apply CI fixes
…nchenguid#2563) Make that a first-class option in always-loaded instructions so firstmate does not default to mediating and tearing the scout down between iteration rounds.
…chenguid#2570) * fix(bin): report remote secondmate delivery and state truthfully A steer to a remote secondmate crosses fm-on.sh to a host-local fm-send leg whose unconfirmed submit read-back (verdict=pending, typically a busy mate whose harness queues the steer) was flattened into exit 1, so the parent printed "error: text not submitted" / "error: text not sent" and discarded the pending-reply expectation for a steer that had actually landed. fm-send now carries the verdict across the ssh boundary as a documented delivered-unconfirmed exit 3: the parent reports the steer as delivered with confirmation pending, exits 0, keeps the expectation armed (awaiting_report), and closes --resolve-key decisions, while transport loss (ssh 255) and real remote failures keep failing loudly with the remote leg's stderr attached. A local unconfirmed submit now also exits 3 with an honest non-error message and still never closes a decision key. fm-crew-state.sh and fm-peek.sh no longer read a remote mate's endpoint through local probes (which misreported a healthy mate as "worktree gone" / "can't find session: remote"): both now use the true remote source over fm-on.sh, and an unreachable or unreadable remote reads as unknown-remote, never as gone or dead. * no-mistakes(document): Document remote delivery and state truth * no-mistakes: apply CI fixes
…ch 2 of 8) under the sequenced merge Batch 2 of the eight-batch sequenced upstream merge (data/map-upstream-firstmate-for-reconciliation/report.md section 6.1). Merges the contiguous upstream prefix through d843712 (kunchenguid#2570), 9 commits, per the 2026-08-12 merge-never-rebase rule. Ancestry preserved. PROOF PASS ON THE kunchenguid#2490 "DUPLICATE" The map classified 362c508 (kunchenguid#2490) as a DUPLICATE of fork PRs #3/#5 and left "does upstream cover fork #5's precondition recheck" as an unverified claim. Both were resolved by content before any conflict was touched: - The fork does NOT carry kunchenguid#2490. Its keyed-answer capability is absent from the fork entirely: bin/fm-decision-hold.sh exposed only complete/resolve/decline, while upstream adds answer, answers, bind, unbind, and binding. The two "answers" hits in the fork's fm-send.sh and fm-procevent.sh are English prose in comments, not the capability. kunchenguid#2490 is new capability, not a duplicate. - Upstream does NOT carry fork #5. print_precondition_reminder appears 3 times in the fork and 0 times upstream, and upstream's skill sequence has no precondition-recheck steps. So the two changes are complementary, and every resolution below is a union that keeps both contracts stated exactly once - not a side-pick. RESOLUTIONS, ONE REASON PER FILE bin/fm-send.sh - took upstream's case body, which records each key into RESOLVE_STATUS_KEYS. This is load-bearing: the close path consumes that variable, so the fork's no-op branch would have left it empty and silently closed nothing. Folded fork PR #3's diagnostic (naming the keys actually open) into upstream's single surviving refusal, so the contract is stated once and the existing comment above it stays true. .agents/skills/decision-hold-lifecycle/SKILL.md - took upstream's step 7, which names the new answer command and the channel-closed case, and kept fork PR #5's steps 8-11 that upstream lacks. Two integration fixes the merge needed: added answer to the unrouted list, since it routes no work and so must not trigger the precondition recheck, and renumbered upstream's "confirm it in step 8" cross-reference to step 12 under the merged numbering. docs/architecture.md - took upstream's evidence-storage description on the facts, not on policy. The fork's text said evidence stays in the crew branch; that is stale. Verified: no-mistakes/evidence exists on both remotes, shares no common ancestor with main (a real orphan branch), holds the evidence files, and the fork's main tracks zero .no-mistakes/ paths. Upstream's kunchenguid#2548/kunchenguid#2549 corrected exactly this. Kept the fork's merge-time static guard clause, which is fork-unique and which the following sentence already depends on. bin/fm-brief.sh - kept the fork's structured 6-part scout report contract and added upstream's new Lavish self-hosted review hint (kunchenguid#2563). Orthogonal additions; neither side loses anything. docs/decision-hold-lifecycle.md - the conflict was a recorded evidence line and neither side's number was correct for the merged tree. Re-ran the check and recorded the measured result (70/259, against the fork's 69/257 and upstream's 68/253), and added the actionlint lines the new workflow gate now emits. VERIFICATION bin/fm-lint.sh rc=0 (ShellCheck 0.11.0 pinned, actionlint 1.7.12 pinned, 3 workflow files valid). Six suites rc=0: fm-decision-hold-lifecycle, fm-send-resolve-key, fm-brief, fm-teardown, fm-fleet-snapshot-view, fm-bearings-snapshot. Both sides' behavior is proven by passing tests that existed independently: "a bound channel's captured answers close their captain holds at answer time" (upstream's new path) and "a refusal names the key(s) actually open for this task" (fork PR #3's diagnostic). bin/fm-decision-hold.sh auto-merged with no marker; confirmed semantically that fork #5's reminder survived at both call sites, that both sit inside command_resolve only, and that all five upstream subcommands are present. CLAUDE.md converts from symlink to the @AGENTS.md pointer (kunchenguid#2512); CI's pointer check (kunchenguid#2515) passes locally on all three assertions. Batch 2 does not touch the pause_state_class region; fork PR #18 is untouched by this merge.
…d corrupted-binding diagnostics
…old-lifecycle verification record
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
Batch 2 of the eight-batch sequenced upstream merge described in data/map-upstream-firstmate-for-reconciliation/report.md section 6.1, which is the authoritative map. Batch 1 landed as fork PR #20 (e518906). Batch 2 merges the contiguous upstream prefix through d843712 (upstream PR kunchenguid#2570): 9 commits, mapped at about +5 files / +12 hunks of new conflicts, LOW-MEDIUM risk.
STANDING CONSTRAINTS FOR THIS WORK (deliberate, not accidental):
-- --mergeprecisely so a squash cannot flatten it. Do not propose squashing, rebasing, cherry-picking, or splitting this into smaller commits.THE PROOF PASS, which was the substantive work of this batch:
The map classified upstream 362c508 (kunchenguid#2490, keyed-answer path) as a DUPLICATE of fork PRs #3 and #5, and separately recorded as an UNVERIFIED CLAIM that it could not tell whether kunchenguid#2490 covers fork PR #5's precondition-recheck behaviour. Both questions were required to be settled by content, with the evidence recorded, before any conflict was resolved. Both were settled, and both went the opposite way from the map's label:
Consequence, and the governing decision for every conflict in this batch: the two sides are complementary, so each conflict was resolved as a UNION that preserves both contracts stated exactly once. This is deliberately NOT a side-pick.
PER-FILE RESOLUTIONS AND WHY (all deliberate):
answerwas added to the unrouted list in step 8 because it routes no work and so must not trigger the precondition recheck, and upstream's cross-reference "confirm it in step 8" was renumbered to step 12 to match the merged numbering.WHAT MUST NOT BE REGRESSED:
Fork PR #18's pause_state_class rewrite fixes a false-stale bug upstream has not fixed. Batch 2 deliberately does not touch that region and this merge leaves it untouched. Separately, two captain decisions recorded 2026-08-24 govern LATER batches only and must not be applied here: taking upstream's watch-triage de-flake (batch 4) and adopting upstream's decision-hold to captain-hold rename (batch 6).
SCOPE:
Batch 2 only. MERGE_HEAD is exactly d843712 and nothing beyond it was pulled in; the batch deliberately does not stop short at upstream commit 6 (88d0f2e), which the map measured as costing more conflicts than going one commit further.
VERIFICATION ALREADY PERFORMED:
bin/fm-lint.sh exits 0 with ShellCheck 0.11.0 (pinned) and actionlint 1.7.12 (pinned), 3 workflow files valid. Ten test suites exit 0, including fm-decision-hold-lifecycle, fm-send-resolve-key, fm-brief, fm-teardown, fm-crew-state, fm-peek-remote, fm-send-remote-delivery, fm-lint-workflows, fm-fleet-snapshot-view and fm-bearings-snapshot. Both sides' behaviour is proven by independently authored passing tests: "a bound channel's captured answers close their captain holds at answer time" for upstream's new path, and "a refusal names the key(s) actually open for this task" for fork PR #3's diagnostic. bin/fm-decision-hold.sh auto-merged without a conflict marker and was checked semantically: fork PR #5's reminder survives at both call sites, both are inside command_resolve only, and all five upstream subcommands are present. CLAUDE.md converting from a symlink to the @AGENTS.md pointer file is upstream kunchenguid#2512 and is intended; CI's pointer check from kunchenguid#2515 passes locally on all three assertions.
What Changed
bin/fm-decision-hold.shgainsanswer,answers,bind,unbind, andbindingsubcommands, andbin/fm-send.sh's keyed-answer path now records each resolved key intoRESOLVE_STATUS_KEYS(closing captain holds at answer time) while a merged single refusal still names the specific keys open for the task;.agents/skills/decision-hold-lifecycle/SKILL.mdanddocs/decision-hold-lifecycle.mddocument the merged step sequence, unrouted-command list, and refreshed verification counts.bin/fm-lint-workflows.shandbin/fm-install-actionlint.sh, wired intobin/fm-lint.shand.github/workflows/ci.yml, plus newtests/fm-lint-workflows.test.shand expandedtests/fm-lint.test.shcoverage.bin/fm-crew-state.sh,bin/fm-peek.sh, andbin/fm-remote-secondmate-control.shreport remote secondmate delivery and state more accurately;bin/fm-brief.shnotes that a live scout may host its own review loop;docs/architecture.mdcorrects the evidence-storage description to match the orphan evidence branch;CLAUDE.mdconverts from a symlink to a real@AGENTS.mdpointer file; new/updated tests cover remote peek, remote send delivery, crew state, and decision-hold binding paths end to end.Risk Assessment
✅ Low: All three prior findings across two fix rounds (stdin leak, swallowed corruption diagnostic, tempfile permission hygiene) were verified as correctly and minimally fixed, matching established sibling patterns with no regressions; the fix-round diff is a single precise line, shellcheck and bash syntax are clean, and no user-intent constraints (required/forbidden behaviors, must-not-regress items) are affected.
Testing
All targeted behavior suites relevant to this batch's union-merge conflict resolutions and its two post-verification review fixes pass (49 existing assertions plus one new one, exit 0 throughout). Added and validated one focused regression test for the previously-uncovered corrupted-binding-diagnostic-forwarding fix in fm-procevent.sh, confirming it fails before the fix and passes after via a real revert/restore cycle. The stdin-leak hardening in command_answers remains only indirectly exercised (the underlying subprocess doesn't currently read stdin, so no observable regression could be constructed), which is disclosed here as a coverage limitation rather than a defect. Worktree is clean except for the intentional new test addition.
Evidence: tests/fm-decision-hold-lifecycle.test.sh full run (post-fix, including new regression test)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 2 issues found → auto-fixed (2) ✅
bin/fm-decision-hold.sh:787- bin/fm-decision-hold.sh:787 — command_answers reads keyed-answer lines from stdin in awhile IFS=$'\t' read -r key answer label; do ... doneloop with no input redirection of its own, and inside that loop it recursively invokes"$0" answer "$origin" "$key" --decision-file "$tmp"without redirecting that child's stdin away (e.g.< /dev/null). The child inherits the same stdin pipe/fd as the outer read loop.command_answer->close_unrouted_hold->tasks_axi()(bin/fm-decision-hold.sh:205-207) runstasks-axi "$@"with no stdin redirect either, so if any tasks-axi subcommand in that chain ever reads from stdin (a prompt, pager, or unexpected TTY-less behavior), it would silently consume bytes intended for the outer loop, truncating a multi-key answer batch (e.g. the 5-choice batch the intent's own verification section describes) with no error surfaced — command_answers only reports closed/skipped counts for the lines it actually saw. This is the exact anti-pattern this same merge batch's sibling calls (bin/fm-crew-state.sh:181, bin/fm-peek.sh:34, bin/fm-send.sh:514,585) defend against by adding< /dev/nullto subprocess invocations. Mechanical, non-controversial fix: add< /dev/nullto the recursive"$0" answer ...call.bin/fm-procevent.sh:184- bin/fm-procevent.sh:184 — feed_keyed_answers doesorigin=$("$SCRIPT_DIR/fm-decision-hold.sh" binding "$id" 2>/dev/null) || return 1, discarding stderr and collapsing two distinct outcomes into one silent skip: a source with no binding (intentional, documented no-op) versus a corrupted binding record. fm-decision-hold.sh's own read_binding (around line 693) is deliberately designed so a wrong-schema or invalid-origin binding file is a loudfailrather than a silent unbound, per its own comment ('feeding nothing is the safe direction only when it is a deliberate choice, never when it is a corrupted record'). In the automated procevent capture path — the only caller that matters in production — that diagnostic is thrown away and cmd_start (line 416) only prints on success ('answers-fed: ...'), never on failure, so a corrupted binding degrades silently to the manual-wake fallback with zero signal anywhere that it was corruption rather than an intentionally unbound source. Not a data-loss bug (the capture and its wake are preserved either way per the documented best-effort contract), just a lost diagnostic for an edge case that atomic bind/unbind writes make unlikely to occur in normal operation.🔧 Fix: Fix stdin leak in keyed-answer replay and forward corrupted-binding diagnostics
1 warning still open:
bin/fm-procevent.sh:186- bin/fm-procevent.sh:186 — the fix-round's new corrupted-binding diagnostic path createserr=$(mktemp "${TMPDIR:-/tmp}/fm-procevent-binding-err.XXXXXX")withoutumask 077before it, so the temp file capturing fm-decision-hold.sh's stderr (which can contain internal paths fromfail "decision binding is unsafe: $path"/"...incompatible schema: $path"/"...invalid origin id: $path") is created with the default (often world-readable) permissions in the shared TMPDIR/tmp. This is inconsistent with the established convention for this exact same pattern elsewhere in the very code this fix models itself on: bin/fm-decision-hold.sh:774-775 useserr=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-keyed-decision-err.XXXXXX")for an identical subprocess-stderr-capture purpose. Mechanical one-line fix: wrap the mktemp call withumask 077; mktemp ...like the sibling code does.🔧 Fix: Harden binding-error tempfile permissions with umask 077
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-decision-hold-lifecycle.test.sh (17/17 assertions pass, including new test_corrupted_binding_forwards_its_diagnostic)bash tests/fm-send-resolve-key.test.sh (16/16 pass)bash tests/fm-procevent.test.sh (17/17 pass)Manual regression proof: reverted bin/fm-procevent.sh's feed_keyed_answers to its pre-fix (commit f6742a2) shape and reran the new test in isolation — it failed exactly as expected ('missing: decision binding has an incompatible schema'), then restored the fix and confirmed it passesmktemp "${TMPDIR:-/tmp}/fm-perm-check.XXXXXX" under default umask 022 — confirmed file mode is already 600, showing the umask-077 hardening in 54a52d4 is defense-in-depth rather than a functional fix✅ **Document** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.