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
20 changes: 10 additions & 10 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=<work-slug>]: {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=<work-slug>]: {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.
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=<slug>]\` 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
Expand Down Expand Up @@ -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: {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.
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=<slug>]\` 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.
Expand Down Expand Up @@ -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: {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.
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=<slug>]\` 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.
Expand Down
99 changes: 81 additions & 18 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,28 @@ 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 may sit between the verb and the
# colon, or anywhere later on the same line:
# needs-decision [key=api-shape]: <summary>
# needs-decision: [key=api-shape] <summary>
# needs-decision: review findings [key=api-shape]
# 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; the verb parser strips any
# key token before the colon so the leading word is recovered cleanly.
# 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() { # <status-line> -> leading verb word
local v=${1%%:*}
v=${v%%\[key=*}
Expand All @@ -180,18 +195,42 @@ status_line_note() { # <status-line> -> text after the first colon, trimmed
*) printf '%s' "$1" ;;
esac
}
_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' ;;
# First "[key=<slug>]" token in <text>: prints the slug and returns 0, returns 1
# when <text> 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() { # <text> -> 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() { # <status-line> -> 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 k
k=$(_fm_key_token "$1")
case $? in
0) printf '%s' "$k" ;;
1) printf 'default' ;;
*) return 1 ;;
esac
}
_fm_activity_key() { # <status-line> -> 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 <key> from a newline-terminated "<key>\t<verb>\t<note>" set.
Expand Down Expand Up @@ -257,7 +296,14 @@ _fm_decision_fold_line() { # <open-set> <status-line> <resolve-verb> <held-verb
stripped=${line//[[:space:]]/}
[ -n "$stripped" ] || { printf '%s' "$open"; return 0; }
verb=$(status_line_verb "$line")
key=$(_fm_decision_key "$line") || { printf '%s' "$open"; return 0; }
if ! key=$(_fm_decision_key "$line"); then
# Malformed token: surface the open, never auto-close (see the grammar
# comment above status_line_verb).
case "$verb" in
needs-decision|blocked) key=default ;;
*) printf '%s' "$open"; return 0 ;;
esac
fi
_fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")" \
|| { printf '%s' "$open"; return 0; }
case "$verb" in
Expand Down Expand Up @@ -298,6 +344,20 @@ status_open_decisions() { # <status-file>
printf '%s' "$open"
}

# 0 if <key> is currently open in <status-file> 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() { # <status-file> <key>
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 <state> 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
Expand Down Expand Up @@ -384,7 +444,7 @@ _fm_open_decisions_cursor_path() { # <status-file>
printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}"
}

FM_OPEN_DECISIONS_FOLD_VERSION=2
FM_OPEN_DECISIONS_FOLD_VERSION=4

# Portable device:inode identity for the rotation/recreation check below.
_fm_open_decisions_file_ident() { # <file> -> "dev:inode", empty on I/O failure
Expand Down Expand Up @@ -528,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.
Expand All @@ -540,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")
Expand Down
26 changes: 12 additions & 14 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
7 changes: 4 additions & 3 deletions bin/fm-wake-drain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions tests/fm-brief.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=<work-slug>]' "$brief" \
assert_grep 'working [key=your-work-slug]' "$brief" \
"secondmate charter did not key material routed-work phases"
assert_grep 'resolved [key=<work-slug>]' "$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"
Expand Down Expand Up @@ -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=<work-slug>]: {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=<work-slug>]' "$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" \
Expand Down
Loading
Loading