Skip to content

fix(bin): support keyed answer resolution - #102

Merged
dnth merged 2 commits into
mainfrom
fm/fm-port-2490-keyed-answer-path
Sep 5, 2026
Merged

dnth merged 2 commits into
mainfrom
fm/fm-port-2490-keyed-answer-path

Conversation

@dnth

@dnth dnth commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Intent

Port upstream kunchenguid/firstmate PR kunchenguid#2490 (keyed-answer path) faithfully into this fork, adapt it to the fork, and preserve OMP. Motivation: a needs-decision whose [key=...] tag sat AFTER the verb colon folded under default, so bin/fm-send.sh --resolve-key <key> was refused three times on 2026-08-30; a keyed decision must resolve correctly via --resolve-key. Port the upstream tests too, adapted for the fork. Do NOT pull in the high-cost upstream kunchenguid#2728 captain-hold migration (explicitly out of scope). This is firstmate shared tracked material (bin/, tests/, docs/, .agents/skills/, AGENTS.md) and follows the firstmate-coding-guidelines skill: one sentence per line in tracked Markdown, plain dash, no agent co-author, bin/*.sh shellcheck-clean via bin/fm-lint.sh, tests colocated in tests/ and behavioral through executables (never asserting implementation-source bytes), one-owner rule for contracts with cross-references patched in place, bin/fm-doc-audience-check.sh passing.

Acceptance criteria:

Decisions made while implementing, carried as accepted scope: (1) kunchenguid#2490 itself never touches the status-key parser; the tag-after-colon fold in AC2 is fixed by upstream kunchenguid#2202 ("honor a decision key stated after the verb colon"), so kunchenguid#2202 is ported too: bin/fm-classify-lib.sh accepts a complete [key=<slug>] token at the head of the note as an equivalent position to the documented before-colon one, strips the consumed token so both positions fold to byte-identical records, treats a mid-note mention as prose, rejects a malformed stated key rather than rewriting it to default, and bumps FM_OPEN_DECISIONS_FOLD_VERSION (5 to 6) so stale cursors rebuild from byte 0; tests/fm-classify-decision-key.test.sh is added (registered in bin/fm-test-run.sh's pure-contract-unit family) plus a colon-first end-to-end case in tests/fm-send-resolve-key.test.sh. (2) Upstream kunchenguid#2490's answer shares an unrouted close with kunchenguid#2330's decline, which this fork never received; only the kunchenguid#2330 mechanics that answer needs are ported (load_decision, ROUTED_NONE, resolution_body with Resolution mode:, body_has_resolution_record, normalized_blocked_by, tasks_blocked_by, parse_decision_only_flags, and resolve now writing through resolution_body with mode routed while older routed records stay valid); decline and repair are deliberately NOT ported, and close_unrouted_hold stays parameterized by mode so they can slot in later without weakening answer. (3) bin/fm-decision-hold.sh gains answer, the channel-agnostic stdin answers intake mapping <key>\t<answer>\t<label> lines to <origin>-decision-<key> and closing through answer, and bind/unbind/binding storing a captured-answer source's origin under state/decision-bindings/ (schema fm-decision-binding.v1). (4) bin/fm-send.sh --resolve-key keeps its status-log close unchanged for keys the status log owns and, for a key the status log no longer owns, checks for an active captain hold on the target task and feeds the intake; a key open in neither ledger is still refused before anything is sent; the hold feed runs from the same shared close function used by every delivery verdict (inbox, typed, busy-confirmed OMP, remote) so OMP behavior is unchanged. (5) bin/fm-procevent.sh feeds a bound source's captured result through fm-procevent-<adapter>.sh answers into the intake right after capture, independent of publication and acknowledgement (never acknowledges, never suppresses the check wake), and unbinds on retire; bin/fm-procevent-lavish.sh gains answers, byte-identical to upstream, reading only rows tagged choice. (6) The upstream kunchenguid#2490 tests are ported into tests/fm-decision-hold-lifecycle.test.sh with one fork adaptation: a local task target in this fork receives the answer as a durable steering-inbox record plus a tmux doorbell, so the chat-channel case asserts the inbox record and doorbell rather than typed text. (7) Docs patched in place, not duplicated: docs/decision-hold-lifecycle.md (mechanism, new Answer-time closure section, verification record dated 2026-09-05 with exact outputs), .agents/skills/decision-hold-lifecycle/SKILL.md, .agents/skills/process-event-sources/SKILL.md, docs/configuration.md process-to-event section, docs/verification/process-event-sources.md, docs/scripts.md, and the state/ inventory line for decision-bindings/ in AGENTS.md. Verified: tests/fm-decision-hold-lifecycle.test.sh 13 ok, tests/fm-classify-decision-key.test.sh 8 ok, tests/fm-send-resolve-key.test.sh 17 ok, tests/fm-procevent.test.sh 41 ok, tests/fm-procevent-when.test.sh 15 ok, fm-send-strict 9 ok, fm-send-secondmate-marker 9 ok, fm-fleet-snapshot-view 16 ok, fm-bearings-snapshot 45 ok, fm-brief 23 ok, fm-wake-drain-open-decisions 9 ok, bin/fm-lint.sh (ShellCheck 0.11.0 pinned) clean, bin/fm-doc-audience-check.sh ok. Two pre-existing failures unrelated to this change reproduce identically on an untouched HEAD copy: one wake-lock timing case in tests/fm-send-turn-start.test.sh and the content-landed case in tests/fm-teardown.test.sh.

