fix(bin): resolve decision keys written anywhere on the status line - #4
Merged
Merged
Conversation
_fm_decision_key only read the prefix before the first colon, so needs-decision: [key=slug] ... folded as default. OPEN DECISIONS then printed the slug from the raw note, and fm-send --resolve-key slug correctly refused a key the ledger did not hold. Scan the whole line for the first [key=...] token, keep default when there is no token, and always print the folded key in OPEN DECISIONS. fm-send now uses status_decision_key_is_open so the resolve check and the drain listing share one fold. Bump the incremental fold version so existing cursors rebuild under the new key grammar.
Pin both token positions, the key-at-end-of-line shape, a colon in the note before the key, and the unkeyed default close. The drain test checks that OPEN DECISIONS prints the folded key, and the send test checks that --resolve-key accepts the same key the drain just listed.
Workers followed needs-decision: {summary} and then put [key=slug] in
the note. Point ship, scout, and secondmate scaffolds at the
key-before-colon form so new status lines match the documented grammar.
The parser still accepts the loose form already on disk.
…d opening decision keys
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
fm-send.sh --resolve-key refuses a key that the wake drain has just displayed as open. Implement the already-diagnosed fix; do not re-derive the root cause.
The defect: bin/fm-classify-lib.sh's _fm_decision_key reads only the prefix before the first colon, so needs-decision: [key=my-slug] text... folds as default (key sits AFTER the colon) while resolved [key=my-slug]: text... folds as my-slug (key sits BEFORE the colon). Generated crewmate briefs tell workers a decision stays open until a resolved line carrying its exact key lands, so workers naturally write needs-decision: [key=slug] .... That records the decision under default, and fm-send.sh --resolve-key then correctly refuses because the ledger genuinely has no such key. The wake drain's OPEN DECISIONS section prints the raw status text, which still contains the literal [key=slug], so the operator sees the slug, uses it, and is told it does not exist. The two readers never actually disagreed - the display is misleading.
Any fix must handle a [key=...] token anywhere in the line, not just adjacent to the colon. The 2026-08-14 reproduction puts the key at the end of the line: needs-decision: review gate 3 ask-user findings [key=review-ask-user]. A status line's text can itself contain a colon before the key. Do not change the meaning of an unkeyed resolved: line: it closes default and only default.
Deliver:
The generated brief's status protocol wording may be worth tightening so new workers write the unambiguous form, but the parser fix must stand on its own - every status log already on disk uses the loose form and must keep working.
This is firstmate shared tracked material; firstmate-coding-guidelines apply. Delivery is no-mistakes through a PR. Do not merge.
What Changed
_fm_decision_keyinbin/fm-classify-lib.shnow scans the whole status line for the first[key=<slug>]token instead of only the prefix before the first colon, soneeds-decision: [key=slug] ...and a key at end of line fold under that slug rather thandefault; a line with no token still folds todefault, and a malformed token on an opening verb (needs-decision/blocked) falls back todefaultso the escalation still surfaces instead of being dropped. The routed-work activity fold keeps its prefix-only reading via a new_fm_activity_key, andFM_OPEN_DECISIONS_FOLD_VERSIONis bumped to 4 to force existing cursors to re-fold.bin/fm-wake-drain.shprints the folded ledger key (includingdefault) on every OPEN DECISIONS item alongside the raw note, so the key the operator is shown is the key--resolve-keywill accept;bin/fm-send.shnow validates through a new sharedstatus_decision_key_is_openpredicate over the same fold instead of inlining its own open-set match. Generated crewmate briefs (bin/fm-brief.sh) template the unambiguousneeds-decision [key=your-slug]:form and spell out the allowed slug characters.tests/fm-watch-triage.test.sh, plus a loose-form drain-then-resolve case intests/fm-send-resolve-key.test.shand a folded-key printing case intests/fm-wake-drain-open-decisions.test.sh;tests/fm-brief.test.shfollows the new brief wording.The pipeline's Review phase left one informational note open:
fm-pending-reply-lib.shstill calls_fm_decision_keydirectly, so for a legacy unkeyed escalation whose payload embeds a malformed token, the fold's newdefaultfallback and that call site can disagree about which key was opened.Risk Assessment
✅ Low: Both requested fixes are implemented exactly as specified, pinned by new colocated tests, and correctly versioned (fold version 4), leaving only one narrow legacy-compat inconsistency between the fold's malformed fallback and the pending-reply closer.
Testing
I reproduced the reported defect end-to-end against the base commit and confirmed it gone at HEAD, driving the real fm-wake-drain.sh and fm-send.sh over a stubbed tmux transport against a throwaway FM_HOME - the same harness shape the existing suite uses. The captured CLI transcripts show the operator's actual experience flipping from "drain shows the slug, fm-send refuses it" to "drain shows the folded key, fm-send accepts it and the decision closes". I also verified the on-disk upgrade path (a stale v2 cursor holding the wrong key is discarded by the fold-version bump), that an unkeyed resolved: still closes default only, and that a freshly generated brief now teaches the unambiguous keyed form. On the automated side I ran the colocated classifier-library test plus the drain, resolve-key, cursor, decision-hold, pending-reply and brief tests - every direct consumer of the changed fold - all passing with no failures or flakes. This change has no rendered UI surface; it is a shell CLI, so the CLI transcripts are the end-user surface and stand in for screenshots. The worktree is clean and all transient scratch outside the evidence directory was removed.
Evidence: BEFORE (base 48ea5d1): drain shows the key, fm-send refuses it
$ cat state/review.status # what the crewmate actually wrote working: gate 3 review under way needs-decision: review gate 3 ask-user findings [key=review-ask-user] working: kept busy on an unrelated stream $ bin/fm-wake-drain.sh # what the operator sees on wake OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line): review needs-decision: review gate 3 ask-user findings [key=review-ask-user] OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>' $ bin/fm-send.sh review --resolve-key review-ask-user 'accept the findings' error: --resolve-key 'review-ask-user': no open decision or blocker with that key in .../review.status (already closed, mistyped, or transferred). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent. (exit 1) $ bin/fm-wake-drain.sh # is the decision still open? OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line): review needs-decision: review gate 3 ask-user findings [key=review-ask-user]Evidence: AFTER (HEAD 557c94e): drain shows the folded key, fm-send resolves it
$ cat state/review.status # what the crewmate actually wrote working: gate 3 review under way needs-decision: review gate 3 ask-user findings [key=review-ask-user] working: kept busy on an unrelated stream $ bin/fm-wake-drain.sh # what the operator sees on wake OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line): review [key=review-ask-user] needs-decision: review gate 3 ask-user findings [key=review-ask-user] OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>' $ bin/fm-send.sh review --resolve-key review-ask-user 'accept the findings' (exit 0) $ cat state/review.status # ledger after the answer working: gate 3 review under way needs-decision: review gate 3 ask-user findings [key=review-ask-user] working: kept busy on an unrelated stream resolved [key=review-ask-user]: answered: accept the findings $ bin/fm-wake-drain.sh # is the decision still open? (no output - nothing open)Evidence: Decision-key fold across all four key positions, base vs HEAD
### BEFORE (base 48ea5d1): only the before-colon key survives; the rest collapse into one 'default' status log: needs-decision [key=before-colon]: pick REST or RPC needs-decision: [key=after-colon] pick REST or RPC needs-decision: review gate 3 ask-user findings [key=end-of-line] needs-decision: option A: ship now vs later [key=after-second-colon] needs-decision: no token at all folded open-decision keys (what --resolve-key will accept): before-colon default ### AFTER (HEAD 557c94e): every position folds to its own slug; the tokenless line still folds to default folded open-decision keys (what --resolve-key will accept): before-colon after-colon end-of-line after-second-colon default ### An unkeyed 'resolved:' must close default and ONLY default (no meaning change) status log: needs-decision: unkeyed escalation needs-decision [key=keep-me]: keyed escalation needs-decision: end-of-line one [key=also-keep] resolved: cleared the unkeyed one still open after the unkeyed resolved: keep-me also-keepEvidence: Upgrade path: a stale v2 cursor holding the wrong key is re-folded
$ <old bin>/fm-wake-drain.sh # operator on the old version - writes a v2 cursor review needs-decision: review gate 3 ask-user findings [key=review-ask-user] $ cat state/.review.open-decisions-cursor # stale cursor recorded the wrong key version=2 offset=103 default needs-decision review gate 3 ask-user findings [key=review-ask-user] $ bin/fm-wake-drain.sh # after the upgrade, same state dir, stale cursor present review [key=review-ask-user] needs-decision: review gate 3 ask-user findings [key=review-ask-user] $ cat state/.review.open-decisions-cursor # cursor re-folded at the new version version=4 offset=103 review-ask-user needs-decision review gate 3 ask-user findings [key=review-ask-user]Evidence: Generated crewmate brief now teaches the unambiguous keyed form
40: appendneeds-decision [key=your-slug]: {summary of options}and stop, replacingyour-slugwith a short name for this decision (letters, digits,.,_, and-only). 41: A decision or blocker you opened stays open until aresolved [key=your-slug]:line carrying that same key lands; a laterdone:orworking:line never closes it. 42: Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, appendresolved: {how it cleared}yourself (same[key=...]if you opened it with one) as you resume.Evidence: E2E reproduction harness (drives the real drain + fm-send against any bin/ tree)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-classify-lib.sh:196- An invalid slug inside a [key=...] token now discards the entire status line from the fold, and the whole-line scan makes that newly reachable from note prose. fm_decision_key returns 1 for any first token containing a character outside [A-Za-z0-9.-], and _fm_decision_fold_line (line 266) treats that failure as 'not a decision transition', so the line opens nothing. Concrete sequence: a worker appendsneeds-decision: should the brief still require [key=<slug>]?- before this change the prefix held no token, the line folded asdefault, and the drain listed it; now the token<slug>is invalid, the line is dropped, the decision never appears in OPEN DECISIONS, and the worker waits on an answer nobody can see. The brief change compounds this: bin/fm-brief.sh rules 6 now templateneeds-decision [key=<slug>]: {summary of options}, so a worker copying the placeholder literally also loses its escalation silently, where the previousneeds-decision: {summary}template always folded to default. Consider making an opening verb (needs-decision/blocked) fall back todefaultwhen the token is malformed - so the escalation still surfaces - while keeping the drop for closing verbs (resolved/captain-held) so a malformed key cannot close anything. That choice changes fold semantics, so it needs your call.bin/fm-classify-lib.sh:186- The widened scan also changes the routed-work activity fold, which is not part of the stated intent and has no test coverage. _fm_status_open_activities_stream (line 563) calls the same _fm_decision_key, so a working/done/paused line whose note merely mentions a key now folds under that key instead ofdefault. Concrete sequence:working: implementing the [key=api-shape] decisionfollowed bydone: shipped- previously both folded asdefaultand the done closed the phase; now the phase opens underapi-shapeand the unkeyeddoneclosesdefaultinstead, soapi-shape\tworking\t...stays open forever in bin/fm-fleet-snapshot.sh:956's activity records, where an open phase is the evidence used to judge whether a parent event was superseded. The new tests in tests/fm-watch-triage.test.sh only exercise the decision fold (keys.status); activity.status at line 230 still uses prefix-only keys, so this path is unpinned either way. Decide whether the whole-line scan should apply to activities as well, and pin whichever behavior you intend.bin/fm-classify-lib.sh:193- Informational, and an accepted consequence of the intent's required 'token anywhere, first token wins' rule: a needs-decision/blocked line that names another decision's key in prose now takes that key over in the fold.needs-decision [key=api-shape]: pick REST or RPCfollowed byblocked: cannot proceed until the [key=api-shape] decision is answereddrops the original entry and re-adds api-shape with the blocker's note, so OPEN DECISIONS shows the blocker text rather than the original question and--resolve-key api-shapecloses the merged entry. This is exactly the invariant the removed assertions in tests/fm-watch-triage.test.sh ('a key token in note prose changed the decision key') used to pin, and the intent explicitly authorizes trading it away for the compatibility path. Noting it so the tradeoff is recorded, not asking for a change.bin/fm-send.sh:369- Informational efficiency note on the shared-predicate refactor: status_decision_key_is_open re-runs the whole-file fold per key, so a send with N repeated --resolve-key flags now performs N full folds of the status log where the previous code computed the open set once. status_open_decisions forks a subshell per status line (bin/fm-classify-lib.sh:302), so on a long-lived log this multiplies an already fork-heavy scan. It runs once per interactive send with a small N, so it is a reasonable price for the single shared predicate the intent asked for; a set-based variant of the predicate would remove it if the cost ever shows up.🔧 Fix: Keep activity keys prefix-only; surface malformed opening decision keys
1 info still open:
bin/fm-classify-lib.sh:299- The new opening-verb fallback lives inside _fm_decision_fold_line, so the fold and the only other _fm_decision_key caller now disagree about which key a malformed line opened. Reachable path (narrow, legacy-compat only): fm-pending-reply-lib.sh:865 still matches a legacy unkeyed escalationblocked: <payload>where payload embedsrequest=<summary>; if that summary carries a malformed token (e.g. the request text quoted[key=<slug>]), the fold now opens the escalation underdefault(line 302), while fm-pending-reply-lib.sh:920 doeskey=$(_fm_decision_key "$escalation") || key=''and gets an empty key, so the open_key comparison at line 925 never matches, noresolved [key=default]:line is appended, and line 939 stamps escalation_closed_epoch unconditionally - the decision stays open in OPEN DECISIONS forever with no retry. Before this commit the same line was dropped by the fold entirely, so the two agreed (nothing opened, nothing to close). The keyed escalation form at line 996 is unaffected because its prefix token always wins and is always valid. Earliest shared boundary: expose the fold's key resolution (whole-line scan plus the opening-verbdefaultfallback) as one helper and have fm-pending-reply-lib.sh:920 ask that instead of _fm_decision_key, so 'which key did this line open under' has a single answer; the equivalent one-line fix is falling back todefaultat that call site, since the closer already requires an exact note match before it appends anything.✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh tests/fm-watch-triage.test.sh- colocated classifier-library test carrying the new key-position regression coverage (47 cases, incl.ok - classifier primitives: keyed decisions and activity phases, ...)bin/fm-test-run.sh tests/fm-send-resolve-key.test.sh- incl. the newtest_loose_form_key_matches_drain_and_resolvesbin/fm-test-run.sh tests/fm-wake-drain-open-decisions.test.sh- incl. the newtest_post_colon_and_end_of_line_keys_print_the_folded_keybin/fm-test-run.sh tests/fm-wake-drain-open-decisions-cursor.test.sh tests/fm-decision-hold-lifecycle.test.sh tests/fm-pending-reply.test.sh tests/fm-brief.test.sh- the other direct consumers of the changed fold and of_fm_decision_keyManual E2E: drove the realbin/fm-wake-drain.shandbin/fm-send.shover a stubbed tmux transport against a throwawayFM_HOME, using the 2026-08-14 end-of-line key shape - run once against agit archiveof the base commit'sbin/(reproduces the refusal) and once against HEAD (drain prints the folded key, send resolves, decision closes)Manual upgrade check: ran the base drain to write aversion=2cursor recording the wrongdefaultkey, then ran the HEAD drain over the same state dir to confirm theFM_OPEN_DECISIONS_FOLD_VERSION=4bump forces a re-fold toreview-ask-userManual library check:status_open_decisionsover a fixture with all four key positions plus a tokenless line, base vs HEAD; plus an unkeyedresolved:against one default and two keyed decisions to confirm it closesdefaultonlyManual copy check: generated a real crewmate brief viabin/fm-brief.sh <task> demo --mode no-mistakesand read back the status-protocol linesAGENTS.md:291- Judgment call, no edit made: the operator-facing rule "pass the key OPEN DECISIONS prints in [key=...]" is now recorded only in bin/fm-send.sh's header and enforced by the drain's output. AGENTS.md sections 8/9 tell firstmate to pass --resolve-key and explicitly delegate the contract to that header, so I did not add a prose copy there - duplicating it would create a second place to drift. If you would rather have it inline for the answering loop, AGENTS.md:291 is the one spot to add it and bin/fm-send.sh's header line should then be reduced to the pointer.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.