From 4a28dcf857ae352eaa8e851b2fa0a35e6092c316 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 1 Sep 2026 23:58:16 -0700 Subject: [PATCH 01/47] Keep parked and aged undated captain holds off live Captain's Call. Bearings was treating undated parked-style holds as live calls; mark those phrasings deferred and project holds older than a configurable 14-day since date as Charted Next gates instead. --- bin/fm-bearings-snapshot.sh | 52 +++++++++++++++------- bin/fm-fleet-snapshot.sh | 51 +++++++++++++++++++--- docs/captain-hold-lifecycle.md | 8 +++- docs/configuration.md | 1 + tests/fm-bearings-snapshot.test.sh | 40 +++++++++++++++++ tests/fm-fleet-snapshot-view.test.sh | 65 ++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 24 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 16230513716..7d57d765a0e 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -34,6 +34,12 @@ # bearings_state. A home classified captain_decision because it has an open # captain hold still contributes each working child as its own Underway row; # the home row on secondmates[] keeps the decision and gate classification. +# An undated captain hold the canonical snapshot marks aged_undated_hold +# (since date at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old) leaves default +# Captain's Call, renders as a Charted Next gate showing its age, is disclosed +# in omitted[], and is revealed by --all-decisions. That aging is a +# projection safety net only; the durable deferral remains re-holding with +# --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory # object (orphan structured in-flight without meta, unstructured current rows). @@ -324,6 +330,18 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson candidate_prs "$CANDIDATE_PRS" ' def trunc($n): if . == null then null else (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; + def live_captain_call: + (.deferred_marker != true) and (.aged_undated_hold != true); + def hold_gate_reason: + if .aged_undated_hold == true and .hold_age_days != null then + ("held " + (.hold_age_days | tostring) + "d: " + (.hold_reason // .blocked_reason // "-")) + elif (.hold_until // null) != null and .hold_until > $today then + ("until " + .hold_until + ": " + (.hold_reason // .blocked_reason // "-")) + else (.hold_reason // .blocked_reason // "-") end; + def as_gate($owner): + {id, title:(.title | trunc(60)), + blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)), + reason:(hold_gate_reason | trunc(40)), owner:$owner}; def round_robin_landed($n): . as $groups | [range(0; (($groups | map(length) | max) // 0)) as $i @@ -358,7 +376,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | {id:($m.id + "/" + .id),backend:"secondmate-home",target:(.endpoint.target // "-"),exists:.endpoint.exists,agent:.endpoint.agent_alive} ]) as $unhealthy_all | ([ (.secondmate_current.records // [])[] | ([.decisions_open[]? | select(.source == "backlog" and .verb == "captain-hold" - and .deferred_marker != true)]) as $captain_holds + and live_captain_call)]) as $captain_holds | ([.holds[]? | select(.source == "backlog")]) as $backlog_holds | . + { bearings_captain_holds:$captain_holds, @@ -410,18 +428,20 @@ MODEL=$(printf '%s' "$SNAP" | jq \ doing:((.doing // .state) | trunc(90))} ]) as $in_flight_all | ([ .backlog.records[] | select(.structured and .captain_actionable == true) - | select(($all_decisions == 1) or (.deferred_marker != true)) + | select(($all_decisions == 1) or live_captain_call) | {id,key:.id,verb:"captain-hold", summary:((.title + ": " + .hold_reason) | trunc(90)),owner:"(main)"} ] + [ (.secondmate_current.records // [])[] as $m | $m.decisions_open[]? | select(.source == "backlog" and .verb == "captain-hold") - | select(($all_decisions == 1) or (.deferred_marker != true)) + | select(($all_decisions == 1) or live_captain_call) | {id:($m.id + "/" + .id),key,verb, summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ]) as $decisions_all | ([ .backlog.records[] - | select(.structured and .captain_actionable == true and .deferred_marker == true) ] + | select(.structured and .captain_actionable == true + and (.deferred_marker == true or .aged_undated_hold == true)) ] + [ (.secondmate_current.records // [])[] | .decisions_open[]? - | select(.source == "backlog" and .verb == "captain-hold" and .deferred_marker == true) ] + | select(.source == "backlog" and .verb == "captain-hold" + and (.deferred_marker == true or .aged_undated_hold == true)) ] | length) as $decisions_marked_deferred | ((if (.main_inventory.valid == false) then [{id:"(main-inventory)", @@ -438,22 +458,22 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select(.captain_actionable != true) | select(($all_queued == 1) or (.deferred_marker != true) or ((.hold_until // null) != null and .hold_until > $today)) - | {id, title:(.title | trunc(60)), - blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)), - reason:((if (.hold_until // null) != null and .hold_until > $today - then ("until " + .hold_until + ": " + (.hold_reason // .blocked_reason // "-")) - else (.hold_reason // .blocked_reason // "-") end) | trunc(40)),owner:"(main)"} ] + | as_gate("(main)") ] + + [ .backlog.records[] + | select(.structured and .captain_actionable == true and .aged_undated_hold == true) + | as_gate("(main)") ] + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") | $m.queued[]? | select(.captain_actionable != true) | select(($all_queued == 1) or (.deferred_marker != true) or ((.hold_until // null) != null and .hold_until > $today)) - | {id,title:(.title | trunc(60)), - blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)), - reason:((if (.hold_until // null) != null and .hold_until > $today - then ("until " + .hold_until + ": " + (.hold_reason // .blocked_reason // "-")) - else (.hold_reason // .blocked_reason // "-") end) | trunc(40)),owner:$m.id} ]) as $gates_all + | as_gate($m.id) ] + + [ (.secondmate_current.records // [])[] as $m + | select($m.provenance.selected == "structured-home") + | $m.queued[]? + | select(.captain_actionable == true and .aged_undated_hold == true) + | as_gate($m.id) ]) as $gates_all | ([ .scout_reports[] | . as $r | select(($all_reports == 1) or (($rel_ids | index($r.id)) != null)) @@ -515,7 +535,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.input_truncated == true or .parent_event.activity_scan.retained_truncated == true)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence truncated for \($n) record(s)"), reveal:"raise FM_SNAPSHOT_PARENT_ACTIVITY_LINES, FM_SNAPSHOT_PARENT_ACTIVITY_BYTES, or FM_SNAPSHOT_PARENT_ACTIVITIES"} else empty end), (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.available == false)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence unavailable for \($n) record(s)"), reveal:"inspect the parent status logs"} else empty end), (if $all_decisions == 0 and ($decisions_all | length) > $decisions_n then {surface:("decisions_open showing \($decisions_n) of \($decisions_all | length)"), reveal:"--all-decisions"} else empty end), - (if $all_decisions == 0 and $decisions_marked_deferred > 0 then {surface:("captain holds marked deferred or superseded: \($decisions_marked_deferred)"), reveal:"--all-decisions"} else empty end), + (if $all_decisions == 0 and $decisions_marked_deferred > 0 then {surface:("captain holds marked deferred, superseded, or aged: \($decisions_marked_deferred)"), reveal:"--all-decisions"} else empty end), (if $all_queued == 0 and ($gates_all | length) > $gates_n then {surface:("gates showing \($gates_n) of \($gates_all | length)"), reveal:"--all-queued"} else empty end), (if $all_reports == 0 and ($reports_all | length) > $reports_n then {surface:("reports showing \($reports_n) of \($reports_all | length)"), reveal:"--all-reports"} else empty end), (if $all_recorded_prs == 0 and ($recorded_prs_all | length) > $recorded_prs_n then {surface:("recorded_prs showing \($recorded_prs_n) of \($recorded_prs_all | length)"), reveal:"--all-recorded-prs"} else empty end), diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 818a365f93d..66ea71ff044 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -30,9 +30,18 @@ # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. # deferred_marker is a presentation hint only: the row's hold reason or -# body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker. -# It never changes captain_actionable; renderers may use it to keep -# prose-deferred rows out of default views. +# body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker, or a +# parked-style phrasing (parked, awaiting captain go, do not dispatch / +# do not auto-dispatch, not urgent, de-prioritized, queued opportunity, +# captain-gated). It never changes captain_actionable; renderers may use it +# to keep prose-deferred rows out of default views. +# aged_undated_hold is a second presentation hint for an undated captain +# hold whose `since` date is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS +# old (default 14). hold_age_days is that age when computable, else null. +# This is a projection safety net only: the durable deferral remains +# re-holding with --until. Renderers project an aged undated hold as a +# Charted Next gate showing its age, disclose it in omitted[], and reveal +# it with --all-decisions; it stays captain_actionable. # tasks[]: one row per task metadata record captured at snapshot start, sorted # by id. A record removed before capture is omitted. If a captured task's # generation changes while observations run, its selected metadata remains @@ -171,6 +180,13 @@ validate_positive_bound FM_SNAPSHOT_REGISTRY_LINES "$FM_SNAPSHOT_REGISTRY_LINES" validate_positive_bound FM_SNAPSHOT_REGISTRY_BYTES "$FM_SNAPSHOT_REGISTRY_BYTES" validate_positive_bound FM_SNAPSHOT_REGISTRY_RECORDS "$FM_SNAPSHOT_REGISTRY_RECORDS" validate_positive_bound FM_SNAPSHOT_REGISTRY_TIMEOUT "$FM_SNAPSHOT_REGISTRY_TIMEOUT" +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=${FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS:-14} +case "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" in + ''|*[!0-9]*) + echo "fm-fleet-snapshot: FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS must be a non-negative integer" >&2 + exit 2 + ;; +esac # shellcheck source=bin/fm-backend.sh # shellcheck disable=SC1091 @@ -225,6 +241,10 @@ FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT, with truncation disclosed in the result. The registered secondmate table uses FM_SNAPSHOT_REGISTRY_LINES, FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. +An undated captain hold whose since date is at least +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14, 0 ages every dated-since +undated captain hold) carries aged_undated_hold and hold_age_days as +presentation hints; re-holding with --until remains the durable deferral. EOF } @@ -325,8 +345,19 @@ backlog_json() { # [] - defaults to this home's $BACKLOG fi # shellcheck disable=SC2094 - jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" ' + jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" \ + --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); + def prose_deferred: + test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED|parked|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated"; "i"); + def ymd_epoch($d): + if ($d | type) != "string" then null + else try (($d + "T00:00:00Z") | fromdateiso8601) catch null end; + def days_between($from; $to): + (ymd_epoch($from)) as $a + | (ymd_epoch($to)) as $b + | if $a == null or $b == null then null + else (($b - $a) / 86400 | floor) end; def section_state: if . == "In flight" then "in_flight" elif . == "Queued" then "queued" @@ -464,9 +495,13 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.state == "queued" and .hold_kind == "captain" and .hold_reason != null and (.unresolved_blocker_ids | length) == 0 and (.hold_until == null or .hold_until <= $today)) + | .hold_age_days = days_between(.since; $today) + | .aged_undated_hold = + (.hold_kind == "captain" and .hold_until == null + and .hold_age_days != null and .hold_age_days >= $age_days) | .deferred_marker = ((((.hold_reason // "") + " " + (.body_excerpt // "")) - | test("SUPERSEDED|NOT REQUIRED|NOT-REQUIRED|DEFERRED"; "i"))) + | prose_deferred)) else . end) | del(.section,.order) ' < "$backlog" @@ -891,7 +926,9 @@ secondmate_home_summary_json() { # | {id,key:.id,verb:"captain-hold",summary:(.title | trunc(160)), reason:(.hold_reason | trunc(160)), hold_until:(.hold_until // null), - deferred_marker:(.deferred_marker // false),source:"backlog"} ]) as $captain_holds_all + deferred_marker:(.deferred_marker // false), + aged_undated_hold:(.aged_undated_hold // false), + hold_age_days:(.hold_age_days // null),source:"backlog"} ]) as $captain_holds_all | ([ $backlog.records[]? | select(.state == "done" and .structured and .hold_kind != "captain") | {id:(.id | trunc(120)),title:(.title | trunc(120)), pr_url:((.pr_url // null) | if . == null then null else trunc(500) end), @@ -999,6 +1036,8 @@ secondmate_home_summary_json() { # hold_kind:((.hold_kind // null) | if . == null then null else trunc(40) end), hold_until:((.hold_until // null) | if . == null then null else trunc(40) end), deferred_marker:(.deferred_marker // false), + aged_undated_hold:(.aged_undated_hold // false), + hold_age_days:(.hold_age_days // null), captain_actionable:(.captain_actionable // false), repo:((.repo // null) | if . == null then null else trunc(120) end), kind:((.kind // null) | if . == null then null else trunc(40) end)}][:$queued_n]), diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 1752504b207..7d9b23f1aef 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -57,11 +57,15 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. -It also emits a presentation-only `deferred_marker` when a hold's reason or body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker. +It also emits a presentation-only `deferred_marker` when a hold's reason or body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). +An undated captain hold whose `since` date is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14) also carries `aged_undated_hold` and `hold_age_days`. +That aging is a projection safety net only. +The durable deferral remains re-holding with `--until`. Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. A date-deferred captain hold renders as a gate with its `until :` reason; a prose-deferred one leaves the default views with an `omitted[]` disclosure, revealed by `--all-decisions` / `--all-queued`. +An aged undated captain hold leaves default Captain's Call, renders as a Charted Next gate showing its age, is disclosed in `omitted[]`, and is revealed by `--all-decisions`. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. @@ -101,5 +105,5 @@ It proves: cleanup of a finished task whose own row is the captain call leaves t `tests/fm-classify-decision-key.test.sh` pins `status_key_closing_verb` itself: it separates a resolution from the durable-transfer close and from a still-open key, reports the last real transition across re-openings and both key positions, and treats a prose mention as no transition. -Projection regressions live in `tests/fm-fleet-snapshot-view.test.sh` (hold-until parsing, the due gate, kind-independent captain actionability, deferred_marker, title stripping) and `tests/fm-bearings-snapshot.test.sh` (Captain's Call membership, the dated-gate rendering, prose-deferral suppression with disclosure, and the landed exclusion by surviving captain-hold annotations). +Projection regressions live in `tests/fm-fleet-snapshot-view.test.sh` (hold-until parsing, the due gate, kind-independent captain actionability, deferred_marker, undated-hold aging, title stripping) and `tests/fm-bearings-snapshot.test.sh` (Captain's Call membership, the dated-gate rendering, prose-deferral and aged-undated suppression with disclosure, and the landed exclusion by surviving captain-hold annotations). The exact commands and their summarized outputs are recorded in the shipping PR's evidence; run the four suites above plus `tests/fm-send-resolve-key.test.sh`, `tests/fm-bearings-board.test.sh`, and `bin/fm-lint.sh` to refresh this record. diff --git a/docs/configuration.md b/docs/configuration.md index 6f5234b0822..8ae3a307c22 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -821,6 +821,7 @@ FM_SNAPSHOT_CREW_STATE_TIMEOUT=10 # seconds bounding each local per-task curre FM_SNAPSHOT_LOCAL_READ_CONCURRENCY=8 # maximum local tasks whose current-state and endpoint observations are collected concurrently during snapshot composition FM_SNAPSHOT_BUDGET=5 # one total seconds budget for all concurrent remote home-ledger reads FM_SNAPSHOT_CACHE_DIR=$FM_HOME/state/secondmate-summary-cache # private parent-side cache of successfully fetched remote home ledgers +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # days after which an undated captain hold (no hold-until; age from since) is projected as a Charted Next gate instead of a live Captain's Call; 0 ages every such hold that has a since date FM_RECONCILE_REQUEST_MAX_BYTES=1048576 # maximum captured Bearings or fleet snapshot accepted for durable reconcile-notify request publication FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 11153bcce6d..2ec0c78894e 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1151,6 +1151,45 @@ EOF pass "captain-held tasks of any kind reach Captain's Call, deferral is honored, and landed excludes answered calls" } +test_undated_hold_phrasing_and_aging_projection() { + local home fakebin json + home=$(make_home undated-aging-proj) + mkdir -p "$home/data" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (hold: parked for a later pass) (hold-kind: captain) +- [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) +- [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) + +## Done +EOF + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "parked-hold") | not) + and (.decisions_open | any(.[]; .id == "aged-call") | not) + and (.gates | any(.[]; .id == "parked-hold") | not) + and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) + and (.gates | any(.[]; .id == "recent-call") | not) + and (.omitted | any(.[]; .surface | startswith("captain holds marked deferred"))) + ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" + json=$(run "$home" "$fakebin" --json --all-decisions) + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.[]; .id == "parked-hold")) + and (.decisions_open | any(.[]; .id == "aged-call")) + and (.decisions_open | any(.[]; .id == "recent-call")) + ' >/dev/null || fail "--all-decisions must reveal parked-style and aged undated holds: $json" + json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.[]; .id == "aged-call")) + and (.gates | any(.[]; .id == "aged-call") | not) + ' >/dev/null || fail "raising the age threshold must restore an undated hold to Captain's Call: $json" + pass "parked-style and aged undated captain holds project to Charted Next/omitted, not live Captain's Call" +} + test_include_prs_is_the_only_fetch_path() { local home fakebin json home=$(make_home prs); write_fixture "$home" @@ -2595,6 +2634,7 @@ test_partial_github_failure_degrades test_perl_fallback_bounds_github_call test_section_caps_and_expansion_flags test_collapsed_captain_call_deferral_and_landed +test_undated_hold_phrasing_and_aging_projection test_pr_repository_cap_and_expansion test_per_repository_pr_cap_is_disclosed test_projection_and_toon_fail_closed diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 3c89d80a17a..eb8e2855dfc 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -582,6 +582,70 @@ EOF pass "snapshot parses tasks-axi rows and respects operational overrides" } +test_undated_captain_hold_phrasing_and_aging() { + local home fakebin out + home=$(make_home undated-aging) + mkdir -p "$home/data" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] parked-hold - Parked style call (repo: sample) (kind: ship) (hold: parked for a later pass) (hold-kind: captain) +- [ ] awaiting-go - Awaiting go call (repo: sample) (kind: ship) (hold: awaiting captain go) (hold-kind: captain) +- [ ] no-dispatch - No dispatch call (repo: sample) (kind: ship) (hold: do not dispatch) (hold-kind: captain) +- [ ] no-auto - No auto-dispatch call (repo: sample) (kind: ship) (hold: do not auto-dispatch) (hold-kind: captain) +- [ ] not-urgent - Not urgent call (repo: sample) (kind: ship) (hold: not urgent) (hold-kind: captain) +- [ ] deprior - Deprioritized call (repo: sample) (kind: ship) (hold: de-prioritized) (hold-kind: captain) +- [ ] queued-opp - Queued opportunity call (repo: sample) (kind: ship) (hold: queued opportunity) (hold-kind: captain) +- [ ] gated-hold - Captain-gated phrasing (repo: sample) (kind: ship) (hold: captain-gated) (hold-kind: captain) +- [ ] aged-call - Aged genuine call (repo: sample) (kind: captain) (since 2026-07-01) (hold: choose a sample route) (hold-kind: captain) +- [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-07-20) (hold: choose a sample route) (hold-kind: captain) +- [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) + +## Done +EOF + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ + FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + ([.backlog.records[] | select(.id == "parked-hold" or .id == "awaiting-go" or .id == "no-dispatch" + or .id == "no-auto" or .id == "not-urgent" or .id == "deprior" or .id == "queued-opp" + or .id == "gated-hold")] + | all(.captain_actionable == true and .deferred_marker == true and .aged_undated_hold == false)) + ' >/dev/null || fail "parked-style undated holds must carry deferred_marker without aging: $out" + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "aged-call") + | .captain_actionable == true + and .deferred_marker == false + and .aged_undated_hold == true + and .hold_age_days == 24 + ' >/dev/null || fail "an undated captain hold older than the default 14-day threshold must age: $out" + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "recent-call") + | .captain_actionable == true + and .deferred_marker == false + and .aged_undated_hold == false + and .hold_age_days == 5 + ' >/dev/null || fail "a recent undated captain hold must stay a live call: $out" + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "live-gated") + | .captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false + ' >/dev/null || fail "captain go pending must not match parked-style markers: $out" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ + FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "aged-call") + | .aged_undated_hold == false and .hold_age_days == 24 + ' >/dev/null || fail "raising the age threshold must leave a 24-day hold unaged: $out" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ + FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=5 "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "recent-call") + | .aged_undated_hold == true and .hold_age_days == 5 + ' >/dev/null || fail "lowering the age threshold to 5 must age a 5-day hold: $out" + pass "undated captain holds age after a configurable threshold and parked phrasing marks deferred" +} + test_view_renders_snapshot() { local home fakebin view home=$(make_home view) @@ -899,6 +963,7 @@ EOF test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory +test_undated_captain_hold_phrasing_and_aging test_main_inventory_orphan_and_unstructured_disclosure test_normalized_roles_and_plural_blocker_readiness test_event_hints_follow_reconciled_current_state From bd207b8d918e7cc9d5bcfa5bf10fb373d097595e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 00:02:40 -0700 Subject: [PATCH 02/47] no-mistakes(review): Bound parked marker matching to lexical tokens --- bin/fm-fleet-snapshot.sh | 2 +- tests/fm-fleet-snapshot-view.test.sh | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 66ea71ff044..0c0c3433697 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -349,7 +349,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); def prose_deferred: - test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED|parked|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated"; "i"); + test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED|\\bparked\\b|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated"; "i"); def ymd_epoch($d): if ($d | type) != "string" then null else try (($d + "T00:00:00Z") | fromdateiso8601) catch null end; diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index eb8e2855dfc..0f948e406de 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -601,6 +601,7 @@ test_undated_captain_hold_phrasing_and_aging() { - [ ] aged-call - Aged genuine call (repo: sample) (kind: captain) (since 2026-07-01) (hold: choose a sample route) (hold-kind: captain) - [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-07-20) (hold: choose a sample route) (hold-kind: captain) - [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) +- [ ] unparked-call - Newly unparked decision (repo: sample) (kind: captain) (hold: unparked; choose a sample route) (hold-kind: captain) ## Done EOF @@ -628,9 +629,9 @@ EOF and .hold_age_days == 5 ' >/dev/null || fail "a recent undated captain hold must stay a live call: $out" printf '%s' "$out" | jq -e ' - .backlog.records[] | select(.id == "live-gated") - | .captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false - ' >/dev/null || fail "captain go pending must not match parked-style markers: $out" + [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call")] + | all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) + ' >/dev/null || fail "captain go pending and unparked decisions must not match parked-style markers: $out" out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) printf '%s' "$out" | jq -e ' From ec4658894794229e3b19c2d9d4f5cf0d07d20df2 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 00:16:26 -0700 Subject: [PATCH 03/47] no-mistakes(review): Age undated holds from durable hold-set dates --- bin/fm-captain-hold.sh | 43 ++++++++++++++++++++++++- bin/fm-fleet-snapshot.sh | 16 +++++---- docs/captain-hold-lifecycle.md | 8 +++-- docs/configuration.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 27 ++++++++++++++-- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 57938ce45e8..de43cb638ea 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -451,8 +451,36 @@ resolve_entry() { # ; prints the resolved id or fails fail "no captain-held task $entry in $CAPTAIN_BACKLOG_FILE" } +body_hold_set_date() { # + printf '%s\n' "$1" \ + | sed -n 's/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p' \ + | head -1 +} + +write_hold_set_stamp() { # + local id=$1 body=$2 hold_set=$3 new_body tmp + body=$(decode_shown_value "$body") \ + || fail "could not decode the existing body for $id" + [ -z "$(body_hold_set_date "$body")" ] || return 0 + new_body=$(printf 'Captain hold set: %s' "$hold_set") + if [ -n "$body" ]; then + new_body=$(printf '%s\n\n%s' "$new_body" "$body") + fi + tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-stamp.XXXXXX") \ + || fail "cannot stage the hold-set stamp" + if ! printf '%s\n' "$new_body" > "$tmp"; then + rm -f -- "$tmp" + fail "cannot stage the hold-set stamp for $id" + fi + if ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then + rm -f -- "$tmp" + fail "could not record the hold-set stamp on $id" + fi + rm -f -- "$tmp" +} + command_hold() { - local id=${1:-} title='' reason='' repo='' origin='' until='' show state existing_title body='' hold_kind occurrence + local id=${1:-} title='' reason='' repo='' origin='' until='' show state existing_title body='' hold_kind hold_set occurrence [ "$#" -ge 1 ] || { usage >&2; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -478,6 +506,12 @@ command_hold() { *) fail "--until must be a YYYY-MM-DD date: $until" ;; esac fi + hold_set=${FM_CAPTAIN_HOLD_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} + hold_set=${hold_set%%T*} + case "$hold_set" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;; + *) fail "FM_CAPTAIN_HOLD_NOW must begin with a YYYY-MM-DD date" ;; + esac acquire_task_control_lock "$id" require_tasks_axi if show=$(task_show "$id"); then @@ -507,6 +541,11 @@ command_hold() { || fail "could not create task $id" fi fi + show=$(task_show "$id") || fail "task $id disappeared before recording its hold-set stamp" + write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" + show=$(task_show "$id") || fail "task $id disappeared while recording its hold-set stamp" + [ -n "$(body_hold_set_date "$(show_field_value "$show" body)")" ] \ + || fail "task $id did not retain its hold-set stamp" if [ -n "$until" ]; then tasks_axi hold "$id" --reason "$reason" --kind captain --until "$until" >/dev/null \ || fail "could not hold task $id for the captain" @@ -518,6 +557,8 @@ command_hold() { hold_kind=$(show_field_value "$show" hold_kind) [ "$hold_kind" = captain ] || fail "task $id did not retain its captain hold" occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 )) + [ -n "$(body_hold_set_date "$(show_field_value "$show" body)")" ] \ + || fail "task $id lost its hold-set stamp while being held" publish_parent_hold "$id" "$occurrence" needs-decision "$reason" printf '%s\n' "$id" } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 0c0c3433697..bb0d292183e 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -36,8 +36,9 @@ # captain-gated). It never changes captain_actionable; renderers may use it # to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain -# hold whose `since` date is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS -# old (default 14). hold_age_days is that age when computable, else null. +# hold whose hold-set date is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS +# old (default 14). Legacy unstamped holds fall back to `since`. +# hold_age_days is that age when computable, else null. # This is a projection safety net only: the durable deferral remains # re-holding with --until. Renderers project an aged undated hold as a # Charted Next gate showing its age, disclose it in omitted[], and reveal @@ -241,10 +242,11 @@ FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT, with truncation disclosed in the result. The registered secondmate table uses FM_SNAPSHOT_REGISTRY_LINES, FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. -An undated captain hold whose since date is at least -FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14, 0 ages every dated-since +An undated captain hold whose hold-set date is at least +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14, 0 ages every dated undated captain hold) carries aged_undated_hold and hold_age_days as -presentation hints; re-holding with --until remains the durable deferral. +presentation hints. Legacy holds without a stamp fall back to their since date; +re-holding with --until remains the durable deferral. EOF } @@ -436,6 +438,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG hold_reason:metadata($rest; "hold"), hold_kind:metadata($rest; "hold-kind"), hold_until:metadata($rest; "hold-until"), + hold_set:null, blocked_by:cap($rest; ".*blocked-by:[[:space:]]*(?[^[:space:])]+).*"), blocked_by_ids:blocked_by_ids($rest), blocked_reason:blocked_reason($rest), @@ -472,6 +475,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | .records |= map( if (.body_lines | length) > 0 then .body_excerpt = ((.body_lines | join(" "))[:240]) + | .hold_set = cap((.body_lines | join(" ")); "Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2})") else . end) | .records as $records | (reduce ($records[] | select(.structured)) as $record ({}; @@ -495,7 +499,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.state == "queued" and .hold_kind == "captain" and .hold_reason != null and (.unresolved_blocker_ids | length) == 0 and (.hold_until == null or .hold_until <= $today)) - | .hold_age_days = days_between(.since; $today) + | .hold_age_days = days_between((.hold_set // .since); $today) | .aged_undated_hold = (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 7d9b23f1aef..e467c11b2d3 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -10,8 +10,8 @@ A decision is not a separate thing in this system: it is an ordinary backlog tas The command addresses the active home's configured data directory the same way `bin/fm-backlog-transition-lib.sh` addresses every backlog transition, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. -The `hold` subcommand places an existing task under an active captain hold, or creates the task when nothing exists to hold, then verifies the hold through `tasks-axi hold --reason --kind captain`. -Repeats are idempotent, a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. +The `hold` subcommand places an existing task under an active captain hold, or creates the task when nothing exists to hold, then verifies the hold through `tasks-axi hold --reason --kind captain` and records its UTC hold-set date in the task body. +Repeats preserve the original hold-set date, a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. It requires a non-empty captain decision file of at most 8192 bytes, writes a resolution block carrying the decision digest and a `Resolution mode:` at the top of the task body (the previous body is preserved below the block and archived through tasks-axi `--archive-body`), then runs `tasks-axi done` - or `tasks-axi unhold` under `--release`, so a captain-gated work item resumes instead of closing. @@ -58,7 +58,8 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. It also emits a presentation-only `deferred_marker` when a hold's reason or body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). -An undated captain hold whose `since` date is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14) also carries `aged_undated_hold` and `hold_age_days`. +An undated captain hold whose recorded hold-set date is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14) also carries `aged_undated_hold` and `hold_age_days`. +Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. The durable deferral remains re-holding with `--until`. Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. @@ -66,6 +67,7 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. A date-deferred captain hold renders as a gate with its `until :` reason; a prose-deferred one leaves the default views with an `omitted[]` disclosure, revealed by `--all-decisions` / `--all-queued`. An aged undated captain hold leaves default Captain's Call, renders as a Charted Next gate showing its age, is disclosed in `omitted[]`, and is revealed by `--all-decisions`. +Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate; re-holding with `--until` remains the durable fix. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. diff --git a/docs/configuration.md b/docs/configuration.md index 8ae3a307c22..f7a71209fd5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -821,7 +821,7 @@ FM_SNAPSHOT_CREW_STATE_TIMEOUT=10 # seconds bounding each local per-task curre FM_SNAPSHOT_LOCAL_READ_CONCURRENCY=8 # maximum local tasks whose current-state and endpoint observations are collected concurrently during snapshot composition FM_SNAPSHOT_BUDGET=5 # one total seconds budget for all concurrent remote home-ledger reads FM_SNAPSHOT_CACHE_DIR=$FM_HOME/state/secondmate-summary-cache # private parent-side cache of successfully fetched remote home ledgers -FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # days after which an undated captain hold (no hold-until; age from since) is projected as a Charted Next gate instead of a live Captain's Call; 0 ages every such hold that has a since date +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # days after which an undated captain hold (no hold-until; age from its hold-set stamp, falling back to since for legacy unstamped holds) is projected as a Charted Next gate instead of a live Captain's Call; 0 ages every such dated hold FM_RECONCILE_REQUEST_MAX_BYTES=1048576 # maximum captured Bearings or fleet snapshot accepted for durable reconcile-notify request publication FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index ec5ef8c8b6b..7be85662b96 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -377,6 +377,20 @@ test_release_frees_held_work() { test_deferral_leaves_captains_call_until_due() { local home json snap show home=$(make_home deferral) + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] sample-existing-call - Decide an existing sample task (repo: sample) (kind: captain) (since 2026-06-01) + +## Done +EOF + FM_CAPTAIN_HOLD_NOW=2026-07-14T12:00:00Z run_captain "$home" hold sample-existing-call \ + --reason "captain choice on existing work" >/dev/null \ + || fail "could not hold the existing task" + FM_CAPTAIN_HOLD_NOW=2026-07-20T12:00:00Z run_captain "$home" hold sample-existing-call \ + --reason "captain choice on existing work" >/dev/null \ + || fail "could not repeat the existing task hold" run_captain "$home" hold sample-later-call --title "Revisit the sample plan" \ --reason "captain deferred revisit later" --repo sample --until 2026-08-01 >/dev/null \ || fail "could not register the deferred captain call" @@ -395,14 +409,18 @@ test_deferral_leaves_captains_call_until_due() { printf '%s' "$snap" | jq -e ' ([.backlog.records[] | select(.id == "sample-later-call")][0]) as $later | ([.backlog.records[] | select(.id == "sample-now-call")][0]) as $now + | ([.backlog.records[] | select(.id == "sample-existing-call")][0]) as $existing | $later.captain_actionable == false and $later.hold_until == "2026-08-01" and $now.captain_actionable == true and $now.hold_until == null + and $existing.since == "2026-06-01" and $existing.hold_set == "2026-07-14" + and $existing.hold_age_days == 0 and $existing.aged_undated_hold == false and ($later.title | contains("hold-until") | not) - ' >/dev/null || fail "the due gate or hold-until parsing is wrong: $snap" + ' >/dev/null || fail "the due gate, hold-set age, or hold-until parsing is wrong: $snap" json=$(run_bearings "$home") || fail "Bearings failed with a deferred call" printf '%s' "$json" | jq -e ' (.decisions_open | any(.id == "sample-now-call")) + and (.decisions_open | any(.id == "sample-existing-call")) and (.decisions_open | any(.id == "sample-later-call") | not) and (.gates | any(.id == "sample-later-call" and (.reason | startswith("until 2026-08-01")))) ' >/dev/null || fail "the deferred call did not render as a dated gate: $json" @@ -414,8 +432,11 @@ test_deferral_leaves_captains_call_until_due() { FM_PROJECTS_OVERRIDE="$home/projects" FM_SNAPSHOT_NOW=2026-08-01T12:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed at the due date" printf '%s' "$snap" | jq -e ' - [.backlog.records[] | select(.id == "sample-later-call")][0].captain_actionable == true - ' >/dev/null || fail "a due deferral did not resurface as captain-actionable" + ([.backlog.records[] | select(.id == "sample-later-call")][0]) as $later + | ([.backlog.records[] | select(.id == "sample-existing-call")][0]) as $existing + | $later.captain_actionable == true + and $existing.hold_age_days == 18 and $existing.aged_undated_hold == true + ' >/dev/null || fail "a due deferral did not resurface or a stamped hold did not age from its hold date" show=$(tasks_in "$home" show sample-later-call --full) assert_contains "$show" "hold_kind: captain" "the expired deferral lost its captain-hold annotations" printf 'Answered on the due date.\n' > "$home/due.txt" From d3f23ab22282ac56e5d7e1622f314d62ec0bb6a5 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 00:20:13 -0700 Subject: [PATCH 04/47] no-mistakes(review): Reset re-held timestamps and scan full bodies --- bin/fm-captain-hold.sh | 19 +++++++++++++++---- bin/fm-fleet-snapshot.sh | 4 ++-- docs/captain-hold-lifecycle.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 19 ++++++++++++++++--- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index de43cb638ea..3153be567ab 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -457,11 +457,16 @@ body_hold_set_date() { # | head -1 } -write_hold_set_stamp() { # - local id=$1 body=$2 hold_set=$3 new_body tmp +write_hold_set_stamp() { # + local id=$1 body=$2 hold_set=$3 preserve=$4 new_body tmp body=$(decode_shown_value "$body") \ || fail "could not decode the existing body for $id" - [ -z "$(body_hold_set_date "$body")" ] || return 0 + if [ "$preserve" = 1 ] && [ -n "$(body_hold_set_date "$body")" ]; then + return 0 + fi + body=$(printf '%s\n' "$body" \ + | sed '/^Captain hold set: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/d') + case "$body" in $'\n'*) body=${body#$'\n'} ;; esac new_body=$(printf 'Captain hold set: %s' "$hold_set") if [ -n "$body" ]; then new_body=$(printf '%s\n\n%s' "$new_body" "$body") @@ -481,6 +486,7 @@ write_hold_set_stamp() { # command_hold() { local id=${1:-} title='' reason='' repo='' origin='' until='' show state existing_title body='' hold_kind hold_set occurrence + local existing_hold_kind='' existing_held='' preserve_hold_set=0 [ "$#" -ge 1 ] || { usage >&2; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -518,6 +524,11 @@ command_hold() { state=$(show_field "$show" state) [ "$state" != "done" ] \ || fail "task $id is already closed; a new captain call needs its own task" + existing_hold_kind=$(show_field_value "$show" hold_kind) + existing_held=$(show_field_value "$show" held) + if [ "$existing_hold_kind" = captain ] && [ "$existing_held" = yes ]; then + preserve_hold_set=1 + fi if [ -n "$title" ]; then existing_title=$(show_field_value "$show" title) [ "$existing_title" = "$title" ] || fail "existing task $id has a different title" @@ -542,7 +553,7 @@ command_hold() { fi fi show=$(task_show "$id") || fail "task $id disappeared before recording its hold-set stamp" - write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" + write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" "$preserve_hold_set" show=$(task_show "$id") || fail "task $id disappeared while recording its hold-set stamp" [ -n "$(body_hold_set_date "$(show_field_value "$show" body)")" ] \ || fail "task $id did not retain its hold-set stamp" diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index bb0d292183e..3a9a907fbe5 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -504,8 +504,8 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) | .deferred_marker = - ((((.hold_reason // "") + " " + (.body_excerpt // "")) - | prose_deferred)) + (((.hold_reason // "") | prose_deferred) + or ((.body_lines | join(" ")) | prose_deferred)) else . end) | del(.section,.order) ' < "$backlog" diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index e467c11b2d3..8a4048780af 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -11,7 +11,7 @@ The command addresses the active home's configured data directory the same way ` It never reads report bodies, review artifacts, terminal output, or chat. The `hold` subcommand places an existing task under an active captain hold, or creates the task when nothing exists to hold, then verifies the hold through `tasks-axi hold --reason --kind captain` and records its UTC hold-set date in the task body. -Repeats preserve the original hold-set date, a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. +Retries of an active hold preserve its hold-set date, while re-holding released work starts a new dated lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. It requires a non-empty captain decision file of at most 8192 bytes, writes a resolution block carrying the decision digest and a `Resolution mode:` at the top of the task body (the previous body is preserved below the block and archived through tasks-axi `--archive-body`), then runs `tasks-axi done` - or `tasks-axi unhold` under `--release`, so a captain-gated work item resumes instead of closing. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 7be85662b96..060bb93b2ce 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -304,12 +304,13 @@ test_answer_records_and_closes() { # --release lifts the hold instead of closing, preserving the work item's own # body under the record; a re-held task later accepts a new answer. test_release_frees_held_work() { - local home show out + local home show out snap home=$(make_home release-work) tasks_in "$home" add sample-widget "Ship the sample widget" --kind ship --repo sample \ --body 'The widget plan body. Literal escape: \n. Unicode: café.' >/dev/null \ || fail "could not create the held work item" - run_captain "$home" hold sample-widget --reason "captain go needed before shipping" >/dev/null \ + FM_CAPTAIN_HOLD_NOW=2026-06-01T12:00:00Z run_captain "$home" hold sample-widget \ + --reason "captain go needed before shipping" >/dev/null \ || fail "could not hold the work item for the captain" printf 'Go: ship it as planned.\n' > "$home/go.txt" run_captain "$home" answer sample-widget --decision-file "$home/go.txt" --release >/dev/null \ @@ -349,8 +350,17 @@ test_release_frees_held_work() { "an empty-label release recorded the wrong close mode" # A NEW captain gate on the same task later takes a NEW answer. - run_captain "$home" hold sample-widget --reason "captain pricing call needed" >/dev/null \ + FM_CAPTAIN_HOLD_NOW=2026-07-14T12:00:00Z run_captain "$home" hold sample-widget \ + --reason "captain pricing call needed" >/dev/null \ || fail "could not re-hold the released work item" + snap=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_SNAPSHOT_NOW=2026-07-14T12:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed after re-hold" + printf '%s' "$snap" | jq -e ' + .backlog.records[] | select(.id == "sample-widget") + | .hold_set == "2026-07-14" and .hold_age_days == 0 and .aged_undated_hold == false + ' >/dev/null || fail "a new hold lifecycle reused the released hold timestamp: $snap" printf 'Price it at nine dollars.\n' > "$home/price.txt" run_captain "$home" answer sample-widget --decision-file "$home/price.txt" --release >/dev/null \ || fail "a re-held task refused a new answer" @@ -382,6 +392,8 @@ test_deferral_leaves_captains_call_until_due() { ## Queued - [ ] sample-existing-call - Decide an existing sample task (repo: sample) (kind: captain) (since 2026-06-01) +- [ ] sample-late-marker - Decide a documented sample route (repo: sample) (kind: captain) (since 2026-07-14) (hold: choose the sample route) (hold-kind: captain) + This deliberately long decision context fills the bounded display excerpt without changing the durable classification contract. Additional synthetic context keeps extending the body beyond that display boundary while remaining ordinary task prose. More synthetic context places the presentation marker after the excerpt cutoff. DEFERRED ## Done EOF @@ -414,6 +426,7 @@ EOF and $now.captain_actionable == true and $now.hold_until == null and $existing.since == "2026-06-01" and $existing.hold_set == "2026-07-14" and $existing.hold_age_days == 0 and $existing.aged_undated_hold == false + and ([.backlog.records[] | select(.id == "sample-late-marker")][0].deferred_marker == true) and ($later.title | contains("hold-until") | not) ' >/dev/null || fail "the due gate, hold-set age, or hold-until parsing is wrong: $snap" From d81f05dccf9f19bb2d005d67a94ea578059fe283 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 00:33:51 -0700 Subject: [PATCH 05/47] no-mistakes(review): Preserve timestamp precision and prioritize parked suppression --- bin/fm-bearings-snapshot.sh | 6 +++-- bin/fm-captain-hold.sh | 20 +++++++++-------- bin/fm-fleet-snapshot.sh | 29 ++++++++++++++----------- docs/captain-hold-lifecycle.md | 8 +++---- docs/configuration.md | 2 +- tests/fm-bearings-snapshot.test.sh | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 10 ++++++--- tests/fm-fleet-snapshot-view.test.sh | 7 ++++++ 8 files changed, 51 insertions(+), 33 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 7d57d765a0e..ba6a9257194 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -460,7 +460,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \ or ((.hold_until // null) != null and .hold_until > $today)) | as_gate("(main)") ] + [ .backlog.records[] - | select(.structured and .captain_actionable == true and .aged_undated_hold == true) + | select(.structured and .captain_actionable == true + and .aged_undated_hold == true and .deferred_marker != true) | as_gate("(main)") ] + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") @@ -472,7 +473,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \ + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") | $m.queued[]? - | select(.captain_actionable == true and .aged_undated_hold == true) + | select(.captain_actionable == true + and .aged_undated_hold == true and .deferred_marker != true) | as_gate($m.id) ]) as $gates_all | ([ .scout_reports[] | . as $r diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 3153be567ab..5719bda0a59 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -451,21 +451,24 @@ resolve_entry() { # ; prints the resolved id or fails fail "no captain-held task $entry in $CAPTAIN_BACKLOG_FILE" } -body_hold_set_date() { # +body_hold_set_timestamp() { # printf '%s\n' "$1" \ - | sed -n 's/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p' \ + | sed -n \ + -e 's/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z\)$/\1/p' \ + -e 's/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p' \ | head -1 } -write_hold_set_stamp() { # +write_hold_set_stamp() { # local id=$1 body=$2 hold_set=$3 preserve=$4 new_body tmp body=$(decode_shown_value "$body") \ || fail "could not decode the existing body for $id" - if [ "$preserve" = 1 ] && [ -n "$(body_hold_set_date "$body")" ]; then + if [ "$preserve" = 1 ] && [ -n "$(body_hold_set_timestamp "$body")" ]; then return 0 fi body=$(printf '%s\n' "$body" \ - | sed '/^Captain hold set: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/d') + | sed -e '/^Captain hold set: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/d' \ + -e '/^Captain hold set: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z$/d') case "$body" in $'\n'*) body=${body#$'\n'} ;; esac new_body=$(printf 'Captain hold set: %s' "$hold_set") if [ -n "$body" ]; then @@ -513,10 +516,9 @@ command_hold() { esac fi hold_set=${FM_CAPTAIN_HOLD_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} - hold_set=${hold_set%%T*} case "$hold_set" in - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;; - *) fail "FM_CAPTAIN_HOLD_NOW must begin with a YYYY-MM-DD date" ;; + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z) : ;; + *) fail "FM_CAPTAIN_HOLD_NOW must be a UTC YYYY-MM-DDTHH:MM:SSZ timestamp" ;; esac acquire_task_control_lock "$id" require_tasks_axi @@ -568,7 +570,7 @@ command_hold() { hold_kind=$(show_field_value "$show" hold_kind) [ "$hold_kind" = captain ] || fail "task $id did not retain its captain hold" occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 )) - [ -n "$(body_hold_set_date "$(show_field_value "$show" body)")" ] \ + [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id lost its hold-set stamp while being held" publish_parent_hold "$id" "$occurrence" needs-decision "$reason" printf '%s\n' "$id" diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 3a9a907fbe5..7df20e407f6 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -36,7 +36,7 @@ # captain-gated). It never changes captain_actionable; renderers may use it # to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain -# hold whose hold-set date is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS +# hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS # old (default 14). Legacy unstamped holds fall back to `since`. # hold_age_days is that age when computable, else null. # This is a projection safety net only: the durable deferral remains @@ -242,10 +242,10 @@ FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT, with truncation disclosed in the result. The registered secondmate table uses FM_SNAPSHOT_REGISTRY_LINES, FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. -An undated captain hold whose hold-set date is at least -FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14, 0 ages every dated -undated captain hold) carries aged_undated_hold and hold_age_days as -presentation hints. Legacy holds without a stamp fall back to their since date; +An undated captain hold whose hold-set timestamp is at least +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14; 0 ages every hold with a +hold-set timestamp or fallback date) carries aged_undated_hold and hold_age_days +as presentation hints. Legacy holds without a stamp fall back to their since date; re-holding with --until remains the durable deferral. EOF } @@ -347,17 +347,18 @@ backlog_json() { # [] - defaults to this home's $BACKLOG fi # shellcheck disable=SC2094 - jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" \ + jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); def prose_deferred: test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED|\\bparked\\b|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated"; "i"); - def ymd_epoch($d): + def timestamp_epoch($d): if ($d | type) != "string" then null + elif ($d | test("T")) then try ($d | fromdateiso8601) catch null else try (($d + "T00:00:00Z") | fromdateiso8601) catch null end; def days_between($from; $to): - (ymd_epoch($from)) as $a - | (ymd_epoch($to)) as $b + (timestamp_epoch($from)) as $a + | (timestamp_epoch($to)) as $b | if $a == null or $b == null then null else (($b - $a) / 86400 | floor) end; def section_state: @@ -474,8 +475,10 @@ backlog_json() { # [] - defaults to this home's $BACKLOG end) | .records |= map( if (.body_lines | length) > 0 then - .body_excerpt = ((.body_lines | join(" "))[:240]) - | .hold_set = cap((.body_lines | join(" ")); "Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2})") + .body_excerpt = ((.body_lines + | map(select(test("^Captain hold set: [0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?$") | not)) + | join(" "))[:240]) + | .hold_set = cap((.body_lines | join(" ")); "Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)") else . end) | .records as $records | (reduce ($records[] | select(.structured)) as $record ({}; @@ -499,13 +502,13 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.state == "queued" and .hold_kind == "captain" and .hold_reason != null and (.unresolved_blocker_ids | length) == 0 and (.hold_until == null or .hold_until <= $today)) - | .hold_age_days = days_between((.hold_set // .since); $today) + | .hold_age_days = days_between((.hold_set // .since); $now) | .aged_undated_hold = (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) | .deferred_marker = (((.hold_reason // "") | prose_deferred) - or ((.body_lines | join(" ")) | prose_deferred)) + or ((.body_excerpt // "") | prose_deferred)) else . end) | del(.section,.order) ' < "$backlog" diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 8a4048780af..e505840c9d7 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -10,8 +10,8 @@ A decision is not a separate thing in this system: it is an ordinary backlog tas The command addresses the active home's configured data directory the same way `bin/fm-backlog-transition-lib.sh` addresses every backlog transition, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. -The `hold` subcommand places an existing task under an active captain hold, or creates the task when nothing exists to hold, then verifies the hold through `tasks-axi hold --reason --kind captain` and records its UTC hold-set date in the task body. -Retries of an active hold preserve its hold-set date, while re-holding released work starts a new dated lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. +The `hold` subcommand places an existing task under an active captain hold, or creates the task when nothing exists to hold, then verifies the hold through `tasks-axi hold --reason --kind captain` and records its UTC hold-set timestamp in the task body. +Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. It requires a non-empty captain decision file of at most 8192 bytes, writes a resolution block carrying the decision digest and a `Resolution mode:` at the top of the task body (the previous body is preserved below the block and archived through tasks-axi `--archive-body`), then runs `tasks-axi done` - or `tasks-axi unhold` under `--release`, so a captain-gated work item resumes instead of closing. @@ -58,7 +58,7 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. It also emits a presentation-only `deferred_marker` when a hold's reason or body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). -An undated captain hold whose recorded hold-set date is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14) also carries `aged_undated_hold` and `hold_age_days`. +An undated captain hold whose recorded hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, using floored elapsed days) also carries `aged_undated_hold` and `hold_age_days`. Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. The durable deferral remains re-holding with `--until`. @@ -66,7 +66,7 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. A date-deferred captain hold renders as a gate with its `until :` reason; a prose-deferred one leaves the default views with an `omitted[]` disclosure, revealed by `--all-decisions` / `--all-queued`. -An aged undated captain hold leaves default Captain's Call, renders as a Charted Next gate showing its age, is disclosed in `omitted[]`, and is revealed by `--all-decisions`. +An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, is disclosed in `omitted[]`, and is revealed by `--all-decisions`; prose deferral takes precedence when both hints apply. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate; re-holding with `--until` remains the durable fix. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. diff --git a/docs/configuration.md b/docs/configuration.md index f7a71209fd5..d57ce36bc27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -821,7 +821,7 @@ FM_SNAPSHOT_CREW_STATE_TIMEOUT=10 # seconds bounding each local per-task curre FM_SNAPSHOT_LOCAL_READ_CONCURRENCY=8 # maximum local tasks whose current-state and endpoint observations are collected concurrently during snapshot composition FM_SNAPSHOT_BUDGET=5 # one total seconds budget for all concurrent remote home-ledger reads FM_SNAPSHOT_CACHE_DIR=$FM_HOME/state/secondmate-summary-cache # private parent-side cache of successfully fetched remote home ledgers -FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # days after which an undated captain hold (no hold-until; age from its hold-set stamp, falling back to since for legacy unstamped holds) is projected as a Charted Next gate instead of a live Captain's Call; 0 ages every such dated hold +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # days after which an undated captain hold (no hold-until; age from its UTC hold-set timestamp using floored elapsed days, falling back to since for legacy unstamped holds) is projected as a Charted Next gate instead of a live Captain's Call; 0 ages every such dated hold FM_RECONCILE_REQUEST_MAX_BYTES=1048576 # maximum captured Bearings or fleet snapshot accepted for durable reconcile-notify request publication FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 2ec0c78894e..c2cbe4fa556 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1159,7 +1159,7 @@ test_undated_hold_phrasing_and_aging_projection() { ## In flight ## Queued -- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (hold: parked for a later pass) (hold-kind: captain) +- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: parked for a later pass) (hold-kind: captain) - [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 060bb93b2ce..a66e5036dd0 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -359,7 +359,7 @@ test_release_frees_held_work() { "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed after re-hold" printf '%s' "$snap" | jq -e ' .backlog.records[] | select(.id == "sample-widget") - | .hold_set == "2026-07-14" and .hold_age_days == 0 and .aged_undated_hold == false + | .hold_set == "2026-07-14T12:00:00Z" and .hold_age_days == 0 and .aged_undated_hold == false ' >/dev/null || fail "a new hold lifecycle reused the released hold timestamp: $snap" printf 'Price it at nine dollars.\n' > "$home/price.txt" run_captain "$home" answer sample-widget --decision-file "$home/price.txt" --release >/dev/null \ @@ -392,6 +392,9 @@ test_deferral_leaves_captains_call_until_due() { ## Queued - [ ] sample-existing-call - Decide an existing sample task (repo: sample) (kind: captain) (since 2026-06-01) +- [ ] sample-near-marker - Decide a deferred sample route (repo: sample) (kind: captain) (since 2026-07-14) (hold: choose the sample route) (hold-kind: captain) + Captain hold set: 2026-07-14T12:00:00Z + abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij abcdefghij DEFERRED - [ ] sample-late-marker - Decide a documented sample route (repo: sample) (kind: captain) (since 2026-07-14) (hold: choose the sample route) (hold-kind: captain) This deliberately long decision context fills the bounded display excerpt without changing the durable classification contract. Additional synthetic context keeps extending the body beyond that display boundary while remaining ordinary task prose. More synthetic context places the presentation marker after the excerpt cutoff. DEFERRED @@ -424,9 +427,10 @@ EOF | ([.backlog.records[] | select(.id == "sample-existing-call")][0]) as $existing | $later.captain_actionable == false and $later.hold_until == "2026-08-01" and $now.captain_actionable == true and $now.hold_until == null - and $existing.since == "2026-06-01" and $existing.hold_set == "2026-07-14" + and $existing.since == "2026-06-01" and $existing.hold_set == "2026-07-14T12:00:00Z" and $existing.hold_age_days == 0 and $existing.aged_undated_hold == false - and ([.backlog.records[] | select(.id == "sample-late-marker")][0].deferred_marker == true) + and ([.backlog.records[] | select(.id == "sample-near-marker")][0].deferred_marker == true) + and ([.backlog.records[] | select(.id == "sample-late-marker")][0].deferred_marker == false) and ($later.title | contains("hold-until") | not) ' >/dev/null || fail "the due gate, hold-set age, or hold-until parsing is wrong: $snap" diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 0f948e406de..02d6f1ee124 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -600,6 +600,8 @@ test_undated_captain_hold_phrasing_and_aging() { - [ ] gated-hold - Captain-gated phrasing (repo: sample) (kind: ship) (hold: captain-gated) (hold-kind: captain) - [ ] aged-call - Aged genuine call (repo: sample) (kind: captain) (since 2026-07-01) (hold: choose a sample route) (hold-kind: captain) - [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-07-20) (hold: choose a sample route) (hold-kind: captain) +- [ ] boundary-call - Almost aged genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) + Captain hold set: 2026-07-11T00:01:00Z - [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) - [ ] unparked-call - Newly unparked decision (repo: sample) (kind: captain) (hold: unparked; choose a sample route) (hold-kind: captain) @@ -628,6 +630,11 @@ EOF and .aged_undated_hold == false and .hold_age_days == 5 ' >/dev/null || fail "a recent undated captain hold must stay a live call: $out" + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "boundary-call") + | .hold_set == "2026-07-11T00:01:00Z" + and .hold_age_days == 13 and .aged_undated_hold == false + ' >/dev/null || fail "a hold one minute short of 14 days must not age early: $out" printf '%s' "$out" | jq -e ' [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call")] | all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) From 815b06360763cc2059265b8c69f1db2a0761ed1e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 00:42:20 -0700 Subject: [PATCH 06/47] no-mistakes(document): Document undated captain-hold aging --- .agents/skills/bearings/SKILL.md | 18 +++++++++++------- bin/fm-bearings-snapshot.sh | 25 +++++++++++++++---------- bin/fm-captain-hold.sh | 10 ++++++---- bin/fm-fleet-snapshot.sh | 19 +++++++++++-------- docs/captain-hold-lifecycle.md | 6 +++--- 5 files changed, 46 insertions(+), 32 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index bd67e5a8f5a..9a2048629c2 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -42,8 +42,11 @@ Board answers are acted on later under the normal authority rules; this skill's Only pass `--include-prs` when the captain asks for live GitHub PR enrichment. For registered secondmates, use the snapshot's structured-home classification and provenance. A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. - A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task appears under `decisions_open`, whatever its kind. - A captain hold deferred by date sits under `gates` with its `until :` reason until it is due, and a hold whose reason or body carries an explicit deferred/superseded marker is suppressed from the default view with an `omitted` disclosure. + A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task is canonically actionable whatever its kind, before the presentation-only filters below. + A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. + A hold whose reason or bounded body excerpt carries a deferred, superseded, or parked-style marker is suppressed from the default view with an `omitted` disclosure. + An undated hold aged past the configured snapshot threshold leaves Captain's Call for an age-labelled Charted Next gate unless a parked-style marker suppresses it; this is only a presentation safety net, and re-holding with `--until` remains the durable deferral. + Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, subject to the snapshot's documented cross-home summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. Until then it stays queued with the reason. @@ -74,10 +77,10 @@ Board answers are acted on later under the normal authority rules; this skill's This is the only file-mode write allowed by the skill. The detailed report includes: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. + - **Captain's Call** - every unsuppressed open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - **Underway** - each live direct report making progress, with its current state, and the plans or main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - - **Charted Next** - queued or gated work, including any main-inventory integrity warning, with each item's blocker, date, or integrity reason. + - **Charted Next** - queued or gated work, including aged undated captain-hold safety gates and any main-inventory integrity warning, with each item's blocker, date, age, or integrity reason. After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section. For a richer review surface, offer `/bearings lavish` when the report has enough structure to deserve one, but only after the required digest is ready. @@ -124,13 +127,14 @@ Only the exact answer value `merge` authorizes a merge; an answer carrying a fre This skill is the one owner of the `/bearings` chat-response format; the snapshot and classifier own the data that feeds it, and no other file restates this contract. Every `/bearings` chat response renders EXACTLY these four sections, in THIS order, and nothing else structural (there is no At Anchor section): -1. **Captain's Call** - ONLY items that need the captain's own action now: a decision to make, a PR to approve or merge, a credential or login to provide, or a blocker only the captain can clear. +1. **Captain's Call** - ONLY unsuppressed items that need the captain's own action now: a decision to make, a PR to approve or merge, a credential or login to provide, or a blocker only the captain can clear. + Aged undated holds and parked-style holds follow the presentation safety rule above instead. Empty-state: "Nothing needs your action right now." 2. **Recently Landed** - the bounded current recent-completions baseline: merged PRs, completed scouts, and finished local-only merges across the main fleet and every registered secondmate home. Empty-state: "No recent completions are in the current baseline." 3. **Underway** - live work progressing on its own, one line of current state per direct report. Empty-state: "Nothing is underway." -4. **Charted Next** - queued or gated work waiting on the fleet or a date, plus action-free fleet-integrity warnings, never on the captain. +4. **Charted Next** - queued or gated work waiting on the fleet or a date, aged undated captain-hold safety gates, plus action-free fleet-integrity warnings. Empty-state: "Nothing is queued." Rules that keep the contract unambiguous: @@ -138,7 +142,7 @@ Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. - Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. -- The four buckets are mutually exclusive per item: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. +- The four buckets are mutually exclusive per item: an unsuppressed needs-your-action item is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work, an aged undated captain-hold safety gate, or an action-free fleet-integrity warning is Charted Next. - A secondmate home can contribute to more than one section at once. Each active child is an Underway row regardless of the home-level `bearings_state`, while that same home's due captain hold is Captain's Call and its queued or external holds stay Charted Next. Do not hide active children because the home also has an open captain hold. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own home-level row is not an Underway unit: `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index ba6a9257194..e7d53be7c68 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -23,23 +23,27 @@ # This wrapper consumes canonical status decisions plus canonically normalized # backlog roles, unresolved blockers, and captain actionability. It never infers # decisions from report or visual-review prose or reimplements snapshot semantics. -# Captain's Call is captain actionability itself: every due, unblocked task held -# for the captain, whatever its kind. A captain hold deferred by date +# Captain's Call starts from captain actionability: every due, unblocked task +# held for the captain, whatever its kind, before the presentation-only filters +# below. A captain hold deferred by date # (hold-until in the future) is not actionable and renders as a Charted Next # gate with its date; a row the canonical snapshot marks prose-deferred # (deferred_marker) leaves the default decisions and gates views and is -# disclosed in omitted[], revealed by --all-decisions / --all-queued. +# disclosed in omitted[]. --all-decisions reveals an actionable prose-deferred +# hold, while --all-queued reveals a non-actionable prose-deferred row. # Underway (in_flight) projects every main live worker plus every active child # from every readable secondmate ledger, independently of that home's # bearings_state. A home classified captain_decision because it has an open # captain hold still contributes each working child as its own Underway row; # the home row on secondmates[] keeps the decision and gate classification. # An undated captain hold the canonical snapshot marks aged_undated_hold -# (since date at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old) leaves default -# Captain's Call, renders as a Charted Next gate showing its age, is disclosed -# in omitted[], and is revealed by --all-decisions. That aging is a -# projection safety net only; the durable deferral remains re-holding with -# --until. +# (recorded hold-set timestamp at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old, +# falling back to since for a legacy unstamped hold) leaves default Captain's +# Call, renders as a Charted Next gate showing its floored age, is disclosed in +# omitted[], and is revealed by --all-decisions. Prose deferral takes +# precedence over aging, so a hold with both hints is omitted rather than +# gated. Aging is a projection safety net only; the durable deferral remains +# re-holding with --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory # object (orphan structured in-flight without meta, unstructured current rows). @@ -63,7 +67,7 @@ # --include-prs ALSO do live GitHub open-PR discovery + checks # --fields opt in to dropped surfaces: bodies,paths,actions,endpoints # --all-in-flight include every in-flight task -# --all-decisions include every open decision +# --all-decisions include every open decision present in the bounded snapshot # --all-secondmates include every aggregated secondmate record # --all-landed include every landed record from every home (default: bounded) # --all-reports include the full scout-report inventory (default: relevant only) @@ -143,7 +147,8 @@ For every registered secondmate, readable structured facts from its own home are evidence and never become current work. The provenance and freshness fields distinguish live and cached ledgers; a home without either is explicitly unreadable. Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, - --all-decisions, --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, + --all-decisions (all open decisions present in the bounded snapshot), + --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). Raise FM_BEARINGS_PR_LIMIT to expand per-repository open-PR results. EOF diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 5719bda0a59..06a993e04d4 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -36,10 +36,12 @@ # task first when no work item exists to hold (--title required to create; the # optional --origin records provenance in the new task's body and supplies the # default repo from that origin's metadata). Prefer holding the work item the -# question gates over minting a new row. Repeating `hold` with the same id is -# idempotent; a task already closed is refused rather than reopened. `--until` -# records the captain's own deferral date through `tasks-axi hold --until`, so -# a "revisit later" answer is stored as a date instead of a live card. +# question gates over minting a new row. The command records a UTC `Captain +# hold set:` timestamp in the task body: repeating an active hold preserves the +# existing timestamp, while re-holding released work starts a new lifecycle. +# A task already closed is refused rather than reopened. `--until` records the +# captain's own deferral date through `tasks-axi hold --until`, so a "revisit +# later" answer is stored as a date instead of a live card. # # `answer` records the captain's exact words and closes the call in the same # act. It requires a non-empty captain decision file of at most 8192 bytes, diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7df20e407f6..7c542cc7428 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -21,7 +21,8 @@ # Structured rows preserve captain-hold metadata such as hold_kind, # hold_reason, and hold_until when tasks-axi emits it. They also carry # normalized current_role, requires_child_metadata, blocked_by_ids, -# unresolved_blocker_ids, captain_actionable, and deferred_marker fields. +# unresolved_blocker_ids, captain_actionable, hold_set, hold_age_days, +# aged_undated_hold, and deferred_marker fields. # Repeated blocker tokens remain ordered; a blocker resolves only when its # structured record is Done, and missing ids stay open. # captain_actionable means "waiting on the captain now": queued, held for @@ -30,11 +31,12 @@ # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. # deferred_marker is a presentation hint only: the row's hold reason or -# body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker, or a -# parked-style phrasing (parked, awaiting captain go, do not dispatch / -# do not auto-dispatch, not urgent, de-prioritized, queued opportunity, -# captain-gated). It never changes captain_actionable; renderers may use it -# to keep prose-deferred rows out of default views. +# first 240 characters of body prose (excluding the machine-generated +# hold-set stamp) carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED +# marker, or a parked-style phrasing (parked, awaiting captain go, do not +# dispatch / do not auto-dispatch, not urgent, de-prioritized, queued +# opportunity, captain-gated). It never changes captain_actionable; +# renderers may use it to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain # hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS # old (default 14). Legacy unstamped holds fall back to `since`. @@ -219,8 +221,9 @@ kind=secondmate meta records are not child inventory for unowned_current or terminal_in_flight; they never have backlog rows. Its invalidity object names the normalized failure kind and affected ids. Actionable tasks-axi captain holds appear as decisions_open and stay visible in -queued with hold_reason, hold_kind, hold_until, deferred_marker, and plural -blocker fields for downstream projections. A captain hold is actionable only +queued with hold_reason, hold_kind, hold_until, deferred_marker, +aged_undated_hold, hold_age_days, and plural blocker fields for downstream +projections. A captain hold is actionable only when every blocker is Done and any hold-until date has arrived. Cross-home collection uses FM_SNAPSHOT_SECONDMATES (default 20, 0 lifts the count bound) and FM_SNAPSHOT_SECONDMATE_MAX_BYTES. diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index e505840c9d7..f3d78e0e92a 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -57,7 +57,7 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. -It also emits a presentation-only `deferred_marker` when a hold's reason or body carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). +It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its body prose, excluding the machine-generated hold-set stamp, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). An undated captain hold whose recorded hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, using floored elapsed days) also carries `aged_undated_hold` and `hold_age_days`. Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. @@ -65,7 +65,7 @@ The durable deferral remains re-holding with `--until`. Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. -A date-deferred captain hold renders as a gate with its `until :` reason; a prose-deferred one leaves the default views with an `omitted[]` disclosure, revealed by `--all-decisions` / `--all-queued`. +A date-deferred captain hold renders as a gate with its `until :` reason; an actionable prose-deferred one leaves the default views with an `omitted[]` disclosure and is revealed by `--all-decisions`, while `--all-queued` reveals non-actionable prose-deferred queued rows. An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, is disclosed in `omitted[]`, and is revealed by `--all-decisions`; prose deferral takes precedence when both hints apply. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate; re-holding with `--until` remains the durable fix. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. @@ -103,7 +103,7 @@ The shim recognizes an exact replay of a pre-collapse routed resolution by its h Verification date: 2026-09-03. The focused end-to-end regression suite is `tests/fm-captain-hold-lifecycle.test.sh`, using only synthetic `sample` identities and decision text. -It proves: cleanup of a finished task whose own row is the captain call leaves that call open, queued, held, carrying its deliverable, and visible in Bearings' Captain's Call, leaves no pending record behind, survives a `--force` cleanup, and closes only when `answer` records the captain's words, while an ordinary finished task in the same home still closes with its report link; an interrupted cleanup leaves the row In flight and untouched with its pending record, and the next session start retains it as queued and held with the deliverable recorded; a relocated data directory keeps the retention in its one configured backlog; a ship row whose captain hold cannot be read refuses cleanup before any destructive step and surfaces the read failure; the reconstructed silent-divergence case is signalled - a status resolution over a still-open captain-held task reaches both `diverged` and the drain's `RECORD DIVERGENCE` section, under the collapsed and the legacy identity alike, while the backlog task, its hold, and the status log all survive the report unchanged and the printed hint names both reconciliation directions; the false-signal boundary holds - a captain call with no routed work item, a verified `captain-held` transfer, a still-open status decision, an already answered call, and an ordinary task whose keyed question was answered all stay silent; a report-only unresolved captain call refuses `--none` completion before teardown can erase the source; non-forced scout teardown always requires the durable inventory verification; the recorded-answer guard (a bare `tasks-axi done` close fails `verify` until `answer` records the captain's word, and an ordinary finished task cannot be dressed up as an answered call); answer-time closure through a bound channel with task-id keys, including the `release` close mode, mode-matched replay idempotence, and the refusal of drifted, mode-mismatched, absent, unheld, and already-closed keys; the chat channel reaching the same intake; deferral through `--until` leaving `captain_actionable` false until due; and every legacy path (composed identities through the shim, pre-collapse `decision_keys=` metadata, routed-resolution replay, and a concrete-origin binding). +It proves: cleanup of a finished task whose own row is the captain call leaves that call open, queued, held, carrying its deliverable, and visible in Bearings' Captain's Call, leaves no pending record behind, survives a `--force` cleanup, and closes only when `answer` records the captain's words, while an ordinary finished task in the same home still closes with its report link; an interrupted cleanup leaves the row In flight and untouched with its pending record, and the next session start retains it as queued and held with the deliverable recorded; a relocated data directory keeps the retention in its one configured backlog; a ship row whose captain hold cannot be read refuses cleanup before any destructive step and surfaces the read failure; the reconstructed silent-divergence case is signalled - a status resolution over a still-open captain-held task reaches both `diverged` and the drain's `RECORD DIVERGENCE` section, under the collapsed and the legacy identity alike, while the backlog task, its hold, and the status log all survive the report unchanged and the printed hint names both reconciliation directions; the false-signal boundary holds - a captain call with no routed work item, a verified `captain-held` transfer, a still-open status decision, an already answered call, and an ordinary task whose keyed question was answered all stay silent; a report-only unresolved captain call refuses `--none` completion before teardown can erase the source; non-forced scout teardown always requires the durable inventory verification; the recorded-answer guard (a bare `tasks-axi done` close fails `verify` until `answer` records the captain's word, and an ordinary finished task cannot be dressed up as an answered call); answer-time closure through a bound channel with task-id keys, including the `release` close mode, mode-matched replay idempotence, and the refusal of drifted, mode-mismatched, absent, unheld, and already-closed keys; the chat channel reaching the same intake; hold-set stamping that precedes visible hold state, preserves an active lifecycle's timestamp, and resets after release; interrupted answer closure retaining the stamp until close and restoring resolution-first ordering on retry; deferral through `--until` leaving `captain_actionable` false until due; and every legacy path (composed identities through the shim, pre-collapse `decision_keys=` metadata, routed-resolution replay, and a concrete-origin binding). `tests/fm-classify-decision-key.test.sh` pins `status_key_closing_verb` itself: it separates a resolution from the durable-transfer close and from a still-open key, reports the last real transition across re-openings and both key positions, and treats a prose mention as no transition. From cbc35883e0828994c86591da56ad93489dd751c9 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 01:09:29 -0700 Subject: [PATCH 07/47] no-mistakes(ci): Fixed stock Bash CI test-count expectations (16 snapshot, 45 Bearings). Prevented fresh holds on old tasks from aging via stale `since` dates by aging only stamped holds. Added behavioral regressions and verified both suites plus Bash 3.2 parsing --- bin/fm-fleet-snapshot.sh | 13 +++++++------ tests/fm-bearings-snapshot.test.sh | 5 +++++ tests/fm-fleet-snapshot-view.test.sh | 21 ++++++++++++++------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7c542cc7428..bf675b018d7 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -39,8 +39,9 @@ # renderers may use it to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain # hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS -# old (default 14). Legacy unstamped holds fall back to `since`. -# hold_age_days is that age when computable, else null. +# old (default 14). Unstamped holds cannot be aged safely because `since` +# predates a fresh hold on an existing task. hold_age_days is the stamped +# hold's age when computable, else null. # This is a projection safety net only: the durable deferral remains # re-holding with --until. Renderers project an aged undated hold as a # Charted Next gate showing its age, disclose it in omitted[], and reveal @@ -247,9 +248,9 @@ FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. An undated captain hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14; 0 ages every hold with a -hold-set timestamp or fallback date) carries aged_undated_hold and hold_age_days -as presentation hints. Legacy holds without a stamp fall back to their since date; -re-holding with --until remains the durable deferral. +hold-set timestamp) carries aged_undated_hold and hold_age_days as presentation +hints. Unstamped holds stay unaged because the task's since date is not the hold +set date; re-holding with --until remains the durable deferral. EOF } @@ -505,7 +506,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.state == "queued" and .hold_kind == "captain" and .hold_reason != null and (.unresolved_blocker_ids | length) == 0 and (.hold_until == null or .hold_until <= $today)) - | .hold_age_days = days_between((.hold_set // .since); $now) + | .hold_age_days = days_between(.hold_set; $now) | .aged_undated_hold = (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index c2cbe4fa556..392e2a24d16 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1161,7 +1161,10 @@ test_undated_hold_phrasing_and_aging_projection() { ## Queued - [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: parked for a later pass) (hold-kind: captain) - [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) + Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z +- [ ] fresh-old-task - Fresh hold on old work (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) ## Done EOF @@ -1169,6 +1172,7 @@ EOF json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "fresh-old-task")) and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) and (.gates | any(.[]; .id == "parked-hold") | not) @@ -1181,6 +1185,7 @@ EOF (.decisions_open | any(.[]; .id == "parked-hold")) and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "fresh-old-task")) ' >/dev/null || fail "--all-decisions must reveal parked-style and aged undated holds: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 02d6f1ee124..9e12a7589c9 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -599,7 +599,10 @@ test_undated_captain_hold_phrasing_and_aging() { - [ ] queued-opp - Queued opportunity call (repo: sample) (kind: ship) (hold: queued opportunity) (hold-kind: captain) - [ ] gated-hold - Captain-gated phrasing (repo: sample) (kind: ship) (hold: captain-gated) (hold-kind: captain) - [ ] aged-call - Aged genuine call (repo: sample) (kind: captain) (since 2026-07-01) (hold: choose a sample route) (hold-kind: captain) -- [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-07-20) (hold: choose a sample route) (hold-kind: captain) + Captain hold set: 2026-07-01T00:00:00Z +- [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) + Captain hold set: 2026-07-20T00:00:00Z +- [ ] fresh-old-task - Fresh hold on old work (repo: sample) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) - [ ] boundary-call - Almost aged genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-11T00:01:00Z - [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) @@ -624,12 +627,16 @@ EOF and .hold_age_days == 24 ' >/dev/null || fail "an undated captain hold older than the default 14-day threshold must age: $out" printf '%s' "$out" | jq -e ' - .backlog.records[] | select(.id == "recent-call") - | .captain_actionable == true - and .deferred_marker == false - and .aged_undated_hold == false - and .hold_age_days == 5 - ' >/dev/null || fail "a recent undated captain hold must stay a live call: $out" + ([.backlog.records[] | select(.id == "recent-call")][0]) as $recent + | ([.backlog.records[] | select(.id == "fresh-old-task")][0]) as $fresh + | $recent.captain_actionable == true + and $recent.deferred_marker == false + and $recent.aged_undated_hold == false + and $recent.hold_age_days == 5 + and $fresh.captain_actionable == true + and $fresh.aged_undated_hold == false + and $fresh.hold_age_days == null + ' >/dev/null || fail "recent and unstamped fresh holds must stay live calls: $out" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "boundary-call") | .hold_set == "2026-07-11T00:01:00Z" From 1a929d5e68877f8d5f726e87746e03eaf4a4ea06 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 01:38:00 -0700 Subject: [PATCH 08/47] no-mistakes(ci): account for rebased snapshot regression --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dacc916027f..09ab94e0615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -377,8 +377,8 @@ jobs: snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 16 ] || { - echo "::error::expected 16 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 17 ] || { + echo "::error::expected 17 snapshot/fleet-view tests, got $snapshot_count" exit 1 } From 2d3e3bc5d76fce407ce253d7d9cb25a415c871a8 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 01:43:57 -0700 Subject: [PATCH 09/47] no-mistakes(review): Restore legacy hold aging and mandate wrapper --- .agents/skills/bearings/SKILL.md | 2 +- .agents/skills/captain-hold-lifecycle/SKILL.md | 4 ++-- .agents/skills/stow/SKILL.md | 2 +- AGENTS.md | 4 ++-- bin/fm-fleet-snapshot.sh | 13 ++++++------- docs/captain-hold-lifecycle.md | 2 +- tests/fm-bearings-snapshot.test.sh | 7 ++++--- tests/fm-fleet-snapshot-view.test.sh | 12 ++++++------ 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 9a2048629c2..e46f10f0121 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -108,7 +108,7 @@ Never run `lavish-axi poll` for the board yourself: the armed source's supervise ### Handling a board wake A board answer arrives as an ordinary `procevent lavish ` check wake. Identify it by comparing the wake source id with `bin/fm-procevent-lavish.sh source-id "$(bin/fm-bearings-board.sh path)"`, regardless of which answer kinds the result contains; then load `process-event-sources` and follow its contract for the result read, adapter classification, and the handled acknowledgement. -Decision answers need no routing from you: the runner feeds the board's binding into `bin/fm-captain-hold.sh`'s one keyed-answer intake, which closes or releases each answered captain-held task at answer time; reconcile any `skipped:` key yourself with a direct `answer`, and when the captain's answer is "later", record it as a deferral with `tasks-axi hold ... --until ` instead of a closure. +Decision answers need no routing from you: the runner feeds the board's binding into `bin/fm-captain-hold.sh`'s one keyed-answer intake, which closes or releases each answered captain-held task at answer time; reconcile any `skipped:` key yourself with a direct `answer`, and when the captain's answer is "later", record it as a deferral with `bin/fm-captain-hold.sh hold --reason "" --until ` instead of a closure. Route the non-decision keys yourself: - `merge.` is the captain's explicit merge order; follow the merge ruling below. diff --git a/.agents/skills/captain-hold-lifecycle/SKILL.md b/.agents/skills/captain-hold-lifecycle/SKILL.md index 6eabcd64dad..424e16daf3b 100644 --- a/.agents/skills/captain-hold-lifecycle/SKILL.md +++ b/.agents/skills/captain-hold-lifecycle/SKILL.md @@ -11,7 +11,7 @@ metadata: # Captain-hold lifecycle A decision is not a separate thing: it is simply a task waiting on the captain. -The one primitive is an ordinary backlog task held for the captain (`tasks-axi hold --kind captain`), its identity is the task id, and `bin/fm-captain-hold.sh` owns the deterministic mechanics this policy relies on. +The one primitive is an ordinary backlog task held for the captain through `bin/fm-captain-hold.sh hold`; its identity is the task id, and that wrapper owns the deterministic mechanics this policy relies on. The agent performs the semantic inventory because scripts must not infer captain calls from report prose, visual-review artifacts, terminal output, or chat. ## Policy @@ -28,7 +28,7 @@ Holding the work item the question gates is safe for exactly that reason: cleanu Never close anything the captain owns without recording what he actually said: `bin/fm-captain-hold.sh answer` writes his exact words into the task and closes it in the same act, with `--release` when the answer frees a captain-gated work item to proceed instead of completing a question. When the answer changes what a task must build, follow `AGENTS.md` section 7's Validate contract to preserve the captain's words in the brief and steer the worker. -When the captain says "later", that is an answer too: re-hold with `tasks-axi hold ... --until ` so the item leaves the live Captain's Call and resurfaces on its date, instead of leaving a live-looking card or fabricating a closure. +When the captain says "later", that is an answer too: re-hold with `bin/fm-captain-hold.sh hold --reason "" --until ` so the item leaves the live Captain's Call and resurfaces on its date, instead of leaving a live-looking card or fabricating a closure. "A keyed answer closes its matching captain-held task" is one capability with one owner, `bin/fm-captain-hold.sh answers`, and every channel that carries a captain answer feeds it the same task id and answer; a channel never maps keys to tasks, records a decision, or closes anything itself. Chat already feeds it through `bin/fm-send.sh --resolve-key`, and a captured-answer source feeds it once bound with `bin/fm-captain-hold.sh bind `; bind before arming the source, and key each structured question by the held task's id. An unbound source and a key that names no captain-held task both simply feed nothing: the answer is still captured and firstmate is still woken, and closing falls back to the direct command above. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 348a9975471..ed32e020fee 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -193,7 +193,7 @@ A local skill exists only in this home, so offloading an entry out of `data/capt Autonomously relocate it only by adding it to an already-existing allowed JIT note, or by routing it through a project's established delivery path to its existing owning `AGENTS.md`, then confirming that destination holds the quoted entry before removing the memory entry. A destination that needs creation, uncompleted project delivery, or any other future work is not live and cannot count as relief, so continue with the next archival or eviction rung instead of leaving an over-budget proposal pending. 2. Propose pinned relocation only. - For a pinned candidate, append a `proposed-offload` section with the same fields to the completion receipt and create or refresh one durable captain-held backlog item using `tasks-axi add`, `tasks-axi hold`, `tasks-axi show --full`, and `tasks-axi update --body-file ` as appropriate. + For a pinned candidate, append a `proposed-offload` section with the same fields to the completion receipt, create or refresh one durable backlog item with `tasks-axi add`, `tasks-axi show --full`, and `tasks-axi update --body-file ` as appropriate, then hold it through `bin/fm-captain-hold.sh hold`. Preserve each candidate's approval state in that item, and require explicit plain-chat approval for that named item before any migration. If the captain never answers, nothing migrates and the held item persists, but it is never treated as budget relief. 3. Migrate an approved pinned candidate outside this pass. diff --git a/AGENTS.md b/AGENTS.md index 1e66eb4fea1..7f4beeb9e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -503,8 +503,8 @@ Mention cost as a courtesy when unusually much work is running, but never block `data/backlog.md` is the durable queue. It tracks work items only, never agents; persistent secondmates never appear as backlog items. Work routed to a secondmate is recorded in that secondmate home's own backlog, not the main backlog. -A decision is simply a task held for the captain: `tasks-axi hold --reason "" --kind captain`, with `--until ` when the captain defers it. -When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item and hold it the same way. +A decision is simply a task held for the captain: create the task with `tasks-axi add` when needed, then always hold it through `bin/fm-captain-hold.sh hold --reason ""`, with `--until ` when the captain defers it. +When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item and hold it through that wrapper. Captain calls discovered by investigations or visual reviews follow `captain-hold-lifecycle`, which owns their completion gate and recorded-answer rules. When the automatic transition gate applies, dispatch and completion move the item themselves - `bin/fm-spawn.sh` and `bin/fm-teardown.sh` own those transitions and refuse rather than report success without them - so what remains yours is filing the item before dispatch, recording decisions, and keeping notes current; `docs/configuration.md` owns gate applicability and the manual-backend exception. Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index bf675b018d7..7c542cc7428 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -39,9 +39,8 @@ # renderers may use it to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain # hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS -# old (default 14). Unstamped holds cannot be aged safely because `since` -# predates a fresh hold on an existing task. hold_age_days is the stamped -# hold's age when computable, else null. +# old (default 14). Legacy unstamped holds fall back to `since`. +# hold_age_days is that age when computable, else null. # This is a projection safety net only: the durable deferral remains # re-holding with --until. Renderers project an aged undated hold as a # Charted Next gate showing its age, disclose it in omitted[], and reveal @@ -248,9 +247,9 @@ FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. An undated captain hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14; 0 ages every hold with a -hold-set timestamp) carries aged_undated_hold and hold_age_days as presentation -hints. Unstamped holds stay unaged because the task's since date is not the hold -set date; re-holding with --until remains the durable deferral. +hold-set timestamp or fallback date) carries aged_undated_hold and hold_age_days +as presentation hints. Legacy holds without a stamp fall back to their since date; +re-holding with --until remains the durable deferral. EOF } @@ -506,7 +505,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.state == "queued" and .hold_kind == "captain" and .hold_reason != null and (.unresolved_blocker_ids | length) == 0 and (.hold_until == null or .hold_until <= $today)) - | .hold_age_days = days_between(.hold_set; $now) + | .hold_age_days = days_between((.hold_set // .since); $now) | .aged_undated_hold = (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index f3d78e0e92a..e2cad077522 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -10,7 +10,7 @@ A decision is not a separate thing in this system: it is an ordinary backlog tas The command addresses the active home's configured data directory the same way `bin/fm-backlog-transition-lib.sh` addresses every backlog transition, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. -The `hold` subcommand places an existing task under an active captain hold, or creates the task when nothing exists to hold, then verifies the hold through `tasks-axi hold --reason --kind captain` and records its UTC hold-set timestamp in the task body. +The `hold` subcommand is the mandatory captain-hold creation path: it places an existing task under an active captain hold, or creates the task when nothing exists to hold, then invokes the underlying tasks-axi hold operation, verifies the result, and records its UTC hold-set timestamp in the task body. Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 392e2a24d16..f7735b16dd8 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1164,7 +1164,7 @@ test_undated_hold_phrasing_and_aging_projection() { Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z -- [ ] fresh-old-task - Fresh hold on old work (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) +- [ ] legacy-old-hold - Legacy unstamped hold (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) ## Done EOF @@ -1172,11 +1172,12 @@ EOF json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) - and (.decisions_open | any(.[]; .id == "fresh-old-task")) + and (.decisions_open | any(.[]; .id == "legacy-old-hold") | not) and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) and (.gates | any(.[]; .id == "parked-hold") | not) and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) + and (.gates | any(.[]; .id == "legacy-old-hold" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "recent-call") | not) and (.omitted | any(.[]; .surface | startswith("captain holds marked deferred"))) ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" @@ -1185,7 +1186,7 @@ EOF (.decisions_open | any(.[]; .id == "parked-hold")) and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "recent-call")) - and (.decisions_open | any(.[]; .id == "fresh-old-task")) + and (.decisions_open | any(.[]; .id == "legacy-old-hold")) ' >/dev/null || fail "--all-decisions must reveal parked-style and aged undated holds: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 9e12a7589c9..a8a429ee856 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -602,7 +602,7 @@ test_undated_captain_hold_phrasing_and_aging() { Captain hold set: 2026-07-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-20T00:00:00Z -- [ ] fresh-old-task - Fresh hold on old work (repo: sample) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) +- [ ] legacy-old-hold - Legacy unstamped hold (repo: sample) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) - [ ] boundary-call - Almost aged genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-11T00:01:00Z - [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) @@ -628,15 +628,15 @@ EOF ' >/dev/null || fail "an undated captain hold older than the default 14-day threshold must age: $out" printf '%s' "$out" | jq -e ' ([.backlog.records[] | select(.id == "recent-call")][0]) as $recent - | ([.backlog.records[] | select(.id == "fresh-old-task")][0]) as $fresh + | ([.backlog.records[] | select(.id == "legacy-old-hold")][0]) as $legacy | $recent.captain_actionable == true and $recent.deferred_marker == false and $recent.aged_undated_hold == false and $recent.hold_age_days == 5 - and $fresh.captain_actionable == true - and $fresh.aged_undated_hold == false - and $fresh.hold_age_days == null - ' >/dev/null || fail "recent and unstamped fresh holds must stay live calls: $out" + and $legacy.captain_actionable == true + and $legacy.aged_undated_hold == true + and $legacy.hold_age_days == 54 + ' >/dev/null || fail "recent stamped and legacy unstamped hold ages are wrong: $out" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "boundary-call") | .hold_set == "2026-07-11T00:01:00Z" From 0ee6c0b5209d4135e68cd4142d53cc31adeaf983 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 01:47:46 -0700 Subject: [PATCH 10/47] no-mistakes(review): Restrict hold stamps to canonical leading lines --- bin/fm-captain-hold.sh | 20 ++++++++++++-------- bin/fm-fleet-snapshot.sh | 5 ++--- docs/captain-hold-lifecycle.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 8 +++++++- tests/fm-fleet-snapshot-view.test.sh | 3 +++ 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 06a993e04d4..167e4136a76 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -456,22 +456,26 @@ resolve_entry() { # ; prints the resolved id or fails body_hold_set_timestamp() { # printf '%s\n' "$1" \ | sed -n \ - -e 's/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z\)$/\1/p' \ - -e 's/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p' \ + -e '1s/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z\)$/\1/p' \ + -e '1s/^Captain hold set: \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p' \ | head -1 } write_hold_set_stamp() { # - local id=$1 body=$2 hold_set=$3 preserve=$4 new_body tmp + local id=$1 body=$2 hold_set=$3 preserve=$4 existing new_body tmp body=$(decode_shown_value "$body") \ || fail "could not decode the existing body for $id" - if [ "$preserve" = 1 ] && [ -n "$(body_hold_set_timestamp "$body")" ]; then + existing=$(body_hold_set_timestamp "$body") + if [ "$preserve" = 1 ] && [ -n "$existing" ]; then return 0 fi - body=$(printf '%s\n' "$body" \ - | sed -e '/^Captain hold set: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/d' \ - -e '/^Captain hold set: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z$/d') - case "$body" in $'\n'*) body=${body#$'\n'} ;; esac + if [ -n "$existing" ]; then + body=${body#"Captain hold set: $existing"} + case "$body" in + $'\n\n'*) body=${body#$'\n\n'} ;; + $'\n'*) body=${body#$'\n'} ;; + esac + fi new_body=$(printf 'Captain hold set: %s' "$hold_set") if [ -n "$body" ]; then new_body=$(printf '%s\n\n%s' "$new_body" "$body") diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7c542cc7428..733a5a87e15 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -478,10 +478,9 @@ backlog_json() { # [] - defaults to this home's $BACKLOG end) | .records |= map( if (.body_lines | length) > 0 then - .body_excerpt = ((.body_lines - | map(select(test("^Captain hold set: [0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?$") | not)) + .hold_set = cap(.body_lines[0]; "^Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)$") + | .body_excerpt = (((if .hold_set != null then .body_lines[1:] else .body_lines end) | join(" "))[:240]) - | .hold_set = cap((.body_lines | join(" ")); "Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)") else . end) | .records as $records | (reduce ($records[] | select(.structured)) as $record ({}; diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index e2cad077522..4a734e7edd7 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -10,7 +10,7 @@ A decision is not a separate thing in this system: it is an ordinary backlog tas The command addresses the active home's configured data directory the same way `bin/fm-backlog-transition-lib.sh` addresses every backlog transition, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. -The `hold` subcommand is the mandatory captain-hold creation path: it places an existing task under an active captain hold, or creates the task when nothing exists to hold, then invokes the underlying tasks-axi hold operation, verifies the result, and records its UTC hold-set timestamp in the task body. +The `hold` subcommand is the mandatory captain-hold creation path: it places an existing task under an active captain hold, or creates the task when nothing exists to hold, then invokes the underlying tasks-axi hold operation, verifies the result, and records its UTC hold-set timestamp as the leading line of the task body. Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index a66e5036dd0..b2ac3524dac 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -306,8 +306,12 @@ test_answer_records_and_closes() { test_release_frees_held_work() { local home show out snap home=$(make_home release-work) + cat > "$home/widget-body.txt" <<'EOF' +The widget plan body. Literal escape: \n. Unicode: café. +Captain hold set: 2025-01-02T03:04:05Z +EOF tasks_in "$home" add sample-widget "Ship the sample widget" --kind ship --repo sample \ - --body 'The widget plan body. Literal escape: \n. Unicode: café.' >/dev/null \ + --body-file "$home/widget-body.txt" >/dev/null \ || fail "could not create the held work item" FM_CAPTAIN_HOLD_NOW=2026-06-01T12:00:00Z run_captain "$home" hold sample-widget \ --reason "captain go needed before shipping" >/dev/null \ @@ -323,6 +327,8 @@ test_release_frees_held_work() { assert_contains "$show" "The widget plan body." "the release destroyed the work item body" assert_contains "$show" 'Literal escape: \\n. Unicode: café.' \ "the release corrupted escaped or Unicode body text" + assert_contains "$show" "Captain hold set: 2025-01-02T03:04:05Z" \ + "hold stamping deleted matching user content outside the leading stamp" run_captain "$home" answer sample-widget --decision-file "$home/go.txt" --release >/dev/null \ || fail "identical release retry was not idempotent" if run_captain "$home" answer sample-widget --decision-file "$home/go.txt" \ diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index a8a429ee856..69efab5a42f 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -603,6 +603,8 @@ test_undated_captain_hold_phrasing_and_aging() { - [ ] recent-call - Recent genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-20T00:00:00Z - [ ] legacy-old-hold - Legacy unstamped hold (repo: sample) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) + Historical notes remain ordinary task content. + Captain hold set: 2026-07-24T00:00:00Z - [ ] boundary-call - Almost aged genuine call (repo: sample) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-11T00:01:00Z - [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) @@ -634,6 +636,7 @@ EOF and $recent.aged_undated_hold == false and $recent.hold_age_days == 5 and $legacy.captain_actionable == true + and $legacy.hold_set == null and $legacy.aged_undated_hold == true and $legacy.hold_age_days == 54 ' >/dev/null || fail "recent stamped and legacy unstamped hold ages are wrong: $out" From 2bd2c0d3978aa0328e50cfd6c38622432b67e766 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 01:57:14 -0700 Subject: [PATCH 11/47] no-mistakes(review): Exclude historical answers and deduplicate revealed holds --- .agents/skills/bearings/SKILL.md | 2 +- bin/fm-bearings-snapshot.sh | 10 +++++----- bin/fm-fleet-snapshot.sh | 10 ++++++++-- docs/captain-hold-lifecycle.md | 4 ++-- tests/fm-bearings-snapshot.test.sh | 3 ++- tests/fm-captain-hold-lifecycle.test.sh | 13 ++++++++----- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index e46f10f0121..9921bdb6e2e 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -46,7 +46,7 @@ Board answers are acted on later under the normal authority rules; this skill's A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. A hold whose reason or bounded body excerpt carries a deferred, superseded, or parked-style marker is suppressed from the default view with an `omitted` disclosure. An undated hold aged past the configured snapshot threshold leaves Captain's Call for an age-labelled Charted Next gate unless a parked-style marker suppresses it; this is only a presentation safety net, and re-holding with `--until` remains the durable deferral. - Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, subject to the snapshot's documented cross-home summary bounds. + Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing any revealed aged hold from Charted Next so the buckets remain exclusive, subject to the snapshot's documented cross-home summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. Until then it stays queued with the reason. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index e7d53be7c68..fbf487e1406 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -40,9 +40,9 @@ # (recorded hold-set timestamp at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old, # falling back to since for a legacy unstamped hold) leaves default Captain's # Call, renders as a Charted Next gate showing its floored age, is disclosed in -# omitted[], and is revealed by --all-decisions. Prose deferral takes -# precedence over aging, so a hold with both hints is omitted rather than -# gated. Aging is a projection safety net only; the durable deferral remains +# omitted[], and is revealed by --all-decisions without retaining its safety +# gate. Prose deferral takes precedence over aging, so a hold with both hints +# is omitted rather than gated. Aging is a projection safety net only; the durable deferral remains # re-holding with --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory @@ -465,7 +465,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ or ((.hold_until // null) != null and .hold_until > $today)) | as_gate("(main)") ] + [ .backlog.records[] - | select(.structured and .captain_actionable == true + | select($all_decisions == 0 and .structured and .captain_actionable == true and .aged_undated_hold == true and .deferred_marker != true) | as_gate("(main)") ] + [ (.secondmate_current.records // [])[] as $m @@ -478,7 +478,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") | $m.queued[]? - | select(.captain_actionable == true + | select($all_decisions == 0 and .captain_actionable == true and .aged_undated_hold == true and .deferred_marker != true) | as_gate($m.id) ]) as $gates_all | ([ .scout_reports[] diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 733a5a87e15..21774b11159 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -364,6 +364,13 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | (timestamp_epoch($to)) as $b | if $a == null or $b == null then null else (($b - $a) / 86400 | floor) end; + def hold_stamp_line: + test("^Captain hold set: [0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?$"); + def resolution_leader: + test("^Resolution recorded by fm-(captain|decision)-hold\\.$"); + def current_prose_lines: + (if length > 0 and (.[0] | hold_stamp_line) then .[1:] else . end) + | if length > 0 and (.[0] | resolution_leader) then [] else . end; def section_state: if . == "In flight" then "in_flight" elif . == "Queued" then "queued" @@ -479,8 +486,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | .records |= map( if (.body_lines | length) > 0 then .hold_set = cap(.body_lines[0]; "^Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)$") - | .body_excerpt = (((if .hold_set != null then .body_lines[1:] else .body_lines end) - | join(" "))[:240]) + | .body_excerpt = ((.body_lines | current_prose_lines | join(" "))[:240]) else . end) | .records as $records | (reduce ($records[] | select(.structured)) as $record ({}; diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 4a734e7edd7..aa63556530e 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -57,7 +57,7 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. -It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its body prose, excluding the machine-generated hold-set stamp, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). +It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its current decision prose, excluding the machine-generated hold-set stamp and preserved historical resolution blocks, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). An undated captain hold whose recorded hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, using floored elapsed days) also carries `aged_undated_hold` and `hold_age_days`. Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. @@ -66,7 +66,7 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. A date-deferred captain hold renders as a gate with its `until :` reason; an actionable prose-deferred one leaves the default views with an `omitted[]` disclosure and is revealed by `--all-decisions`, while `--all-queued` reveals non-actionable prose-deferred queued rows. -An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, is disclosed in `omitted[]`, and is revealed by `--all-decisions`; prose deferral takes precedence when both hints apply. +An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals it in Captain's Call and removes its safety gate so the buckets remain exclusive. Prose deferral takes precedence when both hints apply. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate; re-holding with `--until` remains the durable fix. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index f7735b16dd8..025800bcbbd 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1187,7 +1187,8 @@ EOF and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) - ' >/dev/null || fail "--all-decisions must reveal parked-style and aged undated holds: $json" + and (.gates | any(.[]; .id == "aged-call" or .id == "legacy-old-hold") | not) + ' >/dev/null || fail "--all-decisions must reveal aged holds without duplicating their gates: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "aged-call")) diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index b2ac3524dac..94f2a73babf 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -316,14 +316,14 @@ EOF FM_CAPTAIN_HOLD_NOW=2026-06-01T12:00:00Z run_captain "$home" hold sample-widget \ --reason "captain go needed before shipping" >/dev/null \ || fail "could not hold the work item for the captain" - printf 'Go: ship it as planned.\n' > "$home/go.txt" + printf 'Not urgent; ship it as planned.\n' > "$home/go.txt" run_captain "$home" answer sample-widget --decision-file "$home/go.txt" --release >/dev/null \ || fail "answer --release failed on the held work item" show=$(tasks_in "$home" show sample-widget --full) assert_contains "$show" "state: queued" "a released work item did not stay queued" assert_contains "$show" "held: no" "a released work item kept its hold" assert_contains "$show" "Resolution mode: released" "the release did not record its close path" - assert_contains "$show" "Go: ship it as planned." "the release lost the captain's words" + assert_contains "$show" "Not urgent; ship it as planned." "the release lost the captain's words" assert_contains "$show" "The widget plan body." "the release destroyed the work item body" assert_contains "$show" 'Literal escape: \\n. Unicode: café.' \ "the release corrupted escaped or Unicode body text" @@ -365,14 +365,17 @@ EOF "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed after re-hold" printf '%s' "$snap" | jq -e ' .backlog.records[] | select(.id == "sample-widget") - | .hold_set == "2026-07-14T12:00:00Z" and .hold_age_days == 0 and .aged_undated_hold == false - ' >/dev/null || fail "a new hold lifecycle reused the released hold timestamp: $snap" + | .hold_set == "2026-07-14T12:00:00Z" + and .hold_age_days == 0 + and .aged_undated_hold == false + and .deferred_marker == false + ' >/dev/null || fail "a new hold lifecycle reused historical timestamp or answer text: $snap" printf 'Price it at nine dollars.\n' > "$home/price.txt" run_captain "$home" answer sample-widget --decision-file "$home/price.txt" --release >/dev/null \ || fail "a re-held task refused a new answer" show=$(tasks_in "$home" show sample-widget --full) assert_contains "$show" "Price it at nine dollars." "the new answer was not recorded" - assert_contains "$show" "Go: ship it as planned." "the new answer erased the earlier record" + assert_contains "$show" "Not urgent; ship it as planned." "the new answer erased the earlier record" tasks_in "$home" "done" sample-widget >/dev/null \ || fail "could not complete the released work item normally" From 3b94980f474a9445a5b5e60ccf7644a4694c571c Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:04:30 -0700 Subject: [PATCH 12/47] no-mistakes(document): Correct captain-hold projection documentation --- bin/fm-bearings-snapshot.sh | 2 +- bin/fm-fleet-snapshot.sh | 5 +++-- docs/captain-hold-lifecycle.md | 3 ++- docs/configuration.md | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index fbf487e1406..026f2b5ff20 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -71,7 +71,7 @@ # --all-secondmates include every aggregated secondmate record # --all-landed include every landed record from every home (default: bounded) # --all-reports include the full scout-report inventory (default: relevant only) -# --all-queued include superseded queued items (default: dropped) +# --all-queued include every prose-deferred queued item present in the bounded snapshot # --all-recorded-prs include every locally recorded PR # --all-unhealthy include every unhealthy endpoint # --all-pr-repos query every discovered repository under --include-prs diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 21774b11159..b6fbaa85bcb 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -31,8 +31,9 @@ # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. # deferred_marker is a presentation hint only: the row's hold reason or -# first 240 characters of body prose (excluding the machine-generated -# hold-set stamp) carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED +# first 240 characters of current body prose (excluding the machine-generated +# hold-set stamp and preserved historical resolution blocks) carries an +# explicit SUPERSEDED / NOT REQUIRED / DEFERRED # marker, or a parked-style phrasing (parked, awaiting captain go, do not # dispatch / do not auto-dispatch, not urgent, de-prioritized, queued # opportunity, captain-gated). It never changes captain_actionable; diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index aa63556530e..951d36a573b 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -66,7 +66,8 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. A date-deferred captain hold renders as a gate with its `until :` reason; an actionable prose-deferred one leaves the default views with an `omitted[]` disclosure and is revealed by `--all-decisions`, while `--all-queued` reveals non-actionable prose-deferred queued rows. -An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals it in Captain's Call and removes its safety gate so the buckets remain exclusive. Prose deferral takes precedence when both hints apply. +An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals it in Captain's Call and removes its safety gate so the buckets remain exclusive. +Prose deferral takes precedence when both hints apply. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate; re-holding with `--until` remains the durable fix. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. diff --git a/docs/configuration.md b/docs/configuration.md index d57ce36bc27..e7d06459760 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -821,7 +821,7 @@ FM_SNAPSHOT_CREW_STATE_TIMEOUT=10 # seconds bounding each local per-task curre FM_SNAPSHOT_LOCAL_READ_CONCURRENCY=8 # maximum local tasks whose current-state and endpoint observations are collected concurrently during snapshot composition FM_SNAPSHOT_BUDGET=5 # one total seconds budget for all concurrent remote home-ledger reads FM_SNAPSHOT_CACHE_DIR=$FM_HOME/state/secondmate-summary-cache # private parent-side cache of successfully fetched remote home ledgers -FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # days after which an undated captain hold (no hold-until; age from its UTC hold-set timestamp using floored elapsed days, falling back to since for legacy unstamped holds) is projected as a Charted Next gate instead of a live Captain's Call; 0 ages every such dated hold +FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=14 # floored elapsed-day threshold at which an undated captain hold (no hold-until; age from its UTC hold-set timestamp, falling back to since for legacy unstamped holds) is projected as a Charted Next gate instead of a live Captain's Call; 0 applies once the computed age is non-negative FM_RECONCILE_REQUEST_MAX_BYTES=1048576 # maximum captured Bearings or fleet snapshot accepted for durable reconcile-notify request publication FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap From 5262c2b60a0eab0f4685c9c8b418ed3d49b504f2 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:14:49 -0700 Subject: [PATCH 13/47] no-mistakes(ci): Rebased onto 8988af2 and resolved Bearings conflicts. Fixed the hold timestamp race by persisting and verifying the timestamp before publishing the captain hold; failures now leave the task unheld. Added behavioral coverage for ordering and failure handling. Preserved the required parked-phrase projection behavior. Relevant snapshot, Bearings, lifecycle, syntax, and ShellCheck validations pass --- bin/fm-captain-hold.sh | 5 ++- docs/captain-hold-lifecycle.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 50 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 167e4136a76..113af889f26 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -560,10 +560,13 @@ command_hold() { || fail "could not create task $id" fi fi + # Publish the timestamp before the captain-hold annotation. A concurrent + # snapshot may see the harmless stamp by itself, but can never see a newly + # held task without the timestamp that defines this hold lifecycle's age. show=$(task_show "$id") || fail "task $id disappeared before recording its hold-set stamp" write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" "$preserve_hold_set" show=$(task_show "$id") || fail "task $id disappeared while recording its hold-set stamp" - [ -n "$(body_hold_set_date "$(show_field_value "$show" body)")" ] \ + [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id did not retain its hold-set stamp" if [ -n "$until" ]; then tasks_axi hold "$id" --reason "$reason" --kind captain --until "$until" >/dev/null \ diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 951d36a573b..7ec815e372e 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -10,7 +10,7 @@ A decision is not a separate thing in this system: it is an ordinary backlog tas The command addresses the active home's configured data directory the same way `bin/fm-backlog-transition-lib.sh` addresses every backlog transition, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. -The `hold` subcommand is the mandatory captain-hold creation path: it places an existing task under an active captain hold, or creates the task when nothing exists to hold, then invokes the underlying tasks-axi hold operation, verifies the result, and records its UTC hold-set timestamp as the leading line of the task body. +The `hold` subcommand is the mandatory captain-hold creation path: it uses an existing task or creates one when nothing exists to hold, records its UTC hold-set timestamp as the leading line of the task body, then invokes the underlying tasks-axi hold operation and verifies both records. Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 94f2a73babf..2ccf8ee1df9 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -390,6 +390,55 @@ EOF pass "release frees held work with the captain's words recorded and the body preserved" } +# The hold-set stamp must be durable before the captain hold becomes visible. +# A wrapper observes the real tasks-axi hold boundary, and a forced stamp-write +# failure proves the command never publishes the hold without its timestamp. +test_hold_stamp_precedes_hold_visibility() { + local home show + home=$(make_home hold-stamp-order) + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] sample-old-call - Existing old task (repo: sample) (kind: ship) (since 2026-01-01) +- [ ] sample-stamp-failure - Existing task whose stamp fails (repo: sample) (kind: ship) (since 2026-01-01) + +## Done +EOF + cat > "$home/fakebin/tasks-axi" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = update ] && [ "${2:-}" = sample-stamp-failure ]; then + exit 92 +fi +if [ "${1:-}" = hold ] && [ "${2:-}" = sample-old-call ]; then + show=$("$REAL_TASKS_AXI" show "$2" --full) || exit 93 + printf '%s\n' "$show" | grep -F 'Captain hold set: 2026-07-14T12:00:00Z' >/dev/null || exit 94 + : > "$FM_HOME/hold-observed-after-stamp" +fi +exec "$REAL_TASKS_AXI" "$@" +EOF + chmod +x "$home/fakebin/tasks-axi" + + FM_CAPTAIN_HOLD_NOW=2026-07-14T12:00:00Z run_captain "$home" hold sample-old-call \ + --reason "captain route choice pending" >/dev/null \ + || fail "hold was published before its hold-set stamp" + assert_present "$home/hold-observed-after-stamp" \ + "the tasks-axi hold boundary was not observed" + show=$(tasks_in "$home" show sample-old-call --full) + assert_contains "$show" "hold_kind: captain" "the stamped task was not captain-held" + assert_contains "$show" "Captain hold set: 2026-07-14T12:00:00Z" \ + "the visible captain hold lost its timestamp" + + if FM_CAPTAIN_HOLD_NOW=2026-07-14T12:00:00Z run_captain "$home" hold sample-stamp-failure \ + --reason "captain route choice pending" > "$home/stamp-failure.out" 2> "$home/stamp-failure.err"; then + fail "hold succeeded after its timestamp update failed" + fi + show=$(tasks_in "$home" show sample-stamp-failure --full) + assert_contains "$show" "held: no" "a failed timestamp update still published the hold" + assert_contains "$show" 'hold_kind: "-"' "a failed timestamp update retained captain-hold provenance" + pass "captain holds become visible only after their hold-set timestamp is durable" +} + # Deferral is a date, not a live card: hold --until keeps the task out of # captain_actionable until due, tasks-axi's own date-gate expiry keeps the task # answerable, and Bearings renders the wait as a dated gate. @@ -1574,6 +1623,7 @@ test_uninventoried_report_decision_refuses_completion test_completion_gate_attests_and_transfers test_answer_records_and_closes test_release_frees_held_work +test_hold_stamp_precedes_hold_visibility test_deferral_leaves_captains_call_until_due test_out_of_band_close_is_recordable test_visual_review_uses_shared_completion_owner From bbae2b9149af3a8bae4e32736c7f954e7db27853 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:19:16 -0700 Subject: [PATCH 14/47] no-mistakes(review): Bound current prose before historical resolutions --- bin/fm-fleet-snapshot.sh | 6 ++++-- tests/fm-bearings-snapshot.test.sh | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index b6fbaa85bcb..77fb1799b51 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -370,8 +370,10 @@ backlog_json() { # [] - defaults to this home's $BACKLOG def resolution_leader: test("^Resolution recorded by fm-(captain|decision)-hold\\.$"); def current_prose_lines: - (if length > 0 and (.[0] | hold_stamp_line) then .[1:] else . end) - | if length > 0 and (.[0] | resolution_leader) then [] else . end; + (if length > 0 and (.[0] | hold_stamp_line) then .[1:] else . end) as $lines + | (($lines | map(resolution_leader) | index(true)) + // ($lines | length)) as $resolution + | $lines[:$resolution]; def section_state: if . == "In flight" then "in_flight" elif . == "Queued" then "queued" diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 025800bcbbd..8d6dc84873c 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1164,6 +1164,14 @@ test_undated_hold_phrasing_and_aging_projection() { Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z +- [ ] reheld-current-call - Re-held genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a current sample route) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z + Current decision prose lists route north or route south. + Resolution recorded by fm-captain-hold. + Decision digest: historical-sample + Resolution mode: released + Captain decision: + Not urgent at the time of the previous call. - [ ] legacy-old-hold - Legacy unstamped hold (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) ## Done @@ -1172,6 +1180,7 @@ EOF json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold") | not) and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) @@ -1186,6 +1195,7 @@ EOF (.decisions_open | any(.[]; .id == "parked-hold")) and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) and (.gates | any(.[]; .id == "aged-call" or .id == "legacy-old-hold") | not) ' >/dev/null || fail "--all-decisions must reveal aged holds without duplicating their gates: $json" From f73ad799c2dd3f15e7db9e8188a3c01f40326e2e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:29:20 -0700 Subject: [PATCH 15/47] no-mistakes(review): Preserve hold age across interrupted answers --- bin/fm-captain-hold.sh | 47 +++++++++++++++++++--- docs/captain-hold-lifecycle.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 52 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 113af889f26..d6675a2beab 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -610,14 +610,43 @@ write_resolution_record() { # close_answered() { # if [ "$2" = 1 ]; then - tasks_axi unhold "$1" >/dev/null || fail "could not release captain-held task $1" + tasks_axi unhold "$1" >/dev/null else - tasks_axi "done" "$1" >/dev/null || fail "could not close answered captain-held task $1" + tasks_axi "done" "$1" >/dev/null fi } +repair_interrupted_answer_stamp() { # + local id=$1 hold_set=$2 show + [ -n "$hold_set" ] || return 0 + show=$(task_show "$id") || fail "task $id disappeared after its answer close failed" + write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" 0 +} + +remove_interrupted_answer_stamp() { # + local id=$1 show body existing tmp + show=$(task_show "$id") || fail "task $id disappeared after closing" + body=$(decode_shown_value "$(show_field "$show" body)") \ + || fail "could not decode the closed body for $id" + existing=$(body_hold_set_timestamp "$body") + [ -n "$existing" ] || return 0 + body=${body#"Captain hold set: $existing"} + case "$body" in + $'\n\n'*) body=${body#$'\n\n'} ;; + $'\n'*) body=${body#$'\n'} ;; + esac + tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-normalize.XXXXXX") \ + || fail "cannot stage the closed body for $id" + if ! printf '%s\n' "$body" > "$tmp" \ + || ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then + rm -f -- "$tmp" + fail "could not restore the resolution record ordering for $id" + fi + rm -f -- "$tmp" +} + command_answer() { - local id=${1:-} decision_file='' release=0 show state hold_kind body outcome recorded_mode occurrence + local id=${1:-} decision_file='' release=0 show state hold_kind body outcome recorded_mode occurrence hold_set [ "$#" -ge 1 ] || { usage >&2; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -636,6 +665,7 @@ command_answer() { state=$(show_field "$show" state) hold_kind=$(show_field_value "$show" hold_kind) body=$(show_field "$show" body) + hold_set=$(body_hold_set_timestamp "$(decode_shown_value "$body")") if [ "$release" = 1 ]; then outcome=released; else outcome=answered; fi # The occurrence the parent line names: the record about to be written is # one past those already in the body, and a retry names the newest one. @@ -689,13 +719,20 @@ command_answer() { released) [ "$release" = 1 ] || fail "task $id records this answer as a release; retry with --release" ;; answered) [ "$release" = 0 ] || fail "task $id records this answer as a close; retry without --release" ;; esac - close_answered "$id" "$release" + if ! close_answered "$id" "$release"; then + repair_interrupted_answer_stamp "$id" "$hold_set" + fail "could not close answered captain-held task $id" + fi + remove_interrupted_answer_stamp "$id" publish_parent_hold "$id" $((occurrence - 1)) resolved "$outcome" printf '%s: %s\n' "$outcome" "$id" return 0 fi write_resolution_record "$id" "$outcome" "$body" - close_answered "$id" "$release" + if ! close_answered "$id" "$release"; then + repair_interrupted_answer_stamp "$id" "$hold_set" + fail "could not close answered captain-held task $id" + fi show=$(task_show "$id") || fail "task $id disappeared after closing" body_has_resolution_record "$(show_field "$show" body)" \ || fail "captain-held task $id did not retain its durable resolution record" diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 7ec815e372e..30a2aae7ed2 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -68,7 +68,7 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de A date-deferred captain hold renders as a gate with its `until :` reason; an actionable prose-deferred one leaves the default views with an `omitted[]` disclosure and is revealed by `--all-decisions`, while `--all-queued` reveals non-actionable prose-deferred queued rows. An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals it in Captain's Call and removes its safety gate so the buckets remain exclusive. Prose deferral takes precedence when both hints apply. -Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate; re-holding with `--until` remains the durable fix. +Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding with `--until` remains the durable fix for both limits. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 2ccf8ee1df9..826eca42baa 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -439,6 +439,57 @@ EOF pass "captain holds become visible only after their hold-set timestamp is durable" } +test_interrupted_answer_preserves_hold_age() { + local home snap show + home=$(make_home interrupted-answer-age) + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] sample-interrupted-call - Existing old task (repo: sample) (kind: ship) (since 2026-01-01) + +## Done +EOF + FM_CAPTAIN_HOLD_NOW=2026-07-14T12:00:00Z run_captain "$home" hold sample-interrupted-call \ + --reason "captain route choice pending" >/dev/null \ + || fail "could not hold the interrupted-answer fixture" + printf 'Not urgent in the historical answer.\n' > "$home/interrupted-answer.txt" + cat > "$home/fakebin/tasks-axi" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = done ] && [ "${2:-}" = sample-interrupted-call ] \ + && [ ! -e "$FM_HOME/close-failed-once" ]; then + : > "$FM_HOME/close-failed-once" + exit 92 +fi +exec "$REAL_TASKS_AXI" "$@" +EOF + chmod +x "$home/fakebin/tasks-axi" + + if run_captain "$home" answer sample-interrupted-call \ + --decision-file "$home/interrupted-answer.txt" > "$home/answer.out" 2> "$home/answer.err"; then + fail "the forced answer close failure reported success" + fi + snap=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_SNAPSHOT_NOW=2026-07-14T12:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed after interrupted answer" + printf '%s' "$snap" | jq -e ' + .backlog.records[] | select(.id == "sample-interrupted-call") + | .captain_actionable == true + and .hold_set == "2026-07-14T12:00:00Z" + and .hold_age_days == 0 + and .aged_undated_hold == false + and .deferred_marker == false + ' >/dev/null || fail "an interrupted answer lost the fresh hold age basis: $snap" + + run_captain "$home" answer sample-interrupted-call \ + --decision-file "$home/interrupted-answer.txt" >/dev/null \ + || fail "the interrupted answer could not finish on retry" + show=$(tasks_in "$home" show sample-interrupted-call --full) + assert_contains "$show" "state: done" "the interrupted answer retry did not close the task" + pass "an interrupted answer preserves its hold age until close retry" +} + # Deferral is a date, not a live card: hold --until keeps the task out of # captain_actionable until due, tasks-axi's own date-gate expiry keeps the task # answerable, and Bearings renders the wait as a dated gate. @@ -1624,6 +1675,7 @@ test_completion_gate_attests_and_transfers test_answer_records_and_closes test_release_frees_held_work test_hold_stamp_precedes_hold_visibility +test_interrupted_answer_preserves_hold_age test_deferral_leaves_captains_call_until_due test_out_of_band_close_is_recordable test_visual_review_uses_shared_completion_owner From 87a92e311bda6a8ec1ee4d1ced3dce7ee215a0f4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:33:49 -0700 Subject: [PATCH 16/47] no-mistakes(review): Preserve leading hold stamps until answer closure --- bin/fm-captain-hold.sh | 25 +++++++++++++------------ docs/captain-hold-lifecycle.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 8 ++++++-- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index d6675a2beab..ccd8315a9f5 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -588,10 +588,19 @@ command_hold() { # Record a resolution block at the top of the task body, preserving the # previous body below it and archiving the pristine original. write_resolution_record() { # - local id=$1 mode=$2 body=$3 new_body tmp + local id=$1 mode=$2 body=$3 new_body tmp hold_set new_body=$(resolution_block "$mode") body=$(decode_shown_value "$body") \ || fail "could not decode the existing body for $id" + hold_set=$(body_hold_set_timestamp "$body") + if [ -n "$hold_set" ]; then + body=${body#"Captain hold set: $hold_set"} + case "$body" in + $'\n\n'*) body=${body#$'\n\n'} ;; + $'\n'*) body=${body#$'\n'} ;; + esac + new_body=$(printf 'Captain hold set: %s\n\n%s' "$hold_set" "$new_body") + fi if [ -n "$body" ]; then new_body=$(printf '%s\n\n%s' "$new_body" "$body") fi @@ -616,13 +625,6 @@ close_answered() { # fi } -repair_interrupted_answer_stamp() { # - local id=$1 hold_set=$2 show - [ -n "$hold_set" ] || return 0 - show=$(task_show "$id") || fail "task $id disappeared after its answer close failed" - write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" 0 -} - remove_interrupted_answer_stamp() { # local id=$1 show body existing tmp show=$(task_show "$id") || fail "task $id disappeared after closing" @@ -646,7 +648,7 @@ remove_interrupted_answer_stamp() { # } command_answer() { - local id=${1:-} decision_file='' release=0 show state hold_kind body outcome recorded_mode occurrence hold_set + local id=${1:-} decision_file='' release=0 show state hold_kind body outcome recorded_mode occurrence [ "$#" -ge 1 ] || { usage >&2; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -665,7 +667,6 @@ command_answer() { state=$(show_field "$show" state) hold_kind=$(show_field_value "$show" hold_kind) body=$(show_field "$show" body) - hold_set=$(body_hold_set_timestamp "$(decode_shown_value "$body")") if [ "$release" = 1 ]; then outcome=released; else outcome=answered; fi # The occurrence the parent line names: the record about to be written is # one past those already in the body, and a retry names the newest one. @@ -696,6 +697,7 @@ command_answer() { [ "$hold_kind" = captain ] \ || fail "task $id was never held for the captain; nothing to record an answer on" write_resolution_record "$id" repaired "$body" + remove_interrupted_answer_stamp "$id" show=$(task_show "$id") || fail "task $id disappeared while recording the answer" [ "$(show_field "$show" state)" = "done" ] || fail "recording the answer reopened closed task $id" body_has_resolution_record "$(show_field "$show" body)" \ @@ -720,7 +722,6 @@ command_answer() { answered) [ "$release" = 0 ] || fail "task $id records this answer as a close; retry without --release" ;; esac if ! close_answered "$id" "$release"; then - repair_interrupted_answer_stamp "$id" "$hold_set" fail "could not close answered captain-held task $id" fi remove_interrupted_answer_stamp "$id" @@ -730,9 +731,9 @@ command_answer() { fi write_resolution_record "$id" "$outcome" "$body" if ! close_answered "$id" "$release"; then - repair_interrupted_answer_stamp "$id" "$hold_set" fail "could not close answered captain-held task $id" fi + remove_interrupted_answer_stamp "$id" show=$(task_show "$id") || fail "task $id disappeared after closing" body_has_resolution_record "$(show_field "$show" body)" \ || fail "captain-held task $id did not retain its durable resolution record" diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 30a2aae7ed2..8fde6579aa3 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -14,7 +14,7 @@ The `hold` subcommand is the mandatory captain-hold creation path: it uses an ex Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. -It requires a non-empty captain decision file of at most 8192 bytes, writes a resolution block carrying the decision digest and a `Resolution mode:` at the top of the task body (the previous body is preserved below the block and archived through tasks-axi `--archive-body`), then runs `tasks-axi done` - or `tasks-axi unhold` under `--release`, so a captain-gated work item resumes instead of closing. +It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. An exact retry is idempotent only when the requested close mode matches the newest record; a drifted answer or mode mismatch is rejected, while a re-held task accepts a new answer as a new record on top. On a task closed outside the script, `answer` records the missing block only when the captain-hold annotations tasks-axi preserves through a close prove the captain owned it, and it verifies the task stays closed. A hold whose `--until` date has passed keeps those annotations while tasks-axi reports it no longer held, so an expired deferral remains answerable. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 826eca42baa..fd13dd4f393 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -454,10 +454,12 @@ EOF --reason "captain route choice pending" >/dev/null \ || fail "could not hold the interrupted-answer fixture" printf 'Not urgent in the historical answer.\n' > "$home/interrupted-answer.txt" + mkdir -p "$home/at-close" cat > "$home/fakebin/tasks-axi" <<'EOF' #!/usr/bin/env bash if [ "${1:-}" = done ] && [ "${2:-}" = sample-interrupted-call ] \ && [ ! -e "$FM_HOME/close-failed-once" ]; then + cp "$FM_HOME/data/backlog.md" "$FM_HOME/at-close/backlog.md" || exit 93 : > "$FM_HOME/close-failed-once" exit 92 fi @@ -470,9 +472,9 @@ EOF fail "the forced answer close failure reported success" fi snap=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ - FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + FM_DATA_OVERRIDE="$home/at-close" FM_CONFIG_OVERRIDE="$home/config" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_SNAPSHOT_NOW=2026-07-14T12:00:00Z \ - "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed after interrupted answer" + "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed at interrupted close boundary" printf '%s' "$snap" | jq -e ' .backlog.records[] | select(.id == "sample-interrupted-call") | .captain_actionable == true @@ -487,6 +489,8 @@ EOF || fail "the interrupted answer could not finish on retry" show=$(tasks_in "$home" show sample-interrupted-call --full) assert_contains "$show" "state: done" "the interrupted answer retry did not close the task" + assert_contains "$show" 'body: "Resolution recorded by fm-captain-hold.' \ + "the successful retry did not restore resolution-first body ordering" pass "an interrupted answer preserves its hold age until close retry" } From 5d7fe66eb34c7e63f61a0af763f74f2c2f9481b0 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:37:11 -0700 Subject: [PATCH 17/47] no-mistakes(review): Normalize answer bodies on matching retries --- bin/fm-captain-hold.sh | 2 ++ docs/captain-hold-lifecycle.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 19 ++++++++++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index ccd8315a9f5..c007689b764 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -682,6 +682,7 @@ command_answer() { || fail "task $id records this answer with mode released; a closed task cannot replay that release" [ "$release" = 0 ] \ || fail "task $id records this answer with mode ${recorded_mode:-unknown}; --release cannot reopen a closed task" + remove_interrupted_answer_stamp "$id" if [ "$recorded_mode" = repaired ]; then publish_parent_hold "$id" $((occurrence - 1)) resolved "answered (repaired)" else @@ -749,6 +750,7 @@ command_answer() { || fail "task $id records a different captain decision with mode ${recorded_mode:-unknown}" [ "$recorded_mode" = released ] && [ "$release" = 1 ] \ || fail "task $id records this answer with mode ${recorded_mode:-unknown}; replay requires matching --release" + remove_interrupted_answer_stamp "$id" publish_parent_hold "$id" $((occurrence - 1)) resolved released printf 'released: %s\n' "$id" return 0 diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 8fde6579aa3..da3afcb0dfd 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -14,7 +14,7 @@ The `hold` subcommand is the mandatory captain-hold creation path: it uses an ex Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. -It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. +It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. An exact retry is idempotent only when the requested close mode matches the newest record; a drifted answer or mode mismatch is rejected, while a re-held task accepts a new answer as a new record on top. On a task closed outside the script, `answer` records the missing block only when the captain-hold annotations tasks-axi preserves through a close prove the captain owned it, and it verifies the task stays closed. A hold whose `--until` date has passed keeps those annotations while tasks-axi reports it no longer held, so an expired deferral remains answerable. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index fd13dd4f393..8eba946a01e 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -463,6 +463,14 @@ if [ "${1:-}" = done ] && [ "${2:-}" = sample-interrupted-call ] \ : > "$FM_HOME/close-failed-once" exit 92 fi +if [ "${1:-}" = update ] && [ "${2:-}" = sample-interrupted-call ] \ + && [ ! -e "$FM_HOME/normalize-failed-once" ]; then + state=$("$REAL_TASKS_AXI" show "$2" --full | sed -n 's/^ state: //p' | head -1) + if [ "$state" = done ]; then + : > "$FM_HOME/normalize-failed-once" + exit 94 + fi +fi exec "$REAL_TASKS_AXI" "$@" EOF chmod +x "$home/fakebin/tasks-axi" @@ -484,13 +492,18 @@ EOF and .deferred_marker == false ' >/dev/null || fail "an interrupted answer lost the fresh hold age basis: $snap" + if run_captain "$home" answer sample-interrupted-call \ + --decision-file "$home/interrupted-answer.txt" > "$home/normalize.out" 2> "$home/normalize.err"; then + fail "the forced post-close normalization failure reported success" + fi + show=$(tasks_in "$home" show sample-interrupted-call --full) + assert_contains "$show" "state: done" "the normalization failure undid the successful close" run_captain "$home" answer sample-interrupted-call \ --decision-file "$home/interrupted-answer.txt" >/dev/null \ - || fail "the interrupted answer could not finish on retry" + || fail "the closed answer could not normalize on retry" show=$(tasks_in "$home" show sample-interrupted-call --full) - assert_contains "$show" "state: done" "the interrupted answer retry did not close the task" assert_contains "$show" 'body: "Resolution recorded by fm-captain-hold.' \ - "the successful retry did not restore resolution-first body ordering" + "the matching done retry did not restore resolution-first body ordering" pass "an interrupted answer preserves its hold age until close retry" } From f9404c69a07e7a3d8ed946278d2234001ce8e188 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:41:16 -0700 Subject: [PATCH 18/47] no-mistakes(review): Document concurrent re-hold age-basis limitation --- docs/captain-hold-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index da3afcb0dfd..29aa21af24e 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -14,7 +14,7 @@ The `hold` subcommand is the mandatory captain-hold creation path: it uses an ex Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. -It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. +It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. As an accepted limit, a rare concurrent answer-close and re-hold race can leave the newly re-held task without its age basis; re-holding it with `--until` remains the durable fix rather than relying on this projection safety net. An exact retry is idempotent only when the requested close mode matches the newest record; a drifted answer or mode mismatch is rejected, while a re-held task accepts a new answer as a new record on top. On a task closed outside the script, `answer` records the missing block only when the captain-hold annotations tasks-axi preserves through a close prove the captain owned it, and it verifies the task stays closed. A hold whose `--until` date has passed keeps those annotations while tasks-axi reports it no longer held, so an expired deferral remains answerable. From 5c3578b32c940fcc8e38ab29794e82e94dd1582e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 02:48:57 -0700 Subject: [PATCH 19/47] no-mistakes(document): Refresh captain hold lifecycle documentation --- bin/fm-captain-hold.sh | 23 +++++++++++++---------- docs/captain-hold-lifecycle.md | 11 ++++++++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index c007689b764..e6083668d79 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -10,8 +10,8 @@ # keyed-answer intake every channel feeds. # # There is no separate decision type. A captain call is an ordinary backlog -# task held for the captain (`tasks-axi hold --kind captain`), and its -# identity is simply the task id. Older installs created derived +# task held for the captain through this script's mandatory `hold` subcommand, +# and its identity is simply the task id. Older installs created derived # `-decision-` identities through bin/fm-decision-hold.sh; those # rows are already plain task ids, so they keep working here unchanged, and # the legacy inputs noted below resolve them without a migration. @@ -44,12 +44,14 @@ # later" answer is stored as a date instead of a live card. # # `answer` records the captain's exact words and closes the call in the same -# act. It requires a non-empty captain decision file of at most 8192 bytes, -# writes a resolution block at the top of the task body (the previous body is -# preserved below the block and archived through tasks-axi --archive-body), -# then closes the task with `tasks-axi done` - or, with `--release`, lifts the -# hold with `tasks-axi unhold` so a captain-gated WORK item resumes instead of -# closing. An exact retry is idempotent only when its requested close mode +# act. It requires a non-empty captain decision file of at most 8192 bytes and +# writes a resolution block while preserving the leading hold-set stamp until +# the close succeeds (the previous body is preserved and archived through +# tasks-axi --archive-body). It then closes the task with `tasks-axi done` - or, +# with `--release`, lifts the hold with `tasks-axi unhold` so a captain-gated +# WORK item resumes instead of closing - and restores resolution-first body +# ordering. An exact retry also completes unfinished ordering normalization and +# is idempotent only when its requested close mode # matches the newest record; a changed decision or a mode mismatch is rejected. # A re-held task may record a new answer on top. On a task already closed outside this script, # `answer` records the missing resolution block (the old `repair` path) only @@ -585,8 +587,9 @@ command_hold() { printf '%s\n' "$id" } -# Record a resolution block at the top of the task body, preserving the -# previous body below it and archiving the pristine original. +# Record a resolution block beneath any leading active hold-set stamp, +# preserving the previous body below it and archiving the pristine original. +# Successful closure removes the stamp to restore resolution-first ordering. write_resolution_record() { # local id=$1 mode=$2 body=$3 new_body tmp hold_set new_body=$(resolution_block "$mode") diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 29aa21af24e..e083e757ef2 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -10,11 +10,15 @@ A decision is not a separate thing in this system: it is an ordinary backlog tas The command addresses the active home's configured data directory the same way `bin/fm-backlog-transition-lib.sh` addresses every backlog transition, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. -The `hold` subcommand is the mandatory captain-hold creation path: it uses an existing task or creates one when nothing exists to hold, records its UTC hold-set timestamp as the leading line of the task body, then invokes the underlying tasks-axi hold operation and verifies both records. Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. +The `hold` subcommand is the mandatory captain-hold creation path: it uses an existing task or creates one when nothing exists to hold, records its UTC hold-set timestamp as the leading line of the task body, then invokes the underlying tasks-axi hold operation and verifies both records. +Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. -It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. As an accepted limit, a rare concurrent answer-close and re-hold race can leave the newly re-held task without its age basis; re-holding it with `--until` remains the durable fix rather than relying on this projection safety net. +It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). +If the close is interrupted, the still-held task therefore keeps its original age basis. +A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. +As an accepted limit, a rare concurrent answer-close and re-hold race can leave the newly re-held task without its age basis; re-holding it through the wrapper with `--until` remains the durable fix rather than relying on this projection safety net. An exact retry is idempotent only when the requested close mode matches the newest record; a drifted answer or mode mismatch is rejected, while a re-held task accepts a new answer as a new record on top. On a task closed outside the script, `answer` records the missing block only when the captain-hold annotations tasks-axi preserves through a close prove the captain owned it, and it verifies the task stays closed. A hold whose `--until` date has passed keeps those annotations while tasks-axi reports it no longer held, so an expired deferral remains answerable. @@ -68,7 +72,8 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de A date-deferred captain hold renders as a gate with its `until :` reason; an actionable prose-deferred one leaves the default views with an `omitted[]` disclosure and is revealed by `--all-decisions`, while `--all-queued` reveals non-actionable prose-deferred queued rows. An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals it in Captain's Call and removes its safety gate so the buckets remain exclusive. Prose deferral takes precedence when both hints apply. -Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding with `--until` remains the durable fix for both limits. +Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. +A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding it through the wrapper with `--until` remains the durable fix for both limits. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. From db8d5c31f24e510b98e77fb69a5dbb786b26f6b6 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 03:13:13 -0700 Subject: [PATCH 20/47] no-mistakes(ci): Fixed both CI failures. Updated the macOS Bash snapshot expectation from 45 to 46 Bearings tests. Narrowed parked-style deferral matching to explicit hold-reason prefixes while preserving legacy explicit markers and preventing contextual prose from hiding active decisions. Added behavioral regression coverage. Verified with stock Bash 3.2: 17 fleet snapshot tests and 46 Bearings tests pass; full lint and workflow validation also pass --- bin/fm-fleet-snapshot.sh | 18 +++++++++++------- tests/fm-bearings-snapshot.test.sh | 5 +++++ tests/fm-fleet-snapshot-view.test.sh | 6 ++++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 77fb1799b51..615586e4191 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -33,10 +33,11 @@ # deferred_marker is a presentation hint only: the row's hold reason or # first 240 characters of current body prose (excluding the machine-generated # hold-set stamp and preserved historical resolution blocks) carries an -# explicit SUPERSEDED / NOT REQUIRED / DEFERRED -# marker, or a parked-style phrasing (parked, awaiting captain go, do not +# explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker. A hold reason that +# begins with a parked-style phrasing (parked, awaiting captain go, do not # dispatch / do not auto-dispatch, not urgent, de-prioritized, queued -# opportunity, captain-gated). It never changes captain_actionable; +# opportunity, captain-gated) is also deferred; incidental mentions later in +# a reason or body do not adjudicate the decision. It never changes captain_actionable; # renderers may use it to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain # hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS @@ -354,8 +355,10 @@ backlog_json() { # [] - defaults to this home's $BACKLOG jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); - def prose_deferred: - test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED|\\bparked\\b|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated"; "i"); + def explicitly_deferred: + test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED"; "i"); + def parked_style_reason: + test("^(parked(\\b|$)|awaiting captain go(\\b|$)|do not (auto-)?dispatch(\\b|$)|not urgent(\\b|$)|de-prioritized(\\b|$)|queued opportunity(\\b|$)|captain-gated(\\b|$))"; "i"); def timestamp_epoch($d): if ($d | type) != "string" then null elif ($d | test("T")) then try ($d | fromdateiso8601) catch null @@ -518,8 +521,9 @@ backlog_json() { # [] - defaults to this home's $BACKLOG (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) | .deferred_marker = - (((.hold_reason // "") | prose_deferred) - or ((.body_excerpt // "") | prose_deferred)) + (((.hold_reason // "") | explicitly_deferred) + or ((.hold_reason // "") | parked_style_reason) + or ((.body_excerpt // "") | explicitly_deferred)) else . end) | del(.section,.order) ' < "$backlog" diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 8d6dc84873c..c7dfeed22d9 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1164,6 +1164,9 @@ test_undated_hold_phrasing_and_aging_projection() { Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z +- [ ] contextual-call - Context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose whether to pursue this queued opportunity) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z + This is not urgent context, but the captain decision is current. - [ ] reheld-current-call - Re-held genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a current sample route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z Current decision prose lists route north or route south. @@ -1180,6 +1183,7 @@ EOF json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold") | not) and (.decisions_open | any(.[]; .id == "parked-hold") | not) @@ -1195,6 +1199,7 @@ EOF (.decisions_open | any(.[]; .id == "parked-hold")) and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) and (.gates | any(.[]; .id == "aged-call" or .id == "legacy-old-hold") | not) diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 69efab5a42f..cb4c44a71e6 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -609,6 +609,8 @@ test_undated_captain_hold_phrasing_and_aging() { Captain hold set: 2026-07-11T00:01:00Z - [ ] live-gated - Live captain-gated work (repo: sample) (kind: ship) (hold: captain go pending) (hold-kind: captain) - [ ] unparked-call - Newly unparked decision (repo: sample) (kind: captain) (hold: unparked; choose a sample route) (hold-kind: captain) +- [ ] contextual-call - Context is not a deferral (repo: sample) (kind: captain) (hold: choose whether to pursue this queued opportunity) (hold-kind: captain) + This is not urgent context, but the captain decision is current. ## Done EOF @@ -646,9 +648,9 @@ EOF and .hold_age_days == 13 and .aged_undated_hold == false ' >/dev/null || fail "a hold one minute short of 14 days must not age early: $out" printf '%s' "$out" | jq -e ' - [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call")] + [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call" or .id == "contextual-call")] | all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) - ' >/dev/null || fail "captain go pending and unparked decisions must not match parked-style markers: $out" + ' >/dev/null || fail "contextual parked-style wording must not hide current decisions: $out" out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) printf '%s' "$out" | jq -e ' From 05e710bc1f04ed1c0de5c5774bfdffbabc9831f5 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 03:42:17 -0700 Subject: [PATCH 21/47] =?UTF-8?q?no-mistakes(ci):=20Fixed=20Greptile?= =?UTF-8?q?=E2=80=99s=20P1=20finding=20by=20restricting=20parked-style=20d?= =?UTF-8?q?eferral=20phrases=20to=20complete=20hold-reason=20markers.=20Co?= =?UTF-8?q?ntextual=20reasons=20beginning=20with=20=E2=80=9Cnot=20urgent,?= =?UTF-8?q?=E2=80=9D=20=E2=80=9Cqueued=20opportunity,=E2=80=9D=20or=20?= =?UTF-8?q?=E2=80=9Ccaptain-gated=E2=80=9D=20now=20remain=20visible=20deci?= =?UTF-8?q?sions.=20Added=20behavioral=20coverage=20through=20the=20real?= =?UTF-8?q?=20fleet=20and=20Bearings=20snapshot=20paths=20and=20updated=20?= =?UTF-8?q?documentation.=20Verified=20both=20snapshot=20suites=20under=20?= =?UTF-8?q?Bash=203.2=20(17=20fleet=20tests=20and=2046=20Bearings=20tests)?= =?UTF-8?q?,=20syntax=20checks,=20and=20git=20diff=20checks.=20The=20no-mi?= =?UTF-8?q?stakes=20attestation=20failure=20is=20external/stale=20and=20re?= =?UTF-8?q?quires=20the=20outer=20pipeline=20to=20refresh=20it=20for=20the?= =?UTF-8?q?=20new=20head?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/bearings/SKILL.md | 2 +- bin/fm-fleet-snapshot.sh | 13 +++++++------ docs/captain-hold-lifecycle.md | 2 +- tests/fm-bearings-snapshot.test.sh | 14 +++++++++++++- tests/fm-fleet-snapshot-view.test.sh | 11 ++++++++--- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 9921bdb6e2e..58eab62959a 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -44,7 +44,7 @@ Board answers are acted on later under the normal authority rules; this skill's A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task is canonically actionable whatever its kind, before the presentation-only filters below. A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. - A hold whose reason or bounded body excerpt carries a deferred, superseded, or parked-style marker is suppressed from the default view with an `omitted` disclosure. + A hold whose reason or bounded body excerpt carries an explicit deferred or superseded marker, or whose complete reason is a parked-style marker, is suppressed from the default view with an `omitted` disclosure. Contextual parked-style wording does not suppress a live decision. An undated hold aged past the configured snapshot threshold leaves Captain's Call for an age-labelled Charted Next gate unless a parked-style marker suppresses it; this is only a presentation safety net, and re-holding with `--until` remains the durable deferral. Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing any revealed aged hold from Charted Next so the buckets remain exclusive, subject to the snapshot's documented cross-home summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 615586e4191..69c9c00eda7 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -33,11 +33,12 @@ # deferred_marker is a presentation hint only: the row's hold reason or # first 240 characters of current body prose (excluding the machine-generated # hold-set stamp and preserved historical resolution blocks) carries an -# explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker. A hold reason that -# begins with a parked-style phrasing (parked, awaiting captain go, do not -# dispatch / do not auto-dispatch, not urgent, de-prioritized, queued -# opportunity, captain-gated) is also deferred; incidental mentions later in -# a reason or body do not adjudicate the decision. It never changes captain_actionable; +# explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker. A hold reason whose +# complete value is a parked-style marker (parked, awaiting captain go, do +# not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued +# opportunity, captain-gated) is also deferred; contextual prose containing +# or beginning with those words does not adjudicate the decision. It never +# changes captain_actionable; # renderers may use it to keep prose-deferred rows out of default views. # aged_undated_hold is a second presentation hint for an undated captain # hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS @@ -358,7 +359,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG def explicitly_deferred: test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED"; "i"); def parked_style_reason: - test("^(parked(\\b|$)|awaiting captain go(\\b|$)|do not (auto-)?dispatch(\\b|$)|not urgent(\\b|$)|de-prioritized(\\b|$)|queued opportunity(\\b|$)|captain-gated(\\b|$))"; "i"); + test("^(parked|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated)[.!]?$"; "i"); def timestamp_epoch($d): if ($d | type) != "string" then null elif ($d | test("T")) then try ($d | fromdateiso8601) catch null diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index e083e757ef2..17e2422673d 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -61,7 +61,7 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. -It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its current decision prose, excluding the machine-generated hold-set stamp and preserved historical resolution blocks, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker or a parked-style phrasing (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). +It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its current decision prose, excluding the machine-generated hold-set stamp and preserved historical resolution blocks, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker, or when the complete hold reason is a parked-style marker (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). Contextual prose that merely contains or begins with a parked-style phrase is not treated as a deferral. An undated captain hold whose recorded hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, using floored elapsed days) also carries `aged_undated_hold` and `hold_age_days`. Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index c7dfeed22d9..4022290acc8 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1159,7 +1159,7 @@ test_undated_hold_phrasing_and_aging_projection() { ## In flight ## Queued -- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: parked for a later pass) (hold-kind: captain) +- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: parked) (hold-kind: captain) - [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) @@ -1167,6 +1167,12 @@ test_undated_hold_phrasing_and_aging_projection() { - [ ] contextual-call - Context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose whether to pursue this queued opportunity) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z This is not urgent context, but the captain decision is current. +- [ ] contextual-not-urgent - Leading context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: not urgent but choose the route now) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z +- [ ] contextual-opportunity - Leading opportunity is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: queued opportunity: choose whether to proceed) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z +- [ ] contextual-gated - Leading gate is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: captain-gated decision needs current approval) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z - [ ] reheld-current-call - Re-held genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a current sample route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z Current decision prose lists route north or route south. @@ -1184,6 +1190,9 @@ EOF printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) + and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) + and (.decisions_open | any(.[]; .id == "contextual-opportunity")) + and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold") | not) and (.decisions_open | any(.[]; .id == "parked-hold") | not) @@ -1200,6 +1209,9 @@ EOF and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) + and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) + and (.decisions_open | any(.[]; .id == "contextual-opportunity")) + and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) and (.gates | any(.[]; .id == "aged-call" or .id == "legacy-old-hold") | not) diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index cb4c44a71e6..9702fbeb715 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -590,7 +590,7 @@ test_undated_captain_hold_phrasing_and_aging() { ## In flight ## Queued -- [ ] parked-hold - Parked style call (repo: sample) (kind: ship) (hold: parked for a later pass) (hold-kind: captain) +- [ ] parked-hold - Parked style call (repo: sample) (kind: ship) (hold: parked) (hold-kind: captain) - [ ] awaiting-go - Awaiting go call (repo: sample) (kind: ship) (hold: awaiting captain go) (hold-kind: captain) - [ ] no-dispatch - No dispatch call (repo: sample) (kind: ship) (hold: do not dispatch) (hold-kind: captain) - [ ] no-auto - No auto-dispatch call (repo: sample) (kind: ship) (hold: do not auto-dispatch) (hold-kind: captain) @@ -611,6 +611,9 @@ test_undated_captain_hold_phrasing_and_aging() { - [ ] unparked-call - Newly unparked decision (repo: sample) (kind: captain) (hold: unparked; choose a sample route) (hold-kind: captain) - [ ] contextual-call - Context is not a deferral (repo: sample) (kind: captain) (hold: choose whether to pursue this queued opportunity) (hold-kind: captain) This is not urgent context, but the captain decision is current. +- [ ] contextual-not-urgent - Leading context is not a deferral (repo: sample) (kind: captain) (hold: not urgent but choose the route now) (hold-kind: captain) +- [ ] contextual-opportunity - Leading opportunity is not a deferral (repo: sample) (kind: captain) (hold: queued opportunity: choose whether to proceed) (hold-kind: captain) +- [ ] contextual-gated - Leading gate is not a deferral (repo: sample) (kind: captain) (hold: captain-gated decision needs current approval) (hold-kind: captain) ## Done EOF @@ -648,8 +651,10 @@ EOF and .hold_age_days == 13 and .aged_undated_hold == false ' >/dev/null || fail "a hold one minute short of 14 days must not age early: $out" printf '%s' "$out" | jq -e ' - [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call" or .id == "contextual-call")] - | all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) + [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call" or .id == "contextual-call" + or .id == "contextual-not-urgent" or .id == "contextual-opportunity" or .id == "contextual-gated")] + | length == 6 + and all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) ' >/dev/null || fail "contextual parked-style wording must not hide current decisions: $out" out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) From 8251ffb0ef7da1176de93d43337f40ae204419ce Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Wed, 2 Sep 2026 04:10:17 -0700 Subject: [PATCH 22/47] =?UTF-8?q?no-mistakes(ci):=20Fixed=20parked-style?= =?UTF-8?q?=20undated=20captain=20holds=20disappearing=20from=20the=20defa?= =?UTF-8?q?ult=20Bearings=20board.=20They=20now=20project=20to=20Charted?= =?UTF-8?q?=20Next=20with=20omitted[]=20disclosure,=20while=20--all-decisi?= =?UTF-8?q?ons=20reveals=20them=20and=20removes=20the=20safety=20gate.=20A?= =?UTF-8?q?dded=20behavioral=20coverage=20for=20the=20reported=20=E2=80=9C?= =?UTF-8?q?not=20urgent=E2=80=9D=20case=20and=20aligned=20documentation.?= =?UTF-8?q?=20Verified=20fm-bearings-snapshot,=20fleet=20snapshot=20view,?= =?UTF-8?q?=20and=20captain-hold=20lifecycle=20tests;=20shellcheck,=20bash?= =?UTF-8?q?=20syntax,=20and=20git=20diff=20checks=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/bearings/SKILL.md | 6 +++--- bin/fm-bearings-snapshot.sh | 21 ++++++++++++--------- docs/captain-hold-lifecycle.md | 5 ++--- tests/fm-bearings-snapshot.test.sh | 14 +++++++------- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 58eab62959a..61b720ff5d2 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -44,9 +44,9 @@ Board answers are acted on later under the normal authority rules; this skill's A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task is canonically actionable whatever its kind, before the presentation-only filters below. A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. - A hold whose reason or bounded body excerpt carries an explicit deferred or superseded marker, or whose complete reason is a parked-style marker, is suppressed from the default view with an `omitted` disclosure. Contextual parked-style wording does not suppress a live decision. - An undated hold aged past the configured snapshot threshold leaves Captain's Call for an age-labelled Charted Next gate unless a parked-style marker suppresses it; this is only a presentation safety net, and re-holding with `--until` remains the durable deferral. - Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing any revealed aged hold from Charted Next so the buckets remain exclusive, subject to the snapshot's documented cross-home summary bounds. + An actionable undated hold whose reason or bounded body excerpt carries an explicit deferred or superseded marker, or whose complete reason is a parked-style marker, leaves Captain's Call for a Charted Next gate with an `omitted` disclosure. Contextual parked-style wording does not suppress a live decision. + An undated hold aged past the configured snapshot threshold likewise leaves Captain's Call for an age-labelled Charted Next gate; these are only presentation safety nets, and re-holding with `--until` remains the durable deferral. + Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing each revealed safety gate from Charted Next so the buckets remain exclusive, subject to the snapshot's documented cross-home summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. Until then it stays queued with the reason. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 026f2b5ff20..3ac5f02e5b0 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -27,10 +27,10 @@ # held for the captain, whatever its kind, before the presentation-only filters # below. A captain hold deferred by date # (hold-until in the future) is not actionable and renders as a Charted Next -# gate with its date; a row the canonical snapshot marks prose-deferred -# (deferred_marker) leaves the default decisions and gates views and is -# disclosed in omitted[]. --all-decisions reveals an actionable prose-deferred -# hold, while --all-queued reveals a non-actionable prose-deferred row. +# gate with its date; an actionable undated row the canonical snapshot marks +# prose-deferred (deferred_marker) leaves Captain's Call for a Charted Next gate +# and is disclosed in omitted[]. --all-decisions reveals it without retaining +# the gate, while --all-queued reveals a non-actionable prose-deferred row. # Underway (in_flight) projects every main live worker plus every active child # from every readable secondmate ledger, independently of that home's # bearings_state. A home classified captain_decision because it has an open @@ -41,9 +41,9 @@ # falling back to since for a legacy unstamped hold) leaves default Captain's # Call, renders as a Charted Next gate showing its floored age, is disclosed in # omitted[], and is revealed by --all-decisions without retaining its safety -# gate. Prose deferral takes precedence over aging, so a hold with both hints -# is omitted rather than gated. Aging is a projection safety net only; the durable deferral remains -# re-holding with --until. +# gate. Parked-style and aging hints use the same undated-hold gate projection. +# This is a projection safety net only; the durable deferral remains re-holding +# with --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory # object (orphan structured in-flight without meta, unstructured current rows). @@ -337,6 +337,9 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; def live_captain_call: (.deferred_marker != true) and (.aged_undated_hold != true); + def projected_undated_hold: + (.hold_until == null) + and (.deferred_marker == true or .aged_undated_hold == true); def hold_gate_reason: if .aged_undated_hold == true and .hold_age_days != null then ("held " + (.hold_age_days | tostring) + "d: " + (.hold_reason // .blocked_reason // "-")) @@ -466,7 +469,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | as_gate("(main)") ] + [ .backlog.records[] | select($all_decisions == 0 and .structured and .captain_actionable == true - and .aged_undated_hold == true and .deferred_marker != true) + and projected_undated_hold) | as_gate("(main)") ] + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") @@ -479,7 +482,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select($m.provenance.selected == "structured-home") | $m.queued[]? | select($all_decisions == 0 and .captain_actionable == true - and .aged_undated_hold == true and .deferred_marker != true) + and projected_undated_hold) | as_gate($m.id) ]) as $gates_all | ([ .scout_reports[] | . as $r diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 17e2422673d..3de960bd3bb 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -69,9 +69,8 @@ The durable deferral remains re-holding with `--until`. Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. -A date-deferred captain hold renders as a gate with its `until :` reason; an actionable prose-deferred one leaves the default views with an `omitted[]` disclosure and is revealed by `--all-decisions`, while `--all-queued` reveals non-actionable prose-deferred queued rows. -An aged undated captain hold without prose-deferred phrasing leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals it in Captain's Call and removes its safety gate so the buckets remain exclusive. -Prose deferral takes precedence when both hints apply. +A date-deferred captain hold renders as a gate with its `until :` reason; an actionable undated prose-deferred one leaves default Captain's Call for a Charted Next gate with an `omitted[]` disclosure, while `--all-queued` reveals non-actionable prose-deferred queued rows. +An aged undated captain hold likewise leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals parked-style and aged holds in Captain's Call and removes their safety gates so the buckets remain exclusive. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding it through the wrapper with `--until` remains the durable fix for both limits. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 4022290acc8..6e0f8e03d83 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1107,8 +1107,8 @@ test_superseded_queued_item_dropped_by_default() { # The collapsed captain-call contract: any due, unblocked captain-held task is # Captain's Call whatever its kind; a date-deferred hold is a dated gate until -# due; a prose-deferred hold leaves the default views with a disclosure; and -# Recently Landed excludes only what closed while still held for the captain. +# due; an undated prose-deferred hold becomes a disclosed gate; and Recently +# Landed excludes only what closed while still held for the captain. test_collapsed_captain_call_deferral_and_landed() { local home fakebin json home=$(make_home collapsed-call) @@ -1137,7 +1137,7 @@ EOF and (.decisions_open | any(.[]; .id == "external-gate") | not) and (.gates | any(.[]; .id == "later-call" and (.reason | startswith("until 2026-08-01")))) and (.gates | any(.[]; .id == "work-gate") | not) - and (.gates | any(.[]; .id == "parked-call") | not) + and (.gates | any(.[]; .id == "parked-call" and .reason == "DEFERRED by captain revisit later")) and (.gates | any(.[]; .id == "external-gate")) and (.landed | any(.[]; .id == "shipped-work")) and (.landed | any(.[]; .id == "answered-call") | not) @@ -1159,7 +1159,7 @@ test_undated_hold_phrasing_and_aging_projection() { ## In flight ## Queued -- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-06-01) (hold: parked) (hold-kind: captain) +- [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-07-10) (hold: not urgent) (hold-kind: captain) - [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) @@ -1197,7 +1197,7 @@ EOF and (.decisions_open | any(.[]; .id == "legacy-old-hold") | not) and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) - and (.gates | any(.[]; .id == "parked-hold") | not) + and (.gates | any(.[]; .id == "parked-hold" and .reason == "not urgent")) and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "legacy-old-hold" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "recent-call") | not) @@ -1214,8 +1214,8 @@ EOF and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) - and (.gates | any(.[]; .id == "aged-call" or .id == "legacy-old-hold") | not) - ' >/dev/null || fail "--all-decisions must reveal aged holds without duplicating their gates: $json" + and (.gates | any(.[]; .id == "parked-hold" or .id == "aged-call" or .id == "legacy-old-hold") | not) + ' >/dev/null || fail "--all-decisions must reveal parked-style and aged holds without duplicating their gates: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "aged-call")) From ed1a69376cf6fcd3f64b1481417ee5127815b7c1 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 15:23:14 -0700 Subject: [PATCH 23/47] no-mistakes(test): Stabilize concurrency budget and provision timeout tests --- tests/fm-bearings-snapshot.test.sh | 25 +++++++++++++++++-------- tests/fm-herdr-lab.test.sh | 19 +++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 6e0f8e03d83..840b3184087 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -261,6 +261,17 @@ while IFS= read -r -d '' arg; do args+=("$arg"); done \ < <(perl -MMIME::Base64=decode_base64 -e 'print decode_base64($ARGV[0])' "$4") printf '%s\t%s\n' "$remote_home" "${args[0]:-}" >> "$FM_TEST_LEDGER_CALL_LOG" if [ -f "$remote_home/state/slow-ledger-read" ]; then + active_marker="$FM_TEST_LEDGER_ACTIVE_DIR/collector-$$" + : > "$active_marker" + trap 'rm -f "$active_marker"' EXIT + while [ ! -f "$FM_TEST_LEDGER_ACTIVE_DIR/overlap-proved" ]; do + set -- "$FM_TEST_LEDGER_ACTIVE_DIR"/collector-* + if [ "$#" -ge 5 ] && [ -e "$1" ]; then + : > "$FM_TEST_LEDGER_ACTIVE_DIR/overlap-proved" + break + fi + sleep 0.05 + done sleep 30 & sleeper=$! printf '%s %s\n' "$$" "$sleeper" >> "$FM_TEST_LEDGER_PID_LOG" @@ -287,6 +298,7 @@ run_remote_ledger_bearings() { # FM_HOME="$parent" FM_ROOT_OVERRIDE="$ROOT" FM_SSH_BIN="$fakebin/fake-ssh" \ FM_TEST_LEDGER_CALL_LOG="$parent/ledger-calls.log" \ FM_TEST_LEDGER_PID_LOG="$parent/ledger-pids.log" \ + FM_TEST_LEDGER_ACTIVE_DIR="$parent/ledger-active" \ FM_SNAPSHOT_CACHE_DIR="$parent/state/summary-cache" \ FM_SNAPSHOT_BUDGET=3 FM_SNAPSHOT_NOW_EPOCH="$epoch" \ FM_BEARINGS_NOW=2026-09-01T22:00:00Z "$BEARINGS" --json @@ -2503,10 +2515,11 @@ SH } test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { - local parent fakebin json started elapsed i remote_home pid collector_pid sleeper_pid duplicate_base + local parent fakebin json i remote_home pid collector_pid sleeper_pid duplicate_base parent=$(make_home concurrent-remote-ledgers) make_remote_ledger_fleet "$parent" 5 fakebin=$(make_remote_ledger_ssh "$parent/remote-ssh") + mkdir -p "$parent/ledger-active" : > "$parent/ledger-calls.log" : > "$parent/ledger-pids.log" @@ -2550,14 +2563,10 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { done : > "$parent/ledger-calls.log" : > "$parent/ledger-pids.log" - started=$(date +%s) + rm -f "$parent/ledger-active/overlap-proved" "$parent/ledger-active"/collector-* json=$(run_remote_ledger_bearings "$parent" "$fakebin" 2000) - elapsed=$(( $(date +%s) - started )) - # The three-second bound covers remote collection, while setup, cache validation, - # and projection run outside it. Keep the end-to-end ceiling well below the - # fifteen seconds that five serial three-second reads would require, without - # treating slower stock-macOS jq/process startup as collector serialization. - [ "$elapsed" -lt 12 ] || fail "five wedged remote reads behaved serially despite the shared three-second budget (${elapsed}s)" + [ -f "$parent/ledger-active/overlap-proved" ] \ + || fail "five wedged remote reads never overlapped within the shared three-second budget" printf '%s' "$json" | jq -e ' (.secondmates | length) == 5 and all(.secondmates[]; .freshness == "cached" and .age_seconds == 1000 diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index 14ab7497a09..76ea6241cfc 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -43,8 +43,10 @@ case "$1 ${2:-}" in fi ;; "server --session") - if [ "${FM_FAKE_HERDR_SERVER_DELAY:-0}" != 0 ]; then - "$FM_FAKE_HERDR_REAL_SLEEP" "$FM_FAKE_HERDR_SERVER_DELAY" + if [ "${FM_FAKE_HERDR_NEVER_START:-}" = 1 ]; then + printf '%s\n' "$$" > "$state/$session.server-pid" + : > "$state/$session.server-blocked" + kill -STOP "$$" fi printf '%s\n' running > "$state/$session" ;; @@ -79,7 +81,7 @@ run_with_fake() { FM_FAKE_HERDR_STATE="$FAKE_STATE" \ FM_FAKE_HERDR_LOG="$FAKE_LOG" \ FM_FAKE_HERDR_REAL_SLEEP="$REAL_SLEEP" \ - FM_FAKE_HERDR_SERVER_DELAY="${FM_FAKE_HERDR_SERVER_DELAY:-0}" \ + FM_FAKE_HERDR_NEVER_START="${FM_FAKE_HERDR_NEVER_START:-}" \ FM_FAKE_HERDR_FAST_POLL="${FM_FAKE_HERDR_FAST_POLL:-}" \ FM_FAKE_HERDR_DELETE_FAIL="${FM_FAKE_HERDR_DELETE_FAIL:-}" \ FM_HERDR_LAB_STATE_DIR="$TRIPWIRES" \ @@ -209,7 +211,7 @@ test_failed_delete_retains_tripwire() { } test_timed_out_provision_cancels_late_launch() { - local name="fm-lab-late-launch-$$" status=0 + local name="fm-lab-late-launch-$$" status=0 server_pid cat > "$FAKEBIN/sleep" <<'SH' #!/usr/bin/env bash if [ "${FM_FAKE_HERDR_FAST_POLL:-}" = 1 ]; then @@ -219,15 +221,20 @@ exec "$FM_FAKE_HERDR_REAL_SLEEP" "$@" SH chmod +x "$FAKEBIN/sleep" : > "$FAKE_LOG" - FM_FAKE_HERDR_FAST_POLL=1 FM_FAKE_HERDR_SERVER_DELAY=30 \ + FM_FAKE_HERDR_FAST_POLL=1 FM_FAKE_HERDR_NEVER_START=1 \ run_with_fake fm_herdr_lab_provision "$name" >/dev/null 2>&1 || status=$? expect_code 1 "$status" "timed-out provision must fail" + assert_present "$FAKE_STATE/$name.server-blocked" \ + "timed-out provision never observed the blocked server launch" + server_pid=$(cat "$FAKE_STATE/$name.server-pid") + if kill -0 "$server_pid" 2>/dev/null; then + fail "timed-out provision left its blocked server process alive" + fi assert_present "$TRIPWIRES/$name.fleet-state.json" \ "timed-out provision must retain its tripwire until teardown" run_with_fake fm_herdr_lab_teardown "$name" || fail "teardown after timed-out provision failed" assert_absent "$TRIPWIRES/$name.fleet-state.json" \ "teardown after timed-out provision did not remove its tripwire" - "$REAL_SLEEP" 1.1 if [ -f "$FAKE_STATE/$name" ] && [ "$(cat "$FAKE_STATE/$name")" = running ]; then fail "timed-out provision left a late-starting lab session after teardown" fi From d4369308312c209b444138d1a94b4c9265964c7f Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 15:43:39 -0700 Subject: [PATCH 24/47] no-mistakes(document): Correct captain hold documentation details --- .agents/skills/bearings/SKILL.md | 3 ++- bin/fm-fleet-snapshot.sh | 2 +- docs/captain-hold-lifecycle.md | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 61b720ff5d2..94665dd85a0 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -44,7 +44,8 @@ Board answers are acted on later under the normal authority rules; this skill's A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task is canonically actionable whatever its kind, before the presentation-only filters below. A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. - An actionable undated hold whose reason or bounded body excerpt carries an explicit deferred or superseded marker, or whose complete reason is a parked-style marker, leaves Captain's Call for a Charted Next gate with an `omitted` disclosure. Contextual parked-style wording does not suppress a live decision. + An actionable undated hold whose reason or bounded body excerpt carries an explicit deferred or superseded marker, or whose complete reason is a parked-style marker, leaves Captain's Call for a Charted Next gate with an `omitted` disclosure. + Contextual parked-style wording does not suppress a live decision. An undated hold aged past the configured snapshot threshold likewise leaves Captain's Call for an age-labelled Charted Next gate; these are only presentation safety nets, and re-holding with `--until` remains the durable deferral. Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing each revealed safety gate from Charted Next so the buckets remain exclusive, subject to the snapshot's documented cross-home summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 69c9c00eda7..6a1111d72f2 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -250,7 +250,7 @@ FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. An undated captain hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14; 0 ages every hold with a -hold-set timestamp or fallback date) carries aged_undated_hold and hold_age_days +non-negative computed age) carries aged_undated_hold and hold_age_days as presentation hints. Legacy holds without a stamp fall back to their since date; re-holding with --until remains the durable deferral. EOF diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 3de960bd3bb..02f7e6154f6 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -15,7 +15,7 @@ Publishing the stamp first ensures a snapshot cannot observe a newly captain-hel Retries of an active hold preserve its hold-set timestamp, while re-holding released work starts a new timestamped lifecycle; a closed task is refused rather than reopened, and `--until` stores the captain's own deferral date through tasks-axi's date gate. The `answer` subcommand records the captain's exact words and closes the call in the same act. -It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or `tasks-axi unhold --release` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). +It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or, under `answer --release`, `tasks-axi unhold` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. As an accepted limit, a rare concurrent answer-close and re-hold race can leave the newly re-held task without its age basis; re-holding it through the wrapper with `--until` remains the durable fix rather than relying on this projection safety net. @@ -61,7 +61,8 @@ Trusted external process-event adapters intentionally expose no answer operation `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. -It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its current decision prose, excluding the machine-generated hold-set stamp and preserved historical resolution blocks, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker, or when the complete hold reason is a parked-style marker (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). Contextual prose that merely contains or begins with a parked-style phrase is not treated as a deferral. +It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its current decision prose, excluding the machine-generated hold-set stamp and preserved historical resolution blocks, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker, or when the complete hold reason is a parked-style marker (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). +Contextual prose that merely contains or begins with a parked-style phrase is not treated as a deferral. An undated captain hold whose recorded hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, using floored elapsed days) also carries `aged_undated_hold` and `hold_age_days`. Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. From 3255fc20cd47e02e4185b73647612e9359428ea9 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 16:09:27 -0700 Subject: [PATCH 25/47] no-mistakes(ci): Fixed hold-reason parsing so commas in contextual reasons are preserved and do not incorrectly defer live Captain's Call decisions. Added end-to-end fleet/Bearings regression coverage. Reworked the flaky Herdr timeout test to assert observable late-launch behavior rather than process-ID liveness. Verified both snapshot suites, Herdr test 5 consecutive times, shell syntax, shellcheck, and git diff checks --- bin/fm-fleet-snapshot.sh | 7 ++++++- tests/fm-bearings-snapshot.test.sh | 4 ++++ tests/fm-fleet-snapshot-view.test.sh | 7 +++++-- tests/fm-herdr-lab.test.sh | 15 +++++++++------ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 6a1111d72f2..473a33142b7 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -388,6 +388,11 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | if $v == null then null else ($v | trim) end; def metadata($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + ":[[:space:]]*(?[^,)]*)"); + def hold_metadata($rest): + cap($rest; ".*(?:\\(|,[[:space:]]*)hold:[[:space:]]*(?[^)]*)") + | if . == null then null + else sub(",[[:space:]]*(?:repo|kind|priority|hold-kind|hold-until):.*$"; "") | trim + end; def metadata_word($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + "[[:space:]]+(?[^,)]*)"); def url_pattern: "https?://[^[:space:])\"<>]+"; @@ -453,7 +458,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG repo:metadata($rest; "repo"), kind:metadata($rest; "kind"), priority:metadata($rest; "priority"), - hold_reason:metadata($rest; "hold"), + hold_reason:hold_metadata($rest), hold_kind:metadata($rest; "hold-kind"), hold_until:metadata($rest; "hold-until"), hold_set:null, diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 840b3184087..f8b15146cc0 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1181,6 +1181,8 @@ test_undated_hold_phrasing_and_aging_projection() { This is not urgent context, but the captain decision is current. - [ ] contextual-not-urgent - Leading context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: not urgent but choose the route now) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z +- [ ] contextual-comma - Comma context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: not urgent, choose the launch route now) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z - [ ] contextual-opportunity - Leading opportunity is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: queued opportunity: choose whether to proceed) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z - [ ] contextual-gated - Leading gate is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: captain-gated decision needs current approval) (hold-kind: captain) @@ -1203,6 +1205,7 @@ EOF (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) + and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) and (.decisions_open | any(.[]; .id == "contextual-opportunity")) and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) @@ -1222,6 +1225,7 @@ EOF and (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) + and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) and (.decisions_open | any(.[]; .id == "contextual-opportunity")) and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 9702fbeb715..30e7a701f77 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -612,6 +612,7 @@ test_undated_captain_hold_phrasing_and_aging() { - [ ] contextual-call - Context is not a deferral (repo: sample) (kind: captain) (hold: choose whether to pursue this queued opportunity) (hold-kind: captain) This is not urgent context, but the captain decision is current. - [ ] contextual-not-urgent - Leading context is not a deferral (repo: sample) (kind: captain) (hold: not urgent but choose the route now) (hold-kind: captain) +- [ ] contextual-comma - Comma context is not a deferral (repo: sample) (kind: captain) (hold: not urgent, choose the launch route now) (hold-kind: captain) - [ ] contextual-opportunity - Leading opportunity is not a deferral (repo: sample) (kind: captain) (hold: queued opportunity: choose whether to proceed) (hold-kind: captain) - [ ] contextual-gated - Leading gate is not a deferral (repo: sample) (kind: captain) (hold: captain-gated decision needs current approval) (hold-kind: captain) @@ -652,9 +653,11 @@ EOF ' >/dev/null || fail "a hold one minute short of 14 days must not age early: $out" printf '%s' "$out" | jq -e ' [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call" or .id == "contextual-call" - or .id == "contextual-not-urgent" or .id == "contextual-opportunity" or .id == "contextual-gated")] - | length == 6 + or .id == "contextual-not-urgent" or .id == "contextual-comma" + or .id == "contextual-opportunity" or .id == "contextual-gated")] + | length == 7 and all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) + and (map(select(.id == "contextual-comma" and .hold_reason == "not urgent, choose the launch route now")) | length == 1) ' >/dev/null || fail "contextual parked-style wording must not hide current decisions: $out" out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index 76ea6241cfc..c0865a1d733 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -44,9 +44,11 @@ case "$1 ${2:-}" in ;; "server --session") if [ "${FM_FAKE_HERDR_NEVER_START:-}" = 1 ]; then - printf '%s\n' "$$" > "$state/$session.server-pid" : > "$state/$session.server-blocked" - kill -STOP "$$" + trap 'exit 0' TERM INT + while [ ! -f "$state/$session.server-release" ]; do + "$FM_FAKE_HERDR_REAL_SLEEP" 0.05 + done fi printf '%s\n' running > "$state/$session" ;; @@ -211,7 +213,7 @@ test_failed_delete_retains_tripwire() { } test_timed_out_provision_cancels_late_launch() { - local name="fm-lab-late-launch-$$" status=0 server_pid + local name="fm-lab-late-launch-$$" status=0 cat > "$FAKEBIN/sleep" <<'SH' #!/usr/bin/env bash if [ "${FM_FAKE_HERDR_FAST_POLL:-}" = 1 ]; then @@ -226,9 +228,10 @@ SH expect_code 1 "$status" "timed-out provision must fail" assert_present "$FAKE_STATE/$name.server-blocked" \ "timed-out provision never observed the blocked server launch" - server_pid=$(cat "$FAKE_STATE/$name.server-pid") - if kill -0 "$server_pid" 2>/dev/null; then - fail "timed-out provision left its blocked server process alive" + : > "$FAKE_STATE/$name.server-release" + "$REAL_SLEEP" 0.2 + if [ -f "$FAKE_STATE/$name" ] && [ "$(cat "$FAKE_STATE/$name")" = running ]; then + fail "timed-out provision left a launch that could become live" fi assert_present "$TRIPWIRES/$name.fleet-state.json" \ "timed-out provision must retain its tripwire until teardown" From b10e3300b148e56e50dbcbafd52c089da0b31042 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 16:40:28 -0700 Subject: [PATCH 26/47] Restore the Herdr lab timeout test to its main version. The stabilization rounds reworked tests/fm-herdr-lab.test.sh while chasing a load-induced flake, replacing the fake server's wall-clock delay with a SIGSTOP'd process and asserting that the blocked process is gone after a timed-out provision. A stopped process does not die from SIGTERM, so that assertion fails on Linux and the portable parallel shard stayed red. That test is unrelated to the undated captain-hold projection this branch delivers and was identical to main before these rounds, so restore main's version exactly. It still proves that a timed-out provision cancels its late launch before teardown. --- tests/fm-herdr-lab.test.sh | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index c0865a1d733..14ab7497a09 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -43,12 +43,8 @@ case "$1 ${2:-}" in fi ;; "server --session") - if [ "${FM_FAKE_HERDR_NEVER_START:-}" = 1 ]; then - : > "$state/$session.server-blocked" - trap 'exit 0' TERM INT - while [ ! -f "$state/$session.server-release" ]; do - "$FM_FAKE_HERDR_REAL_SLEEP" 0.05 - done + if [ "${FM_FAKE_HERDR_SERVER_DELAY:-0}" != 0 ]; then + "$FM_FAKE_HERDR_REAL_SLEEP" "$FM_FAKE_HERDR_SERVER_DELAY" fi printf '%s\n' running > "$state/$session" ;; @@ -83,7 +79,7 @@ run_with_fake() { FM_FAKE_HERDR_STATE="$FAKE_STATE" \ FM_FAKE_HERDR_LOG="$FAKE_LOG" \ FM_FAKE_HERDR_REAL_SLEEP="$REAL_SLEEP" \ - FM_FAKE_HERDR_NEVER_START="${FM_FAKE_HERDR_NEVER_START:-}" \ + FM_FAKE_HERDR_SERVER_DELAY="${FM_FAKE_HERDR_SERVER_DELAY:-0}" \ FM_FAKE_HERDR_FAST_POLL="${FM_FAKE_HERDR_FAST_POLL:-}" \ FM_FAKE_HERDR_DELETE_FAIL="${FM_FAKE_HERDR_DELETE_FAIL:-}" \ FM_HERDR_LAB_STATE_DIR="$TRIPWIRES" \ @@ -223,21 +219,15 @@ exec "$FM_FAKE_HERDR_REAL_SLEEP" "$@" SH chmod +x "$FAKEBIN/sleep" : > "$FAKE_LOG" - FM_FAKE_HERDR_FAST_POLL=1 FM_FAKE_HERDR_NEVER_START=1 \ + FM_FAKE_HERDR_FAST_POLL=1 FM_FAKE_HERDR_SERVER_DELAY=30 \ run_with_fake fm_herdr_lab_provision "$name" >/dev/null 2>&1 || status=$? expect_code 1 "$status" "timed-out provision must fail" - assert_present "$FAKE_STATE/$name.server-blocked" \ - "timed-out provision never observed the blocked server launch" - : > "$FAKE_STATE/$name.server-release" - "$REAL_SLEEP" 0.2 - if [ -f "$FAKE_STATE/$name" ] && [ "$(cat "$FAKE_STATE/$name")" = running ]; then - fail "timed-out provision left a launch that could become live" - fi assert_present "$TRIPWIRES/$name.fleet-state.json" \ "timed-out provision must retain its tripwire until teardown" run_with_fake fm_herdr_lab_teardown "$name" || fail "teardown after timed-out provision failed" assert_absent "$TRIPWIRES/$name.fleet-state.json" \ "teardown after timed-out provision did not remove its tripwire" + "$REAL_SLEEP" 1.1 if [ -f "$FAKE_STATE/$name" ] && [ "$(cat "$FAKE_STATE/$name")" = running ]; then fail "timed-out provision left a late-starting lab session after teardown" fi From 493449cd92d4d7c0dd7845d403019d3747d488ac Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 16:48:10 -0700 Subject: [PATCH 27/47] no-mistakes(review): Preserve metadata-like prose in captain hold reasons --- bin/fm-fleet-snapshot.sh | 5 +---- tests/fm-bearings-snapshot.test.sh | 4 ++++ tests/fm-fleet-snapshot-view.test.sh | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 473a33142b7..d8d0fc901a7 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -389,10 +389,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG def metadata($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + ":[[:space:]]*(?[^,)]*)"); def hold_metadata($rest): - cap($rest; ".*(?:\\(|,[[:space:]]*)hold:[[:space:]]*(?[^)]*)") - | if . == null then null - else sub(",[[:space:]]*(?:repo|kind|priority|hold-kind|hold-until):.*$"; "") | trim - end; + cap($rest; ".*\\(hold:[[:space:]]*(?[^)]*)"); def metadata_word($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + "[[:space:]]+(?[^,)]*)"); def url_pattern: "https?://[^[:space:])\"<>]+"; diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index f8b15146cc0..610018de75a 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1183,6 +1183,8 @@ test_undated_hold_phrasing_and_aging_projection() { Captain hold set: 2026-07-10T00:00:00Z - [ ] contextual-comma - Comma context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: not urgent, choose the launch route now) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z +- [ ] metadata-context - Metadata-like context is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: not urgent, priority: decide P1 or P2) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z - [ ] contextual-opportunity - Leading opportunity is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: queued opportunity: choose whether to proceed) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z - [ ] contextual-gated - Leading gate is not a deferral (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: captain-gated decision needs current approval) (hold-kind: captain) @@ -1206,6 +1208,7 @@ EOF and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) + and (.decisions_open | any(.[]; .id == "metadata-context" and .summary == "Metadata-like context is not a deferral: not urgent, priority: decide P1 or P2")) and (.decisions_open | any(.[]; .id == "contextual-opportunity")) and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) @@ -1226,6 +1229,7 @@ EOF and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) + and (.decisions_open | any(.[]; .id == "metadata-context" and .summary == "Metadata-like context is not a deferral: not urgent, priority: decide P1 or P2")) and (.decisions_open | any(.[]; .id == "contextual-opportunity")) and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 30e7a701f77..96932876456 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -613,6 +613,7 @@ test_undated_captain_hold_phrasing_and_aging() { This is not urgent context, but the captain decision is current. - [ ] contextual-not-urgent - Leading context is not a deferral (repo: sample) (kind: captain) (hold: not urgent but choose the route now) (hold-kind: captain) - [ ] contextual-comma - Comma context is not a deferral (repo: sample) (kind: captain) (hold: not urgent, choose the launch route now) (hold-kind: captain) +- [ ] metadata-context - Metadata-like context is not a deferral (repo: sample) (kind: captain) (hold: not urgent, priority: decide P1 or P2) (hold-kind: captain) - [ ] contextual-opportunity - Leading opportunity is not a deferral (repo: sample) (kind: captain) (hold: queued opportunity: choose whether to proceed) (hold-kind: captain) - [ ] contextual-gated - Leading gate is not a deferral (repo: sample) (kind: captain) (hold: captain-gated decision needs current approval) (hold-kind: captain) @@ -653,11 +654,12 @@ EOF ' >/dev/null || fail "a hold one minute short of 14 days must not age early: $out" printf '%s' "$out" | jq -e ' [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call" or .id == "contextual-call" - or .id == "contextual-not-urgent" or .id == "contextual-comma" + or .id == "contextual-not-urgent" or .id == "contextual-comma" or .id == "metadata-context" or .id == "contextual-opportunity" or .id == "contextual-gated")] - | length == 7 + | length == 8 and all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) and (map(select(.id == "contextual-comma" and .hold_reason == "not urgent, choose the launch route now")) | length == 1) + and (map(select(.id == "metadata-context" and .hold_reason == "not urgent, priority: decide P1 or P2")) | length == 1) ' >/dev/null || fail "contextual parked-style wording must not hide current decisions: $out" out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) From 2d4368c3049819fbdc5a3ee3ecc708b1b9f076c3 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 17:34:04 -0700 Subject: [PATCH 28/47] no-mistakes(review): Resurface due dated captain holds --- bin/fm-bearings-snapshot.sh | 10 +++++-- tests/fm-bearings-snapshot.test.sh | 46 +++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 3ac5f02e5b0..961d460d011 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -336,7 +336,9 @@ MODEL=$(printf '%s' "$SNAP" | jq \ def trunc($n): if . == null then null else (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; def live_captain_call: - (.deferred_marker != true) and (.aged_undated_hold != true); + (.aged_undated_hold != true) + and ((.deferred_marker != true) + or ((.hold_until // null) != null and .hold_until <= $today)); def projected_undated_hold: (.hold_until == null) and (.deferred_marker == true or .aged_undated_hold == true); @@ -446,10 +448,12 @@ MODEL=$(printf '%s' "$SNAP" | jq \ summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ]) as $decisions_all | ([ .backlog.records[] | select(.structured and .captain_actionable == true - and (.deferred_marker == true or .aged_undated_hold == true)) ] + and (.deferred_marker == true or .aged_undated_hold == true) + and (live_captain_call | not)) ] + [ (.secondmate_current.records // [])[] | .decisions_open[]? | select(.source == "backlog" and .verb == "captain-hold" - and (.deferred_marker == true or .aged_undated_hold == true)) ] + and (.deferred_marker == true or .aged_undated_hold == true) + and (live_captain_call | not)) ] | length) as $decisions_marked_deferred | ((if (.main_inventory.valid == false) then [{id:"(main-inventory)", diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 610018de75a..284945837e2 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1164,14 +1164,35 @@ EOF } test_undated_hold_phrasing_and_aging_projection() { - local home fakebin json + local home mate fakebin json home=$(make_home undated-aging-proj) - mkdir -p "$home/data" + mate=$(fixture_mate_home "$home") + mkdir -p "$home/data" "$mate/data" "$mate/state" "$mate/config" "$mate/projects" "$mate/bin" + printf '# Firstmate fixture\n' > "$mate/AGENTS.md" + printf 'aging-mate\n' > "$mate/.fm-secondmate-home" + printf -- '- aging-mate - hold aging fixture (home: %s; scope: captain holds; projects: firstmate; added 2026-07-11)\n' \ + "$mate" > "$home/data/secondmates.md" + fm_write_meta "$home/state/aging-mate.meta" \ + "kind=secondmate" "mode=secondmate" "harness=pi" "home=$mate" "projects=firstmate" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] mate-parked - Remote parked call (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) +- [ ] mate-future-parked - Remote parked call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-08-01) +- [ ] mate-due-parked - Remote parked call now due (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) +- [ ] mate-aged - Remote aged call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a remote route) (hold-kind: captain) + Captain hold set: 2026-06-01T00:00:00Z + +## Done +EOF cat > "$home/data/backlog.md" <<'EOF' ## In flight ## Queued - [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-07-10) (hold: not urgent) (hold-kind: captain) +- [ ] future-parked - Parked style call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-08-01) +- [ ] due-parked - Parked style call now due (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) - [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) @@ -1205,6 +1226,10 @@ EOF json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) + and (.decisions_open | any(.[]; .id == "due-parked")) + and (.decisions_open | any(.[]; .id == "future-parked") | not) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-future-parked") | not) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) @@ -1216,15 +1241,25 @@ EOF and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) and (.gates | any(.[]; .id == "parked-hold" and .reason == "not urgent")) + and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) + and (.gates | any(.[]; .id == "due-parked") | not) and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "legacy-old-hold" and (.reason | startswith("held 40d")))) + and (.gates | any(.[]; .id == "mate-parked" and .owner == "aging-mate" and .reason == "parked")) + and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) + and (.gates | any(.[]; .id == "mate-due-parked" and .owner == "aging-mate") | not) + and (.gates | any(.[]; .id == "mate-aged" and .owner == "aging-mate" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "recent-call") | not) - and (.omitted | any(.[]; .surface | startswith("captain holds marked deferred"))) + and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 5")) ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "parked-hold")) and (.decisions_open | any(.[]; .id == "aged-call")) + and (.decisions_open | any(.[]; .id == "due-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-aged")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-parked")) and (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) @@ -1234,7 +1269,10 @@ EOF and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) - and (.gates | any(.[]; .id == "parked-hold" or .id == "aged-call" or .id == "legacy-old-hold") | not) + and (.gates | any(.[]; .id == "parked-hold" or .id == "aged-call" or .id == "legacy-old-hold" + or .id == "mate-parked" or .id == "mate-aged" or .id == "mate-due-parked") | not) + and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) + and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) ' >/dev/null || fail "--all-decisions must reveal parked-style and aged holds without duplicating their gates: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' From 621f18e2f946af6db661652c11446bf2793b6f87 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 17:40:14 -0700 Subject: [PATCH 29/47] no-mistakes(review): Distinguish parked holds from explicit deferrals --- bin/fm-bearings-snapshot.sh | 14 ++++++++------ bin/fm-fleet-snapshot.sh | 9 +++++++-- tests/fm-bearings-snapshot.test.sh | 17 +++++++++++++++-- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 961d460d011..eb8f8e4f4e5 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -337,11 +337,13 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; def live_captain_call: (.aged_undated_hold != true) + and (.explicit_deferred_marker != true) and ((.deferred_marker != true) - or ((.hold_until // null) != null and .hold_until <= $today)); - def projected_undated_hold: - (.hold_until == null) - and (.deferred_marker == true or .aged_undated_hold == true); + or (.parked_style_marker == true + and (.hold_until // null) != null and .hold_until <= $today)); + def projected_deferred_hold: + (.deferred_marker == true or .aged_undated_hold == true) + and (live_captain_call | not); def hold_gate_reason: if .aged_undated_hold == true and .hold_age_days != null then ("held " + (.hold_age_days | tostring) + "d: " + (.hold_reason // .blocked_reason // "-")) @@ -473,7 +475,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | as_gate("(main)") ] + [ .backlog.records[] | select($all_decisions == 0 and .structured and .captain_actionable == true - and projected_undated_hold) + and projected_deferred_hold) | as_gate("(main)") ] + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") @@ -486,7 +488,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select($m.provenance.selected == "structured-home") | $m.queued[]? | select($all_decisions == 0 and .captain_actionable == true - and projected_undated_hold) + and projected_deferred_hold) | as_gate($m.id) ]) as $gates_all | ([ .scout_reports[] | . as $r diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index d8d0fc901a7..c70451ce2ba 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -523,10 +523,11 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | .aged_undated_hold = (.hold_kind == "captain" and .hold_until == null and .hold_age_days != null and .hold_age_days >= $age_days) - | .deferred_marker = + | .explicit_deferred_marker = (((.hold_reason // "") | explicitly_deferred) - or ((.hold_reason // "") | parked_style_reason) or ((.body_excerpt // "") | explicitly_deferred)) + | .parked_style_marker = ((.hold_reason // "") | parked_style_reason) + | .deferred_marker = (.explicit_deferred_marker or .parked_style_marker) else . end) | del(.section,.order) ' < "$backlog" @@ -952,6 +953,8 @@ secondmate_home_summary_json() { # reason:(.hold_reason | trunc(160)), hold_until:(.hold_until // null), deferred_marker:(.deferred_marker // false), + explicit_deferred_marker:(.explicit_deferred_marker // false), + parked_style_marker:(.parked_style_marker // false), aged_undated_hold:(.aged_undated_hold // false), hold_age_days:(.hold_age_days // null),source:"backlog"} ]) as $captain_holds_all | ([ $backlog.records[]? | select(.state == "done" and .structured and .hold_kind != "captain") @@ -1061,6 +1064,8 @@ secondmate_home_summary_json() { # hold_kind:((.hold_kind // null) | if . == null then null else trunc(40) end), hold_until:((.hold_until // null) | if . == null then null else trunc(40) end), deferred_marker:(.deferred_marker // false), + explicit_deferred_marker:(.explicit_deferred_marker // false), + parked_style_marker:(.parked_style_marker // false), aged_undated_hold:(.aged_undated_hold // false), hold_age_days:(.hold_age_days // null), captain_actionable:(.captain_actionable // false), diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 284945837e2..ef8c7573c49 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1181,6 +1181,8 @@ test_undated_hold_phrasing_and_aging_projection() { - [ ] mate-parked - Remote parked call (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) - [ ] mate-future-parked - Remote parked call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-08-01) - [ ] mate-due-parked - Remote parked call now due (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) +- [ ] mate-due-not-required - Remote moot call now due (repo: firstmate) (kind: captain) (hold: choose a remote route) (hold-kind: captain) (hold-until: 2026-07-11) + NOT REQUIRED - the remote decision is moot. - [ ] mate-aged - Remote aged call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a remote route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z @@ -1193,6 +1195,7 @@ EOF - [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-07-10) (hold: not urgent) (hold-kind: captain) - [ ] future-parked - Parked style call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-08-01) - [ ] due-parked - Parked style call now due (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) +- [ ] due-superseded - Superseded call now due (repo: firstmate) (kind: captain) (hold: SUPERSEDED) (hold-kind: captain) (hold-until: 2026-07-11) - [ ] aged-call - Aged genuine call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a sample route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z - [ ] recent-call - Recent genuine call (repo: firstmate) (kind: captain) (since 2026-07-10) (hold: choose a sample route) (hold-kind: captain) @@ -1228,8 +1231,10 @@ EOF (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "due-parked")) and (.decisions_open | any(.[]; .id == "future-parked") | not) + and (.decisions_open | any(.[]; .id == "due-superseded") | not) and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-parked")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-future-parked") | not) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-not-required") | not) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) @@ -1243,23 +1248,30 @@ EOF and (.gates | any(.[]; .id == "parked-hold" and .reason == "not urgent")) and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "due-parked") | not) + and (.gates | any(.[]; .id == "due-superseded" and .reason == "SUPERSEDED")) and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "legacy-old-hold" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "mate-parked" and .owner == "aging-mate" and .reason == "parked")) and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "mate-due-parked" and .owner == "aging-mate") | not) + and (.gates | any(.[]; .id == "mate-due-not-required" and .owner == "aging-mate" and .reason == "choose a remote route")) and (.gates | any(.[]; .id == "mate-aged" and .owner == "aging-mate" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "recent-call") | not) - and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 5")) + and ([.decisions_open[].id, .gates[].id] + | contains(["due-parked", "due-superseded", "future-parked", "parked-hold", "aged-call", + "aging-mate/mate-due-parked", "mate-due-not-required", "mate-future-parked", "mate-parked", "mate-aged"])) + and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 7")) ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "parked-hold")) and (.decisions_open | any(.[]; .id == "aged-call")) and (.decisions_open | any(.[]; .id == "due-parked")) + and (.decisions_open | any(.[]; .id == "due-superseded")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-parked")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-aged")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-not-required")) and (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) @@ -1270,7 +1282,8 @@ EOF and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) and (.gates | any(.[]; .id == "parked-hold" or .id == "aged-call" or .id == "legacy-old-hold" - or .id == "mate-parked" or .id == "mate-aged" or .id == "mate-due-parked") | not) + or .id == "due-superseded" or .id == "mate-parked" or .id == "mate-aged" + or .id == "mate-due-parked" or .id == "mate-due-not-required") | not) and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) ' >/dev/null || fail "--all-decisions must reveal parked-style and aged holds without duplicating their gates: $json" From a7801d705e2808460a776ca8fca487d120b90282 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 17:54:19 -0700 Subject: [PATCH 30/47] no-mistakes(review): Invalidate legacy secondmate summary caches --- bin/fm-fleet-snapshot.sh | 4 +-- bin/fm-home-summary-refresh.sh | 4 +-- tests/fm-bearings-snapshot.test.sh | 51 +++++++++++++++++++++++---- tests/fm-fleet-snapshot-view.test.sh | 2 +- tests/fm-home-summary-refresh.test.sh | 6 ++-- tests/fm-secondmate-reconcile.test.sh | 2 +- tests/fm-session-start.test.sh | 2 +- tests/fm-teardown.test.sh | 2 +- tests/fm-trace-context-spawn.test.sh | 2 +- 9 files changed, 57 insertions(+), 18 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index c70451ce2ba..e3e070c9188 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -1044,7 +1044,7 @@ secondmate_home_summary_json() { # elif ($holds_all | length) > 0 then "externally_held" else "no_active_work" end) as $state | { - schema:"fm-secondmate-home-summary.v1", + schema:"fm-secondmate-home-summary.v2", generated:$generated, generated_epoch:$generated_epoch, home:$home, @@ -1316,7 +1316,7 @@ prepare_remote_summary_collection() { # SNAPSHOT_SUMMARY_FILTER="$SNAPSHOT_COLLECT_DIR/summary-filter.jq" cat > "$SNAPSHOT_SUMMARY_FILTER" <<'JQ' length == 1 and (.[0] | - .schema == "fm-secondmate-home-summary.v1" and .home == $home + .schema == "fm-secondmate-home-summary.v2" and .home == $home and (.generated | type) == "string" and (.generated_epoch | type) == "number" and .generated_epoch >= 0 and (.generated_epoch | floor) == .generated_epoch and (.valid | type) == "boolean" and (.state | type) == "string" diff --git a/bin/fm-home-summary-refresh.sh b/bin/fm-home-summary-refresh.sh index 23813c96a3b..82dc2d6640c 100755 --- a/bin/fm-home-summary-refresh.sh +++ b/bin/fm-home-summary-refresh.sh @@ -5,7 +5,7 @@ # # The published state/home-summary.json is the exact # `fm-fleet-snapshot.sh --secondmate-home-summary` document for this FM_HOME. -# Its schema remains `fm-secondmate-home-summary.v1` and includes both the +# Its schema is `fm-secondmate-home-summary.v2` and includes both the # existing generated timestamp and generated_epoch for freshness arithmetic. # # Publication is atomic: the producer writes and validates a unique mode-0600 @@ -149,7 +149,7 @@ home_summary_refresh_once() { rm -f -- "$HOME_SUMMARY_ERR_TMP" HOME_SUMMARY_ERR_TMP= if ! jq -e --arg home "$FM_HOME" ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and .home == $home and (.generated | type) == "string" and (.generated | length) > 0 diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index ef8c7573c49..43a47dfe68b 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -216,11 +216,19 @@ write_remote_home_summary() { # local home=$1 epoch=$2 mkdir -p "$home/state" jq -n --arg home "$home" --argjson epoch "$epoch" '{ - schema:"fm-secondmate-home-summary.v1", + schema:"fm-secondmate-home-summary.v2", generated:"2026-09-01T22:00:00Z",generated_epoch:$epoch,home:$home, - valid:true,reason:null,invalidity:{kind:null,ids:[]},state:"no_active_work", - active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[], - counts:{active_children:0,decisions_open:0,holds:0,queued:0,landed:0,endpoints:0},omitted:[] + valid:true,reason:null,invalidity:{kind:null,ids:[]},state:"captain_decision", + active_children:[], + decisions_open:[ + {id:"remote-parked",key:"remote-parked",verb:"captain-hold",summary:"Remote parked hold",reason:"parked",hold_until:null,deferred_marker:true,explicit_deferred_marker:false,parked_style_marker:true,aged_undated_hold:false,hold_age_days:null,source:"backlog"}, + {id:"remote-aged",key:"remote-aged",verb:"captain-hold",summary:"Remote aged hold",reason:"choose a route",hold_until:null,deferred_marker:false,explicit_deferred_marker:false,parked_style_marker:false,aged_undated_hold:true,hold_age_days:40,source:"backlog"} + ],holds:[], + queued:[ + {id:"remote-parked",title:"Remote parked hold",blocked_by:null,blocked_by_ids:[],unresolved_blocker_ids:[],blocked_reason:null,hold_reason:"parked",hold_kind:"captain",hold_until:null,deferred_marker:true,explicit_deferred_marker:false,parked_style_marker:true,aged_undated_hold:false,hold_age_days:null,captain_actionable:true,repo:"firstmate",kind:"captain"}, + {id:"remote-aged",title:"Remote aged hold",blocked_by:null,blocked_by_ids:[],unresolved_blocker_ids:[],blocked_reason:null,hold_reason:"choose a route",hold_kind:"captain",hold_until:null,deferred_marker:false,explicit_deferred_marker:false,parked_style_marker:false,aged_undated_hold:true,hold_age_days:40,captain_actionable:true,repo:"firstmate",kind:"captain"} + ],landed:[],endpoints:[], + counts:{active_children:0,decisions_open:2,holds:0,queued:2,landed:0,endpoints:0},omitted:[] }' > "$home/state/home-summary.json" } @@ -2574,7 +2582,7 @@ SH } test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { - local parent fakebin json i remote_home pid collector_pid sleeper_pid duplicate_base + local parent fakebin json i remote_home pid collector_pid sleeper_pid duplicate_base cache_file candidate tmp parent=$(make_home concurrent-remote-ledgers) make_remote_ledger_fleet "$parent" 5 fakebin=$(make_remote_ledger_ssh "$parent/remote-ssh") @@ -2588,7 +2596,38 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { printf '%s' "$json" | jq -e ' (.secondmates | length) == 5 and all(.secondmates[]; .freshness == "fresh" and .age_seconds == 100) - ' >/dev/null || fail "healthy remote ledgers did not project their generated-epoch ages: $json" + and (.decisions_open | all(.owner != "ledger-1")) + and (.gates | any(.id == "remote-parked" and .owner == "ledger-1" and .reason == "parked")) + and (.gates | any(.id == "remote-aged" and .owner == "ledger-1" and (.reason | startswith("held 40d")))) + ' >/dev/null || fail "healthy remote ledgers did not project their generated-epoch ages and deferred holds: $json" + + cache_file= + remote_home=$(cd "$TMP_ROOT/remote-ledger-home-1" && pwd -P) + for candidate in "$parent/state/summary-cache"/*.json; do + if jq -e --arg home "$remote_home" '.home == $home' "$candidate" >/dev/null 2>&1; then + cache_file=$candidate + break + fi + done + [ -n "$cache_file" ] || fail "healthy remote read did not populate its summary cache" + tmp="$cache_file.tmp" + jq '.schema = "fm-secondmate-home-summary.v1"' "$cache_file" > "$tmp" && mv "$tmp" "$cache_file" + tmp="$remote_home/state/home-summary.json.tmp" + jq '.schema = "fm-secondmate-home-summary.v1"' "$remote_home/state/home-summary.json" > "$tmp" \ + && mv "$tmp" "$remote_home/state/home-summary.json" + json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) + printf '%s' "$json" | jq -e ' + (.secondmates | any(.id == "ledger-1" and .state == "unknown" + and .provenance == "unknown" and (.reason | contains("no valid cached copy")))) + and (.omitted | any(.surface == "secondmate home(s) with unreadable structured state: 1")) + ' >/dev/null || fail "a pre-change live and cached summary was not invalidated and disclosed: $json" + write_remote_home_summary "$remote_home" 1000 + json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) + printf '%s' "$json" | jq -e ' + (.secondmates | any(.id == "ledger-1" and .freshness == "fresh")) + and (.gates | any(.id == "remote-parked" and .owner == "ledger-1")) + and (.gates | any(.id == "remote-aged" and .owner == "ledger-1")) + ' >/dev/null || fail "a refreshed current-schema summary did not restore deferred remote holds: $json" duplicate_base="$TMP_ROOT/remote-ledger-home-1/state/home-summary.single" cp "$TMP_ROOT/remote-ledger-home-1/state/home-summary.json" "$duplicate_base" diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 96932876456..3c9aaa28490 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -920,7 +920,7 @@ EOF fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --secondmate-home-summary) printf '%s' "$out" | jq -e ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and .valid == true and .reason == null and .invalidity == {kind:null,ids:[]} diff --git a/tests/fm-home-summary-refresh.test.sh b/tests/fm-home-summary-refresh.test.sh index 77b2244b877..dd31d45958f 100755 --- a/tests/fm-home-summary-refresh.test.sh +++ b/tests/fm-home-summary-refresh.test.sh @@ -124,7 +124,7 @@ wait_for_ledger_generation() { # [tenths] run_writer "$NOW_ONE" "$EPOCH_ONE" || fail "initial home-summary publication failed" jq -e --arg home "$HOME_DIR" --arg now "$NOW_ONE" --argjson epoch "$EPOCH_ONE" ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and .home == $home and .generated == $now and .generated_epoch == $epoch @@ -659,7 +659,7 @@ PATH="$FAKEBIN:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$COST_HOME" \ [ -f "$COST_HOME/state/home-summary.json" ] \ || fail "an accumulated home did not publish within a 30-second deadline: $(cat "$COST_HOME/state/.home-summary-refresh.log" 2>/dev/null)" jq -e --arg home "$COST_HOME" ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and .home == $home and any(.decisions_open[]; .key == "cost-gate") ' "$COST_HOME/state/home-summary.json" >/dev/null \ @@ -721,7 +721,7 @@ elapsed=$(( $(date +%s) - started )) [ ! -e "$TMP_ROOT/stalled-ssh.called" ] \ || fail "the producer issued a remote per-task state probe" jq -e ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and .valid == false and .state == "unknown" and .invalidity.kind == "child_current_unavailable" diff --git a/tests/fm-secondmate-reconcile.test.sh b/tests/fm-secondmate-reconcile.test.sh index 0d2e16fa2c1..6f4fadb1f42 100755 --- a/tests/fm-secondmate-reconcile.test.sh +++ b/tests/fm-secondmate-reconcile.test.sh @@ -880,7 +880,7 @@ test_bearings_request_returns_before_remote_delivery_and_supervision_sends_later rhome=$(cd "$rhome" && pwd -P) home=$(make_remote_parent_home remote-offpath remote-offpath-mate "$rhome" remote-offpath-host) jq -n --arg home "$rhome" '{ - schema:"fm-secondmate-home-summary.v1",generated:"2026-09-01T22:00:00Z",generated_epoch:1900,home:$home, + schema:"fm-secondmate-home-summary.v2",generated:"2026-09-01T22:00:00Z",generated_epoch:1900,home:$home, valid:false,reason:"in-flight backlog item has no child metadata: stale-row", invalidity:{kind:"orphan_in_flight",ids:["stale-row"]},state:"no_active_work", active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[], diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 3436fbd8b30..307804dc0ae 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -715,7 +715,7 @@ EOF out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") jq -e --arg home "$home" ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and .home == $home and (.generated_epoch | type) == "number" ' "$home/state/home-summary.json" >/dev/null \ diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 940a2055db8..34cd4f492e7 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -596,7 +596,7 @@ test_local_only_fork_remote_allows() { [ "$(cat "$case_dir/state/.branch-outcome-index-ready")" = 1 ] \ || fail "fork-allow: post-teardown branch report did not publish its ready sequence" jq -e --arg id task-x1 ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and all(.endpoints[]; .id != $id) ' "$case_dir/state/home-summary.json" >/dev/null \ || fail "successful task teardown did not publish the task's removal from the home summary ledger" diff --git a/tests/fm-trace-context-spawn.test.sh b/tests/fm-trace-context-spawn.test.sh index 567a2e0adcc..5293cd47da9 100755 --- a/tests/fm-trace-context-spawn.test.sh +++ b/tests/fm-trace-context-spawn.test.sh @@ -267,7 +267,7 @@ test_enabled_records_and_injects_identical_carrier_before_launch() { assert_contains "$out" "spawned $CASE_ID" "enabled spawn should report success" meta="$HOME_DIR/state/$CASE_ID.meta" jq -e --arg id "$CASE_ID" ' - .schema == "fm-secondmate-home-summary.v1" + .schema == "fm-secondmate-home-summary.v2" and any(.endpoints[]; .id == $id) ' "$HOME_DIR/state/home-summary.json" >/dev/null \ || fail "successful task spawn did not publish the task in the home summary ledger" From e57c7ef136364e00636f77a5e705ee8fbe646384 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 18:12:59 -0700 Subject: [PATCH 31/47] no-mistakes(review): Keep blocked deferred holds in Charted Next --- bin/fm-bearings-snapshot.sh | 2 ++ tests/fm-bearings-snapshot.test.sh | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index eb8f8e4f4e5..1da26f440f6 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -471,6 +471,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (.state == "in_flight" and .current_role == "held" and ($working_ids | index($record.id) | not)))) | select(.captain_actionable != true) | select(($all_queued == 1) or (.deferred_marker != true) + or (.hold_kind == "captain" and .deferred_marker == true) or ((.hold_until // null) != null and .hold_until > $today)) | as_gate("(main)") ] + [ .backlog.records[] @@ -482,6 +483,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | $m.queued[]? | select(.captain_actionable != true) | select(($all_queued == 1) or (.deferred_marker != true) + or (.hold_kind == "captain" and .deferred_marker == true) or ((.hold_until // null) != null and .hold_until > $today)) | as_gate($m.id) ] + [ (.secondmate_current.records // [])[] as $m diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 43a47dfe68b..a9b0c0537bc 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1187,6 +1187,7 @@ test_undated_hold_phrasing_and_aging_projection() { ## Queued - [ ] mate-parked - Remote parked call (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) +- [ ] mate-blocked-parked - Remote blocked parked call blocked-by: missing-remote-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) - [ ] mate-future-parked - Remote parked call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-08-01) - [ ] mate-due-parked - Remote parked call now due (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) - [ ] mate-due-not-required - Remote moot call now due (repo: firstmate) (kind: captain) (hold: choose a remote route) (hold-kind: captain) (hold-until: 2026-07-11) @@ -1201,6 +1202,7 @@ EOF ## Queued - [ ] parked-hold - Parked style call (repo: firstmate) (kind: ship) (since 2026-07-10) (hold: not urgent) (hold-kind: captain) +- [ ] blocked-parked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) - [ ] future-parked - Parked style call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-08-01) - [ ] due-parked - Parked style call now due (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) - [ ] due-superseded - Superseded call now due (repo: firstmate) (kind: captain) (hold: SUPERSEDED) (hold-kind: captain) (hold-until: 2026-07-11) @@ -1254,20 +1256,22 @@ EOF and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) and (.gates | any(.[]; .id == "parked-hold" and .reason == "not urgent")) + and (.gates | any(.[]; .id == "blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "due-parked") | not) and (.gates | any(.[]; .id == "due-superseded" and .reason == "SUPERSEDED")) and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "legacy-old-hold" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "mate-parked" and .owner == "aging-mate" and .reason == "parked")) + and (.gates | any(.[]; .id == "mate-blocked-parked" and .owner == "aging-mate" and .blocked_by == "missing-remote-blocker" and .reason == "parked")) and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "mate-due-parked" and .owner == "aging-mate") | not) and (.gates | any(.[]; .id == "mate-due-not-required" and .owner == "aging-mate" and .reason == "choose a remote route")) and (.gates | any(.[]; .id == "mate-aged" and .owner == "aging-mate" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "recent-call") | not) and ([.decisions_open[].id, .gates[].id] - | contains(["due-parked", "due-superseded", "future-parked", "parked-hold", "aged-call", - "aging-mate/mate-due-parked", "mate-due-not-required", "mate-future-parked", "mate-parked", "mate-aged"])) + | contains(["due-parked", "due-superseded", "future-parked", "parked-hold", "blocked-parked", "aged-call", + "aging-mate/mate-due-parked", "mate-due-not-required", "mate-future-parked", "mate-parked", "mate-blocked-parked", "mate-aged"])) and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 7")) ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" json=$(run "$home" "$fakebin" --json --all-decisions) @@ -1293,7 +1297,9 @@ EOF or .id == "due-superseded" or .id == "mate-parked" or .id == "mate-aged" or .id == "mate-due-parked" or .id == "mate-due-not-required") | not) and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) + and (.gates | any(.[]; .id == "blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) + and (.gates | any(.[]; .id == "mate-blocked-parked" and .owner == "aging-mate" and .blocked_by == "missing-remote-blocker" and .reason == "parked")) ' >/dev/null || fail "--all-decisions must reveal parked-style and aged holds without duplicating their gates: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' From 3ada675e0371d3ff050b3ab47631c2caeb7d56ea Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 18:21:09 -0700 Subject: [PATCH 32/47] no-mistakes(review): Count blocked deferred holds in omission disclosure --- bin/fm-bearings-snapshot.sh | 10 +++++++--- tests/fm-bearings-snapshot.test.sh | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 1da26f440f6..6b15d81d222 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -449,11 +449,15 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | {id:($m.id + "/" + .id),key,verb, summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ]) as $decisions_all | ([ .backlog.records[] - | select(.structured and .captain_actionable == true + | . as $record + | select(.structured and .hold_kind == "captain" + and (.state == "queued" or + (.state == "in_flight" and .current_role == "held" + and ($working_ids | index($record.id) | not))) and (.deferred_marker == true or .aged_undated_hold == true) and (live_captain_call | not)) ] - + [ (.secondmate_current.records // [])[] | .decisions_open[]? - | select(.source == "backlog" and .verb == "captain-hold" + + [ (.secondmate_current.records // [])[] | .queued[]? + | select(.hold_kind == "captain" and (.deferred_marker == true or .aged_undated_hold == true) and (live_captain_call | not)) ] | length) as $decisions_marked_deferred diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index a9b0c0537bc..d49c5fb3bef 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1272,7 +1272,7 @@ EOF and ([.decisions_open[].id, .gates[].id] | contains(["due-parked", "due-superseded", "future-parked", "parked-hold", "blocked-parked", "aged-call", "aging-mate/mate-due-parked", "mate-due-not-required", "mate-future-parked", "mate-parked", "mate-blocked-parked", "mate-aged"])) - and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 7")) + and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 11")) ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' @@ -1309,6 +1309,26 @@ EOF pass "parked-style and aged undated captain holds project to Charted Next/omitted, not live Captain's Call" } +test_blocked_deferred_hold_has_concrete_disclosure() { + local home fakebin json + home=$(make_home blocked-deferred-disclosure) + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] only-blocked-parked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) + +## Done +EOF + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.gates | any(.id == "only-blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) + and (.omitted | any(.surface == "captain holds marked deferred, superseded, or aged: 1")) + ' >/dev/null || fail "a lone blocked deferred hold lacked its concrete disclosure: $json" + pass "a blocked deferred hold is counted in its concrete omission disclosure" +} + test_include_prs_is_the_only_fetch_path() { local home fakebin json home=$(make_home prs); write_fixture "$home" @@ -2782,6 +2802,7 @@ test_perl_fallback_bounds_github_call test_section_caps_and_expansion_flags test_collapsed_captain_call_deferral_and_landed test_undated_hold_phrasing_and_aging_projection +test_blocked_deferred_hold_has_concrete_disclosure test_pr_repository_cap_and_expansion test_per_repository_pr_cap_is_disclosed test_projection_and_toon_fail_closed From d632d8c2755537dc35b418f3a1b9b6940b1961d9 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 21:13:51 -0700 Subject: [PATCH 33/47] no-mistakes(document): Correct captain-hold projection documentation --- .agents/skills/bearings/SKILL.md | 14 ++++++++------ bin/fm-bearings-snapshot.sh | 25 ++++++++++++++----------- bin/fm-fleet-snapshot.sh | 16 +++++++++++----- docs/captain-hold-lifecycle.md | 8 +++++--- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 94665dd85a0..e0a44bab02f 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -44,10 +44,12 @@ Board answers are acted on later under the normal authority rules; this skill's A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task is canonically actionable whatever its kind, before the presentation-only filters below. A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. - An actionable undated hold whose reason or bounded body excerpt carries an explicit deferred or superseded marker, or whose complete reason is a parked-style marker, leaves Captain's Call for a Charted Next gate with an `omitted` disclosure. + Once the date arrives, a complete parked-style reason becomes live again, but an explicit deferred, superseded, or not-required marker remains gated regardless of date. + An actionable undated hold whose reason or bounded body excerpt carries an explicit marker, or whose complete reason is a parked-style marker, leaves Captain's Call for a Charted Next gate with an `omitted` disclosure. Contextual parked-style wording does not suppress a live decision. + A deferred hold that is also blocked remains a disclosed Charted Next gate rather than disappearing from both buckets. An undated hold aged past the configured snapshot threshold likewise leaves Captain's Call for an age-labelled Charted Next gate; these are only presentation safety nets, and re-holding with `--until` remains the durable deferral. - Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing each revealed safety gate from Charted Next so the buckets remain exclusive, subject to the snapshot's documented cross-home summary bounds. + Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing each revealed safety gate from Charted Next so the buckets remain exclusive; non-actionable holds remain gated, and cross-home visibility remains subject to the snapshot's documented summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. Until then it stays queued with the reason. @@ -81,7 +83,7 @@ Board answers are acted on later under the normal authority rules; this skill's - **Captain's Call** - every unsuppressed open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - **Underway** - each live direct report making progress, with its current state, and the plans or main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - - **Charted Next** - queued or gated work, including aged undated captain-hold safety gates and any main-inventory integrity warning, with each item's blocker, date, age, or integrity reason. + - **Charted Next** - queued or gated work, including deferred or aged captain-hold safety gates and any main-inventory integrity warning, with each item's blocker, date, age, or integrity reason. After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section. For a richer review surface, offer `/bearings lavish` when the report has enough structure to deserve one, but only after the required digest is ready. @@ -129,13 +131,13 @@ This skill is the one owner of the `/bearings` chat-response format; the snapsho Every `/bearings` chat response renders EXACTLY these four sections, in THIS order, and nothing else structural (there is no At Anchor section): 1. **Captain's Call** - ONLY unsuppressed items that need the captain's own action now: a decision to make, a PR to approve or merge, a credential or login to provide, or a blocker only the captain can clear. - Aged undated holds and parked-style holds follow the presentation safety rule above instead. + Deferred or aged holds follow the presentation safety rule above instead. Empty-state: "Nothing needs your action right now." 2. **Recently Landed** - the bounded current recent-completions baseline: merged PRs, completed scouts, and finished local-only merges across the main fleet and every registered secondmate home. Empty-state: "No recent completions are in the current baseline." 3. **Underway** - live work progressing on its own, one line of current state per direct report. Empty-state: "Nothing is underway." -4. **Charted Next** - queued or gated work waiting on the fleet or a date, aged undated captain-hold safety gates, plus action-free fleet-integrity warnings. +4. **Charted Next** - queued or gated work waiting on the fleet or a date, deferred or aged captain-hold safety gates, plus action-free fleet-integrity warnings. Empty-state: "Nothing is queued." Rules that keep the contract unambiguous: @@ -143,7 +145,7 @@ Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. - Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. -- The four buckets are mutually exclusive per item: an unsuppressed needs-your-action item is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work, an aged undated captain-hold safety gate, or an action-free fleet-integrity warning is Charted Next. +- The four buckets are mutually exclusive per item: an unsuppressed needs-your-action item is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work, a deferred or aged captain-hold safety gate, or an action-free fleet-integrity warning is Charted Next. - A secondmate home can contribute to more than one section at once. Each active child is an Underway row regardless of the home-level `bearings_state`, while that same home's due captain hold is Captain's Call and its queued or external holds stay Charted Next. Do not hide active children because the home also has an open captain hold. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own home-level row is not an Underway unit: `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 6b15d81d222..8cbeff5457c 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -27,10 +27,12 @@ # held for the captain, whatever its kind, before the presentation-only filters # below. A captain hold deferred by date # (hold-until in the future) is not actionable and renders as a Charted Next -# gate with its date; an actionable undated row the canonical snapshot marks -# prose-deferred (deferred_marker) leaves Captain's Call for a Charted Next gate -# and is disclosed in omitted[]. --all-decisions reveals it without retaining -# the gate, while --all-queued reveals a non-actionable prose-deferred row. +# gate with its date. Once that date arrives, a complete parked-style reason +# becomes live again, but an explicit SUPERSEDED / NOT REQUIRED / DEFERRED +# marker remains gated. Any prose-deferred row, including one that is also +# blocked, renders as a Charted Next gate and is disclosed in omitted[]. +# --all-decisions reveals actionable presentation-filtered rows without retaining +# their gates; non-actionable rows remain gated. # Underway (in_flight) projects every main live worker plus every active child # from every readable secondmate ledger, independently of that home's # bearings_state. A home classified captain_decision because it has an open @@ -38,10 +40,11 @@ # the home row on secondmates[] keeps the decision and gate classification. # An undated captain hold the canonical snapshot marks aged_undated_hold # (recorded hold-set timestamp at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old, -# falling back to since for a legacy unstamped hold) leaves default Captain's -# Call, renders as a Charted Next gate showing its floored age, is disclosed in -# omitted[], and is revealed by --all-decisions without retaining its safety -# gate. Parked-style and aging hints use the same undated-hold gate projection. +# falling back to since for a legacy unstamped hold) renders as a Charted Next +# gate showing its floored age and is disclosed in omitted[]. When actionable, +# it leaves default Captain's Call and --all-decisions reveals it without +# retaining its safety gate; a non-actionable row remains gated. Parked-style +# and aging hints use the same undated-hold gate projection. # This is a projection safety net only; the durable deferral remains re-holding # with --until. # @@ -67,11 +70,11 @@ # --include-prs ALSO do live GitHub open-PR discovery + checks # --fields opt in to dropped surfaces: bodies,paths,actions,endpoints # --all-in-flight include every in-flight task -# --all-decisions include every open decision present in the bounded snapshot +# --all-decisions include every actionable open decision present in the bounded snapshot # --all-secondmates include every aggregated secondmate record # --all-landed include every landed record from every home (default: bounded) # --all-reports include the full scout-report inventory (default: relevant only) -# --all-queued include every prose-deferred queued item present in the bounded snapshot +# --all-queued include every queued gate present in the bounded snapshot # --all-recorded-prs include every locally recorded PR # --all-unhealthy include every unhealthy endpoint # --all-pr-repos query every discovered repository under --include-prs @@ -147,7 +150,7 @@ For every registered secondmate, readable structured facts from its own home are evidence and never become current work. The provenance and freshness fields distinguish live and cached ledgers; a home without either is explicitly unreadable. Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, - --all-decisions (all open decisions present in the bounded snapshot), + --all-decisions (all actionable open decisions present in the bounded snapshot), --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). Raise FM_BEARINGS_PR_LIMIT to expand per-repository open-PR results. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index e3e070c9188..c3bbedbd10a 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -45,9 +45,10 @@ # old (default 14). Legacy unstamped holds fall back to `since`. # hold_age_days is that age when computable, else null. # This is a projection safety net only: the durable deferral remains -# re-holding with --until. Renderers project an aged undated hold as a -# Charted Next gate showing its age, disclose it in omitted[], and reveal -# it with --all-decisions; it stays captain_actionable. +# re-holding with --until. Aging does not change captain_actionable. +# Renderers project an aged undated hold as a Charted Next gate showing its +# age and disclose it in omitted[]; --all-decisions reveals it only when it +# is otherwise actionable. # tasks[]: one row per task metadata record captured at snapshot start, sorted # by id. A record removed before capture is omitted. If a captured task's # generation changes while observations run, its selected metadata remains @@ -87,6 +88,9 @@ # reconcile_inventory independently of projection trust. # Actionable captain holds # appear in decisions_open; blocked captain holds remain queued with metadata. +# Structured-home input must use fm-secondmate-home-summary.v2; an older live +# ledger or cached copy is invalid and leaves the home explicitly unreadable +# until its producer publishes the current schema. # secondmate_landed: {records[],truncated[],unreadable[],partial[]} - the # compatibility landed-work roll-up derived from secondmate_current. Readable # structured homes are partial, not unreadable, when an unavailable child state @@ -233,8 +237,10 @@ count bound) and FM_SNAPSHOT_SECONDMATE_MAX_BYTES. Every sampled remote home's state/home-summary.json is fetched concurrently under one FM_SNAPSHOT_BUDGET (default 5 seconds), with a valid prior copy under FM_SNAPSHOT_CACHE_DIR used when the live read fails, is invalid, or consumes the -budget. A home with neither a valid ledger nor a valid cached copy is reported -unreadable with the reason; collection never computes a summary in that home. +budget. Only fm-secondmate-home-summary.v2 ledgers and cached copies are valid; +an older schema is rejected. A home with neither a valid current ledger nor a +valid current cached copy is reported unreadable with the reason; collection +never computes a summary in that home. Each local per-task current-state read is bounded by FM_SNAPSHOT_CREW_STATE_TIMEOUT (default 10 seconds); a read that hits the bound reports state unknown. Local task observations run concurrently, up to FM_SNAPSHOT_LOCAL_READ_CONCURRENCY (default 8). diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 02f7e6154f6..a1341a123d3 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -70,8 +70,10 @@ The durable deferral remains re-holding with `--until`. Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. `bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. -A date-deferred captain hold renders as a gate with its `until :` reason; an actionable undated prose-deferred one leaves default Captain's Call for a Charted Next gate with an `omitted[]` disclosure, while `--all-queued` reveals non-actionable prose-deferred queued rows. -An aged undated captain hold likewise leaves default Captain's Call, renders as a Charted Next gate showing its age, and is disclosed in `omitted[]`; `--all-decisions` reveals parked-style and aged holds in Captain's Call and removes their safety gates so the buckets remain exclusive. +A captain hold with a future date renders as a gate with its `until :` reason. +When the date arrives, a complete parked-style reason becomes live again, but an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker remains gated regardless of date. +A deferred or aged captain hold, including one that is also blocked, renders as a Charted Next gate and contributes to the concrete `omitted[]` disclosure. +`--all-decisions` reveals actionable parked-style and aged holds in Captain's Call and removes their safety gates so the buckets remain exclusive; non-actionable holds remain gated. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding it through the wrapper with `--until` remains the durable fix for both limits. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. @@ -113,5 +115,5 @@ It proves: cleanup of a finished task whose own row is the captain call leaves t `tests/fm-classify-decision-key.test.sh` pins `status_key_closing_verb` itself: it separates a resolution from the durable-transfer close and from a still-open key, reports the last real transition across re-openings and both key positions, and treats a prose mention as no transition. -Projection regressions live in `tests/fm-fleet-snapshot-view.test.sh` (hold-until parsing, the due gate, kind-independent captain actionability, deferred_marker, undated-hold aging, title stripping) and `tests/fm-bearings-snapshot.test.sh` (Captain's Call membership, the dated-gate rendering, prose-deferral and aged-undated suppression with disclosure, and the landed exclusion by surviving captain-hold annotations). +Projection regressions live in `tests/fm-fleet-snapshot-view.test.sh` (hold-until parsing, the due gate, kind-independent captain actionability, deferred markers, undated-hold aging, title stripping) and `tests/fm-bearings-snapshot.test.sh` (Captain's Call membership, arrived parked reasons, explicit deferrals, blocked deferred disclosure, aged-undated gates, remote-summary schema invalidation, and the landed exclusion by surviving captain-hold annotations). The exact commands and their summarized outputs are recorded in the shipping PR's evidence; run the four suites above plus `tests/fm-send-resolve-key.test.sh`, `tests/fm-bearings-board.test.sh`, and `bin/fm-lint.sh` to refresh this record. From b4851ac3c994656c0672231ceecfb1a2a9839f98 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 21:43:39 -0700 Subject: [PATCH 34/47] no-mistakes(ci): Fixed both CI failures. Updated the macOS Bearings test count to 51. Preserved the v1 summary schema for compatibility while rejecting hold-bearing summaries missing the new aging fields, preventing stale caches from restoring noisy calls. Verified fleet snapshot, Bearings snapshot (51 tests), home-summary refresh, secondmate reconciliation, Bash 3.2 parsing, and diff checks --- .github/workflows/ci.yml | 4 ++-- bin/fm-fleet-snapshot.sh | 31 ++++++++++++++++++++------- bin/fm-home-summary-refresh.sh | 4 ++-- tests/fm-bearings-snapshot.test.sh | 17 ++++++++++----- tests/fm-fleet-snapshot-view.test.sh | 2 +- tests/fm-home-summary-refresh.test.sh | 6 +++--- tests/fm-secondmate-reconcile.test.sh | 2 +- tests/fm-session-start.test.sh | 2 +- tests/fm-teardown.test.sh | 2 +- tests/fm-trace-context-spawn.test.sh | 2 +- 10 files changed, 47 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09ab94e0615..b75ae663dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -385,8 +385,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 49 ] || { - echo "::error::expected 49 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 51 ] || { + echo "::error::expected 51 Bearings tests, got $bearings_count" exit 1 } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index c3bbedbd10a..5bb1ea0f3c4 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -88,9 +88,9 @@ # reconcile_inventory independently of projection trust. # Actionable captain holds # appear in decisions_open; blocked captain holds remain queued with metadata. -# Structured-home input must use fm-secondmate-home-summary.v2; an older live -# ledger or cached copy is invalid and leaves the home explicitly unreadable -# until its producer publishes the current schema. +# Structured-home input must carry the current hold-aging fields; an older +# live ledger or cached copy containing captain holds without those fields is +# invalid and leaves the home explicitly unreadable until its producer refreshes it. # secondmate_landed: {records[],truncated[],unreadable[],partial[]} - the # compatibility landed-work roll-up derived from secondmate_current. Readable # structured homes are partial, not unreadable, when an unavailable child state @@ -237,9 +237,10 @@ count bound) and FM_SNAPSHOT_SECONDMATE_MAX_BYTES. Every sampled remote home's state/home-summary.json is fetched concurrently under one FM_SNAPSHOT_BUDGET (default 5 seconds), with a valid prior copy under FM_SNAPSHOT_CACHE_DIR used when the live read fails, is invalid, or consumes the -budget. Only fm-secondmate-home-summary.v2 ledgers and cached copies are valid; -an older schema is rejected. A home with neither a valid current ledger nor a -valid current cached copy is reported unreadable with the reason; collection +budget. Ledgers and cached copies containing captain holds must carry the +current hold-aging fields; older hold-bearing summaries are rejected. A home +with neither a valid current ledger nor a valid current cached copy is reported +unreadable with the reason; collection never computes a summary in that home. Each local per-task current-state read is bounded by FM_SNAPSHOT_CREW_STATE_TIMEOUT (default 10 seconds); a read that hits the bound reports state unknown. Local task @@ -1050,7 +1051,7 @@ secondmate_home_summary_json() { # elif ($holds_all | length) > 0 then "externally_held" else "no_active_work" end) as $state | { - schema:"fm-secondmate-home-summary.v2", + schema:"fm-secondmate-home-summary.v1", generated:$generated, generated_epoch:$generated_epoch, home:$home, @@ -1321,14 +1322,28 @@ prepare_remote_summary_collection() { # SNAPSHOT_COLLECT_DIR=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/fm-fleet-ledgers.XXXXXX") || return 1 SNAPSHOT_SUMMARY_FILTER="$SNAPSHOT_COLLECT_DIR/summary-filter.jq" cat > "$SNAPSHOT_SUMMARY_FILTER" <<'JQ' +def current_hold_aging_fields: + (.deferred_marker | type) == "boolean" + and (.explicit_deferred_marker | type) == "boolean" + and (.parked_style_marker | type) == "boolean" + and (.aged_undated_hold | type) == "boolean" + and (.hold_age_days == null + or ((.hold_age_days | type) == "number" + and (.hold_age_days | floor) == .hold_age_days)); length == 1 and (.[0] | - .schema == "fm-secondmate-home-summary.v2" and .home == $home + .schema == "fm-secondmate-home-summary.v1" and .home == $home and (.generated | type) == "string" and (.generated_epoch | type) == "number" and .generated_epoch >= 0 and (.generated_epoch | floor) == .generated_epoch and (.valid | type) == "boolean" and (.state | type) == "string" and (.invalidity | type) == "object" and (.invalidity.ids | type) == "array" and (.active_children | type) == "array" and (.decisions_open | type) == "array" and (.holds | type) == "array" and (.queued | type) == "array" + and (all(.decisions_open[]; + if .source == "backlog" and .verb == "captain-hold" + then current_hold_aging_fields else true end)) + and (all(.queued[]; + if .hold_kind == "captain" + then current_hold_aging_fields else true end)) and (.landed | type) == "array" and (.endpoints | type) == "array" and (.counts | type) == "object" and (.omitted | type) == "array" ) diff --git a/bin/fm-home-summary-refresh.sh b/bin/fm-home-summary-refresh.sh index 82dc2d6640c..23813c96a3b 100755 --- a/bin/fm-home-summary-refresh.sh +++ b/bin/fm-home-summary-refresh.sh @@ -5,7 +5,7 @@ # # The published state/home-summary.json is the exact # `fm-fleet-snapshot.sh --secondmate-home-summary` document for this FM_HOME. -# Its schema is `fm-secondmate-home-summary.v2` and includes both the +# Its schema remains `fm-secondmate-home-summary.v1` and includes both the # existing generated timestamp and generated_epoch for freshness arithmetic. # # Publication is atomic: the producer writes and validates a unique mode-0600 @@ -149,7 +149,7 @@ home_summary_refresh_once() { rm -f -- "$HOME_SUMMARY_ERR_TMP" HOME_SUMMARY_ERR_TMP= if ! jq -e --arg home "$FM_HOME" ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and .home == $home and (.generated | type) == "string" and (.generated | length) > 0 diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index d49c5fb3bef..5249cba6800 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -216,7 +216,7 @@ write_remote_home_summary() { # local home=$1 epoch=$2 mkdir -p "$home/state" jq -n --arg home "$home" --argjson epoch "$epoch" '{ - schema:"fm-secondmate-home-summary.v2", + schema:"fm-secondmate-home-summary.v1", generated:"2026-09-01T22:00:00Z",generated_epoch:$epoch,home:$home, valid:true,reason:null,invalidity:{kind:null,ids:[]},state:"captain_decision", active_children:[], @@ -2637,16 +2637,23 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { done [ -n "$cache_file" ] || fail "healthy remote read did not populate its summary cache" tmp="$cache_file.tmp" - jq '.schema = "fm-secondmate-home-summary.v1"' "$cache_file" > "$tmp" && mv "$tmp" "$cache_file" + jq '(.decisions_open[] | select(.verb == "captain-hold")) + |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days) + | (.queued[] | select(.hold_kind == "captain")) + |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days)' \ + "$cache_file" > "$tmp" && mv "$tmp" "$cache_file" tmp="$remote_home/state/home-summary.json.tmp" - jq '.schema = "fm-secondmate-home-summary.v1"' "$remote_home/state/home-summary.json" > "$tmp" \ - && mv "$tmp" "$remote_home/state/home-summary.json" + jq '(.decisions_open[] | select(.verb == "captain-hold")) + |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days) + | (.queued[] | select(.hold_kind == "captain")) + |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days)' \ + "$remote_home/state/home-summary.json" > "$tmp" && mv "$tmp" "$remote_home/state/home-summary.json" json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' (.secondmates | any(.id == "ledger-1" and .state == "unknown" and .provenance == "unknown" and (.reason | contains("no valid cached copy")))) and (.omitted | any(.surface == "secondmate home(s) with unreadable structured state: 1")) - ' >/dev/null || fail "a pre-change live and cached summary was not invalidated and disclosed: $json" + ' >/dev/null || fail "a hold-bearing live and cached summary without aging fields was not invalidated and disclosed: $json" write_remote_home_summary "$remote_home" 1000 json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 3c9aaa28490..96932876456 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -920,7 +920,7 @@ EOF fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --secondmate-home-summary) printf '%s' "$out" | jq -e ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and .valid == true and .reason == null and .invalidity == {kind:null,ids:[]} diff --git a/tests/fm-home-summary-refresh.test.sh b/tests/fm-home-summary-refresh.test.sh index dd31d45958f..77b2244b877 100755 --- a/tests/fm-home-summary-refresh.test.sh +++ b/tests/fm-home-summary-refresh.test.sh @@ -124,7 +124,7 @@ wait_for_ledger_generation() { # [tenths] run_writer "$NOW_ONE" "$EPOCH_ONE" || fail "initial home-summary publication failed" jq -e --arg home "$HOME_DIR" --arg now "$NOW_ONE" --argjson epoch "$EPOCH_ONE" ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and .home == $home and .generated == $now and .generated_epoch == $epoch @@ -659,7 +659,7 @@ PATH="$FAKEBIN:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$COST_HOME" \ [ -f "$COST_HOME/state/home-summary.json" ] \ || fail "an accumulated home did not publish within a 30-second deadline: $(cat "$COST_HOME/state/.home-summary-refresh.log" 2>/dev/null)" jq -e --arg home "$COST_HOME" ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and .home == $home and any(.decisions_open[]; .key == "cost-gate") ' "$COST_HOME/state/home-summary.json" >/dev/null \ @@ -721,7 +721,7 @@ elapsed=$(( $(date +%s) - started )) [ ! -e "$TMP_ROOT/stalled-ssh.called" ] \ || fail "the producer issued a remote per-task state probe" jq -e ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and .valid == false and .state == "unknown" and .invalidity.kind == "child_current_unavailable" diff --git a/tests/fm-secondmate-reconcile.test.sh b/tests/fm-secondmate-reconcile.test.sh index 6f4fadb1f42..0d2e16fa2c1 100755 --- a/tests/fm-secondmate-reconcile.test.sh +++ b/tests/fm-secondmate-reconcile.test.sh @@ -880,7 +880,7 @@ test_bearings_request_returns_before_remote_delivery_and_supervision_sends_later rhome=$(cd "$rhome" && pwd -P) home=$(make_remote_parent_home remote-offpath remote-offpath-mate "$rhome" remote-offpath-host) jq -n --arg home "$rhome" '{ - schema:"fm-secondmate-home-summary.v2",generated:"2026-09-01T22:00:00Z",generated_epoch:1900,home:$home, + schema:"fm-secondmate-home-summary.v1",generated:"2026-09-01T22:00:00Z",generated_epoch:1900,home:$home, valid:false,reason:"in-flight backlog item has no child metadata: stale-row", invalidity:{kind:"orphan_in_flight",ids:["stale-row"]},state:"no_active_work", active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[], diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 307804dc0ae..3436fbd8b30 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -715,7 +715,7 @@ EOF out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") jq -e --arg home "$home" ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and .home == $home and (.generated_epoch | type) == "number" ' "$home/state/home-summary.json" >/dev/null \ diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 34cd4f492e7..940a2055db8 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -596,7 +596,7 @@ test_local_only_fork_remote_allows() { [ "$(cat "$case_dir/state/.branch-outcome-index-ready")" = 1 ] \ || fail "fork-allow: post-teardown branch report did not publish its ready sequence" jq -e --arg id task-x1 ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and all(.endpoints[]; .id != $id) ' "$case_dir/state/home-summary.json" >/dev/null \ || fail "successful task teardown did not publish the task's removal from the home summary ledger" diff --git a/tests/fm-trace-context-spawn.test.sh b/tests/fm-trace-context-spawn.test.sh index 5293cd47da9..567a2e0adcc 100755 --- a/tests/fm-trace-context-spawn.test.sh +++ b/tests/fm-trace-context-spawn.test.sh @@ -267,7 +267,7 @@ test_enabled_records_and_injects_identical_carrier_before_launch() { assert_contains "$out" "spawned $CASE_ID" "enabled spawn should report success" meta="$HOME_DIR/state/$CASE_ID.meta" jq -e --arg id "$CASE_ID" ' - .schema == "fm-secondmate-home-summary.v2" + .schema == "fm-secondmate-home-summary.v1" and any(.endpoints[]; .id == $id) ' "$HOME_DIR/state/home-summary.json" >/dev/null \ || fail "successful task spawn did not publish the task in the home summary ledger" From 6a7d4c0b4e2b4d99c609c281bf9c16634011cf1e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 22:07:44 -0700 Subject: [PATCH 35/47] =?UTF-8?q?no-mistakes(ci):=20Fixed=20Greptile?= =?UTF-8?q?=E2=80=99s=20valid=20finding:=20`--all-decisions`=20now=20revea?= =?UTF-8?q?ls=20deferred/aged=20captain=20holds=20even=20when=20blocked,?= =?UTF-8?q?=20for=20both=20main=20and=20secondmate=20homes,=20and=20remove?= =?UTF-8?q?s=20their=20duplicate=20Charted=20Next=20gates.=20Added=20behav?= =?UTF-8?q?ioral=20regression=20coverage=20and=20updated=20documentation.?= =?UTF-8?q?=20The=20prose-classifier=20finding=20was=20not=20applied=20bec?= =?UTF-8?q?ause=20exact=20complete-phrase=20matching=20is=20explicitly=20r?= =?UTF-8?q?equired=20by=20the=20author=20intent;=20contextual=20wording=20?= =?UTF-8?q?remains=20live.=20Verified=20with=20Bearings=20and=20fleet=20sn?= =?UTF-8?q?apshot=20tests,=20`bin/fm-lint.sh`,=20Bash=20syntax=20checking,?= =?UTF-8?q?=20and=20`git=20diff=20--check`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/bearings/SKILL.md | 2 +- bin/fm-bearings-snapshot.sh | 36 +++++++++++++++++++++--------- docs/captain-hold-lifecycle.md | 2 +- tests/fm-bearings-snapshot.test.sh | 21 +++++++++++------ 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index e0a44bab02f..00f0bac93bd 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -49,7 +49,7 @@ Board answers are acted on later under the normal authority rules; this skill's Contextual parked-style wording does not suppress a live decision. A deferred hold that is also blocked remains a disclosed Charted Next gate rather than disappearing from both buckets. An undated hold aged past the configured snapshot threshold likewise leaves Captain's Call for an age-labelled Charted Next gate; these are only presentation safety nets, and re-holding with `--until` remains the durable deferral. - Use `--all-decisions` to reveal actionable holds hidden by either presentation hint, removing each revealed safety gate from Charted Next so the buckets remain exclusive; non-actionable holds remain gated, and cross-home visibility remains subject to the snapshot's documented summary bounds. + Use `--all-decisions` to reveal every hold hidden by either presentation hint, including blocked holds, removing each revealed safety gate from Charted Next so the buckets remain exclusive; other non-actionable holds remain gated, and cross-home visibility remains subject to the snapshot's documented summary bounds. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. Until then it stays queued with the reason. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 8cbeff5457c..fc371164bdc 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -31,8 +31,9 @@ # becomes live again, but an explicit SUPERSEDED / NOT REQUIRED / DEFERRED # marker remains gated. Any prose-deferred row, including one that is also # blocked, renders as a Charted Next gate and is disclosed in omitted[]. -# --all-decisions reveals actionable presentation-filtered rows without retaining -# their gates; non-actionable rows remain gated. +# --all-decisions reveals every presentation-filtered captain hold without retaining +# its gate, including a hold that is otherwise non-actionable because it is blocked. +# Other non-actionable rows remain gated. # Underway (in_flight) projects every main live worker plus every active child # from every readable secondmate ledger, independently of that home's # bearings_state. A home classified captain_decision because it has an open @@ -70,7 +71,7 @@ # --include-prs ALSO do live GitHub open-PR discovery + checks # --fields opt in to dropped surfaces: bodies,paths,actions,endpoints # --all-in-flight include every in-flight task -# --all-decisions include every actionable open decision present in the bounded snapshot +# --all-decisions include every open decision and presentation-filtered captain hold # --all-secondmates include every aggregated secondmate record # --all-landed include every landed record from every home (default: bounded) # --all-reports include the full scout-report inventory (default: relevant only) @@ -150,7 +151,7 @@ For every registered secondmate, readable structured facts from its own home are evidence and never become current work. The provenance and freshness fields distinguish live and cached ledgers; a home without either is explicitly unreadable. Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, - --all-decisions (all actionable open decisions present in the bounded snapshot), + --all-decisions (all open decisions and presentation-filtered captain holds), --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). Raise FM_BEARINGS_PR_LIMIT to expand per-repository open-PR results. @@ -442,15 +443,26 @@ MODEL=$(printf '%s' "$SNAP" | jq \ repo:(.repo // null), doing:((.doing // .state) | trunc(90))} ]) as $in_flight_all | ([ .backlog.records[] - | select(.structured and .captain_actionable == true) - | select(($all_decisions == 1) or live_captain_call) + | . as $record + | select(.structured and .hold_kind == "captain" + and (.state == "queued" or + (.state == "in_flight" and .current_role == "held" + and ($working_ids | index($record.id) | not)))) + | select((.captain_actionable == true and (($all_decisions == 1) or live_captain_call)) + or ($all_decisions == 1 and projected_deferred_hold)) | {id,key:.id,verb:"captain-hold", summary:((.title + ": " + .hold_reason) | trunc(90)),owner:"(main)"} ] - + [ (.secondmate_current.records // [])[] as $m | $m.decisions_open[]? - | select(.source == "backlog" and .verb == "captain-hold") - | select(($all_decisions == 1) or live_captain_call) - | {id:($m.id + "/" + .id),key,verb, - summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ]) as $decisions_all + + [ (.secondmate_current.records // [])[] as $m + | ([ $m.decisions_open[]? + | select(.source == "backlog" and .verb == "captain-hold") + | select(($all_decisions == 1) or live_captain_call) + | {id:($m.id + "/" + .id),key,verb, + summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ] + + [ $m.queued[]? + | select($all_decisions == 1 and .captain_actionable != true + and .hold_kind == "captain" and projected_deferred_hold) + | {id:($m.id + "/" + .id),key:.id,verb:"captain-hold", + summary:(((.title // .id) + ": " + (.hold_reason // "captain decision pending")) | trunc(90)),owner:$m.id} ])[] ]) as $decisions_all | ([ .backlog.records[] | . as $record | select(.structured and .hold_kind == "captain" @@ -477,6 +489,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (.state == "queued" or (.state == "in_flight" and .current_role == "held" and ($working_ids | index($record.id) | not)))) | select(.captain_actionable != true) + | select(($all_decisions == 0) or (projected_deferred_hold | not)) | select(($all_queued == 1) or (.deferred_marker != true) or (.hold_kind == "captain" and .deferred_marker == true) or ((.hold_until // null) != null and .hold_until > $today)) @@ -489,6 +502,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select($m.provenance.selected == "structured-home") | $m.queued[]? | select(.captain_actionable != true) + | select(($all_decisions == 0) or (projected_deferred_hold | not)) | select(($all_queued == 1) or (.deferred_marker != true) or (.hold_kind == "captain" and .deferred_marker == true) or ((.hold_until // null) != null and .hold_until > $today)) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index a1341a123d3..84af4ace8e9 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -73,7 +73,7 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de A captain hold with a future date renders as a gate with its `until :` reason. When the date arrives, a complete parked-style reason becomes live again, but an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker remains gated regardless of date. A deferred or aged captain hold, including one that is also blocked, renders as a Charted Next gate and contributes to the concrete `omitted[]` disclosure. -`--all-decisions` reveals actionable parked-style and aged holds in Captain's Call and removes their safety gates so the buckets remain exclusive; non-actionable holds remain gated. +`--all-decisions` reveals every parked-style and aged hold in Captain's Call, including blocked holds, and removes their safety gates so the buckets remain exclusive; other non-actionable holds remain gated. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding it through the wrapper with `--until` remains the durable fix for both limits. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 5249cba6800..84f2ae2ca68 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1284,6 +1284,10 @@ EOF and (.decisions_open | any(.[]; .id == "aging-mate/mate-aged")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-parked")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-not-required")) + and (.decisions_open | any(.[]; .id == "blocked-parked")) + and (.decisions_open | any(.[]; .id == "future-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-blocked-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-future-parked")) and (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) @@ -1294,12 +1298,10 @@ EOF and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold")) and (.gates | any(.[]; .id == "parked-hold" or .id == "aged-call" or .id == "legacy-old-hold" - or .id == "due-superseded" or .id == "mate-parked" or .id == "mate-aged" - or .id == "mate-due-parked" or .id == "mate-due-not-required") | not) - and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) - and (.gates | any(.[]; .id == "blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) - and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) - and (.gates | any(.[]; .id == "mate-blocked-parked" and .owner == "aging-mate" and .blocked_by == "missing-remote-blocker" and .reason == "parked")) + or .id == "due-superseded" or .id == "future-parked" or .id == "blocked-parked" + or .id == "mate-parked" or .id == "mate-aged" or .id == "mate-due-parked" + or .id == "mate-due-not-required" or .id == "mate-future-parked" + or .id == "mate-blocked-parked") | not) ' >/dev/null || fail "--all-decisions must reveal parked-style and aged holds without duplicating their gates: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' @@ -1326,7 +1328,12 @@ EOF (.gates | any(.id == "only-blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) and (.omitted | any(.surface == "captain holds marked deferred, superseded, or aged: 1")) ' >/dev/null || fail "a lone blocked deferred hold lacked its concrete disclosure: $json" - pass "a blocked deferred hold is counted in its concrete omission disclosure" + json=$(run "$home" "$fakebin" --json --all-decisions) + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id == "only-blocked-parked" and .verb == "captain-hold")) + and (.gates | any(.id == "only-blocked-parked") | not) + ' >/dev/null || fail "--all-decisions did not reveal a blocked deferred hold: $json" + pass "a blocked deferred hold is disclosed by default and revealed by --all-decisions" } test_include_prs_is_the_only_fetch_path() { From 3175f891ad7fc0fe165d6aa04dc9333974b6684b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 22:29:31 -0700 Subject: [PATCH 36/47] no-mistakes(ci): Fixed the actionable-state bug in Bearings: an arrived parked-style hold is live only when it is not explicitly non-actionable, so blocked due holds remain gated by default and are revealed by --all-decisions. Added behavioral regression coverage for that case. Preserved complete-reason parked-style classification as required by the author intent. Verified with tests/fm-bearings-snapshot.test.sh, bin/fm-lint.sh, and git diff --check --- bin/fm-bearings-snapshot.sh | 3 ++- tests/fm-bearings-snapshot.test.sh | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index fc371164bdc..c9c34c4e6f7 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -344,7 +344,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \ and (.explicit_deferred_marker != true) and ((.deferred_marker != true) or (.parked_style_marker == true - and (.hold_until // null) != null and .hold_until <= $today)); + and (.hold_until // null) != null and .hold_until <= $today + and .captain_actionable != false)); def projected_deferred_hold: (.deferred_marker == true or .aged_undated_hold == true) and (live_captain_call | not); diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 84f2ae2ca68..d639bd8d010 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1318,7 +1318,7 @@ test_blocked_deferred_hold_has_concrete_disclosure() { ## In flight ## Queued -- [ ] only-blocked-parked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) +- [ ] only-blocked-parked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-07-11) ## Done EOF @@ -1332,8 +1332,8 @@ EOF printf '%s' "$json" | jq -e ' (.decisions_open | any(.id == "only-blocked-parked" and .verb == "captain-hold")) and (.gates | any(.id == "only-blocked-parked") | not) - ' >/dev/null || fail "--all-decisions did not reveal a blocked deferred hold: $json" - pass "a blocked deferred hold is disclosed by default and revealed by --all-decisions" + ' >/dev/null || fail "--all-decisions did not reveal a blocked deferred hold whose date had arrived: $json" + pass "a blocked deferred hold stays gated when its date arrives and is revealed by --all-decisions" } test_include_prs_is_the_only_fetch_path() { From 0a9fcea7fc5eb9c74bd46c3b0982adbdd5102b60 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 23:26:03 -0700 Subject: [PATCH 37/47] Show why a revealed captain hold is deferred. Under --all-decisions a deferred hold is revealed and its Charted Next gate is removed, but the revealed row carried only the bare hold reason. A date-deferred or blocked hold therefore read exactly like a genuine live decision, because the until date, the age, and the blocking work only ever appeared on the gate row that the reveal replaces. Annotate a row that is revealed because it is deferred with the same vocabulary the gate uses - until , held d, and the blocking work - so the expanded view reads as deferred-but-shown. A genuinely live call is left unannotated, and the default board is unchanged. --- bin/fm-bearings-snapshot.sh | 23 +++++++++++++--- tests/fm-bearings-snapshot.test.sh | 42 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index c9c34c4e6f7..7724921f848 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -355,6 +355,14 @@ MODEL=$(printf '%s' "$SNAP" | jq \ elif (.hold_until // null) != null and .hold_until > $today then ("until " + .hold_until + ": " + (.hold_reason // .blocked_reason // "-")) else (.hold_reason // .blocked_reason // "-") end; + def hold_reveal_reason($base): + (((.unresolved_blocker_ids // []) + | if length > 0 then ("blocked-by " + join(",") + "; ") else "" end)) as $blockers + | if .aged_undated_hold == true and .hold_age_days != null then + ($blockers + "held " + (.hold_age_days | tostring) + "d: " + $base) + elif (.hold_until // null) != null and .hold_until > $today then + ($blockers + "until " + .hold_until + ": " + $base) + else ($blockers + $base) end; def as_gate($owner): {id, title:(.title | trunc(60)), blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)), @@ -452,18 +460,27 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select((.captain_actionable == true and (($all_decisions == 1) or live_captain_call)) or ($all_decisions == 1 and projected_deferred_hold)) | {id,key:.id,verb:"captain-hold", - summary:((.title + ": " + .hold_reason) | trunc(90)),owner:"(main)"} ] + summary:((.title + ": " + + (if projected_deferred_hold + then hold_reveal_reason(.hold_reason) + else .hold_reason end)) | trunc(90)),owner:"(main)"} ] + [ (.secondmate_current.records // [])[] as $m | ([ $m.decisions_open[]? | select(.source == "backlog" and .verb == "captain-hold") | select(($all_decisions == 1) or live_captain_call) | {id:($m.id + "/" + .id),key,verb, - summary:(((.summary // .id) + ": " + (.reason // "captain decision pending")) | trunc(90)),owner:$m.id} ] + summary:(((.summary // .id) + ": " + + ((.reason // "captain decision pending") as $base + | if (live_captain_call | not) + then hold_reveal_reason($base) + else $base end)) | trunc(90)),owner:$m.id} ] + [ $m.queued[]? | select($all_decisions == 1 and .captain_actionable != true and .hold_kind == "captain" and projected_deferred_hold) | {id:($m.id + "/" + .id),key:.id,verb:"captain-hold", - summary:(((.title // .id) + ": " + (.hold_reason // "captain decision pending")) | trunc(90)),owner:$m.id} ])[] ]) as $decisions_all + summary:(((.title // .id) + ": " + + ((.hold_reason // "captain decision pending") as $base + | hold_reveal_reason($base))) | trunc(90)),owner:$m.id} ])[] ]) as $decisions_all | ([ .backlog.records[] | . as $record | select(.structured and .hold_kind == "captain" diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index d639bd8d010..6e6d456acc9 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1336,6 +1336,47 @@ EOF pass "a blocked deferred hold stays gated when its date arrives and is revealed by --all-decisions" } +test_revealed_deferred_holds_show_their_deferral_reason() { + local home fakebin json + home=$(make_home revealed-deferral-reason) + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] reveal-blocked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) +- [ ] reveal-future - Parked call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-12-01) +- [ ] reveal-aged - Aged undated call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a route) (hold-kind: captain) + Captain hold set: 2026-06-01T00:00:00Z +- [ ] reveal-live - Genuine live call (repo: firstmate) (kind: captain) (hold: choose a cache) (hold-kind: captain) + +## Done +EOF + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json --all-decisions) + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id == "reveal-blocked" and (.summary | contains("blocked-by missing-blocker")))) + ' >/dev/null || fail "a revealed blocked hold must show its blocked-by: $json" + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id == "reveal-future" and (.summary | contains("until 2026-12-01")))) + ' >/dev/null || fail "a revealed date-deferred hold must show its until date: $json" + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id == "reveal-aged" and (.summary | contains("held ") and contains("d: ")))) + ' >/dev/null || fail "a revealed aged hold must show its age: $json" + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id == "reveal-live" + and (.summary | contains("choose a cache")) + and (.summary | contains("blocked-by") or contains("until ") or contains("held ") | not))) + ' >/dev/null || fail "a genuinely live call must not be annotated as deferred: $json" + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + ([.decisions_open[].id] == ["reveal-live"]) + and (.gates | any(.id == "reveal-blocked")) + and (.gates | any(.id == "reveal-future")) + and (.gates | any(.id == "reveal-aged")) + ' >/dev/null || fail "the default board must keep deferred holds gated and show only the live call: $json" + pass "revealed deferred holds display their deferral reason while live calls stay unannotated" +} + test_include_prs_is_the_only_fetch_path() { local home fakebin json home=$(make_home prs); write_fixture "$home" @@ -2817,6 +2858,7 @@ test_section_caps_and_expansion_flags test_collapsed_captain_call_deferral_and_landed test_undated_hold_phrasing_and_aging_projection test_blocked_deferred_hold_has_concrete_disclosure +test_revealed_deferred_holds_show_their_deferral_reason test_pr_repository_cap_and_expansion test_per_repository_pr_cap_is_disclosed test_projection_and_toon_fail_closed From d2ac77a477dc2b4475bd07a9f1f6787790c63665 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 00:49:53 -0700 Subject: [PATCH 38/47] Classify captain holds from structured fields alone. Bucket membership was decided by several independent expressions, and two of them matched hold reason or body prose. That produced a recurring class of defects: holds that fell through every bucket and vanished from the board, and live decisions silently suppressed because their wording happened to contain a marker word - a reason of "non-deferred release choice" matched DEFERRED and disappeared. Replace all of it with one total classifier over structured fields only: hold_kind, state, hold_until, unresolved_blocker_ids, and the machine-written hold-set timestamp. Every captain hold gets exactly one hold_bucket - blocked, dated, aged, or live - so no hold can fall through and none can match two. captain_actionable is exactly the live bucket, and the --all-decisions reveal is a property of the bucket rather than a second filter. No hold reason or body prose is matched anywhere in the projection, so wording can no longer hide, reveal, or reclassify a decision. A hold that is superseded or no longer required is closed through the hold lifecycle instead of lingering as an open hold flagged by a keyword. --- bin/fm-bearings-snapshot.sh | 118 ++++++++---------------- bin/fm-fleet-snapshot.sh | 96 ++++++++----------- docs/captain-hold-lifecycle.md | 19 ++-- tests/fm-bearings-snapshot.test.sh | 87 +++++++++-------- tests/fm-captain-hold-lifecycle.test.sh | 14 ++- tests/fm-fleet-snapshot-view.test.sh | 92 ++++++++++++++---- 6 files changed, 216 insertions(+), 210 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 7724921f848..5145dfc6765 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -39,15 +39,16 @@ # bearings_state. A home classified captain_decision because it has an open # captain hold still contributes each working child as its own Underway row; # the home row on secondmates[] keeps the decision and gate classification. -# An undated captain hold the canonical snapshot marks aged_undated_hold -# (recorded hold-set timestamp at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old, -# falling back to since for a legacy unstamped hold) renders as a Charted Next -# gate showing its floored age and is disclosed in omitted[]. When actionable, -# it leaves default Captain's Call and --all-decisions reveals it without -# retaining its safety gate; a non-actionable row remains gated. Parked-style -# and aging hints use the same undated-hold gate projection. -# This is a projection safety net only; the durable deferral remains re-holding -# with --until. +# Captain-hold placement follows the canonical snapshot's hold_bucket and +# nothing else; this wrapper never inspects hold reason or body prose. The +# buckets are total and mutually exclusive, so every captain hold appears in +# exactly one place and none can fall through both. A "live" hold is a default +# Captain's Call entry; "blocked", "dated", and "aged" leave the default +# Captain's Call, render as Charted Next gates stating why (the blocking work, +# the until date, or the floored age), and are counted in omitted[]. +# --all-decisions reveals every captain hold and drops its gate, so a hold is +# never shown twice. Aging is a projection safety net only; the durable +# deferral remains re-holding with --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory # object (orphan structured in-flight without meta, unstructured current rows). @@ -339,30 +340,24 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson candidate_prs "$CANDIDATE_PRS" ' def trunc($n): if . == null then null else (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; - def live_captain_call: - (.aged_undated_hold != true) - and (.explicit_deferred_marker != true) - and ((.deferred_marker != true) - or (.parked_style_marker == true - and (.hold_until // null) != null and .hold_until <= $today - and .captain_actionable != false)); + def live_captain_call: .hold_bucket == "live"; def projected_deferred_hold: - (.deferred_marker == true or .aged_undated_hold == true) - and (live_captain_call | not); + .hold_bucket != null and .hold_bucket != "live"; + def hold_note: + if .hold_bucket == "blocked" then + ("blocked-by " + ((.unresolved_blocker_ids // []) | join(","))) + elif .hold_bucket == "dated" then ("until " + (.hold_until // "-")) + elif .hold_bucket == "aged" and .hold_age_days != null then + ("held " + (.hold_age_days | tostring) + "d") + else null end; def hold_gate_reason: - if .aged_undated_hold == true and .hold_age_days != null then - ("held " + (.hold_age_days | tostring) + "d: " + (.hold_reason // .blocked_reason // "-")) - elif (.hold_until // null) != null and .hold_until > $today then - ("until " + .hold_until + ": " + (.hold_reason // .blocked_reason // "-")) - else (.hold_reason // .blocked_reason // "-") end; - def hold_reveal_reason($base): - (((.unresolved_blocker_ids // []) - | if length > 0 then ("blocked-by " + join(",") + "; ") else "" end)) as $blockers - | if .aged_undated_hold == true and .hold_age_days != null then - ($blockers + "held " + (.hold_age_days | tostring) + "d: " + $base) - elif (.hold_until // null) != null and .hold_until > $today then - ($blockers + "until " + .hold_until + ": " + $base) - else ($blockers + $base) end; + (.hold_reason // .blocked_reason // "-") as $base + | (hold_note) as $note + | if $note == null then $base else ($note + ": " + $base) end; + def hold_summary($title; $base): + ($title | trunc(46)) + ": " + + ((hold_note) as $note + | (if $note == null then $base else ($note + ": " + $base) end) | trunc(42)); def as_gate($owner): {id, title:(.title | trunc(60)), blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)), @@ -453,46 +448,28 @@ MODEL=$(printf '%s' "$SNAP" | jq \ doing:((.doing // .state) | trunc(90))} ]) as $in_flight_all | ([ .backlog.records[] | . as $record - | select(.structured and .hold_kind == "captain" - and (.state == "queued" or - (.state == "in_flight" and .current_role == "held" - and ($working_ids | index($record.id) | not)))) - | select((.captain_actionable == true and (($all_decisions == 1) or live_captain_call)) - or ($all_decisions == 1 and projected_deferred_hold)) + | select(.structured and .hold_bucket != null) + | select(($all_decisions == 1) or live_captain_call) | {id,key:.id,verb:"captain-hold", - summary:((.title + ": " - + (if projected_deferred_hold - then hold_reveal_reason(.hold_reason) - else .hold_reason end)) | trunc(90)),owner:"(main)"} ] + summary:hold_summary(.title; .hold_reason),owner:"(main)"} ] + [ (.secondmate_current.records // [])[] as $m | ([ $m.decisions_open[]? | select(.source == "backlog" and .verb == "captain-hold") | select(($all_decisions == 1) or live_captain_call) | {id:($m.id + "/" + .id),key,verb, - summary:(((.summary // .id) + ": " - + ((.reason // "captain decision pending") as $base - | if (live_captain_call | not) - then hold_reveal_reason($base) - else $base end)) | trunc(90)),owner:$m.id} ] + summary:hold_summary((.summary // .id); + (.reason // "captain decision pending")),owner:$m.id} ] + [ $m.queued[]? - | select($all_decisions == 1 and .captain_actionable != true - and .hold_kind == "captain" and projected_deferred_hold) + | select($all_decisions == 1 and .hold_kind == "captain" + and projected_deferred_hold) | {id:($m.id + "/" + .id),key:.id,verb:"captain-hold", - summary:(((.title // .id) + ": " - + ((.hold_reason // "captain decision pending") as $base - | hold_reveal_reason($base))) | trunc(90)),owner:$m.id} ])[] ]) as $decisions_all + summary:hold_summary((.title // .id); + (.hold_reason // "captain decision pending")),owner:$m.id} ])[] ]) as $decisions_all | ([ .backlog.records[] | . as $record - | select(.structured and .hold_kind == "captain" - and (.state == "queued" or - (.state == "in_flight" and .current_role == "held" - and ($working_ids | index($record.id) | not))) - and (.deferred_marker == true or .aged_undated_hold == true) - and (live_captain_call | not)) ] + | select(.structured and projected_deferred_hold) ] + [ (.secondmate_current.records // [])[] | .queued[]? - | select(.hold_kind == "captain" - and (.deferred_marker == true or .aged_undated_hold == true) - and (live_captain_call | not)) ] + | select(.hold_kind == "captain" and projected_deferred_hold) ] | length) as $decisions_marked_deferred | ((if (.main_inventory.valid == false) then [{id:"(main-inventory)", @@ -507,29 +484,13 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (.state == "queued" or (.state == "in_flight" and .current_role == "held" and ($working_ids | index($record.id) | not)))) | select(.captain_actionable != true) - | select(($all_decisions == 0) or (projected_deferred_hold | not)) - | select(($all_queued == 1) or (.deferred_marker != true) - or (.hold_kind == "captain" and .deferred_marker == true) - or ((.hold_until // null) != null and .hold_until > $today)) - | as_gate("(main)") ] - + [ .backlog.records[] - | select($all_decisions == 0 and .structured and .captain_actionable == true - and projected_deferred_hold) + | select((.hold_bucket == null) or ($all_decisions == 0)) | as_gate("(main)") ] + [ (.secondmate_current.records // [])[] as $m | select($m.provenance.selected == "structured-home") | $m.queued[]? | select(.captain_actionable != true) - | select(($all_decisions == 0) or (projected_deferred_hold | not)) - | select(($all_queued == 1) or (.deferred_marker != true) - or (.hold_kind == "captain" and .deferred_marker == true) - or ((.hold_until // null) != null and .hold_until > $today)) - | as_gate($m.id) ] - + [ (.secondmate_current.records // [])[] as $m - | select($m.provenance.selected == "structured-home") - | $m.queued[]? - | select($all_decisions == 0 and .captain_actionable == true - and projected_deferred_hold) + | select((.hold_bucket == null) or ($all_decisions == 0)) | as_gate($m.id) ]) as $gates_all | ([ .scout_reports[] | . as $r @@ -568,7 +529,6 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (if $f_actions then empty else {surface:"watch/steer actions", reveal:"--fields actions"} end), (if $f_endpoints then empty else {surface:"healthy endpoint detail", reveal:"--fields endpoints"} end), (if $all_reports == 1 then empty else {surface:"full scout-report inventory", reveal:"--all-reports"} end), - (if $all_queued == 1 then empty else {surface:"superseded or prose-deferred queued items", reveal:"--all-queued"} end), (if $all_landed == 0 and ($per_home_capped | length) > ($done | length) then {surface:("landed showing \($done | length) of \($per_home_capped | length)" + (($done | map(.home_id) | unique | map(select(. != "(main)")) | length) as $k | if $k > 0 then " (incl. \($k) secondmate home(s))" else "" end)), reveal:"--all-landed"} else empty end), (if $all_landed == 0 and $home_cap_dropped > 0 then {surface:("landed per-home capped at \($landed_per_home_n) for \($home_cap_dropped) home(s)"), reveal:"--all-landed"} else empty end), (if (($snap.secondmate_landed.unreadable // []) | length) > 0 then {surface:("secondmate home(s) with unreadable structured state: \(($snap.secondmate_landed.unreadable // []) | length)"), reveal:"inspect the listed secondmate home ledgers"} else empty end), diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 5bb1ea0f3c4..9b9b959b2da 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -22,33 +22,29 @@ # hold_reason, and hold_until when tasks-axi emits it. They also carry # normalized current_role, requires_child_metadata, blocked_by_ids, # unresolved_blocker_ids, captain_actionable, hold_set, hold_age_days, -# aged_undated_hold, and deferred_marker fields. +# and hold_bucket fields. # Repeated blocker tokens remain ordered; a blocker resolves only when its # structured record is Done, and missing ids stay open. -# captain_actionable means "waiting on the captain now": queued, held for -# the captain, unblocked, and due (no hold_until, or hold_until at or -# before the observation date, matching tasks-axi's own date-gate rule). # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. -# deferred_marker is a presentation hint only: the row's hold reason or -# first 240 characters of current body prose (excluding the machine-generated -# hold-set stamp and preserved historical resolution blocks) carries an -# explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker. A hold reason whose -# complete value is a parked-style marker (parked, awaiting captain go, do -# not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued -# opportunity, captain-gated) is also deferred; contextual prose containing -# or beginning with those words does not adjudicate the decision. It never -# changes captain_actionable; -# renderers may use it to keep prose-deferred rows out of default views. -# aged_undated_hold is a second presentation hint for an undated captain -# hold whose hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS -# old (default 14). Legacy unstamped holds fall back to `since`. -# hold_age_days is that age when computable, else null. -# This is a projection safety net only: the durable deferral remains -# re-holding with --until. Aging does not change captain_actionable. -# Renderers project an aged undated hold as a Charted Next gate showing its -# age and disclose it in omitted[]; --all-decisions reveals it only when it -# is otherwise actionable. +# hold_bucket is the single classification for every captain hold, decided +# only from structured fields - state, hold_kind, hold_until, +# unresolved_blocker_ids, and the machine-written hold-set timestamp. No +# hold reason or body prose is ever matched. The buckets are total and +# mutually exclusive, so every captain hold lands in exactly one and none +# can fall through: "blocked" when any blocker is unresolved, else "dated" +# when hold_until is still in the future, else "aged" when an undated hold +# is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14; legacy +# unstamped holds fall back to `since`), else "live". A non-captain or Done +# row carries null. +# captain_actionable means "waiting on the captain now" and is exactly +# hold_bucket == "live". +# hold_age_days is the hold's age when computable, else null. +# Aging is a projection safety net only: the durable deferral remains +# re-holding with --until. +# Renderers keep every non-live bucket out of the default Captain's Call, +# project it as a Charted Next gate stating why, and disclose it in +# omitted[]; --all-decisions reveals every captain hold. # tasks[]: one row per task metadata record captured at snapshot start, sorted # by id. A record removed before capture is omitted. If a captured task's # generation changes while observations run, its selected metadata remains @@ -228,8 +224,8 @@ kind=secondmate meta records are not child inventory for unowned_current or terminal_in_flight; they never have backlog rows. Its invalidity object names the normalized failure kind and affected ids. Actionable tasks-axi captain holds appear as decisions_open and stay visible in -queued with hold_reason, hold_kind, hold_until, deferred_marker, -aged_undated_hold, hold_age_days, and plural blocker fields for downstream +queued with hold_reason, hold_kind, hold_until, +hold_bucket, hold_age_days, and plural blocker fields for downstream projections. A captain hold is actionable only when every blocker is Done and any hold-until date has arrived. Cross-home collection uses FM_SNAPSHOT_SECONDMATES (default 20, 0 lifts the @@ -255,11 +251,12 @@ FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT, with truncation disclosed in the result. The registered secondmate table uses FM_SNAPSHOT_REGISTRY_LINES, FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed. -An undated captain hold whose hold-set timestamp is at least +Every captain hold carries hold_bucket, decided only from structured fields and +never from hold reason or body prose: "blocked", "dated", "aged", or "live". +An undated hold ages once its hold-set timestamp is at least FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS old (default 14; 0 ages every hold with a -non-negative computed age) carries aged_undated_hold and hold_age_days -as presentation hints. Legacy holds without a stamp fall back to their since date; -re-holding with --until remains the durable deferral. +non-negative computed age); legacy holds without a stamp fall back to their +since date, and re-holding with --until remains the durable deferral. EOF } @@ -363,10 +360,6 @@ backlog_json() { # [] - defaults to this home's $BACKLOG jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); - def explicitly_deferred: - test("SUPERSEDED|NOT[- ]REQUIRED|DEFERRED"; "i"); - def parked_style_reason: - test("^(parked|awaiting captain go|do not (auto-)?dispatch|not urgent|de-prioritized|queued opportunity|captain-gated)[.!]?$"; "i"); def timestamp_epoch($d): if ($d | type) != "string" then null elif ($d | test("T")) then try ($d | fromdateiso8601) catch null @@ -522,19 +515,15 @@ backlog_json() { # [] - defaults to this home's $BACKLOG elif .state == "queued" then "queued" else "done" end) | .requires_child_metadata = (.current_role == "worker") - | .captain_actionable = - (.state == "queued" and .hold_kind == "captain" - and .hold_reason != null and (.unresolved_blocker_ids | length) == 0 - and (.hold_until == null or .hold_until <= $today)) | .hold_age_days = days_between((.hold_set // .since); $now) - | .aged_undated_hold = - (.hold_kind == "captain" and .hold_until == null - and .hold_age_days != null and .hold_age_days >= $age_days) - | .explicit_deferred_marker = - (((.hold_reason // "") | explicitly_deferred) - or ((.body_excerpt // "") | explicitly_deferred)) - | .parked_style_marker = ((.hold_reason // "") | parked_style_reason) - | .deferred_marker = (.explicit_deferred_marker or .parked_style_marker) + | .hold_bucket = + (if .hold_kind != "captain" or .hold_reason == null or .state == "done" then null + elif (.unresolved_blocker_ids | length) > 0 then "blocked" + elif .hold_until != null and .hold_until > $today then "dated" + elif .hold_until == null and .hold_age_days != null + and .hold_age_days >= $age_days then "aged" + else "live" end) + | .captain_actionable = (.hold_bucket == "live") else . end) | del(.section,.order) ' < "$backlog" @@ -959,10 +948,7 @@ secondmate_home_summary_json() { # | {id,key:.id,verb:"captain-hold",summary:(.title | trunc(160)), reason:(.hold_reason | trunc(160)), hold_until:(.hold_until // null), - deferred_marker:(.deferred_marker // false), - explicit_deferred_marker:(.explicit_deferred_marker // false), - parked_style_marker:(.parked_style_marker // false), - aged_undated_hold:(.aged_undated_hold // false), + hold_bucket:(.hold_bucket // null), hold_age_days:(.hold_age_days // null),source:"backlog"} ]) as $captain_holds_all | ([ $backlog.records[]? | select(.state == "done" and .structured and .hold_kind != "captain") | {id:(.id | trunc(120)),title:(.title | trunc(120)), @@ -1070,10 +1056,7 @@ secondmate_home_summary_json() { # hold_reason:((.hold_reason // null) | if . == null then null else trunc(160) end), hold_kind:((.hold_kind // null) | if . == null then null else trunc(40) end), hold_until:((.hold_until // null) | if . == null then null else trunc(40) end), - deferred_marker:(.deferred_marker // false), - explicit_deferred_marker:(.explicit_deferred_marker // false), - parked_style_marker:(.parked_style_marker // false), - aged_undated_hold:(.aged_undated_hold // false), + hold_bucket:(.hold_bucket // null), hold_age_days:(.hold_age_days // null), captain_actionable:(.captain_actionable // false), repo:((.repo // null) | if . == null then null else trunc(120) end), @@ -1323,10 +1306,9 @@ prepare_remote_summary_collection() { # SNAPSHOT_SUMMARY_FILTER="$SNAPSHOT_COLLECT_DIR/summary-filter.jq" cat > "$SNAPSHOT_SUMMARY_FILTER" <<'JQ' def current_hold_aging_fields: - (.deferred_marker | type) == "boolean" - and (.explicit_deferred_marker | type) == "boolean" - and (.parked_style_marker | type) == "boolean" - and (.aged_undated_hold | type) == "boolean" + has("hold_bucket") + and (.hold_bucket as $bucket + | ["live", "blocked", "dated", "aged"] | index($bucket)) != null and (.hold_age_days == null or ((.hold_age_days | type) == "number" and (.hold_age_days | floor) == .hold_age_days)); diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 84af4ace8e9..d215cca9554 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -60,20 +60,21 @@ Trusted external process-event adapters intentionally expose no answer operation ## Structured read surfaces `bin/fm-fleet-snapshot.sh` parses canonical tasks-axi `(hold: ...)`, `(hold-kind: ...)`, and `(hold-until: ...)` metadata alongside existing backlog fields. -It resolves every repeated `blocked-by:` edge against structured Done records, keeps missing blockers unresolved, and classifies a captain hold as `captain_actionable` - waiting on the captain now - only when it is queued, unblocked, and due, whatever kind its row carries. -It also emits a presentation-only `deferred_marker` when a hold's reason or the first 240 characters of its current decision prose, excluding the machine-generated hold-set stamp and preserved historical resolution blocks, carries an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker, or when the complete hold reason is a parked-style marker (parked, awaiting captain go, do not dispatch / do not auto-dispatch, not urgent, de-prioritized, queued opportunity, captain-gated). -Contextual prose that merely contains or begins with a parked-style phrase is not treated as a deferral. -An undated captain hold whose recorded hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, using floored elapsed days) also carries `aged_undated_hold` and `hold_age_days`. +It resolves every repeated `blocked-by:` edge against structured Done records and keeps missing blockers unresolved. +It then assigns every captain hold exactly one `hold_bucket`, decided only from structured fields - `hold_kind`, `state`, `hold_until`, `unresolved_blocker_ids`, and the machine-written hold-set timestamp. +Hold reason and body prose are never matched, so no wording can hide, reveal, or reclassify a decision. +The buckets are total and mutually exclusive: `blocked` when any blocker is unresolved, else `dated` while `hold_until` is in the future, else `aged` when an undated hold's hold-set timestamp is at least `FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS` old (default 14, floored elapsed days), else `live`. +No captain hold can fall through them and none can match two, which is what keeps a hold from vanishing from every view. +`captain_actionable` - waiting on the captain now - is exactly `hold_bucket == "live"`. Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. The durable deferral remains re-holding with `--until`. Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. -`bin/fm-bearings-snapshot.sh` projects actionable captain holds into `decisions_open` and leaves blocked captain holds in ordinary queued gates. -A captain hold with a future date renders as a gate with its `until :` reason. -When the date arrives, a complete parked-style reason becomes live again, but an explicit SUPERSEDED / NOT REQUIRED / DEFERRED marker remains gated regardless of date. -A deferred or aged captain hold, including one that is also blocked, renders as a Charted Next gate and contributes to the concrete `omitted[]` disclosure. -`--all-decisions` reveals every parked-style and aged hold in Captain's Call, including blocked holds, and removes their safety gates so the buckets remain exclusive; other non-actionable holds remain gated. +`bin/fm-bearings-snapshot.sh` places each captain hold by its `hold_bucket` and inspects no prose of its own. +A `live` hold is a default Captain's Call entry. +A `blocked`, `dated`, or `aged` hold leaves the default Captain's Call, renders as a Charted Next gate stating why - the blocking work, the `until `, or the floored age - and contributes to the concrete `omitted[]` disclosure. +`--all-decisions` reveals every captain hold and drops its gate, so a hold is shown once and never twice. Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding it through the wrapper with `--until` remains the durable fix for both limits. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 6e6d456acc9..048be60fbf7 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -221,12 +221,12 @@ write_remote_home_summary() { # valid:true,reason:null,invalidity:{kind:null,ids:[]},state:"captain_decision", active_children:[], decisions_open:[ - {id:"remote-parked",key:"remote-parked",verb:"captain-hold",summary:"Remote parked hold",reason:"parked",hold_until:null,deferred_marker:true,explicit_deferred_marker:false,parked_style_marker:true,aged_undated_hold:false,hold_age_days:null,source:"backlog"}, - {id:"remote-aged",key:"remote-aged",verb:"captain-hold",summary:"Remote aged hold",reason:"choose a route",hold_until:null,deferred_marker:false,explicit_deferred_marker:false,parked_style_marker:false,aged_undated_hold:true,hold_age_days:40,source:"backlog"} + {id:"remote-parked",key:"remote-parked",verb:"captain-hold",summary:"Remote parked hold",reason:"parked",hold_until:null,hold_bucket:"live",hold_age_days:null,source:"backlog"}, + {id:"remote-aged",key:"remote-aged",verb:"captain-hold",summary:"Remote aged hold",reason:"choose a route",hold_until:null,hold_bucket:"aged",hold_age_days:40,source:"backlog"} ],holds:[], queued:[ - {id:"remote-parked",title:"Remote parked hold",blocked_by:null,blocked_by_ids:[],unresolved_blocker_ids:[],blocked_reason:null,hold_reason:"parked",hold_kind:"captain",hold_until:null,deferred_marker:true,explicit_deferred_marker:false,parked_style_marker:true,aged_undated_hold:false,hold_age_days:null,captain_actionable:true,repo:"firstmate",kind:"captain"}, - {id:"remote-aged",title:"Remote aged hold",blocked_by:null,blocked_by_ids:[],unresolved_blocker_ids:[],blocked_reason:null,hold_reason:"choose a route",hold_kind:"captain",hold_until:null,deferred_marker:false,explicit_deferred_marker:false,parked_style_marker:false,aged_undated_hold:true,hold_age_days:40,captain_actionable:true,repo:"firstmate",kind:"captain"} + {id:"remote-parked",title:"Remote parked hold",blocked_by:null,blocked_by_ids:[],unresolved_blocker_ids:[],blocked_reason:null,hold_reason:"parked",hold_kind:"captain",hold_until:null,hold_bucket:"live",hold_age_days:null,captain_actionable:true,repo:"firstmate",kind:"captain"}, + {id:"remote-aged",title:"Remote aged hold",blocked_by:null,blocked_by_ids:[],unresolved_blocker_ids:[],blocked_reason:null,hold_reason:"choose a route",hold_kind:"captain",hold_until:null,hold_bucket:"aged",hold_age_days:40,captain_actionable:false,repo:"firstmate",kind:"captain"} ],landed:[],endpoints:[], counts:{active_children:0,decisions_open:2,holds:0,queued:2,landed:0,endpoints:0},omitted:[] }' > "$home/state/home-summary.json" @@ -1111,24 +1111,27 @@ test_report_pointers_surface() { pass "current report pointers surface" } -test_superseded_queued_item_dropped_by_default() { +test_queued_item_prose_never_hides_it() { local home fakebin json home=$(make_home superseded); write_fixture "$home" fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' - (.gates | any(.[]; .id == "live-gate")) and (.gates | any(.[]; .id == "dead-gate") | not) - ' >/dev/null || fail "default gates must include live and drop superseded: $json" + (.gates | any(.[]; .id == "live-gate")) and (.gates | any(.[]; .id == "dead-gate")) + ' >/dev/null || fail "queued body prose must not hide a gate from the default board: $json" json=$(run "$home" "$fakebin" --json --all-queued) - printf '%s' "$json" | jq -e '.gates | any(.[]; .id == "dead-gate")' >/dev/null \ - || fail "--all-queued must restore the superseded item" - pass "superseded queued items are dropped by default and restored with --all-queued" + printf '%s' "$json" | jq -e ' + (.gates | any(.[]; .id == "dead-gate")) + and (.omitted | any(.reveal == "--all-queued") | not) + ' >/dev/null || fail "--all-queued must not advertise a suppression that no longer exists: $json" + pass "queued body prose never hides an item from the board" } # The collapsed captain-call contract: any due, unblocked captain-held task is # Captain's Call whatever its kind; a date-deferred hold is a dated gate until -# due; an undated prose-deferred hold becomes a disclosed gate; and Recently -# Landed excludes only what closed while still held for the captain. +# due; deferral wording in the reason changes nothing, because only structured +# fields classify; and Recently Landed excludes only what closed while still +# held for the captain. test_collapsed_captain_call_deferral_and_landed() { local home fakebin json home=$(make_home collapsed-call) @@ -1153,11 +1156,11 @@ EOF (.decisions_open | any(.[]; .id == "work-gate")) and (.decisions_open | any(.[]; .id == "due-call")) and (.decisions_open | any(.[]; .id == "later-call") | not) - and (.decisions_open | any(.[]; .id == "parked-call") | not) + and (.decisions_open | any(.[]; .id == "parked-call")) and (.decisions_open | any(.[]; .id == "external-gate") | not) and (.gates | any(.[]; .id == "later-call" and (.reason | startswith("until 2026-08-01")))) and (.gates | any(.[]; .id == "work-gate") | not) - and (.gates | any(.[]; .id == "parked-call" and .reason == "DEFERRED by captain revisit later")) + and (.gates | any(.[]; .id == "parked-call") | not) and (.gates | any(.[]; .id == "external-gate")) and (.landed | any(.[]; .id == "shipped-work")) and (.landed | any(.[]; .id == "answered-call") | not) @@ -1240,11 +1243,13 @@ EOF printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "recent-call")) and (.decisions_open | any(.[]; .id == "due-parked")) + and (.decisions_open | any(.[]; .id == "parked-hold")) + and (.decisions_open | any(.[]; .id == "due-superseded")) and (.decisions_open | any(.[]; .id == "future-parked") | not) - and (.decisions_open | any(.[]; .id == "due-superseded") | not) and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-parked")) + and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-not-required")) and (.decisions_open | any(.[]; .id == "aging-mate/mate-future-parked") | not) - and (.decisions_open | any(.[]; .id == "aging-mate/mate-due-not-required") | not) and (.decisions_open | any(.[]; .id == "contextual-call")) and (.decisions_open | any(.[]; .id == "contextual-not-urgent")) and (.decisions_open | any(.[]; .id == "contextual-comma" and .summary == "Comma context is not a deferral: not urgent, choose the launch route now")) @@ -1253,27 +1258,30 @@ EOF and (.decisions_open | any(.[]; .id == "contextual-gated")) and (.decisions_open | any(.[]; .id == "reheld-current-call")) and (.decisions_open | any(.[]; .id == "legacy-old-hold") | not) - and (.decisions_open | any(.[]; .id == "parked-hold") | not) and (.decisions_open | any(.[]; .id == "aged-call") | not) - and (.gates | any(.[]; .id == "parked-hold" and .reason == "not urgent")) - and (.gates | any(.[]; .id == "blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) + and (.decisions_open | any(.[]; .id == "blocked-parked") | not) + and (.gates | any(.[]; .id == "parked-hold") | not) + and (.gates | any(.[]; .id == "blocked-parked" and .blocked_by == "missing-blocker" + and (.reason | startswith("blocked-by missing-blocker")))) and (.gates | any(.[]; .id == "future-parked" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "due-parked") | not) - and (.gates | any(.[]; .id == "due-superseded" and .reason == "SUPERSEDED")) + and (.gates | any(.[]; .id == "due-superseded") | not) and (.gates | any(.[]; .id == "aged-call" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "legacy-old-hold" and (.reason | startswith("held 40d")))) - and (.gates | any(.[]; .id == "mate-parked" and .owner == "aging-mate" and .reason == "parked")) - and (.gates | any(.[]; .id == "mate-blocked-parked" and .owner == "aging-mate" and .blocked_by == "missing-remote-blocker" and .reason == "parked")) + and (.gates | any(.[]; .id == "mate-parked") | not) + and (.gates | any(.[]; .id == "mate-blocked-parked" and .owner == "aging-mate" + and .blocked_by == "missing-remote-blocker")) and (.gates | any(.[]; .id == "mate-future-parked" and .owner == "aging-mate" and .reason == "until 2026-08-01: parked")) and (.gates | any(.[]; .id == "mate-due-parked" and .owner == "aging-mate") | not) - and (.gates | any(.[]; .id == "mate-due-not-required" and .owner == "aging-mate" and .reason == "choose a remote route")) + and (.gates | any(.[]; .id == "mate-due-not-required" and .owner == "aging-mate") | not) and (.gates | any(.[]; .id == "mate-aged" and .owner == "aging-mate" and (.reason | startswith("held 40d")))) and (.gates | any(.[]; .id == "recent-call") | not) and ([.decisions_open[].id, .gates[].id] | contains(["due-parked", "due-superseded", "future-parked", "parked-hold", "blocked-parked", "aged-call", - "aging-mate/mate-due-parked", "mate-due-not-required", "mate-future-parked", "mate-parked", "mate-blocked-parked", "mate-aged"])) - and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 11")) - ' >/dev/null || fail "parked-style and aged undated holds must leave Captain's Call: $json" + "aging-mate/mate-due-parked", "aging-mate/mate-due-not-required", "mate-future-parked", + "aging-mate/mate-parked", "mate-blocked-parked", "mate-aged"])) + and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 7")) + ' >/dev/null || fail "structured buckets must decide Captain's Call placement: $json" json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "parked-hold")) @@ -1302,13 +1310,13 @@ EOF or .id == "mate-parked" or .id == "mate-aged" or .id == "mate-due-parked" or .id == "mate-due-not-required" or .id == "mate-future-parked" or .id == "mate-blocked-parked") | not) - ' >/dev/null || fail "--all-decisions must reveal parked-style and aged holds without duplicating their gates: $json" + ' >/dev/null || fail "--all-decisions must reveal every captain hold without duplicating its gate: $json" json=$(FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=50 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' (.decisions_open | any(.[]; .id == "aged-call")) and (.gates | any(.[]; .id == "aged-call") | not) ' >/dev/null || fail "raising the age threshold must restore an undated hold to Captain's Call: $json" - pass "parked-style and aged undated captain holds project to Charted Next/omitted, not live Captain's Call" + pass "structured hold buckets decide Captain's Call, Charted Next, and the reveal" } test_blocked_deferred_hold_has_concrete_disclosure() { @@ -1325,7 +1333,8 @@ EOF fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' - (.gates | any(.id == "only-blocked-parked" and .blocked_by == "missing-blocker" and .reason == "parked")) + (.gates | any(.id == "only-blocked-parked" and .blocked_by == "missing-blocker" + and (.reason | startswith("blocked-by missing-blocker")))) and (.omitted | any(.surface == "captain holds marked deferred, superseded, or aged: 1")) ' >/dev/null || fail "a lone blocked deferred hold lacked its concrete disclosure: $json" json=$(run "$home" "$fakebin" --json --all-decisions) @@ -2670,10 +2679,10 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { printf '%s' "$json" | jq -e ' (.secondmates | length) == 5 and all(.secondmates[]; .freshness == "fresh" and .age_seconds == 100) - and (.decisions_open | all(.owner != "ledger-1")) - and (.gates | any(.id == "remote-parked" and .owner == "ledger-1" and .reason == "parked")) + and (.decisions_open | any(.id == "ledger-1/remote-parked" and .owner == "ledger-1")) + and (.gates | all(.id != "remote-parked")) and (.gates | any(.id == "remote-aged" and .owner == "ledger-1" and (.reason | startswith("held 40d")))) - ' >/dev/null || fail "healthy remote ledgers did not project their generated-epoch ages and deferred holds: $json" + ' >/dev/null || fail "healthy remote ledgers did not project their generated-epoch ages and bucketed holds: $json" cache_file= remote_home=$(cd "$TMP_ROOT/remote-ledger-home-1" && pwd -P) @@ -2686,15 +2695,15 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { [ -n "$cache_file" ] || fail "healthy remote read did not populate its summary cache" tmp="$cache_file.tmp" jq '(.decisions_open[] | select(.verb == "captain-hold")) - |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days) + |= del(.hold_bucket, .hold_age_days) | (.queued[] | select(.hold_kind == "captain")) - |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days)' \ + |= del(.hold_bucket, .hold_age_days)' \ "$cache_file" > "$tmp" && mv "$tmp" "$cache_file" tmp="$remote_home/state/home-summary.json.tmp" jq '(.decisions_open[] | select(.verb == "captain-hold")) - |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days) + |= del(.hold_bucket, .hold_age_days) | (.queued[] | select(.hold_kind == "captain")) - |= del(.explicit_deferred_marker, .parked_style_marker, .aged_undated_hold, .hold_age_days)' \ + |= del(.hold_bucket, .hold_age_days)' \ "$remote_home/state/home-summary.json" > "$tmp" && mv "$tmp" "$remote_home/state/home-summary.json" json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' @@ -2706,9 +2715,9 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' (.secondmates | any(.id == "ledger-1" and .freshness == "fresh")) - and (.gates | any(.id == "remote-parked" and .owner == "ledger-1")) + and (.decisions_open | any(.id == "ledger-1/remote-parked" and .owner == "ledger-1")) and (.gates | any(.id == "remote-aged" and .owner == "ledger-1")) - ' >/dev/null || fail "a refreshed current-schema summary did not restore deferred remote holds: $json" + ' >/dev/null || fail "a refreshed current-schema summary did not restore bucketed remote holds: $json" duplicate_base="$TMP_ROOT/remote-ledger-home-1/state/home-summary.single" cp "$TMP_ROOT/remote-ledger-home-1/state/home-summary.json" "$duplicate_base" @@ -2850,7 +2859,7 @@ test_main_captain_readiness_matches_secondmate_projection test_completed_scout_report_not_pending test_open_decision_surfaces_end_to_end test_report_pointers_surface -test_superseded_queued_item_dropped_by_default +test_queued_item_prose_never_hides_it test_include_prs_is_the_only_fetch_path test_partial_github_failure_degrades test_perl_fallback_bounds_github_call diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 8eba946a01e..b7961395526 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -367,8 +367,7 @@ EOF .backlog.records[] | select(.id == "sample-widget") | .hold_set == "2026-07-14T12:00:00Z" and .hold_age_days == 0 - and .aged_undated_hold == false - and .deferred_marker == false + and .hold_bucket == "live" ' >/dev/null || fail "a new hold lifecycle reused historical timestamp or answer text: $snap" printf 'Price it at nine dollars.\n' > "$home/price.txt" run_captain "$home" answer sample-widget --decision-file "$home/price.txt" --release >/dev/null \ @@ -488,8 +487,7 @@ EOF | .captain_actionable == true and .hold_set == "2026-07-14T12:00:00Z" and .hold_age_days == 0 - and .aged_undated_hold == false - and .deferred_marker == false + and .hold_bucket == "live" ' >/dev/null || fail "an interrupted answer lost the fresh hold age basis: $snap" if run_captain "$home" answer sample-interrupted-call \ @@ -554,9 +552,9 @@ EOF | $later.captain_actionable == false and $later.hold_until == "2026-08-01" and $now.captain_actionable == true and $now.hold_until == null and $existing.since == "2026-06-01" and $existing.hold_set == "2026-07-14T12:00:00Z" - and $existing.hold_age_days == 0 and $existing.aged_undated_hold == false - and ([.backlog.records[] | select(.id == "sample-near-marker")][0].deferred_marker == true) - and ([.backlog.records[] | select(.id == "sample-late-marker")][0].deferred_marker == false) + and $existing.hold_age_days == 0 and $existing.hold_bucket == "live" + and ([.backlog.records[] | select(.id == "sample-near-marker")][0].hold_bucket == "live") + and ([.backlog.records[] | select(.id == "sample-late-marker")][0].hold_bucket == "live") and ($later.title | contains("hold-until") | not) ' >/dev/null || fail "the due gate, hold-set age, or hold-until parsing is wrong: $snap" @@ -578,7 +576,7 @@ EOF ([.backlog.records[] | select(.id == "sample-later-call")][0]) as $later | ([.backlog.records[] | select(.id == "sample-existing-call")][0]) as $existing | $later.captain_actionable == true - and $existing.hold_age_days == 18 and $existing.aged_undated_hold == true + and $existing.hold_age_days == 18 and $existing.hold_bucket == "aged" ' >/dev/null || fail "a due deferral did not resurface or a stamped hold did not age from its hold date" show=$(tasks_in "$home" show sample-later-call --full) assert_contains "$show" "hold_kind: captain" "the expired deferral lost its captain-hold annotations" diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 96932876456..71b2acbdc18 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -199,6 +199,63 @@ test_fixture_snapshot_json() { # R1 owner contract: main_inventory discloses orphan in-flight and unstructured # current rows without inventing task rows. +test_hold_buckets_are_total_and_text_blind() { + local home fakebin out + home=$(make_home hold-buckets) + mkdir -p "$home/data" + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] working-held - Held while working (repo: sample) (kind: captain) (hold: choose a route) (hold-kind: captain) + Captain hold set: 2026-07-20T00:00:00Z + +## Queued +- [ ] blocked-hold - Blocked call blocked-by: upstream-work (repo: sample) (kind: captain) (hold: choose a route) (hold-kind: captain) + Captain hold set: 2026-07-20T00:00:00Z +- [ ] dated-hold - Dated call (repo: sample) (kind: captain) (hold: revisit later) (hold-kind: captain) (hold-until: 2026-12-01) + Captain hold set: 2026-07-20T00:00:00Z +- [ ] aged-hold - Aged call (repo: sample) (kind: captain) (hold: choose a route) (hold-kind: captain) + Captain hold set: 2026-06-01T00:00:00Z +- [ ] live-hold - Live call (repo: sample) (kind: captain) (hold: choose a route) (hold-kind: captain) + Captain hold set: 2026-07-20T00:00:00Z +- [ ] opposite-word - Opposite wording (repo: sample) (kind: captain) (hold: non-deferred release choice) (hold-kind: captain) + Captain hold set: 2026-07-20T00:00:00Z +- [ ] marker-prose - Marker prose (repo: sample) (kind: captain) (hold: choose a route) (hold-kind: captain) + Captain hold set: 2026-07-20T00:00:00Z + SUPERSEDED - kept only to prove prose never classifies. +- [ ] upstream-work - Land the upstream change (repo: sample) (kind: ship) + +## Done +EOF + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + [.backlog.records[] | select(.structured and .hold_kind == "captain")] + | length == 7 + and all(.hold_bucket as $bucket + | ["live", "blocked", "dated", "aged"] | index($bucket) != null) + ' >/dev/null || fail "every captain hold must land in exactly one structured bucket: $out" + printf '%s' "$out" | jq -e ' + ([.backlog.records[] | select(.id == "blocked-hold")][0].hold_bucket == "blocked") + and ([.backlog.records[] | select(.id == "dated-hold")][0].hold_bucket == "dated") + and ([.backlog.records[] | select(.id == "aged-hold")][0].hold_bucket == "aged") + and ([.backlog.records[] | select(.id == "live-hold")][0].hold_bucket == "live") + ' >/dev/null || fail "structured fields did not drive the bucket assignment: $out" + printf '%s' "$out" | jq -e ' + ([.backlog.records[] | select(.id == "opposite-word")][0]) as $opposite + | ([.backlog.records[] | select(.id == "marker-prose")][0]) as $prose + | $opposite.hold_bucket == "live" and $opposite.captain_actionable == true + and $prose.hold_bucket == "live" and $prose.captain_actionable == true + ' >/dev/null || fail "hold reason or body prose must never reclassify a live decision: $out" + printf '%s' "$out" | jq -e ' + ([.backlog.records[] | select(.id == "working-held")][0]) + | .hold_bucket == "live" and .captain_actionable == true + ' >/dev/null || fail "a captain hold on a working task must still be bucketed: $out" + printf '%s' "$out" | jq -e ' + ([.backlog.records[] | select(.id == "upstream-work")][0].hold_bucket) == null + ' >/dev/null || fail "a row that is not a captain hold must carry no bucket: $out" + pass "captain-hold buckets are total, mutually exclusive, and never decided by prose" +} + test_main_inventory_orphan_and_unstructured_disclosure() { local home fakebin out home=$(make_home main-inventory) @@ -525,16 +582,16 @@ EOF | .title == "Deferred sample route" and .hold_until == "2026-09-01" and .captain_actionable == false - and .deferred_marker == false + and .hold_bucket == "dated" ' >/dev/null || fail "a dated captain hold did not defer or strip its hold-until from the title" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "captain-gated-work") - | .kind == "ship" and .captain_actionable == true and .deferred_marker == false + | .kind == "ship" and .captain_actionable == true and .hold_bucket == "live" ' >/dev/null || fail "captain actionability must not depend on the row kind" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "parked-prose") - | .captain_actionable == true and .deferred_marker == true - ' >/dev/null || fail "a prose-deferred captain hold did not carry the presentation marker" + | .captain_actionable == true and .hold_bucket == "live" + ' >/dev/null || fail "hold prose must never classify a captain hold" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "done-comma") | .repo == "gamma" @@ -626,38 +683,36 @@ EOF ([.backlog.records[] | select(.id == "parked-hold" or .id == "awaiting-go" or .id == "no-dispatch" or .id == "no-auto" or .id == "not-urgent" or .id == "deprior" or .id == "queued-opp" or .id == "gated-hold")] - | all(.captain_actionable == true and .deferred_marker == true and .aged_undated_hold == false)) - ' >/dev/null || fail "parked-style undated holds must carry deferred_marker without aging: $out" + | all(.captain_actionable == true and .hold_bucket == "live")) + ' >/dev/null || fail "parked-style wording must never classify a fresh undated hold: $out" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "aged-call") - | .captain_actionable == true - and .deferred_marker == false - and .aged_undated_hold == true + | .captain_actionable == false + and .hold_bucket == "aged" and .hold_age_days == 24 ' >/dev/null || fail "an undated captain hold older than the default 14-day threshold must age: $out" printf '%s' "$out" | jq -e ' ([.backlog.records[] | select(.id == "recent-call")][0]) as $recent | ([.backlog.records[] | select(.id == "legacy-old-hold")][0]) as $legacy | $recent.captain_actionable == true - and $recent.deferred_marker == false - and $recent.aged_undated_hold == false + and $recent.hold_bucket == "live" and $recent.hold_age_days == 5 - and $legacy.captain_actionable == true + and $legacy.captain_actionable == false and $legacy.hold_set == null - and $legacy.aged_undated_hold == true + and $legacy.hold_bucket == "aged" and $legacy.hold_age_days == 54 ' >/dev/null || fail "recent stamped and legacy unstamped hold ages are wrong: $out" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "boundary-call") | .hold_set == "2026-07-11T00:01:00Z" - and .hold_age_days == 13 and .aged_undated_hold == false + and .hold_age_days == 13 and .hold_bucket == "live" ' >/dev/null || fail "a hold one minute short of 14 days must not age early: $out" printf '%s' "$out" | jq -e ' [.backlog.records[] | select(.id == "live-gated" or .id == "unparked-call" or .id == "contextual-call" or .id == "contextual-not-urgent" or .id == "contextual-comma" or .id == "metadata-context" or .id == "contextual-opportunity" or .id == "contextual-gated")] | length == 8 - and all(.captain_actionable == true and .deferred_marker == false and .aged_undated_hold == false) + and all(.captain_actionable == true and .hold_bucket == "live") and (map(select(.id == "contextual-comma" and .hold_reason == "not urgent, choose the launch route now")) | length == 1) and (map(select(.id == "metadata-context" and .hold_reason == "not urgent, priority: decide P1 or P2")) | length == 1) ' >/dev/null || fail "contextual parked-style wording must not hide current decisions: $out" @@ -665,15 +720,15 @@ EOF FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=30 "$SNAPSHOT" --json) printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "aged-call") - | .aged_undated_hold == false and .hold_age_days == 24 + | .hold_bucket == "live" and .hold_age_days == 24 ' >/dev/null || fail "raising the age threshold must leave a 24-day hold unaged: $out" out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$home/data" \ FM_SNAPSHOT_NOW=2026-07-25T00:00:00Z FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS=5 "$SNAPSHOT" --json) printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "recent-call") - | .aged_undated_hold == true and .hold_age_days == 5 + | .hold_bucket == "aged" and .hold_age_days == 5 ' >/dev/null || fail "lowering the age threshold to 5 must age a 5-day hold: $out" - pass "undated captain holds age after a configurable threshold and parked phrasing marks deferred" + pass "undated captain holds age after a configurable threshold, decided only from structured fields" } test_view_renders_snapshot() { @@ -994,6 +1049,7 @@ test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory test_undated_captain_hold_phrasing_and_aging +test_hold_buckets_are_total_and_text_blind test_main_inventory_orphan_and_unstructured_disclosure test_normalized_roles_and_plural_blocker_readiness test_event_hints_follow_reconciled_current_state From 84080146b420a4a780a0c510cbb037f913154e3e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 01:03:29 -0700 Subject: [PATCH 39/47] no-mistakes(review): Preserve working captain holds across bucket surfaces --- .agents/skills/bearings/SKILL.md | 14 +++--- bin/fm-bearings-snapshot.sh | 24 +++------- bin/fm-fleet-snapshot.sh | 16 ++----- docs/captain-hold-lifecycle.md | 15 ++++-- tests/fm-bearings-snapshot.test.sh | 75 ++++++++++++++++++++++++++++-- 5 files changed, 99 insertions(+), 45 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 00f0bac93bd..96e5fcbd149 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -42,14 +42,12 @@ Board answers are acted on later under the normal authority rules; this skill's Only pass `--include-prs` when the captain asks for live GitHub PR enrichment. For registered secondmates, use the snapshot's structured-home classification and provenance. A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. - A decision is simply a task held for the captain (`captain-hold-lifecycle`); every due, unblocked captain-held task is canonically actionable whatever its kind, before the presentation-only filters below. - A captain hold deferred by date sits under `gates` with its `until :` reason until it is due. - Once the date arrives, a complete parked-style reason becomes live again, but an explicit deferred, superseded, or not-required marker remains gated regardless of date. - An actionable undated hold whose reason or bounded body excerpt carries an explicit marker, or whose complete reason is a parked-style marker, leaves Captain's Call for a Charted Next gate with an `omitted` disclosure. - Contextual parked-style wording does not suppress a live decision. - A deferred hold that is also blocked remains a disclosed Charted Next gate rather than disappearing from both buckets. - An undated hold aged past the configured snapshot threshold likewise leaves Captain's Call for an age-labelled Charted Next gate; these are only presentation safety nets, and re-holding with `--until` remains the durable deferral. - Use `--all-decisions` to reveal every hold hidden by either presentation hint, including blocked holds, removing each revealed safety gate from Charted Next so the buckets remain exclusive; other non-actionable holds remain gated, and cross-home visibility remains subject to the snapshot's documented summary bounds. + A decision is simply a task held for the captain (`captain-hold-lifecycle`), whatever its kind. + The canonical snapshot assigns every captain hold exactly one bucket from structured fields only: `blocked` when any blocker is unresolved, else `dated` while `hold_until` is in the future, else `aged` when an undated hold has reached the configured age threshold, else `live`. + Never use hold-reason or body prose to classify or place a decision. + A `live` hold appears in Captain's Call; `blocked`, `dated`, and `aged` holds appear as disclosed Charted Next gates stating their structured reason. + Use `--all-decisions` to reveal every captain hold available within the bounded snapshot and remove each revealed gate from Charted Next so the buckets remain exclusive. + Aging is only a presentation safety net, and re-holding with `--until` remains the durable deferral. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. Until then it stays queued with the reason. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 5145dfc6765..9695e004d83 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -23,17 +23,6 @@ # This wrapper consumes canonical status decisions plus canonically normalized # backlog roles, unresolved blockers, and captain actionability. It never infers # decisions from report or visual-review prose or reimplements snapshot semantics. -# Captain's Call starts from captain actionability: every due, unblocked task -# held for the captain, whatever its kind, before the presentation-only filters -# below. A captain hold deferred by date -# (hold-until in the future) is not actionable and renders as a Charted Next -# gate with its date. Once that date arrives, a complete parked-style reason -# becomes live again, but an explicit SUPERSEDED / NOT REQUIRED / DEFERRED -# marker remains gated. Any prose-deferred row, including one that is also -# blocked, renders as a Charted Next gate and is disclosed in omitted[]. -# --all-decisions reveals every presentation-filtered captain hold without retaining -# its gate, including a hold that is otherwise non-actionable because it is blocked. -# Other non-actionable rows remain gated. # Underway (in_flight) projects every main live worker plus every active child # from every readable secondmate ledger, independently of that home's # bearings_state. A home classified captain_decision because it has an open @@ -46,8 +35,9 @@ # Captain's Call entry; "blocked", "dated", and "aged" leave the default # Captain's Call, render as Charted Next gates stating why (the blocking work, # the until date, or the floored age), and are counted in omitted[]. -# --all-decisions reveals every captain hold and drops its gate, so a hold is -# never shown twice. Aging is a projection safety net only; the durable +# --all-decisions reveals every captain hold available within the bounded snapshot +# and drops its gate, so a hold is never shown twice. Aging is a projection safety +# net only; the durable # deferral remains re-holding with --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory @@ -72,7 +62,7 @@ # --include-prs ALSO do live GitHub open-PR discovery + checks # --fields opt in to dropped surfaces: bodies,paths,actions,endpoints # --all-in-flight include every in-flight task -# --all-decisions include every open decision and presentation-filtered captain hold +# --all-decisions include every open decision and captain hold in the bounded snapshot # --all-secondmates include every aggregated secondmate record # --all-landed include every landed record from every home (default: bounded) # --all-reports include the full scout-report inventory (default: relevant only) @@ -152,7 +142,7 @@ For every registered secondmate, readable structured facts from its own home are evidence and never become current work. The provenance and freshness fields distinguish live and cached ledgers; a home without either is explicitly unreadable. Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, - --all-decisions (all open decisions and presentation-filtered captain holds), + --all-decisions (all open decisions and captain holds in the bounded snapshot), --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). Raise FM_BEARINGS_PR_LIMIT to expand per-repository open-PR results. @@ -481,7 +471,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ + [ .backlog.records[] | . as $record | select(.structured and - (.state == "queued" or + (.hold_bucket != null or .state == "queued" or (.state == "in_flight" and .current_role == "held" and ($working_ids | index($record.id) | not)))) | select(.captain_actionable != true) | select((.hold_bucket == null) or ($all_decisions == 0)) @@ -552,7 +542,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.input_truncated == true or .parent_event.activity_scan.retained_truncated == true)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence truncated for \($n) record(s)"), reveal:"raise FM_SNAPSHOT_PARENT_ACTIVITY_LINES, FM_SNAPSHOT_PARENT_ACTIVITY_BYTES, or FM_SNAPSHOT_PARENT_ACTIVITIES"} else empty end), (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.available == false)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence unavailable for \($n) record(s)"), reveal:"inspect the parent status logs"} else empty end), (if $all_decisions == 0 and ($decisions_all | length) > $decisions_n then {surface:("decisions_open showing \($decisions_n) of \($decisions_all | length)"), reveal:"--all-decisions"} else empty end), - (if $all_decisions == 0 and $decisions_marked_deferred > 0 then {surface:("captain holds marked deferred, superseded, or aged: \($decisions_marked_deferred)"), reveal:"--all-decisions"} else empty end), + (if $all_decisions == 0 and $decisions_marked_deferred > 0 then {surface:("captain holds bucketed blocked, dated, or aged: \($decisions_marked_deferred)"), reveal:"--all-decisions"} else empty end), (if $all_queued == 0 and ($gates_all | length) > $gates_n then {surface:("gates showing \($gates_n) of \($gates_all | length)"), reveal:"--all-queued"} else empty end), (if $all_reports == 0 and ($reports_all | length) > $reports_n then {surface:("reports showing \($reports_n) of \($reports_all | length)"), reveal:"--all-reports"} else empty end), (if $all_recorded_prs == 0 and ($recorded_prs_all | length) > $recorded_prs_n then {surface:("recorded_prs showing \($recorded_prs_n) of \($recorded_prs_all | length)"), reveal:"--all-recorded-prs"} else empty end), diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 9b9b959b2da..9df5450853f 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -44,7 +44,8 @@ # re-holding with --until. # Renderers keep every non-live bucket out of the default Captain's Call, # project it as a Charted Next gate stating why, and disclose it in -# omitted[]; --all-decisions reveals every captain hold. +# omitted[]; --all-decisions reveals every captain hold available within the +# bounded snapshot. # tasks[]: one row per task metadata record captured at snapshot start, sorted # by id. A record removed before capture is omitted. If a captured task's # generation changes while observations run, its selected metadata remains @@ -369,15 +370,6 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | (timestamp_epoch($to)) as $b | if $a == null or $b == null then null else (($b - $a) / 86400 | floor) end; - def hold_stamp_line: - test("^Captain hold set: [0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?$"); - def resolution_leader: - test("^Resolution recorded by fm-(captain|decision)-hold\\.$"); - def current_prose_lines: - (if length > 0 and (.[0] | hold_stamp_line) then .[1:] else . end) as $lines - | (($lines | map(resolution_leader) | index(true)) - // ($lines | length)) as $resolution - | $lines[:$resolution]; def section_state: if . == "In flight" then "in_flight" elif . == "Queued" then "queued" @@ -495,7 +487,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | .records |= map( if (.body_lines | length) > 0 then .hold_set = cap(.body_lines[0]; "^Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)$") - | .body_excerpt = ((.body_lines | current_prose_lines | join(" "))[:240]) + | .body_excerpt = ((.body_lines | join(" "))[:240]) else . end) | .records as $records | (reduce ($records[] | select(.structured)) as $record ({}; @@ -939,7 +931,7 @@ secondmate_home_summary_json() { # | ([ $backlog.records[]? | select(.state == "in_flight" and .structured) ]) as $owned_in_flight | ([ $backlog.records[]? | select(.structured and - (.state == "queued" or + (.hold_bucket != null or .state == "queued" or (.state == "in_flight" and .current_role == "held" and (.id as $id | any($tasks[]; .id == $id and .current_state.state == "working") | not)))) ]) as $queued_all diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index d215cca9554..9aca418065d 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -18,7 +18,6 @@ The `answer` subcommand records the captain's exact words and closes the call in It requires a non-empty captain decision file of at most 8192 bytes, durably writes a resolution block carrying the decision digest and a `Resolution mode:` while retaining the leading hold-set stamp until `tasks-axi done` or, under `answer --release`, `tasks-axi unhold` succeeds, then restores the successful record's resolution-first body ordering (the previous body remains preserved below the block and archived through tasks-axi `--archive-body`). If the close is interrupted, the still-held task therefore keeps its original age basis. A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded. -As an accepted limit, a rare concurrent answer-close and re-hold race can leave the newly re-held task without its age basis; re-holding it through the wrapper with `--until` remains the durable fix rather than relying on this projection safety net. An exact retry is idempotent only when the requested close mode matches the newest record; a drifted answer or mode mismatch is rejected, while a re-held task accepts a new answer as a new record on top. On a task closed outside the script, `answer` records the missing block only when the captain-hold annotations tasks-axi preserves through a close prove the captain owned it, and it verifies the task stays closed. A hold whose `--until` date has passed keeps those annotations while tasks-axi reports it no longer held, so an expired deferral remains answerable. @@ -74,11 +73,17 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de `bin/fm-bearings-snapshot.sh` places each captain hold by its `hold_bucket` and inspects no prose of its own. A `live` hold is a default Captain's Call entry. A `blocked`, `dated`, or `aged` hold leaves the default Captain's Call, renders as a Charted Next gate stating why - the blocking work, the `until `, or the floored age - and contributes to the concrete `omitted[]` disclosure. -`--all-decisions` reveals every captain hold and drops its gate, so a hold is shown once and never twice. -Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`, so a remote captain hold beyond those bounds may not project as a Charted Next gate. -A remote or secondmate hold also retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent; re-holding it through the wrapper with `--until` remains the durable fix for both limits. +`--all-decisions` reveals every captain hold available within the remote-summary bound and drops its gate, so an available hold is shown once and never twice. + +Three accepted limits remain deliberate: + +- A remote or secondmate hold retains the producer home's age and aging decision from the summary's capture time and threshold rather than being recomputed by the parent. +- A rare concurrent answer-close and re-hold race can leave the newly re-held task without its age basis. +- Cross-home summaries remain bounded by `FM_SNAPSHOT_SECONDMATE_DECISIONS` and `FM_SNAPSHOT_SECONDMATE_QUEUED`; a remote deferred hold beyond those bounds is not exported, so it can be neither gated nor revealed. + +Re-holding through the wrapper with `--until` remains the durable fix rather than relying on the projection safety net. Recently Landed excludes a record that closed while still held for the captain (surviving `hold-kind: captain` on a Done row), so answered questions do not masquerade as shipped work; a work item released before completion keeps no hold annotations and lands normally. -The projection remains read-only and does not inspect historical prose beyond the canonical snapshot's marker. +The projection remains read-only and uses the canonical snapshot's structured fields, including the machine-written hold-set timestamp. ## Record divergence diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 048be60fbf7..6b73f08d6da 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1164,7 +1164,7 @@ EOF and (.gates | any(.[]; .id == "external-gate")) and (.landed | any(.[]; .id == "shipped-work")) and (.landed | any(.[]; .id == "answered-call") | not) - and (.omitted | any(.[]; .surface | startswith("captain holds marked deferred"))) + and (.omitted | any(.[]; .surface | startswith("captain holds bucketed blocked, dated, or aged"))) ' >/dev/null || fail "the collapsed captain-call projection is wrong: $json" json=$(run "$home" "$fakebin" --json --all-decisions --all-queued) printf '%s' "$json" | jq -e ' @@ -1280,7 +1280,7 @@ EOF | contains(["due-parked", "due-superseded", "future-parked", "parked-hold", "blocked-parked", "aged-call", "aging-mate/mate-due-parked", "aging-mate/mate-due-not-required", "mate-future-parked", "aging-mate/mate-parked", "mate-blocked-parked", "mate-aged"])) - and (.omitted | any(.[]; .surface == "captain holds marked deferred, superseded, or aged: 7")) + and (.omitted | any(.[]; .surface == "captain holds bucketed blocked, dated, or aged: 7")) ' >/dev/null || fail "structured buckets must decide Captain's Call placement: $json" json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' @@ -1335,7 +1335,7 @@ EOF printf '%s' "$json" | jq -e ' (.gates | any(.id == "only-blocked-parked" and .blocked_by == "missing-blocker" and (.reason | startswith("blocked-by missing-blocker")))) - and (.omitted | any(.surface == "captain holds marked deferred, superseded, or aged: 1")) + and (.omitted | any(.surface == "captain holds bucketed blocked, dated, or aged: 1")) ' >/dev/null || fail "a lone blocked deferred hold lacked its concrete disclosure: $json" json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' @@ -1995,6 +1995,74 @@ seed_working_child() { # [repo] printf 'working: %s\n' "$doing" > "$mate/state/$id.status" } +test_working_captain_holds_keep_their_bucket_surfaces() { + local home mate fakebin id summary json expanded + home=$(make_home working-hold-buckets) + mate="$TMP_ROOT/working-hold-buckets-mate" + : > "$home/data/secondmates.md" + make_valid_secondmate_home working-mate "$mate" + append_secondmate_registry "$home" working-mate "$mate" + + for home in "$home" "$mate"; do + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] working-live - Working live call (repo: sample) (kind: captain) (hold: choose release route) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z +- [ ] working-blocked - Working blocked call blocked-by: missing-work (repo: sample) (kind: captain) (hold: choose blocked route) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z +- [ ] working-dated - Working dated call (repo: sample) (kind: captain) (hold: choose dated route) (hold-kind: captain) (hold-until: 2026-08-01) + Captain hold set: 2026-07-10T00:00:00Z +- [ ] working-aged - Working aged call (repo: sample) (kind: captain) (hold: choose aged route) (hold-kind: captain) + Captain hold set: 2026-06-01T00:00:00Z + +## Queued + +## Done +EOF + for id in working-live working-blocked working-dated working-aged; do + mkdir -p "$home/projects/$id" + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" "worktree=$home/projects/$id" "project=sample" \ + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$home/state" "$id" busy + printf 'working: active held work\n' > "$home/state/$id.status" + done + done + home=$(make_home working-hold-buckets) + fakebin=$(make_fakebin "$home") + + summary=$(PATH="$fakebin:$PATH" FM_HOME="$mate" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + "$ROOT/bin/fm-fleet-snapshot.sh" --secondmate-home-summary) + printf '%s' "$summary" | jq -e ' + ([.decisions_open[].id] | contains(["working-live"])) + and ([.queued[].id] | contains(["working-live", "working-blocked", "working-dated", "working-aged"])) + and ([.queued[] | select(.hold_bucket != null)] | length) == 4 + ' >/dev/null || fail "working captain holds were filtered from the secondmate summary: $summary" + + json=$(run "$home" "$fakebin" --json --all-queued) + printf '%s' "$json" | jq -e ' + (.decisions_open | any(.id == "working-live")) + and (.decisions_open | any(.id == "working-mate/working-live")) + and (.gates | any(.id == "working-blocked" and .owner == "(main)" and (.reason | startswith("blocked-by missing-work")))) + and (.gates | any(.id == "working-dated" and .owner == "(main)" and (.reason | startswith("until 2026-08-01")))) + and (.gates | any(.id == "working-aged" and .owner == "(main)" and (.reason | startswith("held 40d")))) + and (.gates | any(.id == "working-blocked" and .owner == "working-mate")) + and (.gates | any(.id == "working-dated" and .owner == "working-mate")) + and (.gates | any(.id == "working-aged" and .owner == "working-mate")) + ' >/dev/null || fail "working captain holds did not surface in their default buckets: $json" + + expanded=$(run "$home" "$fakebin" --json --all-decisions --all-queued) + printf '%s' "$expanded" | jq -e ' + ([.decisions_open[].id] + | contains(["working-live", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-blocked", + "working-mate/working-dated", "working-mate/working-aged"])) + and ([.gates[] | select(.id == "working-live" or .id == "working-blocked" + or .id == "working-dated" or .id == "working-aged")] | length) == 0 + ' >/dev/null || fail "--all-decisions did not reveal every working captain hold gate-free: $expanded" + pass "working captain holds retain main and secondmate bucket surfaces" +} + test_active_children_project_independent_of_home_captain_hold() { local home mate fakebin json home=$(make_home underway-hold-parent) @@ -2853,6 +2921,7 @@ test_captains_call_anti_leak test_main_orphan_in_flight_is_disclosed_not_invented test_main_unstructured_current_is_disclosed_with_structured_sibling test_main_orphan_counterfactual_meta_clears_inventory_warning +test_working_captain_holds_keep_their_bucket_surfaces test_active_children_project_independent_of_home_captain_hold test_mixed_secondmate_roles_partial_state_and_captain_readiness test_main_captain_readiness_matches_secondmate_projection From 7de38ba06ae56c1a8feb1e87d191883d7be76931 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 01:08:54 -0700 Subject: [PATCH 40/47] no-mistakes(review): Reject pre-classifier secondmate summary caches --- bin/fm-fleet-snapshot.sh | 18 ++++-------------- bin/fm-home-summary-refresh.sh | 6 ++++-- tests/fm-bearings-snapshot.test.sh | 26 +++++++++++++++++--------- tests/fm-secondmate-reconcile.test.sh | 3 ++- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 9df5450853f..808e03015c0 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -1030,6 +1030,7 @@ secondmate_home_summary_json() { # else "no_active_work" end) as $state | { schema:"fm-secondmate-home-summary.v1", + hold_classifier_schema:"fm-captain-hold-buckets.v1", generated:$generated, generated_epoch:$generated_epoch, home:$home, @@ -1297,27 +1298,16 @@ prepare_remote_summary_collection() { # SNAPSHOT_COLLECT_DIR=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/fm-fleet-ledgers.XXXXXX") || return 1 SNAPSHOT_SUMMARY_FILTER="$SNAPSHOT_COLLECT_DIR/summary-filter.jq" cat > "$SNAPSHOT_SUMMARY_FILTER" <<'JQ' -def current_hold_aging_fields: - has("hold_bucket") - and (.hold_bucket as $bucket - | ["live", "blocked", "dated", "aged"] | index($bucket)) != null - and (.hold_age_days == null - or ((.hold_age_days | type) == "number" - and (.hold_age_days | floor) == .hold_age_days)); length == 1 and (.[0] | - .schema == "fm-secondmate-home-summary.v1" and .home == $home + .schema == "fm-secondmate-home-summary.v1" + and .hold_classifier_schema == "fm-captain-hold-buckets.v1" + and .home == $home and (.generated | type) == "string" and (.generated_epoch | type) == "number" and .generated_epoch >= 0 and (.generated_epoch | floor) == .generated_epoch and (.valid | type) == "boolean" and (.state | type) == "string" and (.invalidity | type) == "object" and (.invalidity.ids | type) == "array" and (.active_children | type) == "array" and (.decisions_open | type) == "array" and (.holds | type) == "array" and (.queued | type) == "array" - and (all(.decisions_open[]; - if .source == "backlog" and .verb == "captain-hold" - then current_hold_aging_fields else true end)) - and (all(.queued[]; - if .hold_kind == "captain" - then current_hold_aging_fields else true end)) and (.landed | type) == "array" and (.endpoints | type) == "array" and (.counts | type) == "object" and (.omitted | type) == "array" ) diff --git a/bin/fm-home-summary-refresh.sh b/bin/fm-home-summary-refresh.sh index 23813c96a3b..4aab66341ba 100755 --- a/bin/fm-home-summary-refresh.sh +++ b/bin/fm-home-summary-refresh.sh @@ -5,8 +5,9 @@ # # The published state/home-summary.json is the exact # `fm-fleet-snapshot.sh --secondmate-home-summary` document for this FM_HOME. -# Its schema remains `fm-secondmate-home-summary.v1` and includes both the -# existing generated timestamp and generated_epoch for freshness arithmetic. +# Its schema remains `fm-secondmate-home-summary.v1`, declares the current hold +# classifier contract, and includes both the existing generated timestamp and +# generated_epoch for freshness arithmetic. # # Publication is atomic: the producer writes and validates a unique mode-0600 # temporary file on the state directory's filesystem, then renames it over the @@ -150,6 +151,7 @@ home_summary_refresh_once() { HOME_SUMMARY_ERR_TMP= if ! jq -e --arg home "$FM_HOME" ' .schema == "fm-secondmate-home-summary.v1" + and .hold_classifier_schema == "fm-captain-hold-buckets.v1" and .home == $home and (.generated | type) == "string" and (.generated | length) > 0 diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 6b73f08d6da..b2cd6ca7cd2 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -217,6 +217,7 @@ write_remote_home_summary() { # mkdir -p "$home/state" jq -n --arg home "$home" --argjson epoch "$epoch" '{ schema:"fm-secondmate-home-summary.v1", + hold_classifier_schema:"fm-captain-hold-buckets.v1", generated:"2026-09-01T22:00:00Z",generated_epoch:$epoch,home:$home, valid:true,reason:null,invalidity:{kind:null,ids:[]},state:"captain_decision", active_children:[], @@ -2762,23 +2763,30 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { done [ -n "$cache_file" ] || fail "healthy remote read did not populate its summary cache" tmp="$cache_file.tmp" - jq '(.decisions_open[] | select(.verb == "captain-hold")) - |= del(.hold_bucket, .hold_age_days) - | (.queued[] | select(.hold_kind == "captain")) - |= del(.hold_bucket, .hold_age_days)' \ + jq 'del(.hold_classifier_schema)' \ "$cache_file" > "$tmp" && mv "$tmp" "$cache_file" tmp="$remote_home/state/home-summary.json.tmp" - jq '(.decisions_open[] | select(.verb == "captain-hold")) - |= del(.hold_bucket, .hold_age_days) - | (.queued[] | select(.hold_kind == "captain")) - |= del(.hold_bucket, .hold_age_days)' \ + jq 'del(.hold_classifier_schema)' \ "$remote_home/state/home-summary.json" > "$tmp" && mv "$tmp" "$remote_home/state/home-summary.json" json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' (.secondmates | any(.id == "ledger-1" and .state == "unknown" and .provenance == "unknown" and (.reason | contains("no valid cached copy")))) and (.omitted | any(.surface == "secondmate home(s) with unreadable structured state: 1")) - ' >/dev/null || fail "a hold-bearing live and cached summary without aging fields was not invalidated and disclosed: $json" + ' >/dev/null || fail "a hold-bearing pre-classifier live and cached summary was not invalidated and disclosed: $json" + + tmp="$cache_file.tmp" + jq '.decisions_open = [] | .queued = []' "$cache_file" > "$tmp" && mv "$tmp" "$cache_file" + tmp="$remote_home/state/home-summary.json.tmp" + jq '.decisions_open = [] | .queued = []' \ + "$remote_home/state/home-summary.json" > "$tmp" && mv "$tmp" "$remote_home/state/home-summary.json" + json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) + printf '%s' "$json" | jq -e ' + (.secondmates | any(.id == "ledger-1" and .state == "unknown" + and .provenance == "unknown" and (.reason | contains("no valid cached copy")))) + and (.omitted | any(.surface == "secondmate home(s) with unreadable structured state: 1")) + ' >/dev/null || fail "an empty pre-classifier live and cached summary was not invalidated and disclosed: $json" + write_remote_home_summary "$remote_home" 1000 json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' diff --git a/tests/fm-secondmate-reconcile.test.sh b/tests/fm-secondmate-reconcile.test.sh index 0d2e16fa2c1..7cc148b2dff 100755 --- a/tests/fm-secondmate-reconcile.test.sh +++ b/tests/fm-secondmate-reconcile.test.sh @@ -880,7 +880,8 @@ test_bearings_request_returns_before_remote_delivery_and_supervision_sends_later rhome=$(cd "$rhome" && pwd -P) home=$(make_remote_parent_home remote-offpath remote-offpath-mate "$rhome" remote-offpath-host) jq -n --arg home "$rhome" '{ - schema:"fm-secondmate-home-summary.v1",generated:"2026-09-01T22:00:00Z",generated_epoch:1900,home:$home, + schema:"fm-secondmate-home-summary.v1",hold_classifier_schema:"fm-captain-hold-buckets.v1", + generated:"2026-09-01T22:00:00Z",generated_epoch:1900,home:$home, valid:false,reason:"in-flight backlog item has no child metadata: stale-row", invalidity:{kind:"orphan_in_flight",ids:["stale-row"]},state:"no_active_work", active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[], From 7a38735b22a1f55c66c7db240a01daa1ef55674b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 01:13:58 -0700 Subject: [PATCH 41/47] no-mistakes(review): Preserve complete live hold summaries --- bin/fm-bearings-snapshot.sh | 7 ++++--- tests/fm-bearings-snapshot.test.sh | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 9695e004d83..80c446cda44 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -345,9 +345,10 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | (hold_note) as $note | if $note == null then $base else ($note + ": " + $base) end; def hold_summary($title; $base): - ($title | trunc(46)) + ": " - + ((hold_note) as $note - | (if $note == null then $base else ($note + ": " + $base) end) | trunc(42)); + (hold_note) as $note + | if $note == null then (($title + ": " + $base) | trunc(90)) + else (($title | trunc(46)) + ": " + (($note + ": " + $base) | trunc(42))) + end; def as_gate($owner): {id, title:(.title | trunc(60)), blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)), diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index b2cd6ca7cd2..9a756e90bc0 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1354,10 +1354,10 @@ test_revealed_deferred_holds_show_their_deferral_reason() { ## Queued - [ ] reveal-blocked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) -- [ ] reveal-future - Parked call for later (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-12-01) +- [ ] reveal-future - 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-12-01) - [ ] reveal-aged - Aged undated call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z -- [ ] reveal-live - Genuine live call (repo: firstmate) (kind: captain) (hold: choose a cache) (hold-kind: captain) +- [ ] reveal-live - 123456789012345678901234567890123456789012345678901234567890 (repo: firstmate) (kind: captain) (hold: choose A) (hold-kind: captain) ## Done EOF @@ -1374,12 +1374,13 @@ EOF ' >/dev/null || fail "a revealed aged hold must show its age: $json" printf '%s' "$json" | jq -e ' (.decisions_open | any(.id == "reveal-live" - and (.summary | contains("choose a cache")) + and .summary == "123456789012345678901234567890123456789012345678901234567890: choose A" and (.summary | contains("blocked-by") or contains("until ") or contains("held ") | not))) - ' >/dev/null || fail "a genuinely live call must not be annotated as deferred: $json" + ' >/dev/null || fail "a long-titled live call must remain complete and unannotated: $json" json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' ([.decisions_open[].id] == ["reveal-live"]) + and (.decisions_open[0].summary == "123456789012345678901234567890123456789012345678901234567890: choose A") and (.gates | any(.id == "reveal-blocked")) and (.gates | any(.id == "reveal-future")) and (.gates | any(.id == "reveal-aged")) From 97686052b21145fc111962426eabf557f1cc32b7 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 17:14:16 -0700 Subject: [PATCH 42/47] no-mistakes(review): Clarify working hold decision bucket semantics --- bin/fm-bearings-snapshot.sh | 13 +++++----- docs/captain-hold-lifecycle.md | 3 ++- tests/fm-bearings-snapshot.test.sh | 40 +++++++++++++++++++----------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 80c446cda44..d41002dc159 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -31,13 +31,14 @@ # Captain-hold placement follows the canonical snapshot's hold_bucket and # nothing else; this wrapper never inspects hold reason or body prose. The # buckets are total and mutually exclusive, so every captain hold appears in -# exactly one place and none can fall through both. A "live" hold is a default -# Captain's Call entry; "blocked", "dated", and "aged" leave the default -# Captain's Call, render as Charted Next gates stating why (the blocking work, -# the until date, or the floored age), and are counted in omitted[]. +# exactly one decision bucket and none can fall through both. An actively worked +# held task may also appear in Underway. A "live" hold is a default Captain's Call +# entry; "blocked", "dated", and "aged" leave the default Captain's Call, render +# as Charted Next gates stating why (the blocking work, the until date, or the +# floored age), and are counted in omitted[]. # --all-decisions reveals every captain hold available within the bounded snapshot -# and drops its gate, so a hold is never shown twice. Aging is a projection safety -# net only; the durable +# and drops its gate, so a hold is never in both Captain's Call and Charted Next. +# Aging is a projection safety net only; the durable # deferral remains re-holding with --until. # # Main-home inventory validity comes from the canonical snapshot's main_inventory diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 9aca418065d..cd430d2182d 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -73,7 +73,8 @@ Its secondmate-home summary classifies an actionable captain hold as `captain_de `bin/fm-bearings-snapshot.sh` places each captain hold by its `hold_bucket` and inspects no prose of its own. A `live` hold is a default Captain's Call entry. A `blocked`, `dated`, or `aged` hold leaves the default Captain's Call, renders as a Charted Next gate stating why - the blocking work, the `until `, or the floored age - and contributes to the concrete `omitted[]` disclosure. -`--all-decisions` reveals every captain hold available within the remote-summary bound and drops its gate, so an available hold is shown once and never twice. +`--all-decisions` reveals every captain hold available within the remote-summary bound and drops its gate, so an available hold is never in both Captain's Call and Charted Next. +An actively worked held task may also appear in Underway, which reports running work independently of those decision buckets. Three accepted limits remain deliberate: diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 9a756e90bc0..6898057f700 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -2041,27 +2041,39 @@ EOF and ([.queued[] | select(.hold_bucket != null)] | length) == 4 ' >/dev/null || fail "working captain holds were filtered from the secondmate summary: $summary" - json=$(run "$home" "$fakebin" --json --all-queued) + json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' - (.decisions_open | any(.id == "working-live")) + ([.in_flight[].id] | contains([ + "working-live", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-blocked", + "working-mate/working-dated", "working-mate/working-aged"])) + and (.decisions_open | any(.id == "working-live")) and (.decisions_open | any(.id == "working-mate/working-live")) - and (.gates | any(.id == "working-blocked" and .owner == "(main)" and (.reason | startswith("blocked-by missing-work")))) - and (.gates | any(.id == "working-dated" and .owner == "(main)" and (.reason | startswith("until 2026-08-01")))) - and (.gates | any(.id == "working-aged" and .owner == "(main)" and (.reason | startswith("held 40d")))) - and (.gates | any(.id == "working-blocked" and .owner == "working-mate")) - and (.gates | any(.id == "working-dated" and .owner == "working-mate")) - and (.gates | any(.id == "working-aged" and .owner == "working-mate")) - ' >/dev/null || fail "working captain holds did not surface in their default buckets: $json" + and ([.decisions_open[] | select(.id == "working-blocked" or .id == "working-dated" or .id == "working-aged" + or .id == "working-mate/working-blocked" or .id == "working-mate/working-dated" + or .id == "working-mate/working-aged")] | length) == 0 + and ([.gates[] | select(.id == "working-live")] | length) == 0 + and ([.gates[] | select(.id == "working-blocked" and .owner == "(main)" and (.reason | startswith("blocked-by missing-work")))] | length) == 1 + and ([.gates[] | select(.id == "working-dated" and .owner == "(main)" and (.reason | startswith("until 2026-08-01")))] | length) == 1 + and ([.gates[] | select(.id == "working-aged" and .owner == "(main)" and (.reason | startswith("held 40d")))] | length) == 1 + and ([.gates[] | select(.id == "working-blocked" and .owner == "working-mate")] | length) == 1 + and ([.gates[] | select(.id == "working-dated" and .owner == "working-mate")] | length) == 1 + and ([.gates[] | select(.id == "working-aged" and .owner == "working-mate")] | length) == 1 + ' >/dev/null || fail "working captain holds did not surface in Underway and exactly one default decision bucket: $json" expanded=$(run "$home" "$fakebin" --json --all-decisions --all-queued) printf '%s' "$expanded" | jq -e ' - ([.decisions_open[].id] - | contains(["working-live", "working-blocked", "working-dated", "working-aged", - "working-mate/working-live", "working-mate/working-blocked", - "working-mate/working-dated", "working-mate/working-aged"])) + ([.in_flight[].id] | contains([ + "working-live", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-blocked", + "working-mate/working-dated", "working-mate/working-aged"])) + and ([.decisions_open[].id] + | contains(["working-live", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-blocked", + "working-mate/working-dated", "working-mate/working-aged"])) and ([.gates[] | select(.id == "working-live" or .id == "working-blocked" or .id == "working-dated" or .id == "working-aged")] | length) == 0 - ' >/dev/null || fail "--all-decisions did not reveal every working captain hold gate-free: $expanded" + ' >/dev/null || fail "--all-decisions did not keep working holds Underway and reveal each gate-free: $expanded" pass "working captain holds retain main and secondmate bucket surfaces" } From 6cdc078676065e49505efa831c1e0a4958a16df0 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 17:18:31 -0700 Subject: [PATCH 43/47] no-mistakes(review): Reveal bounded remote holds and preserve blocker notes --- bin/fm-bearings-snapshot.sh | 21 ++++++++++++--- tests/fm-bearings-snapshot.test.sh | 42 ++++++++++++++++++------------ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index d41002dc159..ec3c158f7b1 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -331,6 +331,11 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson candidate_prs "$CANDIDATE_PRS" ' def trunc($n): if . == null then null else (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; + def fit($n): + tostring | gsub("\\s+"; " ") + | if $n <= 0 then "" + elif length > $n then (if $n == 1 then "…" else (.[:($n - 1)] + "…") end) + else . end; def live_captain_call: .hold_bucket == "live"; def projected_deferred_hold: .hold_bucket != null and .hold_bucket != "live"; @@ -348,7 +353,13 @@ MODEL=$(printf '%s' "$SNAP" | jq \ def hold_summary($title; $base): (hold_note) as $note | if $note == null then (($title + ": " + $base) | trunc(90)) - else (($title | trunc(46)) + ": " + (($note + ": " + $base) | trunc(42))) + else ($note | length) as $note_n + | (86 - $note_n) as $context_n + | if $context_n < 2 then ($note | fit(90)) + else ([46, ($context_n / 2 | floor)] | min) as $title_n + | (($title | fit($title_n)) + ": " + $note + ": " + + ($base | fit($context_n - $title_n))) + end end; def as_gate($owner): {id, title:(.title | trunc(60)), @@ -452,8 +463,12 @@ MODEL=$(printf '%s' "$SNAP" | jq \ summary:hold_summary((.summary // .id); (.reason // "captain decision pending")),owner:$m.id} ] + [ $m.queued[]? - | select($all_decisions == 1 and .hold_kind == "captain" - and projected_deferred_hold) + | select($all_decisions == 1 and .hold_kind == "captain") + | select(.id as $id + | [$m.decisions_open[]? + | select(.source == "backlog" and .verb == "captain-hold") + | .id] + | index($id) | not) | {id:($m.id + "/" + .id),key:.id,verb:"captain-hold", summary:hold_summary((.title // .id); (.hold_reason // "captain decision pending")),owner:$m.id} ])[] ]) as $decisions_all diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 6898057f700..7b2b1416a07 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1353,7 +1353,7 @@ test_revealed_deferred_holds_show_their_deferral_reason() { ## In flight ## Queued -- [ ] reveal-blocked - Blocked parked call blocked-by: missing-blocker (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) +- [ ] reveal-blocked - Blocked parked call blocked-by: missing-blocker-1234567890123456789012345678901234567890 (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) - [ ] reveal-future - 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 (repo: firstmate) (kind: captain) (hold: parked) (hold-kind: captain) (hold-until: 2026-12-01) - [ ] reveal-aged - Aged undated call (repo: firstmate) (kind: captain) (since 2026-06-01) (hold: choose a route) (hold-kind: captain) Captain hold set: 2026-06-01T00:00:00Z @@ -1364,8 +1364,9 @@ EOF fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json --all-decisions) printf '%s' "$json" | jq -e ' - (.decisions_open | any(.id == "reveal-blocked" and (.summary | contains("blocked-by missing-blocker")))) - ' >/dev/null || fail "a revealed blocked hold must show its blocked-by: $json" + (.decisions_open | any(.id == "reveal-blocked" + and (.summary | contains("blocked-by missing-blocker-1234567890123456789012345678901234567890")))) + ' >/dev/null || fail "a revealed blocked hold must show its complete blocked-by identity: $json" printf '%s' "$json" | jq -e ' (.decisions_open | any(.id == "reveal-future" and (.summary | contains("until 2026-12-01")))) ' >/dev/null || fail "a revealed date-deferred hold must show its until date: $json" @@ -2010,6 +2011,8 @@ test_working_captain_holds_keep_their_bucket_surfaces() { ## In flight - [ ] working-live - Working live call (repo: sample) (kind: captain) (hold: choose release route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z +- [ ] working-live-two - Second working live call (repo: sample) (kind: captain) (hold: choose backup route) (hold-kind: captain) + Captain hold set: 2026-07-10T00:00:00Z - [ ] working-blocked - Working blocked call blocked-by: missing-work (repo: sample) (kind: captain) (hold: choose blocked route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z - [ ] working-dated - Working dated call (repo: sample) (kind: captain) (hold: choose dated route) (hold-kind: captain) (hold-until: 2026-08-01) @@ -2021,7 +2024,7 @@ test_working_captain_holds_keep_their_bucket_surfaces() { ## Done EOF - for id in working-live working-blocked working-dated working-aged; do + for id in working-live working-live-two working-blocked working-dated working-aged; do mkdir -p "$home/projects/$id" fm_write_meta "$home/state/$id.meta" \ "window=firstmate:fm-$id" "worktree=$home/projects/$id" "project=sample" \ @@ -2034,25 +2037,28 @@ EOF fakebin=$(make_fakebin "$home") summary=$(PATH="$fakebin:$PATH" FM_HOME="$mate" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ + FM_SNAPSHOT_SECONDMATE_DECISIONS=1 \ "$ROOT/bin/fm-fleet-snapshot.sh" --secondmate-home-summary) printf '%s' "$summary" | jq -e ' - ([.decisions_open[].id] | contains(["working-live"])) - and ([.queued[].id] | contains(["working-live", "working-blocked", "working-dated", "working-aged"])) - and ([.queued[] | select(.hold_bucket != null)] | length) == 4 + ([.decisions_open[].id] == ["working-live"]) + and ([.queued[].id] | contains(["working-live", "working-live-two", "working-blocked", "working-dated", "working-aged"])) + and ([.queued[] | select(.hold_bucket != null)] | length) == 5 ' >/dev/null || fail "working captain holds were filtered from the secondmate summary: $summary" - json=$(run "$home" "$fakebin" --json) + json=$(FM_SNAPSHOT_SECONDMATE_DECISIONS=1 run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' ([.in_flight[].id] | contains([ - "working-live", "working-blocked", "working-dated", "working-aged", - "working-mate/working-live", "working-mate/working-blocked", + "working-live", "working-live-two", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-live-two", "working-mate/working-blocked", "working-mate/working-dated", "working-mate/working-aged"])) and (.decisions_open | any(.id == "working-live")) + and (.decisions_open | any(.id == "working-live-two")) and (.decisions_open | any(.id == "working-mate/working-live")) + and (.decisions_open | any(.id == "working-mate/working-live-two") | not) and ([.decisions_open[] | select(.id == "working-blocked" or .id == "working-dated" or .id == "working-aged" or .id == "working-mate/working-blocked" or .id == "working-mate/working-dated" or .id == "working-mate/working-aged")] | length) == 0 - and ([.gates[] | select(.id == "working-live")] | length) == 0 + and ([.gates[] | select(.id == "working-live" or .id == "working-live-two")] | length) == 0 and ([.gates[] | select(.id == "working-blocked" and .owner == "(main)" and (.reason | startswith("blocked-by missing-work")))] | length) == 1 and ([.gates[] | select(.id == "working-dated" and .owner == "(main)" and (.reason | startswith("until 2026-08-01")))] | length) == 1 and ([.gates[] | select(.id == "working-aged" and .owner == "(main)" and (.reason | startswith("held 40d")))] | length) == 1 @@ -2061,17 +2067,19 @@ EOF and ([.gates[] | select(.id == "working-aged" and .owner == "working-mate")] | length) == 1 ' >/dev/null || fail "working captain holds did not surface in Underway and exactly one default decision bucket: $json" - expanded=$(run "$home" "$fakebin" --json --all-decisions --all-queued) + expanded=$(FM_SNAPSHOT_SECONDMATE_DECISIONS=1 run "$home" "$fakebin" --json --all-decisions --all-queued) printf '%s' "$expanded" | jq -e ' ([.in_flight[].id] | contains([ - "working-live", "working-blocked", "working-dated", "working-aged", - "working-mate/working-live", "working-mate/working-blocked", + "working-live", "working-live-two", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-live-two", "working-mate/working-blocked", "working-mate/working-dated", "working-mate/working-aged"])) and ([.decisions_open[].id] - | contains(["working-live", "working-blocked", "working-dated", "working-aged", - "working-mate/working-live", "working-mate/working-blocked", + | contains(["working-live", "working-live-two", "working-blocked", "working-dated", "working-aged", + "working-mate/working-live", "working-mate/working-live-two", "working-mate/working-blocked", "working-mate/working-dated", "working-mate/working-aged"])) - and ([.gates[] | select(.id == "working-live" or .id == "working-blocked" + and ([.decisions_open[] | select(.id == "working-mate/working-live")] | length) == 1 + and ([.decisions_open[] | select(.id == "working-mate/working-live-two")] | length) == 1 + and ([.gates[] | select(.id == "working-live" or .id == "working-live-two" or .id == "working-blocked" or .id == "working-dated" or .id == "working-aged")] | length) == 0 ' >/dev/null || fail "--all-decisions did not keep working holds Underway and reveal each gate-free: $expanded" pass "working captain holds retain main and secondmate bucket surfaces" From 9733a7d5fbba7aa9ae36f09b508173e6bc7b0cb4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 17:41:52 -0700 Subject: [PATCH 44/47] no-mistakes(review): Make blocker overflow explicit in hold summaries --- bin/fm-bearings-snapshot.sh | 18 ++++++++++++++++-- tests/fm-bearings-snapshot.test.sh | 9 +++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index ec3c158f7b1..66f7750949f 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -339,9 +339,23 @@ MODEL=$(printf '%s' "$SNAP" | jq \ def live_captain_call: .hold_bucket == "live"; def projected_deferred_hold: .hold_bucket != null and .hold_bucket != "live"; + def bounded_blocker_note($n): + ((.unresolved_blocker_ids // []) | map(tostring)) as $ids + | reduce range(0; $ids | length) as $i + ({shown:[]}; + ($ids[0:($i + 1)]) as $candidate + | (($ids | length) - ($i + 1)) as $remaining + | ("blocked-by " + ($candidate | join(",")) + + (if $remaining > 0 then " +\($remaining) more" else "" end)) as $rendered + | if ($rendered | length) <= $n then .shown = $candidate else . end) + | .shown as $shown + | (($ids | length) - ($shown | length)) as $remaining + | if ($shown | length) == 0 then "blocked-by +\($remaining) more" + else ("blocked-by " + ($shown | join(",")) + + (if $remaining > 0 then " +\($remaining) more" else "" end)) + end; def hold_note: - if .hold_bucket == "blocked" then - ("blocked-by " + ((.unresolved_blocker_ids // []) | join(","))) + if .hold_bucket == "blocked" then bounded_blocker_note(70) elif .hold_bucket == "dated" then ("until " + (.hold_until // "-")) elif .hold_bucket == "aged" and .hold_age_days != null then ("held " + (.hold_age_days | tostring) + "d") diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 7b2b1416a07..b0bfca7ab13 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -2013,7 +2013,7 @@ test_working_captain_holds_keep_their_bucket_surfaces() { Captain hold set: 2026-07-10T00:00:00Z - [ ] working-live-two - Second working live call (repo: sample) (kind: captain) (hold: choose backup route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z -- [ ] working-blocked - Working blocked call blocked-by: missing-work (repo: sample) (kind: captain) (hold: choose blocked route) (hold-kind: captain) +- [ ] working-blocked - Working blocked call blocked-by: blocker-alpha-123456789012345678901234567890 blocked-by: blocker-beta-123456789012345678901234567890 (repo: sample) (kind: captain) (hold: choose blocked route) (hold-kind: captain) Captain hold set: 2026-07-10T00:00:00Z - [ ] working-dated - Working dated call (repo: sample) (kind: captain) (hold: choose dated route) (hold-kind: captain) (hold-until: 2026-08-01) Captain hold set: 2026-07-10T00:00:00Z @@ -2059,7 +2059,8 @@ EOF or .id == "working-mate/working-blocked" or .id == "working-mate/working-dated" or .id == "working-mate/working-aged")] | length) == 0 and ([.gates[] | select(.id == "working-live" or .id == "working-live-two")] | length) == 0 - and ([.gates[] | select(.id == "working-blocked" and .owner == "(main)" and (.reason | startswith("blocked-by missing-work")))] | length) == 1 + and ([.gates[] | select(.id == "working-blocked" and .owner == "(main)" + and .blocked_by == "blocker-alpha-123456789012345678901234567890,blocker-beta-123456789012345678901234567890")] | length) == 1 and ([.gates[] | select(.id == "working-dated" and .owner == "(main)" and (.reason | startswith("until 2026-08-01")))] | length) == 1 and ([.gates[] | select(.id == "working-aged" and .owner == "(main)" and (.reason | startswith("held 40d")))] | length) == 1 and ([.gates[] | select(.id == "working-blocked" and .owner == "working-mate")] | length) == 1 @@ -2079,6 +2080,10 @@ EOF "working-mate/working-dated", "working-mate/working-aged"])) and ([.decisions_open[] | select(.id == "working-mate/working-live")] | length) == 1 and ([.decisions_open[] | select(.id == "working-mate/working-live-two")] | length) == 1 + and ([.decisions_open[] | select( + (.id == "working-blocked" or .id == "working-mate/working-blocked") + and (.summary | contains("blocked-by blocker-alpha-123456789012345678901234567890 +1 more")) + and (.summary | length) <= 90)] | length) == 2 and ([.gates[] | select(.id == "working-live" or .id == "working-live-two" or .id == "working-blocked" or .id == "working-dated" or .id == "working-aged")] | length) == 0 ' >/dev/null || fail "--all-decisions did not keep working holds Underway and reveal each gate-free: $expanded" From 833379757b09f18f184c6b65f898ce11fd651ca4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 17:57:43 -0700 Subject: [PATCH 45/47] no-mistakes(document): Correct captain-hold projection documentation --- .agents/skills/bearings/SKILL.md | 5 +++-- bin/fm-backlog-transition-lib.sh | 6 +++--- bin/fm-fleet-snapshot.sh | 24 ++++++++++++------------ docs/captain-hold-lifecycle.md | 6 +++--- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 96e5fcbd149..5342fbcd730 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -143,8 +143,9 @@ Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. - Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. -- The four buckets are mutually exclusive per item: an unsuppressed needs-your-action item is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work, a deferred or aged captain-hold safety gate, or an action-free fleet-integrity warning is Charted Next. -- A secondmate home can contribute to more than one section at once. Each active child is an Underway row regardless of the home-level `bearings_state`, while that same home's due captain hold is Captain's Call and its queued or external holds stay Charted Next. Do not hide active children because the home also has an open captain hold. +- A captain hold appears in exactly one decision bucket: an unsuppressed live hold is in Captain's Call, while a blocked, dated, or aged hold is in Charted Next; `--all-decisions` moves the latter into Captain's Call and removes its gate. +- Underway independently reports active work, so an actively worked captain-held task may appear there plus its one decision bucket. +- A secondmate home can contribute to more than one section at once. Each active child is an Underway row regardless of the home-level `bearings_state`, while that same home's live captain hold is Captain's Call and its queued or external holds stay Charted Next. Do not hide active children because the home also has an open captain hold. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own home-level row is not an Underway unit: `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. - Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown` or its `invalidity` reports an inventory mismatch. diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index b40deeda6d1..4946bdd716e 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -319,9 +319,9 @@ fm_backlog_done() { # [flag...] # Keep a captain-held row open across the removal of the work record that # discovered it: record the finished work's deliverable as one line at the end # of the task body (a line already present is left alone) and return the row to -# Queued, which is the shape every other captain call has and what -# bin/fm-fleet-snapshot.sh's captain_actionable requires. The hold itself is -# untouched; only bin/fm-captain-hold.sh answer closes the call. The links are +# Queued, the conventional post-cleanup shape for an open captain call. +# bin/fm-fleet-snapshot.sh classifies that retained hold from its structured +# fields; only bin/fm-captain-hold.sh answer closes the call. The links are # written into the body rather than through `tasks-axi update --report`, # because that flag rewrites the title of a row that is not Done. fm_backlog_retain() { # [flag...] diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 808e03015c0..5ef8ffc35d1 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -83,11 +83,11 @@ # freshness is "cached" only for the cache source, and observed_at/age_seconds # come from the selected summary's generation. Every successfully sampled home also carries # reconcile_inventory independently of projection trust. -# Actionable captain holds -# appear in decisions_open; blocked captain holds remain queued with metadata. -# Structured-home input must carry the current hold-aging fields; an older -# live ledger or cached copy containing captain holds without those fields is -# invalid and leaves the home explicitly unreadable until its producer refreshes it. +# Actionable captain holds appear in decisions_open; every captain hold remains +# in the bounded queued inventory with its structured classification metadata. +# Structured-home input must declare the current hold-classifier schema; an +# older live ledger or cached copy is invalid even when it contains no captain +# holds, and leaves the home explicitly unreadable until its producer refreshes it. # secondmate_landed: {records[],truncated[],unreadable[],partial[]} - the # compatibility landed-work roll-up derived from secondmate_current. Readable # structured homes are partial, not unreadable, when an unavailable child state @@ -227,18 +227,18 @@ Its invalidity object names the normalized failure kind and affected ids. Actionable tasks-axi captain holds appear as decisions_open and stay visible in queued with hold_reason, hold_kind, hold_until, hold_bucket, hold_age_days, and plural blocker fields for downstream -projections. A captain hold is actionable only -when every blocker is Done and any hold-until date has arrived. +projections. A captain hold is actionable only when every blocker is Done, any +hold-until date has arrived, and an undated hold remains below the aging threshold. Cross-home collection uses FM_SNAPSHOT_SECONDMATES (default 20, 0 lifts the count bound) and FM_SNAPSHOT_SECONDMATE_MAX_BYTES. Every sampled remote home's state/home-summary.json is fetched concurrently under one FM_SNAPSHOT_BUDGET (default 5 seconds), with a valid prior copy under FM_SNAPSHOT_CACHE_DIR used when the live read fails, is invalid, or consumes the -budget. Ledgers and cached copies containing captain holds must carry the -current hold-aging fields; older hold-bearing summaries are rejected. A home -with neither a valid current ledger nor a valid current cached copy is reported -unreadable with the reason; collection -never computes a summary in that home. +budget. Every ledger and cached copy must declare the current hold-classifier +schema, even when it contains no captain holds; older summaries are rejected. A +home with neither a valid current ledger nor a valid current cached copy is +reported unreadable with the reason; collection never computes a summary in +that home. Each local per-task current-state read is bounded by FM_SNAPSHOT_CREW_STATE_TIMEOUT (default 10 seconds); a read that hits the bound reports state unknown. Local task observations run concurrently, up to FM_SNAPSHOT_LOCAL_READ_CONCURRENCY (default 8). diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index cd430d2182d..2959341f600 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -35,7 +35,7 @@ The `--force` path remains the explicit captain-approved discard escape hatch. The policy prefers holding the very work item a question gates, so the backlog row a finished task's cleanup is about to close is routinely the captain's own call. `bin/fm-teardown.sh` therefore asks the read-only `open` subcommand before its automatic close: exit 0 means the row is still an open captain call (not Done, `hold_kind: captain`), 1 means it is not, and 2 means the answer could not be established, which teardown treats as a refusal before any destructive step rather than as permission to close. -On 0 only the close changes: after cleanup and still under the task's own lock, teardown records one `Deliverable of the finished work: ...` line at the end of the task body and runs `tasks-axi reopen`, so the row returns to Queued with its hold intact and lands in Captain's Call instead of reading as work still under way. +On 0 only the close changes: after cleanup and still under the task's own lock, teardown records one `Deliverable of the finished work: ...` line at the end of the task body and runs `tasks-axi reopen`, so the row returns to Queued with its hold intact and remains on the appropriate Captain's Call or Charted Next decision surface instead of reading as work still under way. The pending-close record teardown already stages before destructive cleanup carries that intent as a `mode=retain` line, so an interrupted cleanup replays the retention at the next session start through the same record, validator, and lock as an ordinary close and never closes the row; an answer that closed the row first simply retires the record. `--force` does not lift the deferral, because it authorizes discarding unlanded work, never the captain's question, and `answer` remains the only act that closes the call. `bin/fm-backlog-transition-lib.sh` owns the transition and its record, and `bin/fm-captain-hold.sh --help` owns the predicate's contract. @@ -68,7 +68,7 @@ No captain hold can fall through them and none can match two, which is what keep Existing undated holds without a hold-set stamp fall back to the task's `since` date. That aging is a projection safety net only. The durable deferral remains re-holding with `--until`. -Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves blocked or deferred captain holds as queued work in the owning home. +Its secondmate-home summary classifies an actionable captain hold as `captain_decision` and preserves every captain hold in the bounded queued inventory of the owning home. `bin/fm-bearings-snapshot.sh` places each captain hold by its `hold_bucket` and inspects no prose of its own. A `live` hold is a default Captain's Call entry. @@ -122,5 +122,5 @@ It proves: cleanup of a finished task whose own row is the captain call leaves t `tests/fm-classify-decision-key.test.sh` pins `status_key_closing_verb` itself: it separates a resolution from the durable-transfer close and from a still-open key, reports the last real transition across re-openings and both key positions, and treats a prose mention as no transition. -Projection regressions live in `tests/fm-fleet-snapshot-view.test.sh` (hold-until parsing, the due gate, kind-independent captain actionability, deferred markers, undated-hold aging, title stripping) and `tests/fm-bearings-snapshot.test.sh` (Captain's Call membership, arrived parked reasons, explicit deferrals, blocked deferred disclosure, aged-undated gates, remote-summary schema invalidation, and the landed exclusion by surviving captain-hold annotations). +Projection regressions live in `tests/fm-fleet-snapshot-view.test.sh` (the total structured-only bucket classifier, hold-until parsing, kind-independent captain actionability, undated-hold aging, and title stripping) and `tests/fm-bearings-snapshot.test.sh` (default and expanded decision-bucket membership, deferral explanations, blocker-overflow disclosure, working-hold dual surfaces, remote-summary schema invalidation, and the landed exclusion by surviving captain-hold annotations). The exact commands and their summarized outputs are recorded in the shipping PR's evidence; run the four suites above plus `tests/fm-send-resolve-key.test.sh`, `tests/fm-bearings-board.test.sh`, and `bin/fm-lint.sh` to refresh this record. From fee5bbe2bc747b48a572c76c14b3710d12f8f641 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 18:14:27 -0700 Subject: [PATCH 46/47] no-mistakes(ci): Updated the stock macOS Bash CI snapshot expectation from 17 to 18 tests. Verified the suite under Bash 3.2.57: all 18 tests pass. `git diff --check` also passes --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b75ae663dfc..8c9274fcebe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -377,8 +377,8 @@ jobs: snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 17 ] || { - echo "::error::expected 17 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 18 ] || { + echo "::error::expected 18 snapshot/fleet-view tests, got $snapshot_count" exit 1 } From 0be7f3543d177c699c76cced2759aee4f9b51ab8 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 18:35:30 -0700 Subject: [PATCH 47/47] no-mistakes(ci): Updated the stock macOS Bash CI expectation from 51 to 53 Bearings tests. Verified all 53 pass under Bash 3.2.57; git diff --check passes --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c9274fcebe..1f45aaf35c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -385,8 +385,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 51 ] || { - echo "::error::expected 51 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 53 ] || { + echo "::error::expected 53 Bearings tests, got $bearings_count" exit 1 }