From db18685bdc345dadc7176d63e457e9904db3c10c Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Thu, 13 Aug 2026 21:44:59 -0400 Subject: [PATCH 1/5] Accept a decision key anywhere on the status line _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. --- bin/fm-classify-lib.sh | 34 +++++++++++++++++++++++++++------- bin/fm-send.sh | 26 ++++++++++++-------------- bin/fm-wake-drain.sh | 7 ++++--- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 3d0583b2ed8..53360418f5b 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -160,13 +160,16 @@ 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 may sit between the verb and the +# colon, or anywhere later on the same line: # needs-decision [key=api-shape]: +# needs-decision: [key=api-shape] +# needs-decision: review findings [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; the verb parser strips any -# key token before the colon so the leading word is recovered cleanly. +# The three parsers are pure reads of a single line; the verb parser still +# strips a key token before the colon so the leading word is recovered cleanly. status_line_verb() { # -> leading verb word local v=${1%%:*} v=${v%%\[key=*} @@ -181,10 +184,13 @@ status_line_note() { # -> text after the first colon, trimmed esac } _fm_decision_key() { # -> key slug, or "default" when no token - local prefix=${1%%:*} k - case "$prefix" in + # Scan the whole line, not only the prefix before the first colon: workers + # commonly write needs-decision: [key=slug] ... and may put the token at + # end of line after other colons. First token wins. + local line=$1 k + case "$line" in *\[key=*\]*) - k=${prefix#*\[key=} + k=${line#*\[key=} k=${k%%\]*} case "$k" in ''|*[!A-Za-z0-9._-]*) return 1 ;; @@ -298,6 +304,20 @@ status_open_decisions() { # printf '%s' "$open" } +# 0 if is currently open in per status_open_decisions. +# This is the membership predicate fm-send --resolve-key uses; the wake drain +# lists the same fold (via scan_open_decisions_incremental) rather than +# re-deriving which keys are open. +status_decision_key_is_open() { # + local f=$1 want=$2 open + [ -n "$want" ] || return 1 + open=$(status_open_decisions "$f") + case "$open" in + "$want"$'\t'*|*$'\n'"$want"$'\t'*) return 0 ;; + *) return 1 ;; + esac +} + # Fleet-wide wrapper around status_open_decisions: scans every task's status # log under and prefixes each still-open decision with its owning task # id, so a per-wake or per-session surface can print the consolidated open set @@ -384,7 +404,7 @@ _fm_open_decisions_cursor_path() { # printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}" } -FM_OPEN_DECISIONS_FOLD_VERSION=2 +FM_OPEN_DECISIONS_FOLD_VERSION=3 # Portable device:inode identity for the rotation/recreation check below. _fm_open_decisions_file_ident() { # -> "dev:inode", empty on I/O failure diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 384645757f6..940cedae34a 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -49,9 +49,9 @@ # folds lives in this home's own state dir (a remote mate's escalations reach # it through the parent-replies ingest); only the answer message crosses the # backend or remote transport. Each named key must currently be open in that -# ledger per status_open_decisions (bin/fm-classify-lib.sh) or fm-send refuses -# before sending, so a mistyped key cannot deliver an answer while silently -# orphaning the decision. A failed or unconfirmed send never closes a key; a +# ledger per status_decision_key_is_open (bin/fm-classify-lib.sh) or fm-send +# refuses before sending, so a mistyped key cannot deliver an answer while +# silently orphaning the decision. A failed or unconfirmed send never closes a key; a # delivered answer whose closing append fails exits nonzero with the exact # manual close command, leaving the decision open to re-surface (the safe # direction). A send without the flag never closes anything: a routine steer, @@ -344,9 +344,11 @@ fi # Validate the answerer-closes request before any durable mutation or send: the # target must have a task ledger in THIS home, the send must carry an answer # message, and every named key must be open right now in that ledger per the -# ONE authoritative fold (status_open_decisions). Refusing here, before the -# send, is what keeps a mistyped key loud instead of delivering an answer that -# silently leaves its decision open. +# ONE authoritative fold (status_open_decisions via +# status_decision_key_is_open). The wake drain lists that same fold, so a key +# printed in OPEN DECISIONS is the key this check will accept. Refusing here, +# before the send, is what keeps a mistyped key loud instead of delivering an +# answer that silently leaves its decision open. RESOLVE_STATUS_FILE= if [ -n "$RESOLVE_KEYS" ]; then if [ -z "$TARGET_SELECTOR" ] || [ -z "$TARGET_META" ]; then @@ -363,15 +365,11 @@ if [ -n "$RESOLVE_KEYS" ]; then fi RESOLVE_TASK_ID=$(fm_send_id_from_meta "$TARGET_META") RESOLVE_STATUS_FILE="$STATE/$RESOLVE_TASK_ID.status" - resolve_open_set=$(status_open_decisions "$RESOLVE_STATUS_FILE") for k in $RESOLVE_KEYS; do - case "$resolve_open_set" in - "$k"$'\t'*|*$'\n'"$k"$'\t'*) ;; - *) - echo "error: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE (already closed, mistyped, or transferred). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent." >&2 - exit 1 - ;; - esac + if ! status_decision_key_is_open "$RESOLVE_STATUS_FILE" "$k"; then + echo "error: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE (already closed, mistyped, or transferred). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent." >&2 + exit 1 + fi done fi diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 0807bb80f82..bc0f49d10af 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -43,6 +43,9 @@ assert_watcher_liveness() { # fm-classify-lib.sh's "incremental (cursor-backed) open-decisions fold"). # Bounded and silent: prints nothing when no decision is open, which is the # common case. +# Each item prints the folded ledger key (including default) before the raw +# note, so the key offered to --resolve-key is the key the ledger actually +# holds, even when the raw status text also contains a [key=...] token. print_open_decisions_section() { local open task key verb note line item_bytes=220 global_bytes=4000 local output='' used=0 shown=0 omitted=0 bytes @@ -52,9 +55,7 @@ print_open_decisions_section() { while IFS=$(printf '\t') read -r task key verb note; do [ -n "$task" ] || continue - line="$task" - [ "$key" = default ] || line="$line [key=$key]" - line="$line $verb: $note" + line="$task [key=$key] $verb: $note" # The shared cut counts the item's own characters; the trailing newline this # section's global budget also pays for is this caller's, so the per-item # allowance passed down is one short of the cap. From 2cc41739c9c55e0a2c50ce2c9d9705d63ebd5eb7 Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Thu, 13 Aug 2026 21:45:24 -0400 Subject: [PATCH 2/5] Cover post-colon and end-of-line decision keys 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. --- tests/fm-send-resolve-key.test.sh | 49 ++++++++++++++++++++++ tests/fm-wake-drain-open-decisions.test.sh | 31 ++++++++++++++ tests/fm-watch-triage.test.sh | 38 ++++++++++++++--- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index 75a8d6661c5..6d258cfbf13 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -404,4 +404,53 @@ test_multiple_keys_close_together test_local_secondmate_answer_marked_and_closed test_remote_secondmate_answer_closes_locally test_remote_transport_failure_does_not_close +# Worker-written needs-decision: [key=slug] ... (key after the colon, or at +# end of line) must be the same open key the drain lists, so --resolve-key +# slug succeeds instead of refusing a key the operator just saw. +test_loose_form_key_matches_drain_and_resolves() { + local dir fb log home rc out err + dir="$TMP_ROOT/loose-form"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/send.log"; err="$dir/send.err" + home=$(setup_home loose-form) + fm_write_meta "$home/state/t8.meta" "window=sess:fm-t8" "kind=ship" + printf 'needs-decision: review gate 3 ask-user findings [key=review-ask-user]\n' > "$home/state/t8.status" + printf 'working: kept busy on an unrelated stream\n' >> "$home/state/t8.status" + + out=$(drain_out "$home") + printf '%s' "$out" | grep -F 't8 [key=review-ask-user]' >/dev/null \ + || fail "precondition: drain should list the end-of-line key as the folded key: $out" + if printf '%s' "$out" | grep -F 't8 [key=default]' >/dev/null; then + fail "drain listed the end-of-line key as default: $out" + fi + + run_send "$fb" "$home" "$log" t8 --resolve-key review-ask-user "accept the findings"; rc=$? + expect_code 0 "$rc" " --resolve-key should accept the folded key drain just printed" + grep -F 'resolved [key=review-ask-user]: answered: accept the findings' "$home/state/t8.status" >/dev/null \ + || fail "fm-send did not close the loose-form key:"$'\n'"$(cat "$home/state/t8.status")" + + out=$(drain_out "$home") + if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then + fail "the loose-form decision still lists as open after --resolve-key: $out" + fi + + fm_write_meta "$home/state/t9.meta" "window=sess:fm-t9" "kind=ship" + printf 'needs-decision: [key=after] pick REST or RPC\n' > "$home/state/t9.status" + out=$(drain_out "$home") + printf '%s' "$out" | grep -F 't9 [key=after]' >/dev/null \ + || fail "precondition: drain should list the post-colon key as the folded key: $out" + run_send "$fb" "$home" "$log" t9 --resolve-key after "go with REST"; rc=$? + expect_code 0 "$rc" " --resolve-key should accept a key sitting immediately after the colon" + + printf 'needs-decision: no token at all\n' > "$home/state/t9.status" + : > "$log" + env PATH="$fb:$PATH" \ + FM_ROOT_OVERRIDE="$home" FM_HOME="$home" FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \ + "$SEND" t9 --resolve-key after "stale slug" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "an unkeyed default decision should refuse a leftover slug" + assert_contains "$(cat "$err")" "--resolve-key 'after'" "the unkeyed refusal should name the leftover slug" + [ ! -s "$log" ] || fail "a refused leftover slug still typed text: $(cat "$log")" + pass "fm-send --resolve-key: post-colon and end-of-line keys match the drain and resolve" +} + test_flag_misuse_refuses +test_loose_form_key_matches_drain_and_resolves diff --git a/tests/fm-wake-drain-open-decisions.test.sh b/tests/fm-wake-drain-open-decisions.test.sh index 4db2c40954d..0695b8f9afa 100755 --- a/tests/fm-wake-drain-open-decisions.test.sh +++ b/tests/fm-wake-drain-open-decisions.test.sh @@ -224,3 +224,34 @@ test_no_open_decisions_prints_nothing test_open_decision_surfaces_even_with_an_unrelated_queued_wake test_buried_decision_surfaces_on_the_empty_queue_fast_path test_status_symlink_is_not_followed + +# The drain must print the folded ledger key, not only the raw status text. +# Worker-written needs-decision: [key=slug] ... used to fold as default, so the +# section showed the slug in the note while --resolve-key slug refused. +test_post_colon_and_end_of_line_keys_print_the_folded_key() { + local dir state out + dir=$(make_case loose-key-display) + state="$dir/state" + out="$dir/drain.out" + printf 'needs-decision: [key=after] pick REST or RPC\n' > "$state/after.status" + printf 'needs-decision: review gate 3 ask-user findings [key=review-ask-user]\n' > "$state/endline.status" + printf 'needs-decision: option A: ship now vs later [key=ship-timing]\n' > "$state/colon.status" + printf 'blocked: waiting on credentials\n' > "$state/unkeyed.status" + + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed on post-colon decision keys" + + grep -F 'after [key=after] needs-decision:' "$out" >/dev/null \ + || fail "a key immediately after the colon was not printed as the folded key: $(cat "$out")" + grep -F 'endline [key=review-ask-user] needs-decision:' "$out" >/dev/null \ + || fail "a key at end of line was not printed as the folded key: $(cat "$out")" + grep -F 'colon [key=ship-timing] needs-decision:' "$out" >/dev/null \ + || fail "a key after another colon in the note was not printed as the folded key: $(cat "$out")" + grep -F 'unkeyed [key=default] blocked: waiting on credentials' "$out" >/dev/null \ + || fail "an unkeyed line did not print its folded default key: $(cat "$out")" + if grep -E 'after \[key=default\]|endline \[key=default\]|colon \[key=default\]' "$out" >/dev/null; then + fail "a loose-form key was displayed as default: $(cat "$out")" + fi + pass "OPEN DECISIONS prints the folded key for prefix, post-colon, end-of-line, and default" +} + +test_post_colon_and_end_of_line_keys_print_the_folded_key diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 28a949e0589..73bfd796a0a 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -191,14 +191,42 @@ test_classifier_primitives() { && fail "FM_CAPTAIN_RE override bypassed paused: suppression" FM_CAPTAIN_RE='custom-verb:' status_is_captain_relevant "custom-verb: x" \ || fail "nonterminal suppression weakened custom bare-line behavior" - printf 'needs-decision: should docs mention [key=prose]?\nneeds-decision [key=q1]: real choice\nresolved: docs still mention [key=q1]\nneeds-decision [key=bad key]: malformed\n' > "$state/keys.status" + # Key-position compatibility: a [key=slug] token anywhere on the line is the + # fold key. Workers write needs-decision: [key=slug] after the colon, and the + # 2026-08-14 form puts the token at end of line after other colons. Both must + # fold as that slug, not default. A line with no token still folds as default, + # and an unkeyed resolved: still closes default only. + printf '%s\n' \ + 'needs-decision [key=before]: pick REST or RPC' \ + 'needs-decision: [key=after] pick REST or RPC' \ + 'needs-decision: review gate 3 ask-user findings [key=review-ask-user]' \ + 'needs-decision: option A: ship now vs later [key=ship-timing]' \ + 'needs-decision: no token at all' \ + 'needs-decision [key=keep]: still open after default close' \ + 'resolved: closed the unkeyed default only' \ + 'needs-decision [key=bad key]: malformed' \ + > "$state/keys.status" open=$(status_open_decisions "$state/keys.status") - printf '%s' "$open" | grep -F $'q1\t' >/dev/null \ - || fail "a key token in resolved note prose closed the keyed decision" - printf '%s' "$open" | grep -F $'prose\t' >/dev/null \ - && fail "a key token in note prose changed the decision key" + printf '%s' "$open" | grep -F $'before\t' >/dev/null \ + || fail "a key token before the colon was not the folded key" + printf '%s' "$open" | grep -F $'after\t' >/dev/null \ + || fail "a key token immediately after the colon folded as default instead of the slug" + printf '%s' "$open" | grep -F $'review-ask-user\t' >/dev/null \ + || fail "a key token at end of line folded as default instead of the slug" + printf '%s' "$open" | grep -F $'ship-timing\t' >/dev/null \ + || fail "a key token after another colon in the note was not the folded key" + printf '%s' "$open" | grep -F $'keep\t' >/dev/null \ + || fail "an unkeyed resolved: closed a keyed decision instead of default only" + printf '%s' "$open" | grep -F $'default\t' >/dev/null \ + && fail "an unkeyed resolved: did not close the default decision" printf '%s' "$open" | grep -F $'bad key\t' >/dev/null \ && fail "an invalid key slug entered the open-decision set" + printf '%s\n' \ + 'needs-decision: [key=loose-close] choose' \ + 'resolved: [key=loose-close] went with A' \ + > "$state/loose-close.status" + [ -z "$(status_open_decisions "$state/loose-close.status")" ] \ + || fail "a resolved line with the key after the colon did not close that key" cat > "$state/activity.status" <<'EOF' working [key=phase7]: Phase 7 started working [key=phase6]: Phase 6 started From e30f02affe9f24827051754d467782091f838aa8 Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Thu, 13 Aug 2026 21:45:25 -0400 Subject: [PATCH 3/5] Show the unambiguous keyed form in generated briefs 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. --- bin/fm-brief.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 9d22467c9c1..9946fef1573 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -245,7 +245,7 @@ Never append \`working:\` merely to acknowledge receipt or announce that a marke When a routed-work phase has a supervisor-actionable material change worth reporting under the rule above, give that reported phase a stable key. If its first reportable event is \`working [key=]: {material phase}\`, use the same key on its later \`$PAUSED_VERB\`, \`done\`, \`failed\`, \`needs-decision\`, or \`blocked\` event so the earlier working phase is superseded. When a keyed phase ends without another reportable state, append \`resolved [key=]: {why it is no longer active}\`. -\`resolved\` separately closes an escalated decision or blocker, and only a \`resolved\` line carrying that decision's exact key closes it: a later \`done\` or \`working\` event never does, even when the answer is what started that work. +\`resolved\` separately closes an escalated decision or blocker, and only a \`resolved [key=]:\` line carrying that decision's exact key closes it: a later \`done\` or \`working\` event never does, even when the answer is what started that work. The main firstmate's answer normally writes that closing line at answer time; when a blocker or wait clears WITHOUT an answer from the main firstmate, append \`resolved: {how it cleared}\` yourself (keyed with \`[key=]\` if you opened it with one) as your domain resumes. Routine internal supervision, heartbeats, retries, and crewmate churn stay inside your own home and must not touch that status file. @@ -329,8 +329,8 @@ The report is the only thing that survives, so anything worth keeping must be in treating it as a possible wedge. Use \`blocked:\` when you are stuck and need help. 5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. 6. If a decision belongs to a human (product choices, destructive actions), - append \`needs-decision: {summary of options}\` and stop. Firstmate will reply with the decision. - A decision or blocker you opened stays open until a \`resolved\` line carrying its exact key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. + append \`needs-decision [key=]: {summary of options}\` and stop. Firstmate will reply with the decision. + A decision or blocker you opened stays open until a \`resolved [key=]:\` line carrying that same key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=]\` if you opened it with one) as you resume. 7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes @@ -445,8 +445,8 @@ $RULE1 cadence instead of treating it as a possible wedge. Use \`blocked:\` when you are stuck and need help. 5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. 6. If a decision belongs above the implementation worker (product choices, destructive actions, ask-user findings), - append \`needs-decision: {summary of options}\` and stop. Firstmate will apply the configured authority and reply with the decision. - A decision or blocker you opened stays open until a \`resolved\` line carrying its exact key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. + append \`needs-decision [key=]: {summary of options}\` and stop. Firstmate will apply the configured authority and reply with the decision. + A decision or blocker you opened stays open until a \`resolved [key=]:\` line carrying that same key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=]\` if you opened it with one) as you resume. 7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes From 557c94e19109fef2e57ccd9fbe5e223dfccd5972 Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Thu, 13 Aug 2026 22:09:34 -0400 Subject: [PATCH 4/5] no-mistakes(review): Keep activity keys prefix-only; surface malformed opening decision keys --- bin/fm-brief.sh | 20 +++++----- bin/fm-classify-lib.sh | 75 +++++++++++++++++++++++++++-------- tests/fm-brief.test.sh | 8 ++-- tests/fm-watch-triage.test.sh | 47 ++++++++++++++++++++-- 4 files changed, 117 insertions(+), 33 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 9946fef1573..fb22a4ac24d 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -243,10 +243,10 @@ This is also how you return the answer to a marked from-firstmate request above. A marked request requires one correlated answer after the work; it does not require a separate receipt or start acknowledgement. Never append \`working:\` merely to acknowledge receipt or announce that a marked request has started. When a routed-work phase has a supervisor-actionable material change worth reporting under the rule above, give that reported phase a stable key. -If its first reportable event is \`working [key=]: {material phase}\`, use the same key on its later \`$PAUSED_VERB\`, \`done\`, \`failed\`, \`needs-decision\`, or \`blocked\` event so the earlier working phase is superseded. -When a keyed phase ends without another reportable state, append \`resolved [key=]: {why it is no longer active}\`. -\`resolved\` separately closes an escalated decision or blocker, and only a \`resolved [key=]:\` line carrying that decision's exact key closes it: a later \`done\` or \`working\` event never does, even when the answer is what started that work. -The main firstmate's answer normally writes that closing line at answer time; when a blocker or wait clears WITHOUT an answer from the main firstmate, append \`resolved: {how it cleared}\` yourself (keyed with \`[key=]\` if you opened it with one) as your domain resumes. +If its first reportable event is \`working [key=your-work-slug]: {material phase}\` (a short name of letters, digits, \`.\`, \`_\`, and \`-\` only), use the same key on its later \`$PAUSED_VERB\`, \`done\`, \`failed\`, \`needs-decision\`, or \`blocked\` event so the earlier working phase is superseded. +When a keyed phase ends without another reportable state, append \`resolved [key=your-work-slug]: {why it is no longer active}\`. +\`resolved\` separately closes an escalated decision or blocker, and only a \`resolved [key=your-slug]:\` line carrying that decision's exact key closes it: a later \`done\` or \`working\` event never does, even when the answer is what started that work. +The main firstmate's answer normally writes that closing line at answer time; when a blocker or wait clears WITHOUT an answer from the main firstmate, append \`resolved: {how it cleared}\` yourself (keyed with the same \`[key=...]\` if you opened it with one) as your domain resumes. Routine internal supervision, heartbeats, retries, and crewmate churn stay inside your own home and must not touch that status file. # Definition of done @@ -329,9 +329,9 @@ The report is the only thing that survives, so anything worth keeping must be in treating it as a possible wedge. Use \`blocked:\` when you are stuck and need help. 5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. 6. If a decision belongs to a human (product choices, destructive actions), - append \`needs-decision [key=]: {summary of options}\` and stop. Firstmate will reply with the decision. - A decision or blocker you opened stays open until a \`resolved [key=]:\` line carrying that same key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. - Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=]\` if you opened it with one) as you resume. + append \`needs-decision [key=your-slug]: {summary of options}\` and stop, replacing \`your-slug\` with a short name for this decision (letters, digits, \`.\`, \`_\`, and \`-\` only). Firstmate will reply with the decision. + A decision or blocker you opened stays open until a \`resolved [key=your-slug]:\` line carrying that same key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. + Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=...]\` if you opened it with one) as you resume. 7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon. @@ -445,9 +445,9 @@ $RULE1 cadence instead of treating it as a possible wedge. Use \`blocked:\` when you are stuck and need help. 5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. 6. If a decision belongs above the implementation worker (product choices, destructive actions, ask-user findings), - append \`needs-decision [key=]: {summary of options}\` and stop. Firstmate will apply the configured authority and reply with the decision. - A decision or blocker you opened stays open until a \`resolved [key=]:\` line carrying that same key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. - Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=]\` if you opened it with one) as you resume. + append \`needs-decision [key=your-slug]: {summary of options}\` and stop, replacing \`your-slug\` with a short name for this decision (letters, digits, \`.\`, \`_\`, and \`-\` only). Firstmate will apply the configured authority and reply with the decision. + A decision or blocker you opened stays open until a \`resolved [key=your-slug]:\` line carrying that same key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. + Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=...]\` if you opened it with one) as you resume. 7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 53360418f5b..5b05c425dec 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -168,7 +168,19 @@ status_is_paused_or_captain_held() { # # 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; the verb parser still +# That whole-line scan is the DECISION fold's rule only. The routed-work +# activity fold further below keeps reading the prefix before the first colon: +# it never had the post-colon defect, and a phase opened under a key that mere +# note prose happened to name would never be closed by that phase's own unkeyed +# terminal line, leaving a permanently open phase that consumers read as +# evidence a parent event was superseded. +# A MALFORMED slug is decided by the fold, not by these parsers: an opening verb +# falls back to "default" so the escalation still surfaces, while a closing verb +# is not a decision transition at all. Losing an escalation silently is strictly +# worse than the refusal this change set out to fix - the old bug at least +# failed loudly - so the safe direction is always surface-the-open, never +# auto-close. +# The parsers are pure reads of a single line; the verb parser still # strips a key token before the colon so the leading word is recovered cleanly. status_line_verb() { # -> leading verb word local v=${1%%:*} @@ -183,21 +195,42 @@ status_line_note() { # -> text after the first colon, trimmed *) printf '%s' "$1" ;; esac } -_fm_decision_key() { # -> key slug, or "default" when no token +# First "[key=]" token in : prints the slug and returns 0, returns 1 +# when carries no token at all, and returns 2 when that first token's +# slug is malformed. The two key parsers below differ only in how much of the +# line they hand in; what a malformed token MEANS is each fold's own call. +_fm_key_token() { # -> slug + local text=$1 k + case "$text" in + *\[key=*\]*) ;; + *) return 1 ;; + esac + k=${text#*\[key=} + k=${k%%\]*} + case "$k" in + ''|*[!A-Za-z0-9._-]*) return 2 ;; + esac + printf '%s' "$k" +} +_fm_decision_key() { # -> key slug, "default" when no token, 1 when malformed # Scan the whole line, not only the prefix before the first colon: workers # commonly write needs-decision: [key=slug] ... and may put the token at # end of line after other colons. First token wins. - local line=$1 k - case "$line" in - *\[key=*\]*) - k=${line#*\[key=} - k=${k%%\]*} - case "$k" in - ''|*[!A-Za-z0-9._-]*) return 1 ;; - *) printf '%s' "$k" ;; - esac - ;; - *) printf 'default' ;; + local k + k=$(_fm_key_token "$1") + case $? in + 0) printf '%s' "$k" ;; + 1) printf 'default' ;; + *) return 1 ;; + esac +} +_fm_activity_key() { # -> phase key from the prefix before the first colon + local k + k=$(_fm_key_token "${1%%:*}") + case $? in + 0) printf '%s' "$k" ;; + 1) printf 'default' ;; + *) return 1 ;; esac } # Drop the record for from a newline-terminated "\t\t" set. @@ -263,7 +296,14 @@ _fm_decision_fold_line() { # printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}" } -FM_OPEN_DECISIONS_FOLD_VERSION=3 +FM_OPEN_DECISIONS_FOLD_VERSION=4 # Portable device:inode identity for the rotation/recreation check below. _fm_open_decisions_file_ident() { # -> "dev:inode", empty on I/O failure @@ -548,6 +588,9 @@ EOF # key closes the phase, because it has moved to a terminal or separately tracked # state. # A bare legacy event uses the default key, preserving one-phase behavior. +# Phase keys are read from the prefix before the first colon only: a note that +# merely names some decision's key must not open a phase under it, because the +# phase's own unkeyed terminal line would then never close it. # This fold is evidence about whether a parent event was explicitly superseded. # It is never authoritative current crew state, and consumers must not let an open # phase outrank a structured home snapshot or fm-crew-state result. @@ -560,7 +603,7 @@ _fm_status_open_activities_stream() { stripped=${line//[[:space:]]/} [ -n "$stripped" ] || continue verb=$(status_line_verb "$line") - key=$(_fm_decision_key "$line") || continue + key=$(_fm_activity_key "$line") || continue case "$verb" in working|"$pause") note=$(status_line_note "$line") diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index d85779956b8..e8f4de519c9 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -459,9 +459,9 @@ test_secondmate_no_projects_charter() { "project-less charter operating model lost the pooled-worktree note" assert_no_grep "The projects above are local clones" "$brief" \ "project-less charter kept the with-projects operating-model line" - assert_grep 'working [key=]' "$brief" \ + assert_grep 'working [key=your-work-slug]' "$brief" \ "secondmate charter did not key material routed-work phases" - assert_grep 'resolved [key=]' "$brief" \ + assert_grep 'resolved [key=your-work-slug]' "$brief" \ "secondmate charter did not close a quietly ended routed-work phase" assert_grep 'use the same key on its later' "$brief" \ "secondmate charter did not supersede working phases with later states" @@ -504,11 +504,11 @@ test_secondmate_marked_request_reporting_contract() { "secondmate charter retained the unconditional working opener" assert_grep 'When a routed-work phase has a supervisor-actionable material change worth reporting under the rule above' "$brief" \ "secondmate charter did not limit keyed phases to reportable material changes" - assert_grep "If its first reportable event is \`working [key=]: {material phase}\`" "$brief" \ + assert_grep "If its first reportable event is \`working [key=your-work-slug]: {material phase}\`" "$brief" \ "secondmate charter lost keyed working syntax for a reportable material phase" assert_grep "use the same key on its later \`paused\`, \`done\`, \`failed\`, \`needs-decision\`, or \`blocked\` event" "$brief" \ "secondmate charter lost same-key closure for a reportable material phase" - assert_grep 'resolved [key=]' "$brief" \ + assert_grep 'resolved [key=your-work-slug]' "$brief" \ "secondmate charter lost resolved closure for a keyed material phase" assert_grep 'include that exact token in your parent status reply' "$brief" \ diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 73bfd796a0a..ca80f8bcec6 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -204,7 +204,6 @@ test_classifier_primitives() { 'needs-decision: no token at all' \ 'needs-decision [key=keep]: still open after default close' \ 'resolved: closed the unkeyed default only' \ - 'needs-decision [key=bad key]: malformed' \ > "$state/keys.status" open=$(status_open_decisions "$state/keys.status") printf '%s' "$open" | grep -F $'before\t' >/dev/null \ @@ -219,14 +218,41 @@ test_classifier_primitives() { || fail "an unkeyed resolved: closed a keyed decision instead of default only" printf '%s' "$open" | grep -F $'default\t' >/dev/null \ && fail "an unkeyed resolved: did not close the default decision" - printf '%s' "$open" | grep -F $'bad key\t' >/dev/null \ - && fail "an invalid key slug entered the open-decision set" printf '%s\n' \ 'needs-decision: [key=loose-close] choose' \ 'resolved: [key=loose-close] went with A' \ > "$state/loose-close.status" [ -z "$(status_open_decisions "$state/loose-close.status")" ] \ || fail "a resolved line with the key after the colon did not close that key" + # A malformed slug never enters the set, but an OPENING verb still surfaces + # under default: silently swallowing an escalation is worse than the loud + # refusal this fold set out to fix. + printf '%s\n' \ + 'needs-decision: should the brief still require [key=]?' \ + > "$state/malformed-open.status" + open=$(status_open_decisions "$state/malformed-open.status") + printf '%s' "$open" | grep -F $'\t' >/dev/null \ + && fail "an invalid key slug entered the open-decision set" + printf '%s' "$open" | grep -F $'default\tneeds-decision\t' >/dev/null \ + || fail "a needs-decision with a malformed key token was dropped instead of surfacing under default" + printf '%s\n' 'blocked [key=bad key]: malformed blocker' > "$state/malformed-block.status" + open=$(status_open_decisions "$state/malformed-block.status") + printf '%s' "$open" | grep -F $'bad key\t' >/dev/null \ + && fail "an invalid key slug entered the open-decision set" + printf '%s' "$open" | grep -F $'default\tblocked\t' >/dev/null \ + || fail "a blocked line with a malformed key token was dropped instead of surfacing under default" + # A CLOSING verb keeps the drop, so a malformed key can never close anything. + printf '%s\n' \ + 'needs-decision: plain unkeyed escalation' \ + 'needs-decision [key=keeper]: keyed escalation' \ + 'resolved [key=bad key]: malformed close before the colon' \ + 'resolved: [key=also bad] malformed close after the colon' \ + > "$state/malformed-close.status" + open=$(status_open_decisions "$state/malformed-close.status") + printf '%s' "$open" | grep -F $'default\t' >/dev/null \ + || fail "a resolved line with a malformed key token closed the default decision" + printf '%s' "$open" | grep -F $'keeper\t' >/dev/null \ + || fail "a resolved line with a malformed key token closed a keyed decision" cat > "$state/activity.status" <<'EOF' working [key=phase7]: Phase 7 started working [key=phase6]: Phase 6 started @@ -249,6 +275,21 @@ EOF printf 'working: legacy start\ndone: legacy completion\n' > "$state/legacy-activity.status" [ -z "$(status_open_activities "$state/legacy-activity.status")" ] \ || fail "a legacy terminal event did not supersede the default working phase" + # The decision fold's whole-line key scan must NOT reach phases: a key named + # only in a note would open a phase that the phase's own unkeyed terminal + # line can never close, and an open phase is read as supersession evidence. + printf '%s\n' \ + 'working: implementing the [key=api-shape] decision' \ + 'done: shipped' \ + > "$state/prose-key-activity.status" + [ -z "$(status_open_activities "$state/prose-key-activity.status")" ] \ + || fail "a key named only in an activity note left a phase open forever" + printf '%s\n' \ + 'working [key=phase9]: rolling out the [key=api-shape] decision' \ + 'done [key=phase9]: rolled out' \ + > "$state/prefix-key-activity.status" + [ -z "$(status_open_activities "$state/prefix-key-activity.status")" ] \ + || fail "an activity note key outranked the phase's own prefix key" pass "classifier primitives: keyed decisions and activity phases, captain relevance, window-to-task, and overrides" } From f2c38c87924b75f4a66c1171d3e00f281a238cbb Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Thu, 13 Aug 2026 22:44:02 -0400 Subject: [PATCH 5/5] no-mistakes(document): Note loose-form key coverage in resolve-key test header --- tests/fm-send-resolve-key.test.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index 6d258cfbf13..806fc3fef69 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -21,6 +21,9 @@ # message crosses the stubbed ssh transport while the close is the same # local ledger append; a failed transport closes nothing. # 7. Flag misuse (--key, empty message, explicit backend target) refuses. +# 8. A worker-written key sitting after the colon or at end of line is the +# same key the drain lists and --resolve-key accepts, while a line with no +# token stays "default" and still refuses a leftover slug. set -u # shellcheck source=tests/lib.sh