From f5e17a4a560646c39dca3229a1128ee07c7a19e4 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Tue, 29 Sep 2026 06:29:58 -0400 Subject: [PATCH 1/6] fix(bin): treat blockers archived as Done in the fleet snapshot as resolved The fleet snapshot resolved a blocked-by edge only from structured Done rows still in data/backlog.md, so a dependent stayed blocked after backlog retention moved its Done blocker into data/done-archive.md, even though tasks-axi already treats it as unblocked. Seed the snapshot's resolved-id set from the archive's structured Done ids before folding in the backlog's own rows, so a live backlog row for the same id still wins and a blocker id present in neither file stays unresolved as before. --- bin/fm-fleet-snapshot.sh | 18 ++++++++---- tests/fm-fleet-snapshot-view.test.sh | 41 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7f830bff272..2515d8711a8 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -24,7 +24,8 @@ # unresolved_blocker_ids, captain_actionable, hold_set, hold_age_days, # and hold_bucket fields. # Repeated blocker tokens remain ordered; a blocker resolves only when its -# structured record is Done, and missing ids stay open. +# structured record is Done in the backlog or the configured Done archive, +# and missing ids stay open. # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. # hold_bucket is the single classification for every captain hold, decided @@ -132,6 +133,7 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" BACKLOG="$DATA/backlog.md" +DONE_ARCHIVE="$DATA/done-archive.md" SNAPSHOT_NOW=${FM_SNAPSHOT_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} if [ -n "${FM_SNAPSHOT_NOW_EPOCH:-}" ]; then SNAPSHOT_EPOCH=$FM_SNAPSHOT_NOW_EPOCH @@ -383,8 +385,8 @@ first_pr_url_in_file() { # grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1 } -backlog_json() ( # [] - defaults to this home's $BACKLOG - local backlog=${1:-$BACKLOG} +backlog_json() ( # [] [] - path defaults to this home's $BACKLOG + local backlog=${1:-$BACKLOG} archived_done_ids=${2:-[]} if [ ! -f "$backlog" ]; then jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}' return 0 @@ -393,7 +395,8 @@ backlog_json() ( # [] - defaults to this home's $BACKLOG set -o pipefail # shellcheck disable=SC2094 jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ - --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' + --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" \ + --argjson archived_done_ids "$archived_done_ids" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); def timestamp_epoch($d): if ($d | type) != "string" then null @@ -545,7 +548,8 @@ backlog_json() ( # [] - defaults to this home's $BACKLOG | .body_excerpt = ((.body_lines | join(" "))[:240]) else . end) | .records as $records - | (reduce ($records[] | select(.structured)) as $record ({}; + | (reduce ($records[] | select(.structured)) as $record ( + (reduce $archived_done_ids[] as $id ({}; .[$id] = true)); .[$record.id] = ((.[$record.id] // true) and ($record.state == "done")))) as $resolved_ids | .records |= map( if .structured then @@ -1975,7 +1979,9 @@ scout_report_lines() { | jq -s 'sort_by(.id)' } -BACKLOG_JSON=$(backlog_json) || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } +DONE_ARCHIVE_IDS=$(backlog_json "$DONE_ARCHIVE" 2>/dev/null | jq -c '[.records[]? | select(.structured) | .id]' 2>/dev/null) +[ -n "$DONE_ARCHIVE_IDS" ] || DONE_ARCHIVE_IDS='[]' +BACKLOG_JSON=$(backlog_json "$BACKLOG" "$DONE_ARCHIVE_IDS") || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } contribution_tasks_json() { local meta id merge_authority for meta in "$STATE"/*.meta; do diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 1238568f31f..3c32bad02d6 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -470,6 +470,46 @@ EOF pass "backlog normalization preserves strict roles and resolves every blocker compatibly" } +test_archived_done_blocker_resolves_alongside_live_and_dangling() { + local home fakebin out + home=$(make_home archived-blocker) + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] archived-hold - Depends on an archived blocker blocked-by: archived-blocker (repo: alpha) (kind: captain) (hold: waiting on archived blocker) (hold-kind: captain) +- [ ] live-hold - Depends on a live blocker blocked-by: live-blocker (repo: alpha) (kind: captain) (hold: waiting on live blocker) (hold-kind: captain) +- [ ] dangling-hold - Depends on no recorded blocker blocked-by: nowhere (repo: alpha) (kind: captain) (hold: waiting on a phantom blocker) (hold-kind: captain) + +## Done +- [x] live-blocker - Live blocker still in the backlog (repo: alpha) (kind: ship) (done 2026-07-20) +EOF + cat > "$home/data/done-archive.md" <<'EOF' +## Done +- [x] archived-blocker - Archived blocker retention moved out (repo: alpha) (kind: ship) (done 2026-07-01) +EOF + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + (.backlog.records[] | select(.id == "archived-hold")) as $archived + | (.backlog.records[] | select(.id == "live-hold")) as $live + | (.backlog.records[] | select(.id == "dangling-hold")) as $dangling + | $archived.blocked_by_ids == ["archived-blocker"] + and $archived.unresolved_blocker_ids == [] + and $archived.hold_bucket == "live" + and $archived.captain_actionable == true + and $live.blocked_by_ids == ["live-blocker"] + and $live.unresolved_blocker_ids == [] + and $live.hold_bucket == "live" + and $live.captain_actionable == true + and $dangling.blocked_by_ids == ["nowhere"] + and $dangling.unresolved_blocker_ids == ["nowhere"] + and $dangling.hold_bucket == "blocked" + and $dangling.captain_actionable == false + ' >/dev/null || fail "an archive-resolved, a backlog-resolved, or a dangling blocker diverged from expectations: $out" + pass "a blocker recorded Done in the archive resolves like one still Done in the backlog, without disturbing a live blocker or a truly dangling one" +} + test_event_hints_follow_reconciled_current_state() { local home fakebin out hint_gen home=$(make_home event-hints) @@ -1159,6 +1199,7 @@ 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_archived_done_blocker_resolves_alongside_live_and_dangling test_event_hints_follow_reconciled_current_state test_open_decision_survives_later_unrelated_event test_secondmate_open_decision_survives_live_endpoint From f6dbc6a2750be58563b4d967ca12e3d2ecbaa24e Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Tue, 29 Sep 2026 08:43:39 -0400 Subject: [PATCH 2/6] no-mistakes(review): Resolve blockers from checked rows in real Done archive --- bin/fm-fleet-snapshot.sh | 7 +++++-- tests/fm-fleet-snapshot-view.test.sh | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 2515d8711a8..20a2d331060 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -1979,8 +1979,11 @@ scout_report_lines() { | jq -s 'sort_by(.id)' } -DONE_ARCHIVE_IDS=$(backlog_json "$DONE_ARCHIVE" 2>/dev/null | jq -c '[.records[]? | select(.structured) | .id]' 2>/dev/null) -[ -n "$DONE_ARCHIVE_IDS" ] || DONE_ARCHIVE_IDS='[]' +DONE_ARCHIVE_IDS='[]' +if [ -f "$DONE_ARCHIVE" ]; then + DONE_ARCHIVE_IDS=$(jq -Rnc '[inputs | capture("^[-*][[:space:]]+\\[[xX]\\][[:space:]]+(?[^[:space:]]+)[[:space:]]+-[[:space:]]+")? | .id] | unique' "$DONE_ARCHIVE") \ + || { echo "fm-fleet-snapshot: done archive read failed" >&2; exit 1; } +fi BACKLOG_JSON=$(backlog_json "$BACKLOG" "$DONE_ARCHIVE_IDS") || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } contribution_tasks_json() { local meta id merge_authority diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 3c32bad02d6..f928feacf66 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -480,13 +480,18 @@ test_archived_done_blocker_resolves_alongside_live_and_dangling() { - [ ] archived-hold - Depends on an archived blocker blocked-by: archived-blocker (repo: alpha) (kind: captain) (hold: waiting on archived blocker) (hold-kind: captain) - [ ] live-hold - Depends on a live blocker blocked-by: live-blocker (repo: alpha) (kind: captain) (hold: waiting on live blocker) (hold-kind: captain) - [ ] dangling-hold - Depends on no recorded blocker blocked-by: nowhere (repo: alpha) (kind: captain) (hold: waiting on a phantom blocker) (hold-kind: captain) +- [ ] pruned-hold - Depends on a blocker pruned while queued blocked-by: pruned-blocker (repo: alpha) (kind: captain) (hold: waiting on pruned blocker) (hold-kind: captain) ## Done - [x] live-blocker - Live blocker still in the backlog (repo: alpha) (kind: ship) (done 2026-07-20) EOF cat > "$home/data/done-archive.md" <<'EOF' -## Done + +## Archived 2026-07-15 - [x] archived-blocker - Archived blocker retention moved out (repo: alpha) (kind: ship) (done 2026-07-01) + +## Archived 2026-07-16 +- [ ] pruned-blocker - Queued blocker pruned without finishing (repo: alpha) (kind: ship) (since 2026-07-02) EOF fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) @@ -494,6 +499,7 @@ EOF (.backlog.records[] | select(.id == "archived-hold")) as $archived | (.backlog.records[] | select(.id == "live-hold")) as $live | (.backlog.records[] | select(.id == "dangling-hold")) as $dangling + | (.backlog.records[] | select(.id == "pruned-hold")) as $pruned | $archived.blocked_by_ids == ["archived-blocker"] and $archived.unresolved_blocker_ids == [] and $archived.hold_bucket == "live" @@ -506,8 +512,11 @@ EOF and $dangling.unresolved_blocker_ids == ["nowhere"] and $dangling.hold_bucket == "blocked" and $dangling.captain_actionable == false - ' >/dev/null || fail "an archive-resolved, a backlog-resolved, or a dangling blocker diverged from expectations: $out" - pass "a blocker recorded Done in the archive resolves like one still Done in the backlog, without disturbing a live blocker or a truly dangling one" + and $pruned.unresolved_blocker_ids == ["pruned-blocker"] + and $pruned.hold_bucket == "blocked" + and $pruned.captain_actionable == false + ' >/dev/null || fail "an archive-resolved, a backlog-resolved, a pruned-unfinished, or a dangling blocker diverged from expectations: $out" + pass "a blocker recorded Done in the archive resolves like one still Done in the backlog, while an archived unfinished or truly dangling blocker stays open" } test_event_hints_follow_reconciled_current_state() { From 09c1600ce7f23eb5ab13da39f3a9e18762dabddf Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Tue, 29 Sep 2026 08:48:31 -0400 Subject: [PATCH 3/6] no-mistakes(document): Document Done-archive blocker resolution in fleet snapshot --- bin/fm-fleet-snapshot.sh | 2 +- docs/captain-hold-lifecycle.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 20a2d331060..63a32fc7590 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -24,7 +24,7 @@ # unresolved_blocker_ids, captain_actionable, hold_set, hold_age_days, # and hold_bucket fields. # Repeated blocker tokens remain ordered; a blocker resolves only when its -# structured record is Done in the backlog or the configured Done archive, +# structured record is Done in the backlog or in data/done-archive.md, # and missing ids stay open. # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 5c19372265c..31d0b733661 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -348,7 +348,7 @@ For a main-home call, the reconcile option is the recovery path for whatever sti ### Fleet snapshot buckets `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 and keeps missing blockers unresolved. +It resolves every repeated `blocked-by:` edge against structured Done records in the backlog or Done rows in the `data/done-archive.md` archive, and keeps missing blockers unresolved. It then assigns every captain hold exactly one `hold_bucket`. The bucket is 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. From 57d17363119903b6d56bb01f0257de22587a02b6 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Tue, 29 Sep 2026 09:22:57 -0400 Subject: [PATCH 4/6] no-mistakes(ci): The "Stock macOS Bash snapshot compatibility" check failed because its expected test count was out of date, not because any test failed. All 19 tests in tests/fm-fleet-snapshot-view.test.sh passed under stock /bin/bash 3.2.57. This PR adds one of them, the regression test for a blocker marked Done in the archive. The workflow still pinned the count at 18, and that count check is the rule that was broken: the number in the CI step must equal the number of `ok -` tests the suite runs. That count appears only in `.github/workflows/ci.yml`, in the test on line 449 and the error message on line 450. I changed both from 18 to 19. Running the suite locally now reports 19 `ok -` lines, and only the workflow file changed --- .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 bfc7127da47..bd3113e69a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,8 +446,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 18 ] || { - echo "::error::expected 18 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 19 ] || { + echo "::error::expected 19 snapshot/fleet-view tests, got $snapshot_count" exit 1 } From 14c1d3161fccd1d1af5c8e66f0b590db26937f9b Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Tue, 29 Sep 2026 10:17:50 -0400 Subject: [PATCH 5/6] no-mistakes(ci): I fixed both review findings the user chose to address. Finding 1: the snapshot must read the same Done archive that tasks-axi writes. It only ever read `$DATA/done-archive.md`. The new `done_archive_path` in `bin/fm-fleet-snapshot.sh` reads the `archive` key from the `[markdown]` section of the home's `.tasks.toml`. That file sits in the data directory's parent, which is the directory tasks-axi runs from. A relative path resolves against that directory, and an absolute path is used as is. It falls back to `data/done-archive.md` only when `.tasks.toml` is missing or has no `archive` key. There is one shared `DONE_ARCHIVE`, and `backlog_json`, the snapshot's only consumer of the archive, reads it. Finding 2: the list of archived ids must never be passed to jq as a command-line argument. I removed the separate archived-id step and the `--argjson` argument. `backlog_json` now gives the archive file to jq with `--rawfile`, or `/dev/null` when the file is missing, and pulls the checked row ids out inside jq. Tests: - I added `test_configured_done_archive_path_resolves_blockers` to `tests/fm-fleet-snapshot-view.test.sh`. It points `.tasks.toml` at `records/done.md` and puts a decoy `data/done-archive.md` next to it. A hold whose blocker is in the configured archive resolves. A hold whose blocker is only in the decoy stays blocked. - The new test fails against the previous code and passes now. The suite passes 20 of 20. I raised the stock macOS Bash count check in `.github/workflows/ci.yml` from 19 to 20. - A 20 MB archive with 200,000 Done rows, far past Linux's 128 KB limit per argument, still resolves. - `shellcheck -x` is clean. - Six other suites that use the snapshot passed with no failures: fm-bearings-snapshot (60), fm-captain-hold-lifecycle (53), fm-crew-state (283), fm-home-summary-refresh (21), fm-pending-reply (41) and fm-contributions (44). Two more had not finished when I returned this result, so I have no result for them: fm-remote-secondmate-lifecycle-e2e and fm-secondmate-reconcile. I updated the matching sentence in `docs/captain-hold-lifecycle.md` and the script's header comment to name the configured archive path. Only `.github/workflows/ci.yml`, `bin/fm-fleet-snapshot.sh`, `docs/captain-hold-lifecycle.md` and `tests/fm-fleet-snapshot-view.test.sh` changed. Commit messages should be kept plain --- .github/workflows/ci.yml | 4 +-- bin/fm-fleet-snapshot.sh | 51 +++++++++++++++++++++------- docs/captain-hold-lifecycle.md | 2 +- tests/fm-fleet-snapshot-view.test.sh | 47 +++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd3113e69a6..637d1ff18c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,8 +446,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 19 ] || { - echo "::error::expected 19 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 20 ] || { + echo "::error::expected 20 snapshot/fleet-view tests, got $snapshot_count" exit 1 } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 63a32fc7590..72229b14fda 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -24,8 +24,9 @@ # unresolved_blocker_ids, captain_actionable, hold_set, hold_age_days, # and hold_bucket fields. # Repeated blocker tokens remain ordered; a blocker resolves only when its -# structured record is Done in the backlog or in data/done-archive.md, -# and missing ids stay open. +# structured record is Done in the backlog or in the Done archive (the +# home's .tasks.toml [markdown] archive, else data/done-archive.md), and +# missing ids stay open. # There is no separate decision type: any captain-held task is the same # primitive, whatever kind its row carries. # hold_bucket is the single classification for every captain hold, decided @@ -133,7 +134,33 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" BACKLOG="$DATA/backlog.md" -DONE_ARCHIVE="$DATA/done-archive.md" +# The Done archive tasks-axi writes: the [markdown] archive key of the home's +# .tasks.toml, relative to the data directory's parent where tasks-axi runs, +# else the tracked default beside the backlog. +done_archive_path() { + local root archive= + root=$(dirname "$DATA") + [ -f "$root/.tasks.toml" ] && archive=$(LC_ALL=C awk ' + { + line = $0 + sub(/[[:space:]]*#.*/, "", line) + sub(/^[[:space:]]+/, "", line); sub(/[[:space:]]+$/, "", line) + if (line ~ /^\[[^]]+\]$/) { inmarkdown = (line == "[markdown]"); next } + if (inmarkdown && line ~ /^archive[[:space:]]*=/) { + sub(/^archive[[:space:]]*=[[:space:]]*/, "", line) + gsub(/^"|"$/, "", line); gsub(/^'\''|'\''$/, "", line) + print line + exit + } + } + ' "$root/.tasks.toml") + case "$archive" in + '') printf '%s\n' "$DATA/done-archive.md" ;; + /*) printf '%s\n' "$archive" ;; + *) printf '%s\n' "$root/$archive" ;; + esac +} +DONE_ARCHIVE=$(done_archive_path) SNAPSHOT_NOW=${FM_SNAPSHOT_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} if [ -n "${FM_SNAPSHOT_NOW_EPOCH:-}" ]; then SNAPSHOT_EPOCH=$FM_SNAPSHOT_NOW_EPOCH @@ -385,8 +412,9 @@ first_pr_url_in_file() { # grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1 } -backlog_json() ( # [] [] - path defaults to this home's $BACKLOG - local backlog=${1:-$BACKLOG} archived_done_ids=${2:-[]} +backlog_json() ( # [] - defaults to this home's $BACKLOG; Done ids also come from $DONE_ARCHIVE + local backlog=${1:-$BACKLOG} archive=$DONE_ARCHIVE + [ -f "$archive" ] || archive=/dev/null if [ ! -f "$backlog" ]; then jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}' return 0 @@ -396,7 +424,7 @@ backlog_json() ( # [] [] - path defaults # shellcheck disable=SC2094 jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" \ - --argjson archived_done_ids "$archived_done_ids" ' + --rawfile archive "$archive" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); def timestamp_epoch($d): if ($d | type) != "string" then null @@ -549,7 +577,9 @@ backlog_json() ( # [] [] - path defaults else . end) | .records as $records | (reduce ($records[] | select(.structured)) as $record ( - (reduce $archived_done_ids[] as $id ({}; .[$id] = true)); + (reduce ($archive | split("\n")[] + | capture("^[-*][[:space:]]+\\[[xX]\\][[:space:]]+(?[^[:space:]]+)[[:space:]]+-[[:space:]]+")? + | .id) as $id ({}; .[$id] = true)); .[$record.id] = ((.[$record.id] // true) and ($record.state == "done")))) as $resolved_ids | .records |= map( if .structured then @@ -1979,12 +2009,7 @@ scout_report_lines() { | jq -s 'sort_by(.id)' } -DONE_ARCHIVE_IDS='[]' -if [ -f "$DONE_ARCHIVE" ]; then - DONE_ARCHIVE_IDS=$(jq -Rnc '[inputs | capture("^[-*][[:space:]]+\\[[xX]\\][[:space:]]+(?[^[:space:]]+)[[:space:]]+-[[:space:]]+")? | .id] | unique' "$DONE_ARCHIVE") \ - || { echo "fm-fleet-snapshot: done archive read failed" >&2; exit 1; } -fi -BACKLOG_JSON=$(backlog_json "$BACKLOG" "$DONE_ARCHIVE_IDS") || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } +BACKLOG_JSON=$(backlog_json) || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } contribution_tasks_json() { local meta id merge_authority for meta in "$STATE"/*.meta; do diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 31d0b733661..0fdb6d2dc7a 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -348,7 +348,7 @@ For a main-home call, the reconcile option is the recovery path for whatever sti ### Fleet snapshot buckets `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 in the backlog or Done rows in the `data/done-archive.md` archive, and keeps missing blockers unresolved. +It resolves every repeated `blocked-by:` edge against structured Done records in the backlog or Done rows in the Done archive (the `archive` key under `[markdown]` in the home's `.tasks.toml`, else `data/done-archive.md`), and keeps missing blockers unresolved. It then assigns every captain hold exactly one `hold_bucket`. The bucket is 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. diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index f928feacf66..bd86bb652fe 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -519,6 +519,52 @@ EOF pass "a blocker recorded Done in the archive resolves like one still Done in the backlog, while an archived unfinished or truly dangling blocker stays open" } +test_configured_done_archive_path_resolves_blockers() { + local home fakebin out + home=$(make_home configured-archive) + cat > "$home/.tasks.toml" <<'EOF' +backend = "markdown" + +[markdown] +path = "data/backlog.md" +archive = "records/done.md" # retention target +done_keep = 10 +EOF + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] configured-hold - Depends on a blocker in the configured archive blocked-by: configured-blocker (repo: alpha) (kind: captain) (hold: waiting on configured blocker) (hold-kind: captain) +- [ ] default-hold - Depends on a blocker only in the unconfigured default archive blocked-by: default-blocker (repo: alpha) (kind: captain) (hold: waiting on default blocker) (hold-kind: captain) + +## Done +EOF + mkdir -p "$home/records" + cat > "$home/records/done.md" <<'EOF' + +## Archived 2026-07-15 +- [x] configured-blocker - Blocker archived to the configured path (repo: alpha) (kind: ship) (done 2026-07-01) +EOF + cat > "$home/data/done-archive.md" <<'EOF' + +## Archived 2026-07-15 +- [x] default-blocker - Blocker in a default archive this home does not use (repo: alpha) (kind: ship) (done 2026-07-01) +EOF + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + (.backlog.records[] | select(.id == "configured-hold")) as $configured + | (.backlog.records[] | select(.id == "default-hold")) as $default + | $configured.unresolved_blocker_ids == [] + and $configured.hold_bucket == "live" + and $configured.captain_actionable == true + and $default.unresolved_blocker_ids == ["default-blocker"] + and $default.hold_bucket == "blocked" + and $default.captain_actionable == false + ' >/dev/null || fail "the snapshot did not resolve blockers from the .tasks.toml archive path alone: $out" + pass "a blocker archived Done at the .tasks.toml [markdown] archive path resolves, and the unconfigured default archive is ignored" +} + test_event_hints_follow_reconciled_current_state() { local home fakebin out hint_gen home=$(make_home event-hints) @@ -1209,6 +1255,7 @@ test_hold_buckets_are_total_and_text_blind test_main_inventory_orphan_and_unstructured_disclosure test_normalized_roles_and_plural_blocker_readiness test_archived_done_blocker_resolves_alongside_live_and_dangling +test_configured_done_archive_path_resolves_blockers test_event_hints_follow_reconciled_current_state test_open_decision_survives_later_unrelated_event test_secondmate_open_decision_survives_live_endpoint From 168a8304326379752561f9b0ae859d2456e610f3 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Tue, 29 Sep 2026 10:51:25 -0400 Subject: [PATCH 6/6] no-mistakes(ci): I fixed the Greptile finding, as requested. The rule is that the `archive` path read from `.tasks.toml` has to be the whole quoted value. A `#` inside quotes is part of the path, not the start of a comment. That value is only parsed in one place: the awk block in `done_archive_path` in `bin/fm-fleet-snapshot.sh`. `backlog_json` gets the path through `DONE_ARCHIVE`, so fixing the parser fixes every reader. **The fix (`bin/fm-fleet-snapshot.sh`):** - Section headers still have trailing `# ...` comments removed before the `[markdown]` check, as you said they could. - For the `archive = ...` line, a value that starts with `"` or `'` is now read up to its matching closing quote. A comment after the closing quote is dropped. - An unquoted value still loses its trailing `# comment`. - The old code stripped quotes after cutting at the first `#`. That step is gone because nothing reaches it now. - A quoted value with no closing quote gives an empty result, so the snapshot uses `data/done-archive.md`, same as when no archive key is set. **Test (`tests/fm-fleet-snapshot-view.test.sh`):** I added `test_quoted_archive_path_keeps_hash` right after the configured-archive-path test. It sets `archive = "records/task#5/done.md" # retention target` under a `[markdown] # ...` header and puts a Done blocker in `records/task#5/done.md`. It then checks that the held item that depends on it has no unresolved blockers and shows as `live`. I raised the stock macOS Bash test count in `.github/workflows/ci.yml` from 20 to 21 to match. **Verification:** - The new test fails against the previous parser ("truncated a quoted .tasks.toml archive path at its #") and passes now. The suite passes 21 of 21. - `shellcheck -x` is clean on both changed shell files. - The quote handling gives `a#b/c.md` under both mawk and gawk. Called directly, the function also reads a single-quoted `'x#y.md'` whole and returns `plain.md` for an unquoted value followed by a `# c` comment. - I did not run the suite under stock Bash 3.2, because it isn't installed here. The CI job covers that. `git status --porcelain` shows only the three intended files changed --- .github/workflows/ci.yml | 4 ++-- bin/fm-fleet-snapshot.sh | 20 ++++++++++++------ tests/fm-fleet-snapshot-view.test.sh | 31 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 637d1ff18c3..f3e5adcf030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,8 +446,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 20 ] || { - echo "::error::expected 20 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 21 ] || { + echo "::error::expected 21 snapshot/fleet-view tests, got $snapshot_count" exit 1 } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 72229b14fda..b09140d90a6 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -143,12 +143,20 @@ done_archive_path() { [ -f "$root/.tasks.toml" ] && archive=$(LC_ALL=C awk ' { line = $0 - sub(/[[:space:]]*#.*/, "", line) - sub(/^[[:space:]]+/, "", line); sub(/[[:space:]]+$/, "", line) - if (line ~ /^\[[^]]+\]$/) { inmarkdown = (line == "[markdown]"); next } - if (inmarkdown && line ~ /^archive[[:space:]]*=/) { - sub(/^archive[[:space:]]*=[[:space:]]*/, "", line) - gsub(/^"|"$/, "", line); gsub(/^'\''|'\''$/, "", line) + sub(/^[[:space:]]+/, "", line) + if (line ~ /^\[/) { + sub(/[[:space:]]*#.*/, "", line); sub(/[[:space:]]+$/, "", line) + if (line ~ /^\[[^]]+\]$/) inmarkdown = (line == "[markdown]") + next + } + if (inmarkdown && sub(/^archive[[:space:]]*=[[:space:]]*/, "", line)) { + # A quoted value ends at its closing quote, so # inside it is kept. + q = substr(line, 1, 1) + if (q == "\"" || q == "'\''") { + line = substr(line, 2); line = substr(line, 1, index(line, q) - 1) + } else { + sub(/[[:space:]]*#.*/, "", line); sub(/[[:space:]]+$/, "", line) + } print line exit } diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index bd86bb652fe..89a2e0f62bf 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -565,6 +565,36 @@ EOF pass "a blocker archived Done at the .tasks.toml [markdown] archive path resolves, and the unconfigured default archive is ignored" } +test_quoted_archive_path_keeps_hash() { + local home fakebin out + home=$(make_home hash-archive) + cat > "$home/.tasks.toml" <<'EOF' +[markdown] # tasks-axi backend +archive = "records/task#5/done.md" # retention target +EOF + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] hash-hold - Depends on a blocker archived under a path with a hash blocked-by: hash-blocker (repo: alpha) (kind: captain) (hold: waiting on hash blocker) (hold-kind: captain) + +## Done +EOF + mkdir -p "$home/records/task#5" + cat > "$home/records/task#5/done.md" <<'EOF' + +## Archived 2026-07-15 +- [x] hash-blocker - Blocker archived to a quoted path containing a hash (repo: alpha) (kind: ship) (done 2026-07-01) +EOF + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + .backlog.records[] | select(.id == "hash-hold") + | .unresolved_blocker_ids == [] and .hold_bucket == "live" + ' >/dev/null || fail "the snapshot truncated a quoted .tasks.toml archive path at its #: $out" + pass "a quoted .tasks.toml archive path containing # is read whole" +} + test_event_hints_follow_reconciled_current_state() { local home fakebin out hint_gen home=$(make_home event-hints) @@ -1256,6 +1286,7 @@ test_main_inventory_orphan_and_unstructured_disclosure test_normalized_roles_and_plural_blocker_readiness test_archived_done_blocker_resolves_alongside_live_and_dangling test_configured_done_archive_path_resolves_blockers +test_quoted_archive_path_keeps_hash test_event_hints_follow_reconciled_current_state test_open_decision_survives_later_unrelated_event test_secondmate_open_decision_survives_live_endpoint