From 9bf07d73d79c90e2037dd0d6be1d5622ead02f1c Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:24:03 +0000 Subject: [PATCH 1/7] feat(backlog): lint stale dependency edges --- .agents/skills/bootstrap-diagnostics/SKILL.md | 5 +- AGENTS.md | 2 +- bin/fm-backlog-lint.sh | 161 +++++++++++++++ bin/fm-bootstrap.sh | 4 + bin/fm-fleet-snapshot.sh | 24 ++- docs/configuration.md | 2 + tests/fm-backlog-lint.test.sh | 193 ++++++++++++++++++ 7 files changed, 388 insertions(+), 3 deletions(-) create mode 100755 bin/fm-backlog-lint.sh create mode 100755 tests/fm-backlog-lint.test.sh diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 8a195085a4..aefe9a84ef 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, ROLE_INVALID, ROLE_OVERLAY_MISSING, NEEDS_GH_AUTH, TANGLE, SELF_DRIFT, CREW_DISPATCH invalid, CURRENCY_BASE, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, AXI_SUITE_UPDATED, AXI_SUITE_REVIEW, AXI_SUITE_STUCK, FIRSTMATE_UPDATE_AVAILABLE, FIRSTMATE_UPDATE_STUCK, FORK_SYNC, FORK_SYNC_STUCK, WATCHER_UNIT, FREQUENCY_MONITOR_UNIT, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, ROLE_INVALID, ROLE_OVERLAY_MISSING, NEEDS_GH_AUTH, TANGLE, SELF_DRIFT, CREW_DISPATCH invalid, CURRENCY_BASE, BACKLOG_STALE, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, AXI_SUITE_UPDATED, AXI_SUITE_REVIEW, AXI_SUITE_STUCK, FIRSTMATE_UPDATE_AVAILABLE, FIRSTMATE_UPDATE_STUCK, FORK_SYNC, FORK_SYNC_STUCK, WATCHER_UNIT, FREQUENCY_MONITOR_UNIT, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -66,6 +66,9 @@ When any diagnostic needs captain attention, report the plain consequence and re - `CURRENCY_BASE: config/ is unusable - ; ` - the home configured a comparison base for one of the two upstream checks, but the file cannot be used, so that check refuses rather than silently comparing against the wrong source. Fix the named file to one non-empty git URL or absolute path, or remove it to fall back to the canonical upstream template; `docs/configuration.md` "Upstream firstmate and curated-fork checks" owns which check reads which file and why they are separate. Until it is fixed, treat that check's currency signal as absent rather than as evidence the deployment is current. +- `BACKLOG_STALE: task has ; fix: ` - a current durable backlog record has a mechanically stale dependency edge. + Inspect the named record, run the exact printed `tasks-axi unblock` command to remove that edge, and add the intended existing blocker afterward only when the dangling id was a typo. + The check itself is detect-only and must never run the repair command, block startup, or mutate retention behavior. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. diff --git a/AGENTS.md b/AGENTS.md index 82bede5210..6bcd28a0cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -495,7 +495,7 @@ Bootstrap separately detects when the primary checkout's own default branch has These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `ROLE_INVALID:`, `ROLE_OVERLAY_MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `SELF_DRIFT:`, `CREW_DISPATCH: invalid`, `CURRENCY_BASE:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `AXI_SUITE_UPDATED:`, `AXI_SUITE_REVIEW:`, `AXI_SUITE_STUCK:`, `FIRSTMATE_UPDATE_AVAILABLE:`, `FIRSTMATE_UPDATE_STUCK:`, `FORK_SYNC:`, `FORK_SYNC_STUCK:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `ROLE_INVALID:`, `ROLE_OVERLAY_MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `SELF_DRIFT:`, `CREW_DISPATCH: invalid`, `CURRENCY_BASE:`, `BACKLOG_STALE:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `AXI_SUITE_UPDATED:`, `AXI_SUITE_REVIEW:`, `AXI_SUITE_STUCK:`, `FIRSTMATE_UPDATE_AVAILABLE:`, `FIRSTMATE_UPDATE_STUCK:`, `FORK_SYNC:`, `FORK_SYNC_STUCK:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/bin/fm-backlog-lint.sh b/bin/fm-backlog-lint.sh new file mode 100755 index 0000000000..e126885e19 --- /dev/null +++ b/bin/fm-backlog-lint.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# fm-backlog-lint.sh - detect mechanically stale blocked-by edges. +# +# Usage: +# fm-backlog-lint.sh +# fm-backlog-lint.sh --help +# +# Reads data/backlog.md and data/done-archive.md from FM_HOME, reports one +# actionable BACKLOG_STALE line per bad current-task edge, and exits 0 even +# when findings exist. +# It is detect-only: it never edits, removes, or repairs an edge. +# Silence means every current blocked-by target exists as a non-Done live task +# and tasks-axi agrees with fm-fleet-snapshot about whether the edge blocks. +# +# Findings are intentionally limited to three mechanically closable classes: +# - dangling: the target exists in neither the live backlog nor the archive +# - satisfied: the target is already Done in the live backlog +# - reader-disagreement: an archived Done target is treated as satisfied by +# tasks-axi but unresolved by fm-fleet-snapshot +# +# Every finding names the dependent task, blocker, fault, and exact tasks-axi +# command that removes the stale edge. +# Missing prerequisites and unreadable structured inputs are command errors, +# not findings; bootstrap calls this only after its normal tool checks and +# treats command failure as non-blocking. +set -uf + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +BACKLOG="$DATA/backlog.md" +ARCHIVE="$DATA/done-archive.md" +SNAPSHOT="$SCRIPT_DIR/fm-fleet-snapshot.sh" + +usage() { + sed -n '2,/^set -uf$/s/^# \{0,1\}//p' "$0" +} + +case "${1:-}" in + "") ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac + +[ -f "$BACKLOG" ] || exit 0 +command -v jq >/dev/null 2>&1 \ + || { echo "fm-backlog-lint: jq not found" >&2; exit 1; } +command -v tasks-axi >/dev/null 2>&1 \ + || { echo "fm-backlog-lint: tasks-axi not found" >&2; exit 1; } +[ -x "$SNAPSHOT" ] \ + || { echo "fm-backlog-lint: fleet snapshot reader not executable: $SNAPSHOT" >&2; exit 1; } + +LINT_TMP=$( + mktemp -d "${TMPDIR:-/tmp}/fm-backlog-lint.XXXXXX" +) || { echo "fm-backlog-lint: could not create private temporary directory" >&2; exit 1; } + +cleanup() { + rm -rf "$LINT_TMP" +} +trap cleanup EXIT +BACKLOG_JSON_FILE="$LINT_TMP/backlog.json" +ARCHIVE_JSON_FILE="$LINT_TMP/archive.json" + +FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_HOME="$FM_HOME" \ + FM_DATA_OVERRIDE="$DATA" \ + "$SNAPSHOT" --backlog-json "$BACKLOG" > "$BACKLOG_JSON_FILE" \ + || { echo "fm-backlog-lint: live backlog read failed: $BACKLOG" >&2; exit 1; } + +if [ -f "$ARCHIVE" ]; then + FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_HOME="$FM_HOME" \ + FM_DATA_OVERRIDE="$DATA" \ + "$SNAPSHOT" --backlog-json "$ARCHIVE" > "$ARCHIVE_JSON_FILE" \ + || { echo "fm-backlog-lint: done archive read failed: $ARCHIVE" >&2; exit 1; } +else + printf '%s\n' '{"records":[]}' > "$ARCHIVE_JSON_FILE" +fi + +csv_has() { # + local csv=$1 value=$2 entry old_ifs + old_ifs=$IFS + IFS=, + for entry in $csv; do + if [ "$entry" = "$value" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + return 1 +} + +task_axi_unresolved= +last_task= +while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved; do + [ -n "$task" ] || continue + case "$target_class" in + missing) + printf "BACKLOG_STALE: task %s has dangling blocked-by %s (target is absent from data/backlog.md and data/done-archive.md); fix: run tasks-axi unblock %s --by %s, then add the intended existing blocker if this id was a typo\n" \ + "$task" "$blocker" "$task" "$blocker" + ;; + done) + printf "BACKLOG_STALE: task %s has satisfied blocked-by %s (target is already Done in data/backlog.md); fix: run tasks-axi unblock %s --by %s\n" \ + "$task" "$blocker" "$task" "$blocker" + ;; + archived) + if [ "$task" != "$last_task" ]; then + TASK_AXI_SHOW=$(tasks-axi show "$task" --file "$BACKLOG" 2>/dev/null) || TASK_AXI_SHOW= + task_axi_unresolved=$( + printf '%s\n' "$TASK_AXI_SHOW" | + sed -n 's/^[[:space:]]*blocked_by:[[:space:]]*//p' | + head -1 + ) + case "$task_axi_unresolved" in + none|-) task_axi_unresolved= ;; + \"*) + task_axi_unresolved=${task_axi_unresolved#\"} + task_axi_unresolved=${task_axi_unresolved%\"} + ;; + esac + last_task=$task + fi + if [ "$snapshot_unresolved" = true ] \ + && ! csv_has "$task_axi_unresolved" "$blocker"; then + printf "BACKLOG_STALE: task %s has reader-disagreement blocked-by %s (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock %s --by %s\n" \ + "$task" "$blocker" "$task" "$blocker" + fi + ;; + esac +done < <( + jq -nr \ + --slurpfile backlog "$BACKLOG_JSON_FILE" \ + --slurpfile archive "$ARCHIVE_JSON_FILE" ' + ($backlog[0].records + | map(select(.structured == true)) + | map({key:.id,value:.state}) + | from_entries) as $live + | ($archive[0].records + | map(select(.structured == true)) + | map({key:.id,value:true}) + | from_entries) as $archived + | $backlog[0].records[] + | select(.structured == true and .state != "done") + | . as $record + | .blocked_by_ids[] + | . as $blocker + | [ + $record.id, + $blocker, + (if $live[$blocker] == "done" then "done" + elif $live[$blocker] != null then "live" + elif $archived[$blocker] == true then "archived" + else "missing" + end), + (($record.unresolved_blocker_ids | index($blocker)) != null) + ] + | @tsv + ' +) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1c33141f3b..f15624aa50 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -12,6 +12,7 @@ # "ROLE_OVERLAY_MISSING: (expected: roles/.md)", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", # "CURRENCY_BASE: config/ is unusable - ; ", +# "BACKLOG_STALE: task has ; fix: ", # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "PR_CHECK_MIGRATION: ", # "TANGLE: ", @@ -922,6 +923,9 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && [ -n "$crew" ] && [ "$crew" != fi crew_dispatch_validate currency_base_validate +if fm_tasks_axi_compatible && command -v jq >/dev/null 2>&1; then + "$SCRIPT_DIR/fm-backlog-lint.sh" || true +fi if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ && ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then echo "BOOTSTRAP_INFO: tasks-axi available" diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 1dee81bb84..ecd4c173c5 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -3,6 +3,10 @@ # # Output contract: `--json` prints one object with schema # `fm-fleet-snapshot.v1`. +# `--backlog-json []` prints only the same parsed backlog object used by +# that snapshot, without scanning live task or secondmate state. +# It accepts tasks-axi live backlogs and done archives; `## Archived ` +# sections normalize to Done records. # The command is read-only: it does not acquire the session lock, drain wakes, # arm watchers, mutate backlog state, or write reports. # @@ -139,10 +143,15 @@ usage() { cat <<'EOF' usage: fm-fleet-snapshot.sh --json fm-fleet-snapshot.sh --secondmate-home-summary + fm-fleet-snapshot.sh --backlog-json [] Print a read-only structured snapshot of the firstmate fleet. JSON is the stable machine-readable output contract. +--backlog-json exposes the snapshot's own structured Markdown reader without +scanning fleet state. It defaults to data/backlog.md and accepts a tasks-axi +done archive path, whose dated Archived sections normalize to Done records. + --secondmate-home-summary emits the bounded structured summary used after a validated registered-home handoff. It is local-only, skips nested secondmate aggregation, and marks inventory contradictions or unavailable child state invalid. @@ -165,9 +174,15 @@ EOF } OUTPUT_MODE=json +BACKLOG_PATH=$BACKLOG case "${1:---json}" in --json) ;; --secondmate-home-summary) OUTPUT_MODE=secondmate-home-summary ;; + --backlog-json) + [ "$#" -le 2 ] || { usage >&2; exit 2; } + OUTPUT_MODE=backlog-json + BACKLOG_PATH=${2:-$BACKLOG} + ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; esac @@ -261,6 +276,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG if . == "In flight" then "in_flight" elif . == "Queued" then "queued" elif . == "Done" then "done" + elif test("^Archived[[:space:]]+") then "done" else null end; def cap($rest; $re): (((($rest | capture($re)?) // {}) | .v) // null) as $v @@ -1290,7 +1306,13 @@ scout_report_lines() { | jq -s 'sort_by(.id)' } -BACKLOG_JSON=$(backlog_json) || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } +BACKLOG_JSON=$(backlog_json "$BACKLOG_PATH") || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } + +if [ "$OUTPUT_MODE" = backlog-json ]; then + printf '%s\n' "$BACKLOG_JSON" + exit 0 +fi + TASKS_JSON=$(task_json_lines) || { echo "fm-fleet-snapshot: task snapshot failed" >&2; exit 1; } if [ "$OUTPUT_MODE" = secondmate-home-summary ]; then diff --git a/docs/configuration.md b/docs/configuration.md index 7639d20009..3a1a9a6168 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,8 @@ Bootstrap requires compatible `tasks-axi` on every profile; see "Toolchain" belo Set the local, gitignored `config/backlog-backend` file to `manual` to force manual backlog editing and suppress the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not missing-tool reporting. Absent or `tasks-axi` selects the default tasks-axi backend. The file format is unchanged in both modes; tasks-axi and manual edits produce the same `## In flight`, `## Queued`, and `## Done` sections. +At session start, `bin/fm-backlog-lint.sh` read-only checks current `blocked-by:` edges for missing targets, already-Done targets, and the archived-target disagreement between `tasks-axi` and `bin/fm-fleet-snapshot.sh`. +It is silent when clean, never blocks or mutates records, and is also available as a standalone command. ## Runtime backend (config/backend / FM_BACKEND) diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh new file mode 100755 index 0000000000..7ea9f5b232 --- /dev/null +++ b/tests/fm-backlog-lint.test.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Behavior tests for the detect-only durable backlog dependency lint. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LINT="$ROOT/bin/fm-backlog-lint.sh" +SNAPSHOT="$ROOT/bin/fm-fleet-snapshot.sh" +fm_test_tmproot TMP_ROOT fm-backlog-lint + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; } + +make_home() { # + local home=$TMP_ROOT/$1 + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + printf '%s\n' "$home" +} + +run_lint() { # + FM_HOME="$1" "$LINT" +} + +assert_clean() { # + local out + out=$(run_lint "$1") + [ -z "$out" ] || fail "$2: $out" +} + +test_clean_live_edge_is_silent() { + local home + home=$(make_home clean) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] clean-blocker - Still active (repo: sample) (kind: ship) +- [ ] clean-dependent - Correctly blocked blocked-by: clean-blocker (repo: sample) (kind: ship) +## Done +EOF + assert_clean "$home" "a live unresolved edge must be silent" + pass "clean live dependency edge is silent" +} + +test_large_clean_backlog_is_silent() { + local home n + home=$(make_home large-clean) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] large-clean-task - Large clean task (repo: sample) (kind: ship) +EOF + n=0 + while [ "$n" -lt 1800 ]; do + printf ' Large durable body line %04d carries enough detail to exceed one operating-system argument while remaining a clean record.\n' "$n" \ + >> "$home/data/backlog.md" + n=$((n + 1)) + done + printf '%s\n' '## Done' >> "$home/data/backlog.md" + assert_clean "$home" "a clean backlog larger than one process argument must stay silent" + pass "large clean backlog avoids process-argument limits and stays silent" +} + +test_dangling_edge_names_fault_and_closable_fix() { + local home out + home=$(make_home dangling) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] dangling-dependent - Bad missing edge blocked-by: never-existed (repo: sample) (kind: ship) +## Done +EOF + out=$(run_lint "$home") + assert_contains "$out" "BACKLOG_STALE: task dangling-dependent has dangling blocked-by never-existed" \ + "dangling finding must name the record and missing target" + assert_contains "$out" "target is absent from data/backlog.md and data/done-archive.md" \ + "dangling finding must name both checked stores" + assert_contains "$out" "fix: run tasks-axi unblock dangling-dependent --by never-existed" \ + "dangling finding must provide its exact closing command" + (cd "$home" && tasks-axi unblock dangling-dependent --by never-existed >/dev/null) + assert_clean "$home" "dangling finding must clear after its printed fix" + pass "dangling edge fires and its printed fix makes the lint silent" +} + +test_done_edge_names_fault_and_closable_fix() { + local home out + home=$(make_home done) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] done-dependent - Bad satisfied edge blocked-by: done-blocker (repo: sample) (kind: ship) +## Done +- [x] done-blocker - Already complete (repo: sample) (kind: ship) (done 2026-07-28) +EOF + out=$(run_lint "$home") + assert_contains "$out" "BACKLOG_STALE: task done-dependent has satisfied blocked-by done-blocker" \ + "Done finding must name the record and satisfied target" + assert_contains "$out" "target is already Done in data/backlog.md" \ + "Done finding must state the mechanical fault" + assert_contains "$out" "fix: run tasks-axi unblock done-dependent --by done-blocker" \ + "Done finding must provide its exact closing command" + (cd "$home" && tasks-axi unblock done-dependent --by done-blocker >/dev/null) + assert_clean "$home" "Done finding must clear after its printed fix" + pass "already-Done edge fires and its printed fix makes the lint silent" +} + +test_archive_rotation_reader_disagreement_and_fix() { + local home n show snapshot out + home=$(make_home archived) + ( + cd "$home" || exit 1 + tasks-axi add archived-blocker "Retention blocker" --kind ship --repo sample >/dev/null + tasks-axi add archived-dependent "Captain decision" --kind captain --repo sample \ + --blocked-by archived-blocker >/dev/null + tasks-axi hold archived-dependent --reason "captain decision" --kind captain >/dev/null + tasks-axi done archived-blocker --note "completed" >/dev/null + for n in 01 02 03 04 05 06 07 08 09 10; do + tasks-axi add "archived-filler-$n" "Unrelated filler $n" --kind ship --repo sample >/dev/null + tasks-axi done "archived-filler-$n" --note "completed" >/dev/null + done + ) + + assert_grep "archived-blocker" "$home/data/done-archive.md" \ + "retention reproduction did not rotate the blocker into the archive" + assert_grep "blocked-by: archived-blocker" "$home/data/backlog.md" \ + "retention reproduction unexpectedly rewrote the dependent edge" + show=$(cd "$home" && tasks-axi show archived-dependent --full) + assert_contains "$show" "blocked: no" "tasks-axi must reproduce the satisfied answer" + assert_contains "$show" "blocked_by: none" "tasks-axi must drop the archived target from blockers" + snapshot=$(FM_HOME="$home" "$SNAPSHOT" --backlog-json) + printf '%s\n' "$snapshot" | jq -e ' + .records[] | select(.id == "archived-dependent") + | .unresolved_blocker_ids == ["archived-blocker"] + and .captain_actionable == false + ' >/dev/null || fail "fleet snapshot did not reproduce the opposite unresolved answer" + + out=$(run_lint "$home") + assert_contains "$out" "BACKLOG_STALE: task archived-dependent has reader-disagreement blocked-by archived-blocker" \ + "reader-disagreement finding must name the record and edge" + assert_contains "$out" "tasks-axi says satisfied; fm-fleet-snapshot says unresolved" \ + "reader-disagreement finding must state both opposite answers" + assert_contains "$out" "fix: run tasks-axi unblock archived-dependent --by archived-blocker" \ + "reader-disagreement finding must provide its exact closing command" + (cd "$home" && tasks-axi unblock archived-dependent --by archived-blocker >/dev/null) + assert_clean "$home" "reader-disagreement finding must clear after its printed fix" + pass "retention-created reader disagreement fires and its printed fix makes the lint silent" +} + +test_bootstrap_surfaces_findings_and_stays_silent_when_clean() { + local bad_home clean_home bad_out clean_out + bad_home=$(make_home bootstrap-bad) + cat > "$bad_home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] bootstrap-dependent - Bad edge blocked-by: bootstrap-missing (repo: sample) (kind: ship) +## Done +EOF + clean_home=$(make_home bootstrap-clean) + cat > "$clean_home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] bootstrap-ready - Clean queued task (repo: sample) (kind: ship) +## Done +EOF + bad_out=$(FM_HOME="$bad_home" FM_BOOTSTRAP_DETECT_ONLY=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_contains "$bad_out" "BACKLOG_STALE: task bootstrap-dependent has dangling blocked-by bootstrap-missing" \ + "session-start bootstrap must surface lint findings" + clean_out=$(FM_HOME="$clean_home" FM_BOOTSTRAP_DETECT_ONLY=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_not_contains "$clean_out" "BACKLOG_STALE:" \ + "session-start bootstrap must keep a clean backlog lint-silent" + pass "bootstrap runs the detect-only lint and keeps clean homes silent" +} + +test_clean_live_edge_is_silent +test_large_clean_backlog_is_silent +test_dangling_edge_names_fault_and_closable_fix +test_done_edge_names_fault_and_closable_fix +test_archive_rotation_reader_disagreement_and_fix +test_bootstrap_surfaces_findings_and_stays_silent_when_clean From b003478072aa38d9b77887e3e4c0dfd81203156e Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:44:12 +0000 Subject: [PATCH 2/7] no-mistakes(review): fix backlog lint false alarms, manual-mode fixes, test rigor --- .agents/skills/bootstrap-diagnostics/SKILL.md | 2 +- bin/fm-backlog-lint.sh | 138 +++++++++++------- docs/configuration.md | 2 + tests/fm-backlog-lint.test.sh | 81 +++++++++- 4 files changed, 170 insertions(+), 53 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index aefe9a84ef..1d58b3a809 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -67,7 +67,7 @@ When any diagnostic needs captain attention, report the plain consequence and re Fix the named file to one non-empty git URL or absolute path, or remove it to fall back to the canonical upstream template; `docs/configuration.md` "Upstream firstmate and curated-fork checks" owns which check reads which file and why they are separate. Until it is fixed, treat that check's currency signal as absent rather than as evidence the deployment is current. - `BACKLOG_STALE: task has ; fix: ` - a current durable backlog record has a mechanically stale dependency edge. - Inspect the named record, run the exact printed `tasks-axi unblock` command to remove that edge, and add the intended existing blocker afterward only when the dangling id was a typo. + Inspect the named record and apply the exact printed fix - the `tasks-axi unblock` command on the default backend, or the named hand edit to `data/backlog.md` when `config/backlog-backend` is `manual` - and add the intended existing blocker afterward only when the dangling id was a typo. The check itself is detect-only and must never run the repair command, block startup, or mutate retention behavior. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. diff --git a/bin/fm-backlog-lint.sh b/bin/fm-backlog-lint.sh index e126885e19..24efa0634f 100755 --- a/bin/fm-backlog-lint.sh +++ b/bin/fm-backlog-lint.sh @@ -18,21 +18,27 @@ # - reader-disagreement: an archived Done target is treated as satisfied by # tasks-axi but unresolved by fm-fleet-snapshot # -# Every finding names the dependent task, blocker, fault, and exact tasks-axi -# command that removes the stale edge. +# Every finding names the dependent task, blocker, fault, and the exact fix that +# removes the stale edge: the tasks-axi command on the default backend, and the +# precise hand edit when config/backlog-backend is manual. # Missing prerequisites and unreadable structured inputs are command errors, -# not findings; bootstrap calls this only after its normal tool checks and -# treats command failure as non-blocking. +# not findings: they go to stderr and set exit status 1, never a BACKLOG_STALE +# line. Bootstrap calls this only after its normal tool checks and treats +# command failure as non-blocking. set -uf SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" BACKLOG="$DATA/backlog.md" ARCHIVE="$DATA/done-archive.md" SNAPSHOT="$SCRIPT_DIR/fm-fleet-snapshot.sh" +# shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-tasks-axi-lib.sh" + usage() { sed -n '2,/^set -uf$/s/^# \{0,1\}//p' "$0" } @@ -92,70 +98,100 @@ csv_has() { # return 1 } +BACKEND_MANUAL=0 +fm_backlog_backend_manual "$CONFIG" && BACKEND_MANUAL=1 + +fix_clause() { # + if [ "$BACKEND_MANUAL" = 1 ]; then + printf 'edit data/backlog.md by hand and delete the exact text "blocked-by: %s" from the record for task %s' \ + "$2" "$1" + else + printf 'run tasks-axi unblock %s --by %s' "$1" "$2" + fi +} + +LINT_STATUS=0 +ROWS_FILE="$LINT_TMP/edges.tsv" + +jq -nr \ + --slurpfile backlog "$BACKLOG_JSON_FILE" \ + --slurpfile archive "$ARCHIVE_JSON_FILE" ' + ($backlog[0].records + | map(select(.structured == true)) + | map({key:.id,value:.state}) + | from_entries) as $live + | ($archive[0].records + | map(select(.structured == true)) + | map({key:.id,value:true}) + | from_entries) as $archived + | $backlog[0].records[] + | select(.structured == true and .state != "done") + | . as $record + | (.blocked_by_ids // [])[] + | . as $blocker + | [ + $record.id, + $blocker, + (if $live[$blocker] == "done" then "done" + elif $live[$blocker] != null then "live" + elif $archived[$blocker] == true then "archived" + else "missing" + end), + ((($record.unresolved_blocker_ids // []) | index($blocker)) != null) + ] + | @tsv + ' > "$ROWS_FILE" \ + || { echo "fm-backlog-lint: dependency edge extraction failed for $BACKLOG" >&2; exit 1; } + task_axi_unresolved= +task_axi_readable=1 last_task= while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved; do [ -n "$task" ] || continue case "$target_class" in missing) - printf "BACKLOG_STALE: task %s has dangling blocked-by %s (target is absent from data/backlog.md and data/done-archive.md); fix: run tasks-axi unblock %s --by %s, then add the intended existing blocker if this id was a typo\n" \ - "$task" "$blocker" "$task" "$blocker" + printf "BACKLOG_STALE: task %s has dangling blocked-by %s (target is absent from data/backlog.md and data/done-archive.md); fix: %s, then add the intended existing blocker if this id was a typo\n" \ + "$task" "$blocker" "$(fix_clause "$task" "$blocker")" ;; done) - printf "BACKLOG_STALE: task %s has satisfied blocked-by %s (target is already Done in data/backlog.md); fix: run tasks-axi unblock %s --by %s\n" \ - "$task" "$blocker" "$task" "$blocker" + printf "BACKLOG_STALE: task %s has satisfied blocked-by %s (target is already Done in data/backlog.md); fix: %s\n" \ + "$task" "$blocker" "$(fix_clause "$task" "$blocker")" ;; archived) if [ "$task" != "$last_task" ]; then - TASK_AXI_SHOW=$(tasks-axi show "$task" --file "$BACKLOG" 2>/dev/null) || TASK_AXI_SHOW= - task_axi_unresolved=$( + last_task=$task + task_axi_unresolved= + task_axi_readable=1 + show_status=0 + TASK_AXI_SHOW=$(tasks-axi show "$task" --file "$BACKLOG" /dev/null) || show_status=$? + blocked_by_line=$( printf '%s\n' "$TASK_AXI_SHOW" | sed -n 's/^[[:space:]]*blocked_by:[[:space:]]*//p' | head -1 ) - case "$task_axi_unresolved" in - none|-) task_axi_unresolved= ;; - \"*) - task_axi_unresolved=${task_axi_unresolved#\"} - task_axi_unresolved=${task_axi_unresolved%\"} - ;; - esac - last_task=$task + if [ "$show_status" -ne 0 ] || [ -z "$blocked_by_line" ]; then + task_axi_readable=0 + LINT_STATUS=1 + echo "fm-backlog-lint: tasks-axi could not resolve task $task in $BACKLOG; its archived-target edges are unchecked" >&2 + else + task_axi_unresolved=$blocked_by_line + case "$task_axi_unresolved" in + none|-) task_axi_unresolved= ;; + \"*) + task_axi_unresolved=${task_axi_unresolved#\"} + task_axi_unresolved=${task_axi_unresolved%\"} + ;; + esac + fi fi - if [ "$snapshot_unresolved" = true ] \ + if [ "$task_axi_readable" = 1 ] \ + && [ "$snapshot_unresolved" = true ] \ && ! csv_has "$task_axi_unresolved" "$blocker"; then - printf "BACKLOG_STALE: task %s has reader-disagreement blocked-by %s (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock %s --by %s\n" \ - "$task" "$blocker" "$task" "$blocker" + printf "BACKLOG_STALE: task %s has reader-disagreement blocked-by %s (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: %s\n" \ + "$task" "$blocker" "$(fix_clause "$task" "$blocker")" fi ;; esac -done < <( - jq -nr \ - --slurpfile backlog "$BACKLOG_JSON_FILE" \ - --slurpfile archive "$ARCHIVE_JSON_FILE" ' - ($backlog[0].records - | map(select(.structured == true)) - | map({key:.id,value:.state}) - | from_entries) as $live - | ($archive[0].records - | map(select(.structured == true)) - | map({key:.id,value:true}) - | from_entries) as $archived - | $backlog[0].records[] - | select(.structured == true and .state != "done") - | . as $record - | .blocked_by_ids[] - | . as $blocker - | [ - $record.id, - $blocker, - (if $live[$blocker] == "done" then "done" - elif $live[$blocker] != null then "live" - elif $archived[$blocker] == true then "archived" - else "missing" - end), - (($record.unresolved_blocker_ids | index($blocker)) != null) - ] - | @tsv - ' -) +done < "$ROWS_FILE" + +exit "$LINT_STATUS" diff --git a/docs/configuration.md b/docs/configuration.md index 3a1a9a6168..6e74e9ccf4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,8 @@ Absent or `tasks-axi` selects the default tasks-axi backend. The file format is unchanged in both modes; tasks-axi and manual edits produce the same `## In flight`, `## Queued`, and `## Done` sections. At session start, `bin/fm-backlog-lint.sh` read-only checks current `blocked-by:` edges for missing targets, already-Done targets, and the archived-target disagreement between `tasks-axi` and `bin/fm-fleet-snapshot.sh`. It is silent when clean, never blocks or mutates records, and is also available as a standalone command. +It runs in both backend modes; under `manual` each finding's fix clause names the backlog file, the record, and the exact `blocked-by:` text to delete instead of prescribing a `tasks-axi unblock` command. +A record `tasks-axi` cannot resolve is a command error on stderr with exit status 1, never a `BACKLOG_STALE` finding. ## Runtime backend (config/backend / FM_BACKEND) diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh index 7ea9f5b232..7fbb56b51f 100755 --- a/tests/fm-backlog-lint.test.sh +++ b/tests/fm-backlog-lint.test.sh @@ -20,14 +20,35 @@ make_home() { # printf '%s\n' "$home" } +# run_lint keeps the lint's stdout on stdout so callers can capture it, and +# records exit status and stderr in files that survive the capture subshell. +LINT_STATUS_FILE=$TMP_ROOT/lint-status +LINT_ERR_FILE=$TMP_ROOT/lint-stderr + run_lint() { # - FM_HOME="$1" "$LINT" + local status=0 + FM_HOME="$1" "$LINT" 2>"$LINT_ERR_FILE" || status=$? + printf '%s\n' "$status" > "$LINT_STATUS_FILE" +} + +lint_status() { + cat "$LINT_STATUS_FILE" +} + +lint_stderr() { + cat "$LINT_ERR_FILE" +} + +assert_ok() { # + [ "$(lint_status)" = 0 ] || fail "$1: lint exited $(lint_status) instead of succeeding" + [ -z "$(lint_stderr)" ] || fail "$1: lint wrote to stderr: $(lint_stderr)" } assert_clean() { # local out out=$(run_lint "$1") [ -z "$out" ] || fail "$2: $out" + assert_ok "$2" } test_clean_live_edge_is_silent() { @@ -79,6 +100,7 @@ test_dangling_edge_names_fault_and_closable_fix() { ## Done EOF out=$(run_lint "$home") + assert_ok "a dangling edge is a finding, not a command error" assert_contains "$out" "BACKLOG_STALE: task dangling-dependent has dangling blocked-by never-existed" \ "dangling finding must name the record and missing target" assert_contains "$out" "target is absent from data/backlog.md and data/done-archive.md" \ @@ -103,6 +125,7 @@ test_done_edge_names_fault_and_closable_fix() { - [x] done-blocker - Already complete (repo: sample) (kind: ship) (done 2026-07-28) EOF out=$(run_lint "$home") + assert_ok "an already-Done edge is a finding, not a command error" assert_contains "$out" "BACKLOG_STALE: task done-dependent has satisfied blocked-by done-blocker" \ "Done finding must name the record and satisfied target" assert_contains "$out" "target is already Done in data/backlog.md" \ @@ -145,6 +168,7 @@ test_archive_rotation_reader_disagreement_and_fix() { ' >/dev/null || fail "fleet snapshot did not reproduce the opposite unresolved answer" out=$(run_lint "$home") + assert_ok "a reader-disagreement edge is a finding, not a command error" assert_contains "$out" "BACKLOG_STALE: task archived-dependent has reader-disagreement blocked-by archived-blocker" \ "reader-disagreement finding must name the record and edge" assert_contains "$out" "tasks-axi says satisfied; fm-fleet-snapshot says unresolved" \ @@ -156,6 +180,59 @@ test_archive_rotation_reader_disagreement_and_fix() { pass "retention-created reader disagreement fires and its printed fix makes the lint silent" } +test_unresolvable_record_is_a_command_error_not_a_finding() { + local home out + home=$(make_home unresolvable) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] **unresolvable-dependent** - Id tasks-axi cannot resolve blocked-by: unresolvable-blocker (repo: sample) (kind: ship) +## Done +EOF + cat > "$home/data/done-archive.md" <<'EOF' +## Archived 2026-07-01 + +- [x] unresolvable-blocker - Rotated out of the live backlog (repo: sample) (kind: ship) (done 2026-06-01) +EOF + out=$(run_lint "$home") + assert_not_contains "$out" "BACKLOG_STALE:" \ + "a record tasks-axi cannot resolve must never become a finding" + [ "$(lint_status)" = 1 ] \ + || fail "an unreadable tasks-axi answer must exit 1, got $(lint_status)" + assert_contains "$(lint_stderr)" "tasks-axi could not resolve task **unresolvable-dependent**" \ + "an unreadable tasks-axi answer must name the record on stderr" + pass "an unresolvable record is a command error instead of a reader-disagreement false alarm" +} + +test_manual_backend_prints_hand_edit_fix() { + local home out + home=$(make_home manual-backend) + printf '%s\n' manual > "$home/config/backlog-backend" + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] manual-dependent - Bad missing edge blocked-by: manual-missing (repo: sample) (kind: ship) +- [ ] manual-done-dependent - Bad satisfied edge blocked-by: manual-done (repo: sample) (kind: ship) +## Done +- [x] manual-done - Already complete (repo: sample) (kind: ship) (done 2026-07-28) +EOF + out=$(run_lint "$home") + assert_ok "the lint must still run under config/backlog-backend=manual" + assert_contains "$out" "BACKLOG_STALE: task manual-dependent has dangling blocked-by manual-missing" \ + "manual mode must keep the finding message shape" + assert_contains "$out" 'fix: edit data/backlog.md by hand and delete the exact text "blocked-by: manual-missing" from the record for task manual-dependent' \ + "manual mode must name the file, record, and exact blocked-by text" + assert_contains "$out" 'fix: edit data/backlog.md by hand and delete the exact text "blocked-by: manual-done" from the record for task manual-done-dependent' \ + "manual mode must give hand-edit guidance for every finding class" + assert_not_contains "$out" "tasks-axi unblock" \ + "manual mode must not prescribe the backend the home opted out of" + pass "manual backend keeps the lint enabled with closable hand-edit fixes" +} + test_bootstrap_surfaces_findings_and_stays_silent_when_clean() { local bad_home clean_home bad_out clean_out bad_home=$(make_home bootstrap-bad) @@ -190,4 +267,6 @@ test_large_clean_backlog_is_silent test_dangling_edge_names_fault_and_closable_fix test_done_edge_names_fault_and_closable_fix test_archive_rotation_reader_disagreement_and_fix +test_unresolvable_record_is_a_command_error_not_a_finding +test_manual_backend_prints_hand_edit_fix test_bootstrap_surfaces_findings_and_stays_silent_when_clean From 27d45459705e279432f131e8993f7fd77cc0caa3 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:54:19 +0000 Subject: [PATCH 3/7] no-mistakes(review): quote real blocked-by token, add coded BACKLOG_UNREADABLE diagnostic --- .agents/skills/bootstrap-diagnostics/SKILL.md | 6 +- AGENTS.md | 2 +- bin/fm-backlog-lint.sh | 42 ++++++++----- bin/fm-bootstrap.sh | 1 + docs/configuration.md | 4 +- tests/fm-backlog-lint.test.sh | 60 +++++++++++++++---- 6 files changed, 86 insertions(+), 29 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 1d58b3a809..31cc483cf5 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, ROLE_INVALID, ROLE_OVERLAY_MISSING, NEEDS_GH_AUTH, TANGLE, SELF_DRIFT, CREW_DISPATCH invalid, CURRENCY_BASE, BACKLOG_STALE, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, AXI_SUITE_UPDATED, AXI_SUITE_REVIEW, AXI_SUITE_STUCK, FIRSTMATE_UPDATE_AVAILABLE, FIRSTMATE_UPDATE_STUCK, FORK_SYNC, FORK_SYNC_STUCK, WATCHER_UNIT, FREQUENCY_MONITOR_UNIT, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, ROLE_INVALID, ROLE_OVERLAY_MISSING, NEEDS_GH_AUTH, TANGLE, SELF_DRIFT, CREW_DISPATCH invalid, CURRENCY_BASE, BACKLOG_STALE, BACKLOG_UNREADABLE, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, AXI_SUITE_UPDATED, AXI_SUITE_REVIEW, AXI_SUITE_STUCK, FIRSTMATE_UPDATE_AVAILABLE, FIRSTMATE_UPDATE_STUCK, FORK_SYNC, FORK_SYNC_STUCK, WATCHER_UNIT, FREQUENCY_MONITOR_UNIT, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -69,6 +69,10 @@ When any diagnostic needs captain attention, report the plain consequence and re - `BACKLOG_STALE: task has ; fix: ` - a current durable backlog record has a mechanically stale dependency edge. Inspect the named record and apply the exact printed fix - the `tasks-axi unblock` command on the default backend, or the named hand edit to `data/backlog.md` when `config/backlog-backend` is `manual` - and add the intended existing blocker afterward only when the dangling id was a typo. The check itself is detect-only and must never run the repair command, block startup, or mutate retention behavior. +- `BACKLOG_UNREADABLE: task in is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it ...; fix: ` - the two structured readers cannot be compared on that one record, so its archived-target edges were skipped rather than guessed at. + This is a malformed-record report, not a stale-edge finding: open the named row in the named backlog file and check the id token first, because `tasks-axi` resolves only a slug-shaped id (letters, digits, `.`, `_`, `-`, no spaces and no Markdown emphasis) inside a `- [ ] - ` row, while `fm-fleet-snapshot` also accepts a `- **<id>** - <title>` row. + Rewrite the id or the row shape, then confirm the repair with `tasks-axi show <id> --file <backlog file>` printing a `blocked_by:` line; the record's other stale-edge classes are still reported normally, and the rest of the backlog is still checked. + It repeats every session start until the row is fixed, it never blocks startup, and the check must not rewrite the row itself. - `FLEET_SYNC: <repo>: skipped: <reason>` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: <repo>: recovered: <detail>` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. diff --git a/AGENTS.md b/AGENTS.md index 6bcd28a0cf..76a1637710 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -495,7 +495,7 @@ Bootstrap separately detects when the primary checkout's own default branch has These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `ROLE_INVALID:`, `ROLE_OVERLAY_MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `SELF_DRIFT:`, `CREW_DISPATCH: invalid`, `CURRENCY_BASE:`, `BACKLOG_STALE:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `AXI_SUITE_UPDATED:`, `AXI_SUITE_REVIEW:`, `AXI_SUITE_STUCK:`, `FIRSTMATE_UPDATE_AVAILABLE:`, `FIRSTMATE_UPDATE_STUCK:`, `FORK_SYNC:`, `FORK_SYNC_STUCK:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `ROLE_INVALID:`, `ROLE_OVERLAY_MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `SELF_DRIFT:`, `CREW_DISPATCH: invalid`, `CURRENCY_BASE:`, `BACKLOG_STALE:`, `BACKLOG_UNREADABLE:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `AXI_SUITE_UPDATED:`, `AXI_SUITE_REVIEW:`, `AXI_SUITE_STUCK:`, `FIRSTMATE_UPDATE_AVAILABLE:`, `FIRSTMATE_UPDATE_STUCK:`, `FORK_SYNC:`, `FORK_SYNC_STUCK:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/bin/fm-backlog-lint.sh b/bin/fm-backlog-lint.sh index 24efa0634f..395ad0af68 100755 --- a/bin/fm-backlog-lint.sh +++ b/bin/fm-backlog-lint.sh @@ -19,12 +19,17 @@ # tasks-axi but unresolved by fm-fleet-snapshot # # Every finding names the dependent task, blocker, fault, and the exact fix that -# removes the stale edge: the tasks-axi command on the default backend, and the -# precise hand edit when config/backlog-backend is manual. -# Missing prerequisites and unreadable structured inputs are command errors, -# not findings: they go to stderr and set exit status 1, never a BACKLOG_STALE -# line. Bootstrap calls this only after its normal tool checks and treats -# command failure as non-blocking. +# removes the stale edge: the tasks-axi command on the default backend, and on +# the manual backend the hand edit, quoting the blocked-by token as the record +# actually spells it. +# A record fm-fleet-snapshot parses but tasks-axi cannot resolve is never a +# BACKLOG_STALE finding: it prints one coded BACKLOG_UNREADABLE line naming the +# record, the reader that failed, and the row repair that closes it. +# Missing prerequisites and unreadable structured inputs are command errors, not +# findings: they abort on stderr. +# Either condition sets exit status 1, meaning some edge stayed unchecked; +# bootstrap calls this only after its normal tool checks and treats command +# failure as non-blocking. set -uf SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -101,10 +106,10 @@ csv_has() { # <csv> <value> BACKEND_MANUAL=0 fm_backlog_backend_manual "$CONFIG" && BACKEND_MANUAL=1 -fix_clause() { # <task> <blocker> +fix_clause() { # <task> <blocker> <edge-token-as-written> if [ "$BACKEND_MANUAL" = 1 ]; then - printf 'edit data/backlog.md by hand and delete the exact text "blocked-by: %s" from the record for task %s' \ - "$2" "$1" + printf 'edit data/backlog.md by hand and delete the blocked-by token "%s" naming blocker %s from the record for task %s' \ + "$3" "$2" "$1" else printf 'run tasks-axi unblock %s --by %s' "$1" "$2" fi @@ -116,6 +121,11 @@ ROWS_FILE="$LINT_TMP/edges.tsv" jq -nr \ --slurpfile backlog "$BACKLOG_JSON_FILE" \ --slurpfile archive "$ARCHIVE_JSON_FILE" ' + def edge_token($raw; $blocker): + ([($raw // "") | scan("blocked-by:[[:space:]]+[^[:space:])]+")] + | map(select(sub("^blocked-by:[[:space:]]+"; "") == $blocker)) + | .[0]) + // ("blocked-by: " + $blocker); ($backlog[0].records | map(select(.structured == true)) | map({key:.id,value:.state}) @@ -137,7 +147,8 @@ jq -nr \ elif $archived[$blocker] == true then "archived" else "missing" end), - ((($record.unresolved_blocker_ids // []) | index($blocker)) != null) + ((($record.unresolved_blocker_ids // []) | index($blocker)) != null), + edge_token($record.raw; $blocker) ] | @tsv ' > "$ROWS_FILE" \ @@ -146,16 +157,16 @@ jq -nr \ task_axi_unresolved= task_axi_readable=1 last_task= -while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved; do +while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved edge_token; do [ -n "$task" ] || continue case "$target_class" in missing) printf "BACKLOG_STALE: task %s has dangling blocked-by %s (target is absent from data/backlog.md and data/done-archive.md); fix: %s, then add the intended existing blocker if this id was a typo\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token")" ;; done) printf "BACKLOG_STALE: task %s has satisfied blocked-by %s (target is already Done in data/backlog.md); fix: %s\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token")" ;; archived) if [ "$task" != "$last_task" ]; then @@ -172,7 +183,8 @@ while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved if [ "$show_status" -ne 0 ] || [ -z "$blocked_by_line" ]; then task_axi_readable=0 LINT_STATUS=1 - echo "fm-backlog-lint: tasks-axi could not resolve task $task in $BACKLOG; its archived-target edges are unchecked" >&2 + printf "BACKLOG_UNREADABLE: task %s in data/backlog.md is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it, so this record's archived-target edges stay unchecked; fix: repair that row in data/backlog.md - tasks-axi resolves only a slug-shaped id (letters, digits, \".\", \"_\", \"-\", no spaces and no Markdown emphasis) in a \"- [ ] <id> - <title>\" row - until tasks-axi show <id> --file data/backlog.md prints a blocked_by: line\n" \ + "$task" else task_axi_unresolved=$blocked_by_line case "$task_axi_unresolved" in @@ -188,7 +200,7 @@ while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved && [ "$snapshot_unresolved" = true ] \ && ! csv_has "$task_axi_unresolved" "$blocker"; then printf "BACKLOG_STALE: task %s has reader-disagreement blocked-by %s (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: %s\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token")" fi ;; esac diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index f15624aa50..f010ab7563 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -13,6 +13,7 @@ # "CREW_DISPATCH: invalid config/crew-dispatch.json - <reason>", # "CURRENCY_BASE: config/<file> is unusable - <reason>; <remediation>", # "BACKLOG_STALE: task <id> has <fault>; fix: <command>", +# "BACKLOG_UNREADABLE: task <id> in <backlog file> is parsed by <reader> but not <reader>; fix: <row repair>", # "FLEET_SYNC: <repo>: skipped|recovered|STUCK: <detail>", # "PR_CHECK_MIGRATION: <private remediation>", # "TANGLE: <remediation>", diff --git a/docs/configuration.md b/docs/configuration.md index 6e74e9ccf4..268ac2dbf3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,8 +80,8 @@ Absent or `tasks-axi` selects the default tasks-axi backend. The file format is unchanged in both modes; tasks-axi and manual edits produce the same `## In flight`, `## Queued`, and `## Done` sections. At session start, `bin/fm-backlog-lint.sh` read-only checks current `blocked-by:` edges for missing targets, already-Done targets, and the archived-target disagreement between `tasks-axi` and `bin/fm-fleet-snapshot.sh`. It is silent when clean, never blocks or mutates records, and is also available as a standalone command. -It runs in both backend modes; under `manual` each finding's fix clause names the backlog file, the record, and the exact `blocked-by:` text to delete instead of prescribing a `tasks-axi unblock` command. -A record `tasks-axi` cannot resolve is a command error on stderr with exit status 1, never a `BACKLOG_STALE` finding. +It runs in both backend modes; under `manual` each finding's fix clause names the backlog file, the record, the blocker id, and the `blocked-by:` token quoted as that record actually spells it, instead of prescribing a `tasks-axi unblock` command. +A record `fm-fleet-snapshot.sh` parses but `tasks-axi` cannot resolve is never a `BACKLOG_STALE` finding: it reports the coded `BACKLOG_UNREADABLE` bootstrap diagnostic naming the record and the row repair that closes it, skips only that record's archived-target edges, and exits 1 to mark the run incomplete without blocking session start. ## Runtime backend (config/backend / FM_BACKEND) diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh index 7fbb56b51f..be74f2f2d7 100755 --- a/tests/fm-backlog-lint.test.sh +++ b/tests/fm-backlog-lint.test.sh @@ -180,7 +180,7 @@ test_archive_rotation_reader_disagreement_and_fix() { pass "retention-created reader disagreement fires and its printed fix makes the lint silent" } -test_unresolvable_record_is_a_command_error_not_a_finding() { +test_unresolvable_record_is_a_coded_diagnostic_not_a_finding() { local home out home=$(make_home unresolvable) cat > "$home/data/backlog.md" <<'EOF' @@ -201,9 +201,44 @@ EOF "a record tasks-axi cannot resolve must never become a finding" [ "$(lint_status)" = 1 ] \ || fail "an unreadable tasks-axi answer must exit 1, got $(lint_status)" - assert_contains "$(lint_stderr)" "tasks-axi could not resolve task **unresolvable-dependent**" \ - "an unreadable tasks-axi answer must name the record on stderr" - pass "an unresolvable record is a command error instead of a reader-disagreement false alarm" + [ -z "$(lint_stderr)" ] \ + || fail "the unreadable-record report must be a coded line, not stderr: $(lint_stderr)" + assert_contains "$out" "BACKLOG_UNREADABLE: task **unresolvable-dependent** in data/backlog.md" \ + "the unreadable-record diagnostic must be coded and name the record and file" + assert_contains "$out" "is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it" \ + "the unreadable-record diagnostic must name the reader that failed and the missing property" + assert_contains "$out" "fix: repair that row in data/backlog.md" \ + "the unreadable-record diagnostic must say which record text to repair" + assert_contains "$out" "tasks-axi show <id> --file data/backlog.md prints a blocked_by: line" \ + "the unreadable-record diagnostic must name its closing condition" + assert_grep "BACKLOG_UNREADABLE" "$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" \ + "the coded diagnostic must have a documented handling procedure" + assert_grep "BACKLOG_UNREADABLE" "$ROOT/AGENTS.md" \ + "the coded diagnostic must be registered with the other bootstrap codes" + pass "an unresolvable record is a coded documented diagnostic, not a reader-disagreement false alarm" +} + +test_bootstrap_surfaces_the_unreadable_record_diagnostic() { + local home out + home=$(make_home bootstrap-unreadable) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] **bootstrap-unreadable-dependent** - Id tasks-axi cannot resolve blocked-by: bootstrap-archived (repo: sample) (kind: ship) +## Done +EOF + cat > "$home/data/done-archive.md" <<'EOF' +## Archived 2026-07-01 + +- [x] bootstrap-archived - Rotated out of the live backlog (repo: sample) (kind: ship) (done 2026-06-01) +EOF + out=$(FM_HOME="$home" FM_BOOTSTRAP_DETECT_ONLY=1 "$ROOT/bin/fm-bootstrap.sh" 2>&1) \ + || fail "the unreadable-record diagnostic must not make bootstrap fail" + assert_contains "$out" "BACKLOG_UNREADABLE: task **bootstrap-unreadable-dependent** in data/backlog.md" \ + "session-start bootstrap must surface the coded unreadable-record diagnostic" + pass "bootstrap surfaces the coded unreadable-record diagnostic without blocking" } test_manual_backend_prints_hand_edit_fix() { @@ -216,7 +251,7 @@ test_manual_backend_prints_hand_edit_fix() { ## In flight ## Queued - [ ] manual-dependent - Bad missing edge blocked-by: manual-missing (repo: sample) (kind: ship) -- [ ] manual-done-dependent - Bad satisfied edge blocked-by: manual-done (repo: sample) (kind: ship) +- [ ] manual-done-dependent - Bad satisfied edge blocked-by: manual-done (repo: sample) (kind: ship) ## Done - [x] manual-done - Already complete (repo: sample) (kind: ship) (done 2026-07-28) EOF @@ -224,12 +259,16 @@ EOF assert_ok "the lint must still run under config/backlog-backend=manual" assert_contains "$out" "BACKLOG_STALE: task manual-dependent has dangling blocked-by manual-missing" \ "manual mode must keep the finding message shape" - assert_contains "$out" 'fix: edit data/backlog.md by hand and delete the exact text "blocked-by: manual-missing" from the record for task manual-dependent' \ - "manual mode must name the file, record, and exact blocked-by text" - assert_contains "$out" 'fix: edit data/backlog.md by hand and delete the exact text "blocked-by: manual-done" from the record for task manual-done-dependent' \ - "manual mode must give hand-edit guidance for every finding class" + assert_contains "$out" 'fix: edit data/backlog.md by hand and delete the blocked-by token "blocked-by: manual-missing" naming blocker manual-missing from the record for task manual-dependent' \ + "manual mode must name the file, record, blocked-by token, and blocker id" + assert_contains "$out" 'delete the blocked-by token "blocked-by: manual-done" naming blocker manual-done from the record for task manual-done-dependent' \ + "manual mode must quote the blocked-by token as the record actually spells it" assert_not_contains "$out" "tasks-axi unblock" \ "manual mode must not prescribe the backend the home opted out of" + while IFS= read -r quoted; do + assert_grep "$quoted" "$home/data/backlog.md" \ + "a quoted blocked-by token must be findable in the backlog file" + done < <(printf '%s\n' "$out" | sed -n 's/.*delete the blocked-by token "\([^"]*\)".*/\1/p') pass "manual backend keeps the lint enabled with closable hand-edit fixes" } @@ -267,6 +306,7 @@ test_large_clean_backlog_is_silent test_dangling_edge_names_fault_and_closable_fix test_done_edge_names_fault_and_closable_fix test_archive_rotation_reader_disagreement_and_fix -test_unresolvable_record_is_a_command_error_not_a_finding +test_unresolvable_record_is_a_coded_diagnostic_not_a_finding +test_bootstrap_surfaces_the_unreadable_record_diagnostic test_manual_backend_prints_hand_edit_fix test_bootstrap_surfaces_findings_and_stays_silent_when_clean From abdf8999ed41a79f373dac93995a6f9054580306 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:03:37 +0000 Subject: [PATCH 4/7] no-mistakes(review): decide stale edges without tasks-axi, round-trip edge tokens --- .agents/skills/bootstrap-diagnostics/SKILL.md | 10 +- bin/fm-backlog-lint.sh | 120 +++++++++++------- docs/configuration.md | 4 +- tests/fm-backlog-lint.test.sh | 87 +++++++++++-- 4 files changed, 159 insertions(+), 62 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 31cc483cf5..fb337b78af 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -67,11 +67,13 @@ When any diagnostic needs captain attention, report the plain consequence and re Fix the named file to one non-empty git URL or absolute path, or remove it to fall back to the canonical upstream template; `docs/configuration.md` "Upstream firstmate and curated-fork checks" owns which check reads which file and why they are separate. Until it is fixed, treat that check's currency signal as absent rather than as evidence the deployment is current. - `BACKLOG_STALE: task <id> has <fault>; fix: <command>` - a current durable backlog record has a mechanically stale dependency edge. - Inspect the named record and apply the exact printed fix - the `tasks-axi unblock` command on the default backend, or the named hand edit to `data/backlog.md` when `config/backlog-backend` is `manual` - and add the intended existing blocker afterward only when the dangling id was a typo. + Inspect the named record and apply the exact printed fix, and add the intended existing blocker afterward only when the dangling id was a typo. + The fix is a `tasks-axi unblock` command only when that command would actually run; when the clause instead names a hand edit to `data/backlog.md` - because `config/backlog-backend` is `manual`, or because the line says no tasks-axi fix is available since tasks-axi cannot resolve that record - delete the quoted `blocked-by:` token from the named record by hand and do not substitute a `tasks-axi` command for it. The check itself is detect-only and must never run the repair command, block startup, or mutate retention behavior. -- `BACKLOG_UNREADABLE: task <id> in <backlog file> is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it ...; fix: <row repair>` - the two structured readers cannot be compared on that one record, so its archived-target edges were skipped rather than guessed at. - This is a malformed-record report, not a stale-edge finding: open the named row in the named backlog file and check the id token first, because `tasks-axi` resolves only a slug-shaped id (letters, digits, `.`, `_`, `-`, no spaces and no Markdown emphasis) inside a `- [ ] <id> - <title>` row, while `fm-fleet-snapshot` also accepts a `- **<id>** - <title>` row. - Rewrite the id or the row shape, then confirm the repair with `tasks-axi show <id> --file <backlog file>` printing a `blocked_by:` line; the record's other stale-edge classes are still reported normally, and the rest of the backlog is still checked. +- `BACKLOG_UNREADABLE: task <id> in <backlog file> is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it ...; fix: <row repair>` - the two structured readers cannot be compared on that one record, so whether its archived-target edges are stale stayed undecided and none was reported. + This is a malformed-record report, not a stale-edge finding, and it marks a deliberate miss rather than a guess: only the reader-disagreement class needs tasks-axi's own answer, so that record's dangling and already-Done edges are still reported as `BACKLOG_STALE` with hand-edit fixes, and the rest of the backlog is still checked. + Open the named row in the named backlog file and check the id token first, because `tasks-axi` resolves only a slug-shaped id (letters, digits, `.`, `_`, `-`, no spaces and no Markdown emphasis) inside a `- [ ] <id> - <title>` row, while `fm-fleet-snapshot` also accepts a `- **<id>** - <title>` row. + Rewrite the id or the row shape, then confirm the repair with `tasks-axi show <id> --file <backlog file>` printing a `blocked_by:` line. It repeats every session start until the row is fixed, it never blocks startup, and the check must not rewrite the row itself. - `FLEET_SYNC: <repo>: skipped: <reason>` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. diff --git a/bin/fm-backlog-lint.sh b/bin/fm-backlog-lint.sh index 395ad0af68..55e311ade9 100755 --- a/bin/fm-backlog-lint.sh +++ b/bin/fm-backlog-lint.sh @@ -19,17 +19,27 @@ # tasks-axi but unresolved by fm-fleet-snapshot # # Every finding names the dependent task, blocker, fault, and the exact fix that -# removes the stale edge: the tasks-axi command on the default backend, and on -# the manual backend the hand edit, quoting the blocked-by token as the record -# actually spells it. -# A record fm-fleet-snapshot parses but tasks-axi cannot resolve is never a -# BACKLOG_STALE finding: it prints one coded BACKLOG_UNREADABLE line naming the -# record, the reader that failed, and the row repair that closes it. +# removes the stale edge. +# +# The classification boundary is what the readers could decide about the edge, +# not whether the dependent record is well formed: +# - dangling and satisfied are decided from the parsed backlog and archive +# alone, so they stay BACKLOG_STALE findings even when tasks-axi cannot +# resolve the dependent record +# - reader-disagreement needs tasks-axi's own answer about that record, so a +# record tasks-axi cannot resolve leaves the edge undecided and reports one +# coded BACKLOG_UNREADABLE line instead - never a stale-edge finding, since +# a miss is preferred to a false positive +# +# The fix clause is the tasks-axi command only when that command would actually +# run: on the manual backend, and on any record tasks-axi cannot resolve, it is +# instead hand-edit guidance naming the backlog file, the dependent task, the +# blocker, and the blocked-by token quoted as the record actually spells it. # Missing prerequisites and unreadable structured inputs are command errors, not # findings: they abort on stderr. -# Either condition sets exit status 1, meaning some edge stayed unchecked; -# bootstrap calls this only after its normal tool checks and treats command -# failure as non-blocking. +# An undecided edge sets exit status 1; findings alone leave it 0. Bootstrap +# calls this only after its normal tool checks and treats command failure as +# non-blocking. set -uf SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -106,15 +116,59 @@ csv_has() { # <csv> <value> BACKEND_MANUAL=0 fm_backlog_backend_manual "$CONFIG" && BACKEND_MANUAL=1 -fix_clause() { # <task> <blocker> <edge-token-as-written> +decode_token() { # <base64 edge token> + printf '%s' "$1" | jq -Rrj '@base64d' 2>/dev/null +} + +fix_clause() { # <task> <blocker> <base64 edge token> <task-resolvable> + local token + if [ "$BACKEND_MANUAL" = 1 ] || [ "$4" = 0 ]; then + token=$(decode_token "$3") + [ -n "$token" ] || token="blocked-by: $2" + fi if [ "$BACKEND_MANUAL" = 1 ]; then printf 'edit data/backlog.md by hand and delete the blocked-by token "%s" naming blocker %s from the record for task %s' \ - "$3" "$2" "$1" + "$token" "$2" "$1" + elif [ "$4" = 0 ]; then + printf 'no tasks-axi fix is available because tasks-axi cannot resolve task %s in data/backlog.md, so edit data/backlog.md by hand and delete the blocked-by token "%s" naming blocker %s from the record for task %s' \ + "$1" "$token" "$2" "$1" else printf 'run tasks-axi unblock %s --by %s' "$1" "$2" fi } +probe_task= +task_axi_readable=1 +task_axi_unresolved= +unreadable_reported=0 + +probe_dependent_record() { # <task> - memoized per task, read-only + local show_status=0 show_out blocked_by_line + [ "$1" != "$probe_task" ] || return 0 + probe_task=$1 + task_axi_readable=1 + task_axi_unresolved= + unreadable_reported=0 + show_out=$(tasks-axi show "$1" --file "$BACKLOG" </dev/null 2>/dev/null) || show_status=$? + blocked_by_line=$( + printf '%s\n' "$show_out" | + sed -n 's/^[[:space:]]*blocked_by:[[:space:]]*//p' | + head -1 + ) + if [ "$show_status" -ne 0 ] || [ -z "$blocked_by_line" ]; then + task_axi_readable=0 + return 0 + fi + task_axi_unresolved=$blocked_by_line + case "$task_axi_unresolved" in + none|-) task_axi_unresolved= ;; + \"*) + task_axi_unresolved=${task_axi_unresolved#\"} + task_axi_unresolved=${task_axi_unresolved%\"} + ;; + esac +} + LINT_STATUS=0 ROWS_FILE="$LINT_TMP/edges.tsv" @@ -148,59 +202,37 @@ jq -nr \ else "missing" end), ((($record.unresolved_blocker_ids // []) | index($blocker)) != null), - edge_token($record.raw; $blocker) + (edge_token($record.raw; $blocker) | @base64) ] | @tsv ' > "$ROWS_FILE" \ || { echo "fm-backlog-lint: dependency edge extraction failed for $BACKLOG" >&2; exit 1; } -task_axi_unresolved= -task_axi_readable=1 -last_task= while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved edge_token; do [ -n "$task" ] || continue + [ "$target_class" = live ] && continue + probe_dependent_record "$task" case "$target_class" in missing) printf "BACKLOG_STALE: task %s has dangling blocked-by %s (target is absent from data/backlog.md and data/done-archive.md); fix: %s, then add the intended existing blocker if this id was a typo\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$task_axi_readable")" ;; done) printf "BACKLOG_STALE: task %s has satisfied blocked-by %s (target is already Done in data/backlog.md); fix: %s\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$task_axi_readable")" ;; archived) - if [ "$task" != "$last_task" ]; then - last_task=$task - task_axi_unresolved= - task_axi_readable=1 - show_status=0 - TASK_AXI_SHOW=$(tasks-axi show "$task" --file "$BACKLOG" </dev/null 2>/dev/null) || show_status=$? - blocked_by_line=$( - printf '%s\n' "$TASK_AXI_SHOW" | - sed -n 's/^[[:space:]]*blocked_by:[[:space:]]*//p' | - head -1 - ) - if [ "$show_status" -ne 0 ] || [ -z "$blocked_by_line" ]; then - task_axi_readable=0 + if [ "$task_axi_readable" = 0 ]; then + if [ "$unreadable_reported" = 0 ]; then + unreadable_reported=1 LINT_STATUS=1 - printf "BACKLOG_UNREADABLE: task %s in data/backlog.md is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it, so this record's archived-target edges stay unchecked; fix: repair that row in data/backlog.md - tasks-axi resolves only a slug-shaped id (letters, digits, \".\", \"_\", \"-\", no spaces and no Markdown emphasis) in a \"- [ ] <id> - <title>\" row - until tasks-axi show <id> --file data/backlog.md prints a blocked_by: line\n" \ + printf "BACKLOG_UNREADABLE: task %s in data/backlog.md is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it, so whether its archived-target edges are stale stays undecided and none is reported; fix: repair that row in data/backlog.md - tasks-axi resolves only a slug-shaped id (letters, digits, \".\", \"_\", \"-\", no spaces and no Markdown emphasis) in a \"- [ ] <id> - <title>\" row - until tasks-axi show <id> --file data/backlog.md prints a blocked_by: line\n" \ "$task" - else - task_axi_unresolved=$blocked_by_line - case "$task_axi_unresolved" in - none|-) task_axi_unresolved= ;; - \"*) - task_axi_unresolved=${task_axi_unresolved#\"} - task_axi_unresolved=${task_axi_unresolved%\"} - ;; - esac fi - fi - if [ "$task_axi_readable" = 1 ] \ - && [ "$snapshot_unresolved" = true ] \ + elif [ "$snapshot_unresolved" = true ] \ && ! csv_has "$task_axi_unresolved" "$blocker"; then printf "BACKLOG_STALE: task %s has reader-disagreement blocked-by %s (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: %s\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$task_axi_readable")" fi ;; esac diff --git a/docs/configuration.md b/docs/configuration.md index 268ac2dbf3..4eda952384 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,8 +80,8 @@ Absent or `tasks-axi` selects the default tasks-axi backend. The file format is unchanged in both modes; tasks-axi and manual edits produce the same `## In flight`, `## Queued`, and `## Done` sections. At session start, `bin/fm-backlog-lint.sh` read-only checks current `blocked-by:` edges for missing targets, already-Done targets, and the archived-target disagreement between `tasks-axi` and `bin/fm-fleet-snapshot.sh`. It is silent when clean, never blocks or mutates records, and is also available as a standalone command. -It runs in both backend modes; under `manual` each finding's fix clause names the backlog file, the record, the blocker id, and the `blocked-by:` token quoted as that record actually spells it, instead of prescribing a `tasks-axi unblock` command. -A record `fm-fleet-snapshot.sh` parses but `tasks-axi` cannot resolve is never a `BACKLOG_STALE` finding: it reports the coded `BACKLOG_UNREADABLE` bootstrap diagnostic naming the record and the row repair that closes it, skips only that record's archived-target edges, and exits 1 to mark the run incomplete without blocking session start. +It runs in both backend modes; the fix clause prescribes `tasks-axi unblock` only where that command would actually run, and otherwise names the backlog file, the record, the blocker id, and the `blocked-by:` token quoted as that record actually spells it - under `manual`, and on any record `tasks-axi` cannot resolve, where it also says no automated fix is available. +The classification boundary is what the readers could decide: dangling and already-Done edges are decided from the parsed backlog and archive alone and stay `BACKLOG_STALE` findings even for an unresolvable record, while the reader-disagreement class needs `tasks-axi`'s own answer, so an unresolvable record reports the coded `BACKLOG_UNREADABLE` bootstrap diagnostic naming the record and the row repair that closes it, reports no stale edge for that class, and exits 1 to mark the run undecided without blocking session start. ## Runtime backend (config/backend / FM_BACKEND) diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh index be74f2f2d7..6a766fa2ad 100755 --- a/tests/fm-backlog-lint.test.sh +++ b/tests/fm-backlog-lint.test.sh @@ -198,7 +198,7 @@ EOF EOF out=$(run_lint "$home") assert_not_contains "$out" "BACKLOG_STALE:" \ - "a record tasks-axi cannot resolve must never become a finding" + "an edge whose staleness could not be decided must never become a finding" [ "$(lint_status)" = 1 ] \ || fail "an unreadable tasks-axi answer must exit 1, got $(lint_status)" [ -z "$(lint_stderr)" ] \ @@ -218,6 +218,65 @@ EOF pass "an unresolvable record is a coded documented diagnostic, not a reader-disagreement false alarm" } +test_unresolvable_record_still_reports_edges_decided_without_tasks_axi() { + local home out + home=$(make_home unresolvable-decidable) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] **decidable-dependent** - Id tasks-axi cannot resolve blocked-by: decidable-missing (repo: sample) (kind: ship) +- [ ] **decidable-done-dependent** - Id tasks-axi cannot resolve blocked-by: decidable-done (repo: sample) (kind: ship) +## Done +- [x] decidable-done - Already complete (repo: sample) (kind: ship) (done 2026-07-28) +EOF + out=$(run_lint "$home") + assert_ok "edges the parsed backlog alone decides must leave the run decided" + assert_not_contains "$out" "BACKLOG_UNREADABLE" \ + "BACKLOG_UNREADABLE is only for edges whose staleness could not be decided" + assert_contains "$out" "BACKLOG_STALE: task **decidable-dependent** has dangling blocked-by decidable-missing" \ + "a dangling edge stays a finding when the readers decide it without tasks-axi" + assert_contains "$out" "BACKLOG_STALE: task **decidable-done-dependent** has satisfied blocked-by decidable-done" \ + "an already-Done edge stays a finding when the readers decide it without tasks-axi" + assert_contains "$out" 'fix: no tasks-axi fix is available because tasks-axi cannot resolve task **decidable-dependent** in data/backlog.md, so edit data/backlog.md by hand and delete the blocked-by token "blocked-by: decidable-missing" naming blocker decidable-missing from the record for task **decidable-dependent**' \ + "an unresolvable record must get hand-edit guidance naming file, record, token, and blocker" + assert_not_contains "$out" "tasks-axi unblock" \ + "the lint must never prescribe a tasks-axi command that cannot run" + pass "an unresolvable record keeps the findings its own row decides, with a runnable fix" +} + +test_unresolvable_record_separates_decided_and_undecided_edges() { + local home out unreadable_lines + home=$(make_home unresolvable-mixed) + cat > "$home/data/backlog.md" <<'EOF' +# Backlog + +## In flight +## Queued +- [ ] **mixed-dependent** - Two faults blocked-by: mixed-missing blocked-by: mixed-archived (repo: sample) (kind: ship) +## Done +EOF + cat > "$home/data/done-archive.md" <<'EOF' +## Archived 2026-07-01 + +- [x] mixed-archived - Rotated out of the live backlog (repo: sample) (kind: ship) (done 2026-06-01) +EOF + out=$(run_lint "$home") + [ "$(lint_status)" = 1 ] \ + || fail "one undecided edge must still mark the run undecided, got $(lint_status)" + assert_contains "$out" "BACKLOG_STALE: task **mixed-dependent** has dangling blocked-by mixed-missing" \ + "the decided edge of an unresolvable record must still be reported" + assert_not_contains "$out" "reader-disagreement" \ + "the undecided edge of the same record must not be guessed at" + assert_contains "$out" "BACKLOG_UNREADABLE: task **mixed-dependent** in data/backlog.md" \ + "the undecided edge must be reported as the coded diagnostic" + unreadable_lines=$(printf '%s\n' "$out" | grep -c "^BACKLOG_UNREADABLE:") + [ "$unreadable_lines" = 1 ] \ + || fail "a record must report BACKLOG_UNREADABLE once, got $unreadable_lines" + pass "one record reports its decided edge and its undecided edge under the right code" +} + test_bootstrap_surfaces_the_unreadable_record_diagnostic() { local home out home=$(make_home bootstrap-unreadable) @@ -245,16 +304,14 @@ test_manual_backend_prints_hand_edit_fix() { local home out home=$(make_home manual-backend) printf '%s\n' manual > "$home/config/backlog-backend" - cat > "$home/data/backlog.md" <<'EOF' -# Backlog - -## In flight -## Queued -- [ ] manual-dependent - Bad missing edge blocked-by: manual-missing (repo: sample) (kind: ship) -- [ ] manual-done-dependent - Bad satisfied edge blocked-by: manual-done (repo: sample) (kind: ship) -## Done -- [x] manual-done - Already complete (repo: sample) (kind: ship) (done 2026-07-28) -EOF + { + printf '%s\n' '# Backlog' '' '## In flight' '## Queued' + printf -- '- [ ] manual-dependent - Bad missing edge blocked-by: manual-missing (repo: sample) (kind: ship)\n' + printf -- '- [ ] manual-done-dependent - Bad satisfied edge blocked-by: manual-done (repo: sample) (kind: ship)\n' + printf -- '- [ ] manual-tab-dependent - Tab separated edge blocked-by:\tmanual-tab-missing (repo: sample) (kind: ship)\n' + printf '%s\n' '## Done' + printf -- '- [x] manual-done - Already complete (repo: sample) (kind: ship) (done 2026-07-28)\n' + } > "$home/data/backlog.md" out=$(run_lint "$home") assert_ok "the lint must still run under config/backlog-backend=manual" assert_contains "$out" "BACKLOG_STALE: task manual-dependent has dangling blocked-by manual-missing" \ @@ -262,7 +319,11 @@ EOF assert_contains "$out" 'fix: edit data/backlog.md by hand and delete the blocked-by token "blocked-by: manual-missing" naming blocker manual-missing from the record for task manual-dependent' \ "manual mode must name the file, record, blocked-by token, and blocker id" assert_contains "$out" 'delete the blocked-by token "blocked-by: manual-done" naming blocker manual-done from the record for task manual-done-dependent' \ - "manual mode must quote the blocked-by token as the record actually spells it" + "manual mode must quote a multi-space blocked-by token as the record actually spells it" + assert_contains "$out" $'delete the blocked-by token "blocked-by:\tmanual-tab-missing" naming blocker manual-tab-missing from the record for task manual-tab-dependent' \ + "manual mode must quote a tab-separated blocked-by token without escaping it" + assert_not_contains "$out" 'blocked-by:\tmanual-tab-missing' \ + "the quoted token must not carry a two-character escape the file does not contain" assert_not_contains "$out" "tasks-axi unblock" \ "manual mode must not prescribe the backend the home opted out of" while IFS= read -r quoted; do @@ -307,6 +368,8 @@ test_dangling_edge_names_fault_and_closable_fix test_done_edge_names_fault_and_closable_fix test_archive_rotation_reader_disagreement_and_fix test_unresolvable_record_is_a_coded_diagnostic_not_a_finding +test_unresolvable_record_still_reports_edges_decided_without_tasks_axi +test_unresolvable_record_separates_decided_and_undecided_edges test_bootstrap_surfaces_the_unreadable_record_diagnostic test_manual_backend_prints_hand_edit_fix test_bootstrap_surfaces_findings_and_stays_silent_when_clean From 7e36daa28953b199c3958b7d9089920ece97a954 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:15:11 +0000 Subject: [PATCH 5/7] no-mistakes(review): resolve backlog lint blockers with one bulk tasks-axi call --- .agents/skills/bootstrap-diagnostics/SKILL.md | 4 +- bin/fm-backlog-lint.sh | 142 +++++++++--------- docs/configuration.md | 1 + tests/fm-backlog-lint.test.sh | 56 ++++++- 4 files changed, 130 insertions(+), 73 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index fb337b78af..867175e84f 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -70,10 +70,10 @@ When any diagnostic needs captain attention, report the plain consequence and re Inspect the named record and apply the exact printed fix, and add the intended existing blocker afterward only when the dangling id was a typo. The fix is a `tasks-axi unblock` command only when that command would actually run; when the clause instead names a hand edit to `data/backlog.md` - because `config/backlog-backend` is `manual`, or because the line says no tasks-axi fix is available since tasks-axi cannot resolve that record - delete the quoted `blocked-by:` token from the named record by hand and do not substitute a `tasks-axi` command for it. The check itself is detect-only and must never run the repair command, block startup, or mutate retention behavior. -- `BACKLOG_UNREADABLE: task <id> in <backlog file> is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it ...; fix: <row repair>` - the two structured readers cannot be compared on that one record, so whether its archived-target edges are stale stayed undecided and none was reported. +- `BACKLOG_UNREADABLE: task <id> in <backlog file> is parsed by fm-fleet-snapshot but tasks-axi does not list that record when reading the same file ...; fix: <row repair>` - the two structured readers cannot be compared on that one record, so whether its archived-target edges are stale stayed undecided and none was reported. This is a malformed-record report, not a stale-edge finding, and it marks a deliberate miss rather than a guess: only the reader-disagreement class needs tasks-axi's own answer, so that record's dangling and already-Done edges are still reported as `BACKLOG_STALE` with hand-edit fixes, and the rest of the backlog is still checked. Open the named row in the named backlog file and check the id token first, because `tasks-axi` resolves only a slug-shaped id (letters, digits, `.`, `_`, `-`, no spaces and no Markdown emphasis) inside a `- [ ] <id> - <title>` row, while `fm-fleet-snapshot` also accepts a `- **<id>** - <title>` row. - Rewrite the id or the row shape, then confirm the repair with `tasks-axi show <id> --file <backlog file>` printing a `blocked_by:` line. + Rewrite the id or the row shape, then confirm the repair with `tasks-axi list --file <backlog file>` showing that id. It repeats every session start until the row is fixed, it never blocks startup, and the check must not rewrite the row itself. - `FLEET_SYNC: <repo>: skipped: <reason>` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. diff --git a/bin/fm-backlog-lint.sh b/bin/fm-backlog-lint.sh index 55e311ade9..c428d8efea 100755 --- a/bin/fm-backlog-lint.sh +++ b/bin/fm-backlog-lint.sh @@ -116,61 +116,23 @@ csv_has() { # <csv> <value> BACKEND_MANUAL=0 fm_backlog_backend_manual "$CONFIG" && BACKEND_MANUAL=1 -decode_token() { # <base64 edge token> - printf '%s' "$1" | jq -Rrj '@base64d' 2>/dev/null -} - -fix_clause() { # <task> <blocker> <base64 edge token> <task-resolvable> - local token - if [ "$BACKEND_MANUAL" = 1 ] || [ "$4" = 0 ]; then - token=$(decode_token "$3") - [ -n "$token" ] || token="blocked-by: $2" - fi +fix_clause() { # <task> <blocker> <edge token as written> <task-resolvable> if [ "$BACKEND_MANUAL" = 1 ]; then printf 'edit data/backlog.md by hand and delete the blocked-by token "%s" naming blocker %s from the record for task %s' \ - "$token" "$2" "$1" - elif [ "$4" = 0 ]; then - printf 'no tasks-axi fix is available because tasks-axi cannot resolve task %s in data/backlog.md, so edit data/backlog.md by hand and delete the blocked-by token "%s" naming blocker %s from the record for task %s' \ - "$1" "$token" "$2" "$1" + "$3" "$2" "$1" + elif [ "$4" = false ]; then + printf 'no tasks-axi fix is available because tasks-axi does not list task %s when reading data/backlog.md, so edit data/backlog.md by hand and delete the blocked-by token "%s" naming blocker %s from the record for task %s' \ + "$1" "$3" "$2" "$1" else printf 'run tasks-axi unblock %s --by %s' "$1" "$2" fi } -probe_task= -task_axi_readable=1 -task_axi_unresolved= -unreadable_reported=0 - -probe_dependent_record() { # <task> - memoized per task, read-only - local show_status=0 show_out blocked_by_line - [ "$1" != "$probe_task" ] || return 0 - probe_task=$1 - task_axi_readable=1 - task_axi_unresolved= - unreadable_reported=0 - show_out=$(tasks-axi show "$1" --file "$BACKLOG" </dev/null 2>/dev/null) || show_status=$? - blocked_by_line=$( - printf '%s\n' "$show_out" | - sed -n 's/^[[:space:]]*blocked_by:[[:space:]]*//p' | - head -1 - ) - if [ "$show_status" -ne 0 ] || [ -z "$blocked_by_line" ]; then - task_axi_readable=0 - return 0 - fi - task_axi_unresolved=$blocked_by_line - case "$task_axi_unresolved" in - none|-) task_axi_unresolved= ;; - \"*) - task_axi_unresolved=${task_axi_unresolved#\"} - task_axi_unresolved=${task_axi_unresolved%\"} - ;; - esac -} - LINT_STATUS=0 -ROWS_FILE="$LINT_TMP/edges.tsv" +UNIT=$(printf '\001') +EDGES_FILE="$LINT_TMP/edges" +AXI_LIST_FILE="$LINT_TMP/axi-list" +ROWS_FILE="$LINT_TMP/rows" jq -nr \ --slurpfile backlog "$BACKLOG_JSON_FILE" \ @@ -193,46 +155,92 @@ jq -nr \ | . as $record | (.blocked_by_ids // [])[] | . as $blocker + | (if $live[$blocker] == "done" then "done" + elif $live[$blocker] != null then "live" + elif $archived[$blocker] == true then "archived" + else "missing" + end) as $class + | select($class != "live") | [ $record.id, $blocker, - (if $live[$blocker] == "done" then "done" - elif $live[$blocker] != null then "live" - elif $archived[$blocker] == true then "archived" - else "missing" - end), - ((($record.unresolved_blocker_ids // []) | index($blocker)) != null), - (edge_token($record.raw; $blocker) | @base64) + $class, + ((($record.unresolved_blocker_ids // []) | index($blocker)) != null | tostring), + edge_token($record.raw; $blocker) ] - | @tsv - ' > "$ROWS_FILE" \ + | join("\u0001") + ' > "$EDGES_FILE" \ || { echo "fm-backlog-lint: dependency edge extraction failed for $BACKLOG" >&2; exit 1; } -while IFS="$(printf '\t')" read -r task blocker target_class snapshot_unresolved edge_token; do +[ -s "$EDGES_FILE" ] || exit 0 + +tasks-axi list --file "$BACKLOG" --fields blocked_by </dev/null > "$AXI_LIST_FILE" 2>/dev/null \ + || { echo "fm-backlog-lint: tasks-axi blocker listing failed: $BACKLOG" >&2; exit 1; } +grep -q '^tasks\[[0-9][0-9]*\]{id,state,kind,repo,title,blocked_by}:$' "$AXI_LIST_FILE" \ + || grep -q '^count: 0$' "$AXI_LIST_FILE" \ + || { echo "fm-backlog-lint: unexpected tasks-axi list schema for $BACKLOG" >&2; exit 1; } + +awk -v listing="$AXI_LIST_FILE" ' + BEGIN { FS = "\001"; OFS = "\001" } + FILENAME == listing { + if (rows && $0 !~ /^ /) { rows = 0 } + if ($0 ~ /^tasks\[/) { rows = 1; next } + if (!rows) { next } + line = substr($0, 3) + comma = index(line, ",") + if (comma < 2) { next } + id = substr(line, 1, comma - 1) + if (substr(line, length(line), 1) == "\"") { + start = 0 + scan = 1 + while ((hit = index(substr(line, scan), ",\"")) > 0) { + start = scan + hit - 1 + scan = start + 1 + } + if (start == 0) { next } + blockers = substr(line, start + 2, length(line) - start - 2) + } else { + start = 0 + for (i = length(line); i > 0; i--) { + if (substr(line, i, 1) == ",") { start = i; break } + } + if (start == 0) { next } + blockers = substr(line, start + 1) + } + if (blockers == "none" || blockers == "-") { blockers = "" } + listed[id] = 1 + blocked_by[id] = blockers + next + } + { print $1, $2, $3, $4, ($1 in listed ? "true" : "false"), blocked_by[$1], $5 } +' "$AXI_LIST_FILE" "$EDGES_FILE" > "$ROWS_FILE" \ + || { echo "fm-backlog-lint: tasks-axi blocker table read failed for $BACKLOG" >&2; exit 1; } + +unreadable_task= +while IFS="$UNIT" read -r task blocker target_class snapshot_unresolved \ + axi_resolvable axi_blocked_by edge_token; do [ -n "$task" ] || continue - [ "$target_class" = live ] && continue - probe_dependent_record "$task" case "$target_class" in missing) printf "BACKLOG_STALE: task %s has dangling blocked-by %s (target is absent from data/backlog.md and data/done-archive.md); fix: %s, then add the intended existing blocker if this id was a typo\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$task_axi_readable")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$axi_resolvable")" ;; done) printf "BACKLOG_STALE: task %s has satisfied blocked-by %s (target is already Done in data/backlog.md); fix: %s\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$task_axi_readable")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$axi_resolvable")" ;; archived) - if [ "$task_axi_readable" = 0 ]; then - if [ "$unreadable_reported" = 0 ]; then - unreadable_reported=1 + if [ "$axi_resolvable" = false ]; then + if [ "$task" != "$unreadable_task" ]; then + unreadable_task=$task LINT_STATUS=1 - printf "BACKLOG_UNREADABLE: task %s in data/backlog.md is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it, so whether its archived-target edges are stale stays undecided and none is reported; fix: repair that row in data/backlog.md - tasks-axi resolves only a slug-shaped id (letters, digits, \".\", \"_\", \"-\", no spaces and no Markdown emphasis) in a \"- [ ] <id> - <title>\" row - until tasks-axi show <id> --file data/backlog.md prints a blocked_by: line\n" \ + printf "BACKLOG_UNREADABLE: task %s in data/backlog.md is parsed by fm-fleet-snapshot but tasks-axi does not list that record when reading the same file, so whether its archived-target edges are stale stays undecided and none is reported; fix: repair that row in data/backlog.md - tasks-axi resolves only a slug-shaped id (letters, digits, \".\", \"_\", \"-\", no spaces and no Markdown emphasis) in a \"- [ ] <id> - <title>\" row - until tasks-axi list --file data/backlog.md shows that id\n" \ "$task" fi elif [ "$snapshot_unresolved" = true ] \ - && ! csv_has "$task_axi_unresolved" "$blocker"; then + && ! csv_has "$axi_blocked_by" "$blocker"; then printf "BACKLOG_STALE: task %s has reader-disagreement blocked-by %s (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: %s\n" \ - "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$task_axi_readable")" + "$task" "$blocker" "$(fix_clause "$task" "$blocker" "$edge_token" "$axi_resolvable")" fi ;; esac diff --git a/docs/configuration.md b/docs/configuration.md index 4eda952384..e845a979d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,7 @@ Absent or `tasks-axi` selects the default tasks-axi backend. The file format is unchanged in both modes; tasks-axi and manual edits produce the same `## In flight`, `## Queued`, and `## Done` sections. At session start, `bin/fm-backlog-lint.sh` read-only checks current `blocked-by:` edges for missing targets, already-Done targets, and the archived-target disagreement between `tasks-axi` and `bin/fm-fleet-snapshot.sh`. It is silent when clean, never blocks or mutates records, and is also available as a standalone command. +Session-start cost stays bounded regardless of how much rot the backlog carries: a clean backlog runs no `tasks-axi` process at all, and any number of stale edges is resolved against one `tasks-axi list` call. It runs in both backend modes; the fix clause prescribes `tasks-axi unblock` only where that command would actually run, and otherwise names the backlog file, the record, the blocker id, and the `blocked-by:` token quoted as that record actually spells it - under `manual`, and on any record `tasks-axi` cannot resolve, where it also says no automated fix is available. The classification boundary is what the readers could decide: dangling and already-Done edges are decided from the parsed backlog and archive alone and stay `BACKLOG_STALE` findings even for an unresolvable record, while the reader-disagreement class needs `tasks-axi`'s own answer, so an unresolvable record reports the coded `BACKLOG_UNREADABLE` bootstrap diagnostic naming the record and the row repair that closes it, reports no stale edge for that class, and exits 1 to mark the run undecided without blocking session start. diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh index 6a766fa2ad..8d659f095d 100755 --- a/tests/fm-backlog-lint.test.sh +++ b/tests/fm-backlog-lint.test.sh @@ -205,11 +205,11 @@ EOF || fail "the unreadable-record report must be a coded line, not stderr: $(lint_stderr)" assert_contains "$out" "BACKLOG_UNREADABLE: task **unresolvable-dependent** in data/backlog.md" \ "the unreadable-record diagnostic must be coded and name the record and file" - assert_contains "$out" "is parsed by fm-fleet-snapshot but tasks-axi show returns no blocked_by: property for it" \ - "the unreadable-record diagnostic must name the reader that failed and the missing property" + assert_contains "$out" "is parsed by fm-fleet-snapshot but tasks-axi does not list that record when reading the same file" \ + "the unreadable-record diagnostic must name the reader that failed and the mismatch" assert_contains "$out" "fix: repair that row in data/backlog.md" \ "the unreadable-record diagnostic must say which record text to repair" - assert_contains "$out" "tasks-axi show <id> --file data/backlog.md prints a blocked_by: line" \ + assert_contains "$out" "tasks-axi list --file data/backlog.md shows that id" \ "the unreadable-record diagnostic must name its closing condition" assert_grep "BACKLOG_UNREADABLE" "$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" \ "the coded diagnostic must have a documented handling procedure" @@ -239,7 +239,7 @@ EOF "a dangling edge stays a finding when the readers decide it without tasks-axi" assert_contains "$out" "BACKLOG_STALE: task **decidable-done-dependent** has satisfied blocked-by decidable-done" \ "an already-Done edge stays a finding when the readers decide it without tasks-axi" - assert_contains "$out" 'fix: no tasks-axi fix is available because tasks-axi cannot resolve task **decidable-dependent** in data/backlog.md, so edit data/backlog.md by hand and delete the blocked-by token "blocked-by: decidable-missing" naming blocker decidable-missing from the record for task **decidable-dependent**' \ + assert_contains "$out" 'fix: no tasks-axi fix is available because tasks-axi does not list task **decidable-dependent** when reading data/backlog.md, so edit data/backlog.md by hand and delete the blocked-by token "blocked-by: decidable-missing" naming blocker decidable-missing from the record for task **decidable-dependent**' \ "an unresolvable record must get hand-edit guidance naming file, record, token, and blocker" assert_not_contains "$out" "tasks-axi unblock" \ "the lint must never prescribe a tasks-axi command that cannot run" @@ -277,6 +277,53 @@ EOF pass "one record reports its decided edge and its undecided edge under the right code" } +test_lint_cost_stays_bounded_as_backlog_rot_grows() { + local home_clean home_rot shim log n calls + shim=$TMP_ROOT/tasks-axi-shim + log=$TMP_ROOT/tasks-axi-calls + mkdir -p "$shim" + cat > "$shim/tasks-axi" <<EOF +#!/usr/bin/env bash +printf 'call\n' >> "\$FM_TEST_TASKS_AXI_CALL_LOG" +exec $(command -v tasks-axi) "\$@" +EOF + chmod +x "$shim/tasks-axi" + + home_clean=$(make_home bounded-clean) + home_rot=$(make_home bounded-rot) + { + printf '%s\n' '# Backlog' '' '## In flight' '## Queued' + n=0 + while [ "$n" -lt 40 ]; do + printf -- '- [ ] bounded-live-%02d - Clean queued task (repo: sample) (kind: ship)\n' "$n" + n=$((n + 1)) + done + printf '%s\n' '## Done' + } > "$home_clean/data/backlog.md" + { + printf '%s\n' '# Backlog' '' '## In flight' '## Queued' + n=0 + while [ "$n" -lt 40 ]; do + printf -- '- [ ] bounded-rot-%02d - Stale task blocked-by: bounded-ghost-%02d (repo: sample) (kind: ship)\n' "$n" "$n" + n=$((n + 1)) + done + printf '%s\n' '## Done' + } > "$home_rot/data/backlog.md" + + : > "$log" + PATH="$shim:$PATH" FM_TEST_TASKS_AXI_CALL_LOG="$log" FM_HOME="$home_clean" "$LINT" >/dev/null 2>&1 + calls=$(wc -l < "$log" | tr -d '[:space:]') + [ "$calls" = 0 ] \ + || fail "a clean backlog must start no tasks-axi process, started $calls" + + : > "$log" + PATH="$shim:$PATH" FM_TEST_TASKS_AXI_CALL_LOG="$log" FM_HOME="$home_rot" "$LINT" >/dev/null 2>&1 + calls=$(wc -l < "$log" | tr -d '[:space:]') + [ "$calls" = 1 ] \ + || fail "40 stale edges must be resolved by one tasks-axi process, started $calls" + pass "session-start lint cost stays bounded as backlog rot grows" +} + test_bootstrap_surfaces_the_unreadable_record_diagnostic() { local home out home=$(make_home bootstrap-unreadable) @@ -370,6 +417,7 @@ test_archive_rotation_reader_disagreement_and_fix test_unresolvable_record_is_a_coded_diagnostic_not_a_finding test_unresolvable_record_still_reports_edges_decided_without_tasks_axi test_unresolvable_record_separates_decided_and_undecided_edges +test_lint_cost_stays_bounded_as_backlog_rot_grows test_bootstrap_surfaces_the_unreadable_record_diagnostic test_manual_backend_prints_hand_edit_fix test_bootstrap_surfaces_findings_and_stays_silent_when_clean From 2f2a63204cfa0a43535df3077f7ff4a4cd8699c8 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:27:37 +0000 Subject: [PATCH 6/7] no-mistakes(document): list fm-backlog-lint.sh in the bin toolbelt inventory --- docs/scripts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/scripts.md b/docs/scripts.md index a5898d90b5..93730e725f 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -22,6 +22,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | +| `fm-backlog-lint.sh` | Detect-only report of mechanically stale `blocked-by:` edges in the durable backlog | | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs | | `fm-model-panel.sh` | Run a model panel: two independent analysts on different models, then a judge that re-verifies both | From 60669959dcd78d291fa137dcd132b924b839acb7 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:48:21 +0000 Subject: [PATCH 7/7] no-mistakes(lint): quote literal done args, silence indirect-trap SC2329 --- bin/fm-backlog-lint.sh | 1 + bin/fm-fleet-snapshot.sh | 2 +- tests/fm-backlog-lint.test.sh | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/fm-backlog-lint.sh b/bin/fm-backlog-lint.sh index c428d8efea..9ec3da9b65 100755 --- a/bin/fm-backlog-lint.sh +++ b/bin/fm-backlog-lint.sh @@ -76,6 +76,7 @@ LINT_TMP=$( mktemp -d "${TMPDIR:-/tmp}/fm-backlog-lint.XXXXXX" ) || { echo "fm-backlog-lint: could not create private temporary directory" >&2; exit 1; } +# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below cleanup() { rm -rf "$LINT_TMP" } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index ecd4c173c5..0f2b26348b 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -180,7 +180,7 @@ case "${1:---json}" in --secondmate-home-summary) OUTPUT_MODE=secondmate-home-summary ;; --backlog-json) [ "$#" -le 2 ] || { usage >&2; exit 2; } - OUTPUT_MODE=backlog-json + OUTPUT_MODE='backlog-json' BACKLOG_PATH=${2:-$BACKLOG} ;; -h|--help) usage; exit 0 ;; diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh index 8d659f095d..3ec0469c43 100755 --- a/tests/fm-backlog-lint.test.sh +++ b/tests/fm-backlog-lint.test.sh @@ -114,7 +114,7 @@ EOF test_done_edge_names_fault_and_closable_fix() { local home out - home=$(make_home done) + home=$(make_home 'done') cat > "$home/data/backlog.md" <<'EOF' # Backlog @@ -146,10 +146,10 @@ test_archive_rotation_reader_disagreement_and_fix() { tasks-axi add archived-dependent "Captain decision" --kind captain --repo sample \ --blocked-by archived-blocker >/dev/null tasks-axi hold archived-dependent --reason "captain decision" --kind captain >/dev/null - tasks-axi done archived-blocker --note "completed" >/dev/null + tasks-axi 'done' archived-blocker --note "completed" >/dev/null for n in 01 02 03 04 05 06 07 08 09 10; do tasks-axi add "archived-filler-$n" "Unrelated filler $n" --kind ship --repo sample >/dev/null - tasks-axi done "archived-filler-$n" --note "completed" >/dev/null + tasks-axi 'done' "archived-filler-$n" --note "completed" >/dev/null done )