Firstmate-Validation-Generation: 37c09d4d4d57711ecc4cef455a2e21f5

What Changed

  • Accept keyed decision tokens after the colon, normalize both key positions, and let --resolve-key close active captain holds after status-ledger transfer.
  • Add durable answer-time hold closure, channel-agnostic keyed-answer intake, source bindings, and process-event adapter feeding while preserving routed resolve and OMP delivery behavior.
  • Add and update parser, lifecycle, resolve-key, and process-event regression coverage, with corresponding documentation and skill-contract updates.

Risk Assessment

⚠️ Medium: The change is largely bounded and addresses the requested keyed-answer path, but the new parser can still misclassify malformed colon-first markers and produce incorrect decision-key routing.

Testing

Ran the focused behavioral test set for the requested keyed-answer and OMP-preserving change; every targeted test passed, and a reviewer-visible CLI transcript was captured as evidence. No source or transient worktree artifacts were created.

Evidence: Keyed answer resolve transcript

CLI transcript showing keyed --resolve-key behavior and parser edge cases; all listed cases report ok.

ok - fm-send --resolve-key closes the open decision at answer time
ok - fm-send --resolve-key: a colon-first stated key is open under that key and answerable
ok - an answer that starts a different workstream leaves no orphaned decision
ok - a send without --resolve-key and working/done never close a decision
ok - a key that is not open refuses before anything is sent
ok - a failed send never closes the decision
ok - an unconfirmed send never closes the decision
ok - a busy OMP send without a native receipt leaves the decision open
ok - one answer closes each named key and only those keys
ok - a long key keeps its complete structured resolved prefix
ok - a marked local secondmate answer closes the same local ledger with plain text
ok - a remote secondmate answer closes the same local ledger
ok - a remote OMP delivered-no-turn result propagates without redelivery
ok - a remote OMP persistence failure propagates without redelivery
ok - a failed remote transport never closes the decision
ok - append failure reports every shell-safe manual close command
ok - confirmed secondmate delivery closes before pending-reply bookkeeping
ok - a stated [key=X] opens X whether it precedes or follows the verb colon
ok - a keyless needs-decision still opens and closes the default key
ok - a resolution closes its decision regardless of either line's key position
ok - blocked [key=X] opens X in both key positions
ok - two colon-form keyed decisions never collapse into one shared bucket
ok - a [key=x] mentioned mid-note is prose, never an opened or closed key
ok - a malformed stated key is rejected in both positions, never folded as default
ok - the incremental fold matches the full fold across appends in both key positions

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 warning
  • ⚠️ bin/fm-classify-lib.sh:286 - _fm_key_at_note_head recognizes any text beginning with [key=... as a complete token and extracts up to the first ], without requiring a token boundary. For input needs-decision: [key=alpha]beta, it incorrectly assigns key alpha and strips the marker, rather than treating the text as prose or rejecting the malformed stated key. This can place/resolve a decision under the wrong key despite the new contract requiring a complete head token.
