diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 5db5690ebc9..98b4a10ee14 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -21,7 +21,14 @@ After inventorying the whole report and review surface, run `bin/fm-decision-hol A completed investigation and an ended visual review use this same owner and completion command; a visual tool, including Lavish, never owns a parallel completion policy. Run the command in the originating work's authoritative `FM_HOME`; main-home work creates main-home holds, and secondmate-owned work creates holds in that secondmate home's backlog rather than copying them into the main backlog. 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. -The hold remains the authoritative Captain's Call item until the captain's answer is durably recorded, dependent work is created in the same backlog and blocked by that 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 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 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; neither close 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. @@ -32,9 +39,10 @@ Bearings reads the resulting structured state and must never compensate by scrap 3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, and repository. 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. After the captain decides, record dependent work with normal tasks-axi commands and block it by the hold identity. -7. Put the captain's exact durable decision in a file and use the script's `resolve` command with every routed task. -8. Confirm Bearings no longer shows the closed hold and that routed work remains in structured backlog state. +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, or its `answer` command when the captain answered a hold with no routed work behind it. + A hold that a channel already closed by feeding its keyed answer needs neither; 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. `docs/decision-hold-lifecycle.md` records the mechanism and regression evidence without restating this policy. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index a023fff33d1..32f9f2c654c 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -29,6 +29,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 ab8a66bd438..69207bf27ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,7 @@ state/ volatile runtime 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 by bin/fm-procevent.sh or an adapter through the shared registration publisher, 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 X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode 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-classify-lib.sh b/bin/fm-classify-lib.sh index 37be682330c..878eedc4674 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -209,12 +209,25 @@ status_is_paused_or_captain_held() { # # rule 6), so closure never depends on a busy worker's discipline. # # Decision key grammar (backward-compatible with the existing ": " -# format): an OPTIONAL "[key=]" token sits between the verb and the colon, +# format): an OPTIONAL "[key=]" token names the decision. Its documented +# position sits between the verb and the colon, and a complete token at the +# head of the note is accepted as an EQUIVALENT position, because that +# misplaced-colon shape is common real worker output whose stated key must +# never silently collapse into the shared "default" bucket (upstream issue +# #2109; observed here on 2026-08-30 as three refused --resolve-key answers): # needs-decision [key=api-shape]: +# needs-decision: [key=api-shape] # resolved [key=api-shape]: -# A line with no token uses the key "default", preserving the historical -# one-open-decision-per-task behavior (a bare "resolved:" closes "default"). -# The three parsers are pure reads of a single line. A prefix is structured +# Both positions state the same key and yield the same note (a consumed +# note-head token is key metadata, stripped from the note); when both positions +# carry a token, the documented before-colon one wins and the note-head token +# stays note text. A token deeper inside the note is prose, never a stated key, +# so a summary merely MENTIONING "[key=x]" cannot open or close that decision. +# A line with no token in either position uses the key "default", preserving +# the historical one-open-decision-per-task behavior (a bare "resolved:" closes +# "default"). A stated key whose slug fails the charset below is rejected (the +# folds skip the line), never rewritten to "default". +# The parsers are pure reads of a single line. A prefix is structured # only when every token after its first word is a space-delimited [key=...] or # [corr=...] token; otherwise trimmed pre-colon text retains legacy semantics. status_line_verb() { # -> structured verb or legacy pre-colon text @@ -249,25 +262,65 @@ status_line_verb() { # -> structured verb or legacy pre-colon tex esac printf '%s' "$prefix" } +# 0 when a complete "[key=...]" token sits in the documented position before +# the line's first colon (or anywhere on a line that has no colon at all). +_fm_key_before_colon() { # + case "${1%%:*}" in + *\[key=*\]*) return 0 ;; + *) return 1 ;; + esac +} +# Raw slug of a complete "[key=]" token at the head of the note (the +# first thing after the line's first colon, ignoring whitespace). Fails when +# the line has no colon or no complete token there; slug charset validity is +# the caller's check via _fm_decision_slug_ok, exactly as for the before-colon +# position. +_fm_key_at_note_head() { # -> raw slug + local rest + case "$1" in + *:*) rest=${1#*:} ;; + *) return 1 ;; + esac + rest=${rest#"${rest%%[![:space:]]*}"} + case "$rest" in + \[key=*\]*) rest=${rest#\[key=}; printf '%s' "${rest%%\]*}" ;; + *) return 1 ;; + esac +} +# 0 when a stated key slug is well-formed: nonempty, A-Za-z0-9._- only. +_fm_decision_slug_ok() { # + case "$1" in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + *) return 0 ;; + esac +} status_line_note() { # -> text after the first colon, trimmed + local n k case "$1" in - *:*) local n=${1#*:}; printf '%s' "${n#"${n%%[![:space:]]*}"}" ;; - *) printf '%s' "$1" ;; + *:*) n=${1#*:}; n=${n#"${n%%[![:space:]]*}"} ;; + *) printf '%s' "$1"; return 0 ;; esac + # A note-head token that states this line's key (no before-colon token, valid + # slug) is key metadata, not note text: strip it so both stated-key positions + # yield the same note. + if ! _fm_key_before_colon "$1" && k=$(_fm_key_at_note_head "$1") \ + && _fm_decision_slug_ok "$k"; then + n=${n#"[key=$k]"} + n=${n#"${n%%[![:space:]]*}"} + fi + printf '%s' "$n" } _fm_decision_key() { # -> key slug, or "default" when no token - local prefix=${1%%:*} k - case "$prefix" in - *\[key=*\]*) - k=${prefix#*\[key=} - k=${k%%\]*} - case "$k" in - ''|*[!A-Za-z0-9._-]*) return 1 ;; - *) printf '%s' "$k" ;; - esac - ;; - *) printf 'default' ;; - esac + local k + if _fm_key_before_colon "$1"; then + k=${1%%:*} + k=${k#*\[key=} + k=${k%%\]*} + else + k=$(_fm_key_at_note_head "$1") || { printf 'default'; return 0; } + fi + _fm_decision_slug_ok "$k" || return 1 + printf '%s' "$k" } # Drop the record for from a newline-terminated "\t\t" set. # Portable (no associative arrays) so the fold runs on bash 3.2 as well as 4+. @@ -430,7 +483,7 @@ _fm_open_decisions_file_ident() { # -> "dev:inode", empty on I/O failure fi } -FM_OPEN_DECISIONS_FOLD_VERSION=5 +FM_OPEN_DECISIONS_FOLD_VERSION=6 _fm_status_file_size() { # local f=$1 diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index aeb140a296a..64f23490baa 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -7,8 +7,8 @@ # The invoking agent inventories unresolved decisions, assigns stable keys, and # routes dependent work. This script supplies deterministic identities, creates # and verifies structured tasks-axi captain holds, records completion attestation -# in the originating task's metadata, and closes a hold only after a durable -# decision record has been linked to existing dependent work. +# in the originating task's metadata, and requires a durable captain decision +# record before it closes a hold. # # A hold identity is -decision-. Origin ids and decision # keys must already be privacy-safe slugs. Repeating `hold` with the same identity @@ -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 # # `complete` is the shared investigation and visual-review completion gate. # `--none` is an explicit semantic attestation that the just-reviewed surface has @@ -33,10 +38,61 @@ # `verify` is read-only and is called by scout teardown so teardown cannot erase a # source before this gate has succeeded. # -# `resolve` 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. +# `resolve` and `answer` close active holds. Both 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 takes no +# --routed-to task and refuses 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. The unrouted close is one +# shared implementation (close_unrouted_hold) parameterized only by the recorded +# resolution mode, so a later unrouted path such as upstream's `decline` cannot +# drift into a weaker close than `answer`. +# +# 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