From 362c508666432e35c8608b6c526464acba366307 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:22:49 -0700 Subject: [PATCH 01/12] fix(decisions): close decision holds at answer time via one general keyed-answer path (#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 --source ` 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 ` answers ` 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 --- .../skills/decision-hold-lifecycle/SKILL.md | 9 +- .agents/skills/process-event-sources/SKILL.md | 10 + AGENTS.md | 1 + bin/fm-decision-hold.sh | 216 ++++++++++- bin/fm-procevent-lavish.sh | 87 ++++- bin/fm-procevent.sh | 47 ++- bin/fm-send.sh | 75 +++- docs/configuration.md | 7 + docs/decision-hold-lifecycle.md | 57 ++- docs/verification/process-event-sources.md | 2 + tests/fm-decision-hold-lifecycle.test.sh | 348 ++++++++++++++++++ 11 files changed, 828 insertions(+), 31 deletions(-) diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index cacc0948fe9..43e327dd623 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -23,6 +23,12 @@ Run the command in the originating work's authoritative `FM_HOME`; main-home wor Do not close a hold merely because the originating investigation completed, its report was archived, its visual review ended, or its task was torn down. When the captain's answer authorizes follow-up work, the hold remains the authoritative Captain's Call item until that answer is durably recorded, dependent work is created in the same backlog and blocked by the hold, and `bin/fm-decision-hold.sh resolve` routes the answer by clearing those dependency edges before closing the hold. When the captain's answer routes no follow-up work at all, such as a declined proposal, `bin/fm-decision-hold.sh decline` records that answer and closes the hold; it never substitutes for routing work the captain did authorize. +When the captain simply answers a hold that has no follow-up work routed behind it yet, `bin/fm-decision-hold.sh answer` records that answer and closes the hold, so answering is closing rather than a separate later act that can be forgotten. +"A keyed answer closes its matching hold" is one capability with one owner, `bin/fm-decision-hold.sh answers`, and every channel that carries a captain answer feeds it the same `` and answer. +A channel never maps a key to a hold, records a decision, or closes anything itself, so no channel is special and a new one needs no new closing logic. +Chat already feeds it: `bin/fm-send.sh --resolve-key` answers a decision in whichever ledger still holds it open, including a decision already transferred to its durable hold. +A captured-answer source feeds it too once bound with `bin/fm-decision-hold.sh bind `; bind before arming the source, and key each structured question by the hold's own decision key. +An unbound source and a question slug that is not a decision key both simply feed nothing: the answer is still captured and firstmate is still woken, and closing falls back to the commands above. A hold closed outside this owner leaves no durable answer, so the completion gate keeps failing until `bin/fm-decision-hold.sh repair` records the decision the captain actually gave; neither unrouted path may stand in for an answer the captain has not given. Resolved findings, recommendations that need no captain choice, and prose that merely sounds decision-like do not create holds. Bearings reads the resulting structured state and must never compensate by scraping historical reports, visual-review artifacts, terminal output, chat, or other prose. @@ -35,7 +41,8 @@ Bearings reads the resulting structured state and must never compensate by scrap 4. Run the script's `complete` command with the full unresolved-key inventory for that review pass. 5. Relay the choices to the captain as decisions from Bearings' Captain's Call section under `AGENTS.md` section 9; do not use the word hold in captain chat. 6. If the captain authorizes dependent work, record it with normal tasks-axi commands and block it by the hold identity. -7. Put the captain's exact durable decision in a file and close the hold with the script's `resolve` command and every routed task, its `decline` command when the answer routes no work, or its `repair` command when the hold was already closed outside the script. +7. Put the captain's exact durable decision in a file and close the hold with the script's `resolve` command and every routed task, its `answer` command when the captain answered a hold with no routed work behind it, its `decline` command when the answer routes no work at all, or its `repair` command when the hold was already closed outside the script. + A hold that a channel already closed by feeding its keyed answer needs none of these; confirm it in step 8 instead. 8. Confirm Bearings no longer shows the closed hold and that any routed work remains in structured backlog state. `bin/fm-decision-hold.sh --help` owns command syntax, identity construction, completion attestation, retry behavior, and close ordering. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 093272c41a2..e5fd0c9b1c0 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -31,6 +31,16 @@ For a Lavish review artifact: bin/fm-procevent-lavish.sh arm ``` +When a source carries captain answers to decisions that already have durable holds, bind it to their origin BEFORE arming it, so it can never produce an answer that has nowhere to go: + +```sh +bin/fm-decision-hold.sh bind +``` + +The runner then passes each captured result to that source's own adapter `answers` command and pipes the keyed answers it prints into the one keyed-answer intake, which owns every rule about what they mean. +This is generic: any adapter with an `answers` command works, and the runner still wakes you to act on the result. +`decision-hold-lifecycle` owns when a binding is required and what the keys must be. + A configured remote secondmate reply source is armed and handled through `bin/fm-procevent-remote-reply.sh`. Its header owns exact commands, while the adapter owns cursor continuity, validated deduplicated status ingest, path-confined document fetch, acknowledgement, and re-arming after a good delta. A continuity break is escalated once and stays unarmed until an operator deliberately rebases it. diff --git a/AGENTS.md b/AGENTS.md index 0d06b4229b8..b0d4f7688bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ state/ runtime records and signals; gitignored pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line + decision-bindings/ private bindings from a captured-answer source id to the captain-hold origin its keyed answers close; written only by bin/fm-decision-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/decision-hold-lifecycle.md) when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) x-context/ generated Relay durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 523fef60847..6f82e0676d8 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -24,6 +24,11 @@ # fm-decision-hold.sh verify # fm-decision-hold.sh resolve \ # --decision-file --routed-to [--routed-to ...] +# fm-decision-hold.sh answer --decision-file +# fm-decision-hold.sh answers --source (keyed answers on stdin) +# fm-decision-hold.sh bind +# fm-decision-hold.sh unbind +# fm-decision-hold.sh binding # fm-decision-hold.sh decline --decision-file # fm-decision-hold.sh repair --decision-file # @@ -35,18 +40,62 @@ # `verify` is read-only and is called by scout teardown so teardown cannot erase a # source before this gate has succeeded. # -# `resolve` and `decline` close active holds; `repair` attests a hold already closed -# outside this script. All three paths require a non-empty captain decision file of -# at most 8192 bytes, record the same durable resolution block in the hold body, and -# store the decision digest plus routed identities so an exact retry is idempotent -# while a changed decision or, for `resolve`, routed set is rejected. New records -# include a `Resolution mode:` naming their path; older routed records remain valid. +# `resolve`, `answer`, and `decline` close active holds; `repair` attests a hold +# already closed outside this script. All four paths require a non-empty captain +# decision file of at most 8192 bytes, record the same durable resolution block in +# the hold body, and store the decision digest plus routed identities so an exact +# retry is idempotent while a changed decision or, for `resolve`, routed set is +# rejected. New records include a `Resolution mode:` naming their path; older +# routed records remain valid. # # `resolve` is the routed path. It requires every --routed-to task to exist and to # be blocked by the hold. It writes the captain decision and routed identities into # the hold body, clears those dependency edges, and only then marks the hold Done. # A failure before the final step leaves the captain hold open. # +# `answer` is the answer-time closure path, the hold ledger's counterpart to +# `fm-send.sh --resolve-key`: it exists so the act that carries the captain's +# answer is the act that closes the hold, instead of leaving closure to a +# separate later call nobody is forced to make. It records the captain's answer +# on an actively held hold, records `(none)` as the routed identities because no +# follow-up work has been routed behind the hold yet, and closes it. It shares +# every guard `decline` has, including the refusal while any task is still +# blocked by the hold, so a decision whose follow-up work is already routed still +# goes through `resolve` and the routed-vs-unrouted distinction survives. It says +# only that the captain answered; `decline` still says the captain answered with +# no follow-up work at all. +# +# ONE KEYED-ANSWER INTAKE, FED BY EVERY CHANNEL. +# "A keyed answer closes its matching hold" is a single capability, owned here +# and nowhere else. `answers` is its channel-agnostic entry point: it reads +# `\t\t