diff --git a/bin/fm-backlog-audit.sh b/bin/fm-backlog-audit.sh new file mode 100755 index 00000000000..50ce41a5b1c --- /dev/null +++ b/bin/fm-backlog-audit.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Read-only consistency audit for firstmate backlog/state drift. +# +# Checks data/backlog.md against state/*.meta for common supervision drift: +# duplicate In flight/Done entries, orphan meta files, In flight items without +# meta, PR-ready/merged work still parked In flight, and Watchlist items that +# already have local adoption signals. +# Usage: fm-backlog-audit.sh +set -eu + +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}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +BACKLOG="$DATA/backlog.md" + +declare -A IN_FLIGHT_LINES=() +declare -A DONE_LINES=() +declare -A WATCHLIST_LINES=() +FINDINGS=() + +add_finding() { + FINDINGS+=("$1") +} + +parse_backlog() { + awk ' + function task_id(line, rest, id) { + rest = line + sub(/^[[:space:]]*-[[:space:]]+/, "", rest) + sub(/^\[[ xX]\][[:space:]]+/, "", rest) + if (rest ~ /^\*\*/) { + sub(/^\*\*/, "", rest) + id = rest + sub(/\*\*.*/, "", id) + return id + } + id = rest + sub(/[[:space:]].*/, "", id) + return id + } + /^##[[:space:]]+/ { + section = $0 + sub(/^##[[:space:]]+/, "", section) + next + } + /^[[:space:]]*-[[:space:]]+/ { + id = task_id($0) + if (id != "") { + print section "\t" id "\t" $0 + } + } + ' "$BACKLOG" +} + +looks_pr_ready_or_merged() { + local line=$1 + case "$line" in + *"PR ready"*|*"checks green"*|*"merged"*|*"MERGED"*) + return 0 + ;; + esac + return 1 +} + +if [ ! -f "$BACKLOG" ]; then + echo "backlog-audit: missing backlog at $BACKLOG" >&2 + exit 1 +fi + +while IFS=$'\t' read -r section id line; do + case "$section" in + "In flight") + IN_FLIGHT_LINES["$id"]=$line + ;; + "Done") + DONE_LINES["$id"]=$line + ;; + "Watchlist") + WATCHLIST_LINES["$id"]=$line + ;; + esac +done < <(parse_backlog) + +for id in "${!IN_FLIGHT_LINES[@]}"; do + if [ -n "${DONE_LINES[$id]+set}" ]; then + add_finding "duplicate-done: $id listed in both In flight and Done" + fi + if [ ! -f "$STATE/$id.meta" ]; then + add_finding "inflight-without-meta: $id is In flight but has no state meta" + fi + if looks_pr_ready_or_merged "${IN_FLIGHT_LINES[$id]}"; then + add_finding "inflight-pr-ready: $id is still In flight but looks PR-ready or merged" + fi +done + +if [ -d "$STATE" ]; then + shopt -s nullglob + for meta in "$STATE"/*.meta; do + id=$(basename "$meta" .meta) + if [ -z "${IN_FLIGHT_LINES[$id]+set}" ]; then + add_finding "meta-without-inflight: $id has state meta but is not in In flight" + fi + done + shopt -u nullglob +fi + +for id in "${!WATCHLIST_LINES[@]}"; do + if [ -n "${IN_FLIGHT_LINES[$id]+set}" ]; then + add_finding "watchlist-adopted: $id is still on Watchlist but already listed In flight" + elif [ -f "$STATE/$id.meta" ]; then + add_finding "watchlist-adopted: $id is still on Watchlist but already has local state meta" + fi +done + +if [ "${#FINDINGS[@]}" -eq 0 ]; then + echo "No backlog/state drift found." + echo "No changes made." + exit 0 +fi + +echo "Backlog/state drift found:" +for finding in "${FINDINGS[@]}"; do + printf -- '- %s\n' "$finding" +done +echo "No changes made." +exit 1 diff --git a/docs/scripts.md b/docs/scripts.md index 44c8f4aea5e..107ce1dbe3b 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -9,6 +9,7 @@ Each file also starts with a short header comment. | `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | | `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | | `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | +| `fm-backlog-audit.sh` | Read-only audit for backlog/state drift between `data/backlog.md`, `state/*.meta`, and local adoption signals | | `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion, a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` | | `fm-ensure-agents-md.sh` | Ensure project `AGENTS.md` is the real memory file and `CLAUDE.md` symlinks to it | | `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when watcher liveness is not proved by a fresh beacon plus a live matching lock | diff --git a/tests/fm-backlog-audit.test.sh b/tests/fm-backlog-audit.test.sh new file mode 100644 index 00000000000..df0d610266a --- /dev/null +++ b/tests/fm-backlog-audit.test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Tests for bin/fm-backlog-audit.sh's read-only backlog/state drift checks. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +AUDIT="$ROOT/bin/fm-backlog-audit.sh" +TMP_ROOT=$(fm_test_tmproot fm-backlog-audit-tests) + +make_home() { + local name=$1 home + home="$TMP_ROOT/$name" + mkdir -p "$home/data" "$home/state" + printf '%s\n' "$home" +} + +write_backlog() { + local home=$1 + cat > "$home/data/backlog.md" +} + +write_meta() { + local home=$1 id=$2 + fm_write_meta "$home/state/$id.meta" \ + "window=fm-$id" \ + "worktree=$home/worktrees/$id" \ + "project=$home/projects/demo" \ + "kind=ship" \ + "mode=direct-PR" +} + +run_audit() { + local home=$1 out=$2 rc + set +e + FM_HOME="$home" "$AUDIT" > "$out" 2>&1 + rc=$? + printf '%s\n' "$rc" +} + +test_clean_backlog_passes() { + local home out rc + home=$(make_home clean) + write_backlog "$home" <<'MD' +## In flight +- [ ] alpha-task - Active work (repo: demo, since 2026-06-29) + +## Queued +- [ ] beta-task - Later work (repo: demo) + +## Done +- [x] old-task - Shipped work - https://github.com/example/repo/pull/1 (merged 2026-06-28) + +## Watchlist +- [ ] external-task - Follow upstream PR +MD + write_meta "$home" alpha-task + out="$home/out.txt" + rc=$(run_audit "$home" "$out") + expect_code 0 "$rc" "clean backlog audit" + assert_contains "$(cat "$out")" "No backlog/state drift found." "clean audit reports no drift" + pass "clean backlog/state audit passes" +} + +test_detects_required_drift_cases() { + local home out rc + home=$(make_home drift) + write_backlog "$home" <<'MD' +## In flight +- [ ] dup-task - Active and also done (repo: demo, since 2026-06-29) +- [ ] no-meta-task - Missing meta file (repo: demo, since 2026-06-29) +- [ ] pr-ready-task - PR ready https://github.com/example/repo/pull/2 (repo: demo, since 2026-06-29) +- **bold-task** - Bold in-flight item form (repo: demo, since 2026-06-29) + +## Done +- [x] dup-task - Active and also done - https://github.com/example/repo/pull/1 (merged 2026-06-28) + +## Watchlist +- [ ] watched-task - Track until adopted locally +MD + write_meta "$home" dup-task + write_meta "$home" meta-only-task + write_meta "$home" watched-task + write_meta "$home" pr-ready-task + write_meta "$home" bold-task + out="$home/out.txt" + rc=$(run_audit "$home" "$out") + expect_code 1 "$rc" "drift backlog audit" + assert_contains "$(cat "$out")" "duplicate-done: dup-task listed in both In flight and Done" "duplicate done drift reported" + assert_contains "$(cat "$out")" "meta-without-inflight: meta-only-task has state meta but is not in In flight" "meta-only drift reported" + assert_contains "$(cat "$out")" "inflight-without-meta: no-meta-task is In flight but has no state meta" "missing meta drift reported" + assert_contains "$(cat "$out")" "inflight-pr-ready: pr-ready-task is still In flight but looks PR-ready or merged" "PR-ready drift reported" + assert_contains "$(cat "$out")" "watchlist-adopted: watched-task is still on Watchlist but already has local state meta" "watchlist adoption drift reported" + assert_not_contains "$(cat "$out")" "inflight-without-meta: bold-task" "bold in-flight form is parsed" + pass "required backlog/state drift cases are reported" +} + +test_audit_is_read_only() { + local home out before after rc + home=$(make_home readonly) + write_backlog "$home" <<'MD' +## In flight +- [ ] no-meta-task - Missing meta file (repo: demo, since 2026-06-29) + +## Done +MD + out="$home/out.txt" + before=$(find "$home" -type f -print0 | sort -z | xargs -0 sha256sum) + rc=$(run_audit "$home" "$out") + expect_code 1 "$rc" "read-only audit with drift" + after=$(find "$home" -type f ! -name out.txt -print0 | sort -z | xargs -0 sha256sum) + [ "$before" = "$after" ] || fail "audit modified home files" + pass "backlog audit is read-only" +} + +test_clean_backlog_passes +test_detects_required_drift_cases +test_audit_is_read_only