feat(bin): add detect-only staleness lint for backlog dependency edges - #35
Merged
Conversation
…NREADABLE diagnostic
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Build a detect-only staleness lint for the no-write class of durable backlog dependency rot. At session start and as a standalone command, silently pass a clean backlog and emit one closable actionable line for each current blocked-by edge whose target exists nowhere, is already Done, or is treated as satisfied by tasks-axi while fm-fleet-snapshot treats it as unresolved after archive retention. Every finding must name the record, fault, and exact fix; never block, mutate, auto-repair, or change retention. Keep missed-write semantic prose and false-at-birth claims out of scope. Reproduce archive rotation and regress all bad and clean cases, then validate the live backlog without noisy false alarms. The PR body must state that among the six observed instances this catches instances 5 and 6 only, while instances 1 through 4 are intentionally not caught.
What Changed
bin/fm-backlog-lint.shreports one actionableBACKLOG_STALE:line per stale currentblocked-by:edge in three mechanically closable classes — target missing everywhere, target already Done, and an archived target thattasks-axitreats as satisfied whilefm-fleet-snapshottreats as unresolved. It is detect-only (never edits, repairs, or changes archive retention), silent on a clean backlog, and names the record, the fault, and the exact fix. The fix clause prescribestasks-axi unblockonly where that command would actually run; under themanualbackend and on recordstasks-axicannot resolve it names the backlog file, record, blocker, and theblocked-by:token quoted as the record actually spells it. A recordfm-fleet-snapshotparses buttasks-axicannot list emits the codedBACKLOG_UNREADABLE:diagnostic and exits 1 rather than guessing a stale edge. Cost is bounded: a clean backlog spawns notasks-axiprocess, and any number of stale edges resolves through a singletasks-axi listcall.bin/fm-bootstrap.shruns the lint at session start (after its existingtasks-axi/jqchecks, failure non-blocking);bin/fm-fleet-snapshot.shgains a--backlog-json [<path>]reader mode that exposes the snapshot's structured Markdown parse without scanning fleet state and normalizes dated## Archived <date>sections to Done records.AGENTS.md, thebootstrap-diagnosticsskill,docs/configuration.md, anddocs/scripts.mddocument the two new diagnostic codes and the script.tests/fm-backlog-lint.test.sh(12 cases) covering real archive-rotation reproduction, each bad class and its clean counterpart, the manual backend, the unreadable record, and the bounded single-tasks-axi-call cost, plus regressions for the new snapshot reader mode and the bootstrap wiring.Scope note: among the six observed instances of durable backlog rot, this catches instances 5 and 6 only. Instances 1 through 4 are intentionally not caught — missed-write semantic prose and false-at-birth claims are out of scope for a mechanical, no-write lint.
Review raised five findings across the branch (unreadable
tasks-axi showresults misclassified as stale, the manual-backend gate, byte-exactness and tab round-tripping of the quotedblocked-by:token, and a ~22x session-start latency regression from per-task probes); all were resolved in follow-up commits, the last by replacing per-recordshowprobes with one bulktasks-axi listcall. One informational note remains open about that listing parser pinningtasks-axi list's header format and column order.Risk Assessment
✅ Low: The rewrite removes the round-4 latency regression (300 stale edges now cost 0.79s and one tasks-axi call, versus 3.74s for 40 edges before) while preserving every prior behavior I re-verified, adds a shim-based bounded-cost regression test, and leaves only an info-level coupling to tasks-axi's list output format that fails loudly and non-blockingly.
Testing
Ran the focused lint suite plus the two suites owning the touched files (fleet-snapshot reader, bootstrap wiring) - all green - and then drove the feature by hand the way a captain meets it: a clean backlog passes silently, a backlog with a tasks-axi-reproduced archive rotation and an already-Done blocker prints one closable BACKLOG_STALE line per bad edge both standalone and at session start with exit 0, the durable files stay byte-identical across the run, and applying each printed fix verbatim returns the lint to silence; a second transcript covers the dangling class, the manual-backend hand-edit fix clause, and the BACKLOG_UNREADABLE coded diagnostic. Against the real ~/coditan-firstmate backlog the lint fired on 4 of 6 current edges, all archive-retention reader-disagreements that I independently confirmed are true positives (target Done and rotated to done-archive.md, tasks-axi says blocked: no, fm-fleet-snapshot says unresolved), while the 2 edges pointing at still-open blockers stayed silent, so there is no false-alarm noise, and both live files were unchanged. No UI surface is involved - the end-user surface is the session-start and standalone CLI output, captured as transcripts. The intent's PR-body requirement (catches observed instances 5 and 6 only, 1 through 4 intentionally not caught) is a downstream PR-body claim and was not verifiable during testing.
Evidence: CLI transcript: clean pass, archive-rotation reproduction, findings, session-start surface, no-mutation proof, fixes closing
Evidence: CLI transcript: dangling class, manual-backend hand-edit fix, BACKLOG_UNREADABLE coded diagnostic
Evidence: Live durable backlog run (read-only): 4 of 6 edges flagged, 2 correctly silent, files unchanged
Evidence: Independent verification that each live finding is a true positive
Evidence: Live backlog lint output (real rot found by this change)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-backlog-lint.sh:110- An unreadabletasks-axi showresult is silently converted into a BACKLOG_STALE finding instead of an error.tasks-axi show <id> --file <path>printserror: "Task ... not found in this backlog"on stdout and exits 0, so|| TASK_AXI_SHOW=never triggers; thesedfinds noblocked_by:line,task_axi_unresolvedstays empty, andcsv_hasreturns false, so the archived branch emits a reader-disagreement line. Verified repro: a backlog row- [ ] **bold-dep** - dep blocked-by: arch-b ...witharch-bin data/done-archive.md yieldsBACKLOG_STALE: task **bold-dep** has reader-disagreement blocked-by arch-b ...; fix: run tasks-axi unblock **bold-dep** --by arch-beven though tasks-axi never resolved the task at all. Any id the snapshot reader parses but tasks-axi does not (or any future change toshow's text output) turns every archived edge on that task into a false alarm that prescribes a destructivetasks-axi unblock. This contradicts the script's own header contract ("unreadable structured inputs are command errors, not findings") and the intent's "without noisy false alarms". Detect the absence of ablocked_by:line (or anerror:/code: NOT_FOUNDline) explicitly and treat it as a command error or a skip rather than as evidence tasks-axi considers the edge satisfied.bin/fm-bootstrap.sh:926- The new session-start gate isfm_tasks_axi_compatible && command -v jq, which ignoresconfig/backlog-backend=manual. The repo already hasfm_tasks_axi_backend_available "$CONFIG"(bin/fm-tasks-axi-lib.sh:73) for exactly this, and the very next block plus bin/fm-session-start.sh:202 use the manual check. In a home that has deliberately setmanualto force hand-editing, the lint still runstasks-axi showand printsfix: run tasks-axi unblock <task> --by <blocker>, prescribing the backend the home opted out of. Confirm whether the lint should run in manual mode at all, and if so whether the fix line should describe a hand edit instead.bin/fm-backlog-lint.sh:132- The row generator runs in a process substitution (done < <( jq -nr ... )), so jq's exit status is never observed. If the jq program fails (schema drift in the snapshot object, a record missingblocked_by_ids/unresolved_blocker_ids), the loop reads zero rows and the lint reports a clean backlog while the real rot goes unnoticed - the inverse of the header's "unreadable structured inputs are command errors, not findings". Write the TSV to a file under $LINT_TMP first and check jq's status, or capture it via a status file, before iterating.tests/fm-backlog-lint.test.sh:29-assert_cleancaptures only stdout and never checks the exit status, and the file runs underset -uwithout-e. If the lint aborts early (jq/tasks-axi prerequisite failure, snapshot read failure - all of which write to stderr and exit 1), stdout is empty and every clean assertion passes, including the post-fixassert_cleancalls that are the regression proof for each bad case. Assertrun_lintexits 0 and that stderr is empty as well.🔧 Fix: fix backlog lint false alarms, manual-mode fixes, test rigor
2 issues (1 warning, 1 info) still open:
bin/fm-backlog-lint.sh:106- The manual-backend fix clause claims byte-exactness it does not have. It hardcodes a single space ("blocked-by: %s"), but the snapshot reader that produced the blocker id matchesblocked-by:[[:space:]]+(bin/fm-fleet-snapshot.sh:311), so any hand-written row using two spaces or a tab is detected while the named "exact text" is absent from the file. Verified repro withconfig/backlog-backend=manualand the row- [ ] sp-dep - hand written blocked-by: ghost (repo: s) (kind: ship): the lint printsfix: edit data/backlog.md by hand and delete the exact text "blocked-by: ghost" from the record for task sp-dep, yetgrep -c 'blocked-by: ghost' data/backlog.mdreturns 0. Irregular spacing is most likely precisely in manual mode, which is the only mode this clause serves. Emit the substring actually matched in the record's raw line (the recordrawfield is already in the snapshot JSON), or reword so the clause names theblocked-by:token and the blocker id without asserting a literal byte match.bin/fm-backlog-lint.sh:175- The new unresolvable-record error surfaces in the session-start digest without a diagnostic code. bin/fm-session-start.sh:275 runsBOOT_OUT=$("$SCRIPT_DIR/fm-bootstrap.sh" 2>&1)and prints it raw under the BOOTSTRAP section, sofm-backlog-lint: tasks-axi could not resolve task <id> in <path>; its archived-target edges are uncheckedlands there verbatim. It carries none of the enumerated prefixes in AGENTS.md:498 or .agents/skills/bootstrap-diagnostics/SKILL.md, whose contract is that a non-silent bootstrap section means a coded actionable line with a handling playbook; the agent gets an unexplained error with no guidance. It is also not transient: a record whose id the snapshot parses but tasks-axi does not keeps firing at every session start until the row is edited, and manual-backend homes (where the lint now deliberately stays enabled) are the most likely source of such rows. Decide whether this should be a coded diagnostic line the skill documents, or stay an uncoded stderr error.🔧 Fix: quote real blocked-by token, add coded BACKLOG_UNREADABLE diagnostic
2 issues (1 warning, 1 info) still open:
bin/fm-backlog-lint.sh:164- The readability check that produces BACKLOG_UNREADABLE only runs on thearchivedbranch, so a record tasks-axi cannot resolve still gets a normal BACKLOG_STALE finding for themissinganddoneclasses, with a fix command that cannot run and no diagnostic explaining why. Verified repro on the default backend with- [ ] **bold-dep** - snapshot-only id blocked-by: ghost (repo: s) (kind: ship)and no archive: the lint exits 0 and printsBACKLOG_STALE: task **bold-dep** has dangling blocked-by ghost ...; fix: run tasks-axi unblock **bold-dep** --by ghost, while running that exact command yieldserror: "Invalid id \"**bold-dep**\" - ids must be slug-shaped ..." / code: VALIDATION_ERROR. This is the same malformed-record class BACKLOG_UNREADABLE was added for, and it conflicts with the intent's "emit one closable actionable line" and "Every finding must name the record, fault, and exact fix". Decide whether the tasks-axi resolvability probe should run for every record with a stale edge (not just archived ones) so these records report BACKLOG_UNREADABLE instead of an unrunnable fix, or whether the fix clause should fall back to hand-edit guidance when the id is not slug-shaped.bin/fm-backlog-lint.sh:150-edge_tokenis carried through@tsv, which escapes tabs and backslashes, so ablocked-by:token separated by a tab is quoted with a literal two-character\tand is again not findable in the file - the narrow residual of the byte-exactness fix. Verified repro withconfig/backlog-backend=manualand the row- [ ] tab-dep - tab sep blocked-by:<TAB>ghost2 (repo: s) (kind: ship): the lint printsdelete the blocked-by token "blocked-by:\tghost2"andgrep -Fc -- 'blocked-by:\tghost2' data/backlog.mdreturns 0, while the multi-space case in the same run returns 1. The snapshot'sblocked-by:[[:space:]]+match accepts tabs, and manual mode is exactly where irregular whitespace appears. Emit the token through a transport that survives round-tripping (for example@base64in the jq row andbase64 -dafterread), or unescape\tand\\in the shell before quoting it.🔧 Fix: decide stale edges without tasks-axi, round-trip edge tokens
1 warning still open:
bin/fm-backlog-lint.sh:214- Movingprobe_dependent_recordahead of thecaserunstasks-axi showonce per task with any stale edge, not just archived ones, so session-start latency now scales with the number of findings. Measured on a 40-record backlog where every record has one dangling edge: HEAD takes 3.74s versus 0.166s for the same input on 27d4545 - a ~22x regression, ~90ms of node startup per distinct stale task, with no timeout and no cap. Clean backlogs stay fast (0.22s for 80 records, all live edges) because[ "$target_class" = live ] && continueskips the probe, but a home with unfixed rot is exactly the steady state this lint creates, and bootstrap runs it synchronously at every session start while bounding comparable startup work elsewhere (FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT). The probe's only job for themissinganddoneclasses is to word the fix clause, so gate it behind a cheap local slug-shape test on the id (case "$task" in *[!a-zA-Z0-9._-]*)) matching the rule the BACKLOG_UNREADABLE text already documents - accepting that a row tasks-axi rejects for a non-id reason would fall back to the pre-change tasks-axi command - and/or bound the total probes so session start stays bounded regardless of backlog rot.🔧 Fix: resolve backlog lint blockers with one bulk tasks-axi call
1 info still open:
bin/fm-backlog-lint.sh:179- The new listing path pinstasks-axi list's human-readable header byte-for-byte and assumesblocked_byis the last column, so any additive upstream change turns a working lint into a hard session-start error.grep -q '^tasks\[[0-9][0-9]*\]{id,state,kind,repo,title,blocked_by}:$'is fully anchored, and the awk extraction at lines 193-209 takes everything after the last,"or the last,- both break if tasks-axi inserts or reorders a field. The result isfm-backlog-lint: unexpected tasks-axi list schemaon stderr with exit 1 at every session start, uncoded in the digest, whereas theshow-based parse this replaced (sed -n 's/^[[:space:]]*blocked_by:[[:space:]]*//p') tolerated added fields.fm_tasks_axi_compatibleis a version floor, not a ceiling, so nothing prevents the drift. Separately, a listing row that fails thecomma < 2orstart == 0guards is silentlynext-ed, which marks that task unresolvable with no signal - fail-safe, but invisible. Parse the header's brace list once to locate theblocked_bycolumn index and read that column, so an added field degrades gracefully instead of aborting; verified today's format is handled correctly (300 tasks listed with no default limit, quoted comma titles and escaped inner quotes parsed right,count: 0branch exercised).✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh tests/fm-backlog-lint.test.sh- 12/12 pass, including real tasks-axi archive rotation, bad and clean cases, manual backend, unreadable record, and the bounded one-call cost checkbin/fm-test-run.sh tests/fm-fleet-snapshot-view.test.sh- regression for the new--backlog-jsonreader modebin/fm-test-run.sh tests/fm-bootstrap.test.sh- regression for the session-start wiringManual CLI walkthrough: clean backlog silent -> reproduced archive retention rotation withtasks-axi add/hold/done+ 10 fillers -> showedtasks-axi show archived-dependent --full(blocked: no) vsbin/fm-fleet-snapshot.sh --backlog-json(unresolved) ->bin/fm-backlog-lint.shfindings -> same lines viabin/fm-bootstrap.sh(exit 0) -> md5 before/after proving no mutation -> applied each printedtasks-axi unblockverbatim and confirmed silenceManual CLI walkthrough 2: dangling class via hand edit (after showing tasks-axi refuses both--blocked-by never-existedandrmof a still-blocking task),config/backlog-backend=manualhand-edit fix clause with the quoted blocked-by token, and theBACKLOG_UNREADABLEcoded diagnostic exiting 1 with no guessed stale edgeFM_HOME=~/coditan-firstmate bin/fm-backlog-lint.sh- live durable backlog run (617ms), plus per-edge verification of all 6 current edges againstdata/done-archive.md,tasks-axi show --full, andfm-fleet-snapshot.sh --backlog-json, with md5 of both durable files before/after✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix: quote literal done args, silence indirect-trap SC2329
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.