✅ **Test** - passed

✅ No issues found.

  • tests/fm-classify-decision-key.test.sh
  • tests/fm-send-resolve-key.test.sh
  • tests/fm-decision-hold-lifecycle.test.sh
  • tests/fm-procevent.test.sh
  • tests/fm-procevent-when.test.sh
  • tests/fm-send-strict.test.sh
  • tests/fm-send-secondmate-marker.test.sh
  • tests/fm-fleet-snapshot-view.test.sh
  • tests/fm-bearings-snapshot.test.sh
  • tests/fm-brief.test.sh
  • tests/fm-wake-drain-open-decisions.test.sh
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dnth added 2 commits September 5, 2026 01:40
Port upstream kunchenguid/firstmate kunchenguid#2490 (keyed-answer path), adapted to
this fork, together with the two smaller upstream pieces it depends on
for the incident it is meant to fix: the kunchenguid#2202 colon-first decision-key
parser and the unrouted-close mechanics from kunchenguid#2330 that `answer` shares.

On 2026-08-30 a worker wrote `needs-decision: [key=X] ...` with the tag
after the verb colon. The status fold collapsed that stated key into the
shared `default` bucket, so `fm-send --resolve-key X` was refused three
times while the captain's answer sat on disk.

bin/fm-classify-lib.sh now honors a complete `[key=<slug>]` token at the
head of the note as an equivalent position to the documented before-colon
one, strips the consumed token from the note so both positions fold to
byte-identical records, keeps a mid-note mention as prose, rejects a
malformed stated key instead of rewriting it to `default`, and bumps the
persisted fold version so stale cursors are rebuilt from byte 0.

bin/fm-decision-hold.sh owns "a keyed answer closes its matching hold" as
one channel-agnostic capability: `answer` closes a single actively held
hold that routes no work through a shared unrouted close that keeps every
guard (captain decision file, active-hold requirement, digest-based retry
identity, refusal while routed work is still blocked); `answers` is the
stdin intake that maps `<key>\t<answer>\t<label>` lines to
`<origin>-decision-<key>` and closes each through `answer`; `bind`,
`unbind`, and `binding` record a captured-answer source's origin under
state/decision-bindings/. Every path records a `Resolution mode:` and
older routed records remain valid.

Every channel is an ordinary caller. bin/fm-send.sh --resolve-key still
closes a key the status log owns exactly as before and now feeds the
intake for a key that names an active captain hold on the target task,
which is what lets chat answer a decision `complete` already transferred
to its durable hold; a key open in neither ledger is still refused before
anything is sent. bin/fm-procevent.sh pipes a bound source's captured
result through `fm-procevent-<adapter>.sh answers` into the intake without
naming an adapter or parsing a result, never acknowledges the capture, and
drops the binding on retire. bin/fm-procevent-lavish.sh gains `answers`,
which reports only rows tagged `choice` so freeform prose cannot forge a
key.

Not pulled in: upstream kunchenguid#2728's captain-hold migration, and kunchenguid#2330's
`decline` and `repair` commands; the shared close is parameterized so
they slot in later without weakening `answer`.

Tests: the four upstream kunchenguid#2490 regressions (fixture adapter through the
real runner, unbound source, answer guards, chat channel) join
tests/fm-decision-hold-lifecycle.test.sh, the kunchenguid#2202 parser suite is added
as tests/fm-classify-decision-key.test.sh, and the colon-first end-to-end
case joins tests/fm-send-resolve-key.test.sh.

Claude-Session: https://claude.ai/code/session_01SSAeLowy3hRkryXkiKqYvD
@dnth
dnth merged commit 0fd72e5 into main Sep 5, 2026
15 checks passed
@dnth
dnth deleted the fm/fm-port-2490-keyed-answer-path branch September 5, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant