diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc7127da47..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 18 ] || { - echo "::error::expected 18 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 7f830bff272..b09140d90a6 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -24,7 +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, 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 @@ -132,6 +134,41 @@ 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" +# 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) + 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 + } + } + ' "$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 @@ -383,8 +420,9 @@ 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() ( # [] - 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 @@ -393,7 +431,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" \ + --rawfile archive "$archive" ' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); def timestamp_epoch($d): if ($d | type) != "string" then null @@ -545,7 +584,10 @@ 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 ($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 diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 5c19372265c..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 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 1238568f31f..89a2e0f62bf 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -470,6 +470,131 @@ 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) +- [ ] 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' + +## 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) + 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 + | (.backlog.records[] | select(.id == "pruned-hold")) as $pruned + | $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 + 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_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_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) @@ -1159,6 +1284,9 @@ 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_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