Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions bin/fm-backlog-audit.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
118 changes: 118 additions & 0 deletions tests/fm-backlog-audit.test.sh
Original file line number Diff line number Diff line change
@@ -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