Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .agents/skills/decision-hold-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<decision-key>` 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 <source-id> <origin-id>`; 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.

Expand All @@ -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.
10 changes: 10 additions & 0 deletions .agents/skills/process-event-sources/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ For a Lavish review artifact:
bin/fm-procevent-lavish.sh arm <artifact.html>
```

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 <source-id> <origin-id>
```

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.
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
91 changes: 72 additions & 19 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,25 @@ status_is_paused_or_captain_held() { # <status-line>
# rule 6), so closure never depends on a busy worker's discipline.
#
# Decision key grammar (backward-compatible with the existing "<verb>: <note>"
# format): an OPTIONAL "[key=<slug>]" token sits between the verb and the colon,
# format): an OPTIONAL "[key=<slug>]" 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]: <summary>
# needs-decision: [key=api-shape] <summary>
# resolved [key=api-shape]: <how it was decided>
# 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() { # <status-line> -> structured verb or legacy pre-colon text
Expand Down Expand Up @@ -249,25 +262,65 @@ status_line_verb() { # <status-line> -> 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() { # <status-line>
case "${1%%:*}" in
*\[key=*\]*) return 0 ;;
*) return 1 ;;
esac
}
# Raw slug of a complete "[key=<slug>]" 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() { # <status-line> -> 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() { # <slug>
case "$1" in
''|*[!A-Za-z0-9._-]*) return 1 ;;
*) return 0 ;;
esac
}
status_line_note() { # <status-line> -> 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() { # <status-line> -> 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 <key> from a newline-terminated "<key>\t<verb>\t<note>" set.
# Portable (no associative arrays) so the fold runs on bash 3.2 as well as 4+.
Expand Down Expand Up @@ -430,7 +483,7 @@ _fm_open_decisions_file_ident() { # <file> -> "dev:inode", empty on I/O failure
fi
}

FM_OPEN_DECISIONS_FOLD_VERSION=5
FM_OPEN_DECISIONS_FOLD_VERSION=6

_fm_status_file_size() { # <status-file>
local f=$1
Expand Down
Loading
Loading