diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 8a195085a4..867175e84f 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, 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: @@ -66,6 +66,15 @@ 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 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 in is parsed by fm-fleet-snapshot but tasks-axi does not list that record when reading the same file ...; fix: ` - 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 `- [ ] - ` 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 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. - `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 82bede5210..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:`, `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 new file mode 100755 index 0000000000..9ec3da9b65 --- /dev/null +++ b/bin/fm-backlog-lint.sh @@ -0,0 +1,250 @@ +#!/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 the exact fix that +# 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. +# 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)" +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" +} + +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; } + +# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below +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() { # <csv> <value> + 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 +} + +BACKEND_MANUAL=0 +fm_backlog_backend_manual "$CONFIG" && BACKEND_MANUAL=1 + +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' \ + "$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 +} + +LINT_STATUS=0 +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" \ + --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}) + | 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 + | (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, + $class, + ((($record.unresolved_blocker_ids // []) | index($blocker)) != null | tostring), + edge_token($record.raw; $blocker) + ] + | join("\u0001") + ' > "$EDGES_FILE" \ + || { echo "fm-backlog-lint: dependency edge extraction failed for $BACKLOG" >&2; exit 1; } + +[ -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 + 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" "$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" "$axi_resolvable")" + ;; + archived) + 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 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 "$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" "$axi_resolvable")" + fi + ;; + esac +done < "$ROWS_FILE" + +exit "$LINT_STATUS" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1c33141f3b..f010ab7563 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -12,6 +12,8 @@ # "ROLE_OVERLAY_MISSING: <name> (expected: roles/<name>.md)", # "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>", @@ -922,6 +924,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..0f2b26348b 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 [<path>]` 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 <date>` +# 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 [<path>] 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() { # [<backlog-path>] - 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..e845a979d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,11 @@ 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. +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. ## Runtime backend (config/backend / FM_BACKEND) 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 | diff --git a/tests/fm-backlog-lint.test.sh b/tests/fm-backlog-lint.test.sh new file mode 100755 index 0000000000..3ec0469c43 --- /dev/null +++ b/tests/fm-backlog-lint.test.sh @@ -0,0 +1,423 @@ +#!/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() { # <name> + 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 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() { # <home> + 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() { # <message> + [ "$(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() { # <home> <message> + local out + out=$(run_lint "$1") + [ -z "$out" ] || fail "$2: $out" + assert_ok "$2" +} + +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_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" \ + "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_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" \ + "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_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" \ + "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_unresolvable_record_is_a_coded_diagnostic_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:" \ + "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)" ] \ + || 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 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 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" + 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_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 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" + 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_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) + 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() { + local home out + home=$(make_home manual-backend) + printf '%s\n' manual > "$home/config/backlog-backend" + { + 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" \ + "manual mode must keep the finding message shape" + 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 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 + 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" +} + +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_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