Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
52 changes: 47 additions & 5 deletions bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -383,8 +420,9 @@ first_pr_url_in_file() { # <file>
grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1
}

backlog_json() ( # [<backlog-path>] - defaults to this home's $BACKLOG
local backlog=${1:-$BACKLOG}
backlog_json() ( # [<backlog-path>] - 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
Expand All @@ -393,7 +431,8 @@ backlog_json() ( # [<backlog-path>] - 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
Expand Down Expand Up @@ -545,7 +584,10 @@ backlog_json() ( # [<backlog-path>] - 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:]]+(?<id>[^[: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
Expand Down
2 changes: 1 addition & 1 deletion docs/captain-hold-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
128 changes: 128 additions & 0 deletions tests/fm-fleet-snapshot-view.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading