Skip to content

feat(bin): add detect-only staleness lint for backlog dependency edges - #35

Merged
Freudator86 merged 7 commits into
mainfrom
fm/fm-staleness-lint
Jul 28, 2026
Merged

feat(bin): add detect-only staleness lint for backlog dependency edges#35
Freudator86 merged 7 commits into
mainfrom
fm/fm-staleness-lint

Conversation

@Freudator86

Copy link
Copy Markdown
Owner

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

  • New bin/fm-backlog-lint.sh reports one actionable BACKLOG_STALE: line per stale current blocked-by: edge in three mechanically closable classes — target missing everywhere, target already Done, and an archived target that tasks-axi treats as satisfied while fm-fleet-snapshot treats 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 prescribes tasks-axi unblock only where that command would actually run; under the manual backend and on records tasks-axi cannot resolve it names the backlog file, record, blocker, and the blocked-by: token quoted as the record actually spells it. A record fm-fleet-snapshot parses but tasks-axi cannot list emits the coded BACKLOG_UNREADABLE: diagnostic and exits 1 rather than guessing a stale edge. Cost is bounded: a clean backlog spawns no tasks-axi process, and any number of stale edges resolves through a single tasks-axi list call.
  • bin/fm-bootstrap.sh runs the lint at session start (after its existing tasks-axi/jq checks, failure non-blocking); bin/fm-fleet-snapshot.sh gains 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, the bootstrap-diagnostics skill, docs/configuration.md, and docs/scripts.md document the two new diagnostic codes and the script.
  • Adds 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 show results misclassified as stale, the manual-backend gate, byte-exactness and tab round-tripping of the quoted blocked-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-record show probes with one bulk tasks-axi list call. One informational note remains open about that listing parser pinning tasks-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
=== 1. Clean backlog: the session-start lint is silent ===

$ bin/fm-backlog-lint.sh
[exit 0] (no output above = clean backlog passes silently)

=== 2. Reproduce real archive-retention rotation with tasks-axi ===

$ grep -n 'archived-blocker' data/done-archive.md   # retention rotated the blocker out
3:- [x] archived-blocker - Retention blocker (repo: sample) (kind: ship) (done 2026-07-28)

$ grep -n 'blocked-by: archived-blocker' data/backlog.md   # the edge survived rotation
5:- [ ] archived-dependent - Captain decision blocked-by: archived-blocker (repo: sample) (kind: captain) (since 2026-07-28) (hold: captain decision) (hold-kind: captain)

$ tasks-axi show archived-dependent --full   # tasks-axi treats the edge as satisfied
  blocked: no
  blocked_by: none

$ bin/fm-fleet-snapshot.sh --backlog-json   # fm-fleet-snapshot still calls it unresolved
{"id":"archived-dependent","unresolved_blocker_ids":["archived-blocker"]}

=== 3. Standalone lint: one closable actionable line per bad edge ===

$ bin/fm-backlog-lint.sh
BACKLOG_STALE: task archived-dependent has reader-disagreement blocked-by archived-blocker (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock archived-dependent --by archived-blocker
BACKLOG_STALE: task done-dependent has satisfied blocked-by done-blocker (target is already Done in data/backlog.md); fix: run tasks-axi unblock done-dependent --by done-blocker
[exit 0]

=== 4. Session start (bin/fm-bootstrap.sh) surfaces the same lines, non-blocking ===

$ bin/fm-bootstrap.sh
BACKLOG_STALE: task archived-dependent has reader-disagreement blocked-by archived-blocker (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock archived-dependent --by archived-blocker
BACKLOG_STALE: task done-dependent has satisfied blocked-by done-blocker (target is already Done in data/backlog.md); fix: run tasks-axi unblock done-dependent --by done-blocker
[bootstrap exit 0 - startup is never blocked]

=== 5. Detect-only: the durable files are byte-identical after the run ===

$ md5sum data/backlog.md and data/done-archive.md before/after
backlog.md      before=6a8a6e3905f15608938bf65ccb3c26c0 after=6a8a6e3905f15608938bf65ccb3c26c0
done-archive.md before=19941ebf99849aae04ffe3a5270da0ce after=19941ebf99849aae04ffe3a5270da0ce
UNCHANGED - no mutation, no auto-repair, retention untouched

=== 6. Applying each printed fix verbatim makes the lint silent again ===

$ tasks-axi unblock archived-dependent --by archived-blocker

$ tasks-axi unblock done-dependent --by done-blocker

$ bin/fm-backlog-lint.sh
[exit 0] (no output above = every finding closed)
Evidence: CLI transcript: dangling class, manual-backend hand-edit fix, BACKLOG_UNREADABLE coded diagnostic
=== A. Dangling edge: a hand edit drops the blocker row after the edge was recorded ===
    (tasks-axi itself refuses both halves - it will not add an edge to an unknown
     blocker, and 'rm' fails while an active task still blocks on that id - so this
     class only reaches the durable file through a hand edit.)

$ tasks-axi add ship-login ... --blocked-by never-existed   # backend refuses a birth-time typo
error: "blocker \"never-existed\" not found"
code: VALIDATION_ERROR
help[1]: "Create the blocker task first, or choose an existing task id"
[exit 2]

$ tasks-axi rm spike-auth   # backend refuses to strand the edge
error: "Task \"spike-auth\" is still blocking active tasks: ship-login"
code: VALIDATION_ERROR
help[1]: "Unblock them first, e.g. `tasks-axi unblock ship-login --by spike-auth`"
[exit 2]

$ $EDITOR data/backlog.md   # a captain hand-deletes the blocker row instead

$ cat data/backlog.md
# Backlog

## In flight
## Queued
- [ ] ship-login - Login screen blocked-by: spike-auth (repo: sample) (kind: ship) (since 2026-07-28)
## Done

$ bin/fm-backlog-lint.sh
BACKLOG_STALE: task ship-login has dangling blocked-by spike-auth (target is absent from data/backlog.md and data/done-archive.md); fix: run tasks-axi unblock ship-login --by spike-auth, then add the intended existing blocker if this id was a typo
[exit 0]

$ tasks-axi unblock ship-login --by spike-auth   # apply the printed fix verbatim

$ bin/fm-backlog-lint.sh
[exit 0] (silent = finding closed)

=== B. Manual backend: the fix clause names the hand edit, never a tasks-axi command ===

$ cat config/backlog-backend
manual

$ bin/fm-backlog-lint.sh
BACKLOG_STALE: task ship-report has dangling blocked-by spike-typo (target is absent from data/backlog.md and data/done-archive.md); fix: edit data/backlog.md by hand and delete the blocked-by token "blocked-by: spike-typo" naming blocker spike-typo from the record for task ship-report, then add the intended existing blocker if this id was a typo
[exit 0]

=== C. Unreadable record: coded diagnostic instead of a guessed stale edge ===

$ bin/fm-backlog-lint.sh
BACKLOG_UNREADABLE: task **ship-migrate** 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
[exit 1  - undecided run, still non-blocking at session start]
Evidence: Live durable backlog run (read-only): 4 of 6 edges flagged, 2 correctly silent, files unchanged
=== Live durable backlog validation (read-only, detect-only) ===

$ wc -l data/backlog.md data/done-archive.md
    424 data/backlog.md
   1380 data/done-archive.md
$ grep -o "blocked-by:" data/backlog.md | wc -l   # current blocked-by edges
6

$ FM_HOME=~/coditan-firstmate bin/fm-backlog-lint.sh
BACKLOG_STALE: task firstmate-fork-genuine-upstream-sync has reader-disagreement blocked-by admiralty-ancestry-merge (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock firstmate-fork-genuine-upstream-sync --by admiralty-ancestry-merge
BACKLOG_STALE: task beer-tasting-fichas-gestaltung has reader-disagreement blocked-by beer-tasting-preis-leistung (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock beer-tasting-fichas-gestaltung --by beer-tasting-preis-leistung
BACKLOG_STALE: task beer-tasting-bastards-karte has reader-disagreement blocked-by beer-tasting-preis-leistung (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock beer-tasting-bastards-karte --by beer-tasting-preis-leistung
BACKLOG_STALE: task beer-tasting-bastards-karte has reader-disagreement blocked-by beer-tasting-fumaconica-karte (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock beer-tasting-bastards-karte --by beer-tasting-fumaconica-karte
[exit 0] elapsed=617ms

4 of the 6 current edges fired, all of them the archive-retention reader-disagreement
class (each verified true-positive in live-findings-verification.txt).
The 2 edges below did not fire - their targets are still live, unfinished tasks:

$ grep -n "^- \[ \] fleet-repo-migration " data/backlog.md   # blocker of fleet-repo-vessel-manifests is live and open
32:- [ ] fleet-repo-migration - Migrate the fleet off the firstmate fork to a fleet repo with firstmate vendored-at-root
$ grep -n "^- \[ \] fmc-tugboat-key-rename-plan " data/backlog.md   # blocker of fmc-tugboat-chat-channel is live and open
306:- [ ] fmc-tugboat-key-rename-plan - Untangle the mislabelled relay keys before accepting Tugboat's new one (repo: co

$ md5sum of the live durable files before/after the run
backlog.md      before=43b5da993d35a037b78b6148db54852f after=43b5da993d35a037b78b6148db54852f
done-archive.md before=66b6f22c7d70e12595588ca3a8e68524 after=66b6f22c7d70e12595588ca3a8e68524
UNCHANGED - the live run reported only; it mutated no record and changed no retention
Evidence: Independent verification that each live finding is a true positive
=== Independent verification that each live finding is a true positive ===

$ grep -n "blocked-by:" data/backlog.md   # all 5 current edges
12:- [ ] firstmate-fork-genuine-upstream-sync - Genuine upstream sync: fork is really behind kunchenguid/firstmate (~20 net-new commits post-68ed7ed, plus older overlapping commits not mechanically-provable as absorbed); real merge + fork-patches.md re-eval needed blocked-by: admiralty-ancestry-merge (repo: coditan-firstmate) (kind: task) 
54:- [ ] fleet-repo-vessel-manifests - Fleet repo: declarative vessel manifests, roster as identity source, and the capability model written down blocked-by: fleet-repo-migration (repo: coditan-firstmate) (kind: task) 
122:- [ ] beer-tasting-fichas-gestaltung - Bewertungsboegen (fichas) staerker gestalten - Druckbild ueber die reine Tabelle hinaus blocked-by: beer-tasting-preis-leistung (repo: beer-tasting) (kind: task) 
130:- [ ] beer-tasting-bastards-karte - Naechste Verkostung: Bastards Brewery (Curitiba) als neue Karte in der Bier-App blocked-by: beer-tasting-preis-leistung blocked-by: beer-tasting-fumaconica-karte (repo: beer-tasting) (kind: task) 
226:- [ ] fmc-tugboat-chat-channel - Wire the Tugboat chat target into fleet-mobile-command once Tugboat's endpoint exists blocked-by: fmc-tugboat-key-rename-plan (repo: fleet-mobile-command) (kind: task) (priority: 3) 

--- edge firstmate-fork-genuine-upstream-sync -> admiralty-ancestry-merge ---
$ grep -n "^- \[x\] admiralty-ancestry-merge" data/done-archive.md   # target Done and rotated out
1257:- [x] admiralty-ancestry-merge - Restore fast-forwardable ancestry: true merge of fork main into admiralty main with the manifest regenerated in the same commit https://github.com/Freudator86/admiralty/pull/3 (repo: coditan-firstmate) (kind: task) (priority: 1) (merged 2026-07-27)
$ grep -c "^- \[.\] admiralty-ancestry-merge " data/backlog.md   # target absent from the live file
0
$ tasks-axi show firstmate-fork-genuine-upstream-sync --full | grep blocked   # tasks-axi answer
  blocked: no
  blocked_by: none
$ bin/fm-fleet-snapshot.sh --backlog-json   # fm-fleet-snapshot answer
{"id":"firstmate-fork-genuine-upstream-sync","unresolved_blocker_ids":["admiralty-ancestry-merge"]}

--- edge beer-tasting-fichas-gestaltung -> beer-tasting-preis-leistung ---
$ grep -n "^- \[x\] beer-tasting-preis-leistung" data/done-archive.md   # target Done and rotated out
1100:- [x] beer-tasting-preis-leistung - Preis-Leistungs-Sieger im Zertifikat und in der Ergebnisansicht (repo: beer-tasting) (kind: task) (done 2026-07-26)
$ grep -c "^- \[.\] beer-tasting-preis-leistung " data/backlog.md   # target absent from the live file
0
$ tasks-axi show beer-tasting-fichas-gestaltung --full | grep blocked   # tasks-axi answer
  blocked: no
  blocked_by: none
$ bin/fm-fleet-snapshot.sh --backlog-json   # fm-fleet-snapshot answer
{"id":"beer-tasting-fichas-gestaltung","unresolved_blocker_ids":["beer-tasting-preis-leistung"]}

--- edge beer-tasting-bastards-karte -> beer-tasting-fumaconica-karte ---
$ grep -n "^- \[x\] beer-tasting-fumaconica-karte" data/done-archive.md   # target Done and rotated out
1111:- [x] beer-tasting-fumaconica-karte - Karte Fumaconica: neun Haehne von der Hahntafel, Foto vom Captain (repo: beer-tasting) (kind: task) (done 2026-07-26)
$ grep -c "^- \[.\] beer-tasting-fumaconica-karte " data/backlog.md   # target absent from the live file
0
$ tasks-axi show beer-tasting-bastards-karte --full | grep blocked   # tasks-axi answer
  blocked: no
  blocked_by: none
$ bin/fm-fleet-snapshot.sh --backlog-json   # fm-fleet-snapshot answer
{"id":"beer-tasting-bastards-karte","unresolved_blocker_ids":["beer-tasting-preis-leistung","beer-tasting-fumaconica-karte"]}

=== The one live edge the lint did NOT flag (no false-alarm noise) ===
$ tasks-axi show gnhf-heizung-kalibrierung --full | grep blocked
Evidence: Live backlog lint output (real rot found by this change)
$ FM_HOME=~/coditan-firstmate bin/fm-backlog-lint.sh
BACKLOG_STALE: task firstmate-fork-genuine-upstream-sync has reader-disagreement blocked-by admiralty-ancestry-merge (tasks-axi says satisfied; fm-fleet-snapshot says unresolved after the target moved to data/done-archive.md); fix: run tasks-axi unblock firstmate-fork-genuine-upstream-sync --by admiralty-ancestry-merge
BACKLOG_STALE: task beer-tasting-fichas-gestaltung has reader-disagreement blocked-by beer-tasting-preis-leistung (...); fix: run tasks-axi unblock beer-tasting-fichas-gestaltung --by beer-tasting-preis-leistung
BACKLOG_STALE: task beer-tasting-bastards-karte has reader-disagreement blocked-by beer-tasting-preis-leistung (...); fix: run tasks-axi unblock beer-tasting-bastards-karte --by beer-tasting-preis-leistung
BACKLOG_STALE: task beer-tasting-bastards-karte has reader-disagreement blocked-by beer-tasting-fumaconica-karte (...); fix: run tasks-axi unblock beer-tasting-bastards-karte --by beer-tasting-fumaconica-karte
[exit 0] elapsed=617ms
backlog.md and done-archive.md md5 unchanged before/after

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/fm-backlog-lint.sh:110 - An unreadable tasks-axi show result is silently converted into a BACKLOG_STALE finding instead of an error. tasks-axi show &lt;id&gt; --file &lt;path&gt; prints error: &#34;Task ... not found in this backlog&#34; on stdout and exits 0, so || TASK_AXI_SHOW= never triggers; the sed finds no blocked_by: line, task_axi_unresolved stays empty, and csv_has returns false, so the archived branch emits a reader-disagreement line. Verified repro: a backlog row - [ ] **bold-dep** - dep blocked-by: arch-b ... with arch-b in data/done-archive.md yields BACKLOG_STALE: task **bold-dep** has reader-disagreement blocked-by arch-b ...; fix: run tasks-axi unblock **bold-dep** --by arch-b even though tasks-axi never resolved the task at all. Any id the snapshot reader parses but tasks-axi does not (or any future change to show's text output) turns every archived edge on that task into a false alarm that prescribes a destructive tasks-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 a blocked_by: line (or an error:/code: NOT_FOUND line) 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 is fm_tasks_axi_compatible &amp;&amp; command -v jq, which ignores config/backlog-backend=manual. The repo already has fm_tasks_axi_backend_available &#34;$CONFIG&#34; (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 set manual to force hand-editing, the lint still runs tasks-axi show and prints fix: run tasks-axi unblock &lt;task&gt; --by &lt;blocker&gt;, 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 &lt; &lt;( jq -nr ... )), so jq's exit status is never observed. If the jq program fails (schema drift in the snapshot object, a record missing blocked_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_clean captures only stdout and never checks the exit status, and the file runs under set -u without -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-fix assert_clean calls that are the regression proof for each bad case. Assert run_lint exits 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 (&#34;blocked-by: %s&#34;), but the snapshot reader that produced the blocker id matches blocked-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 with config/backlog-backend=manual and the row - [ ] sp-dep - hand written blocked-by: ghost (repo: s) (kind: ship): the lint prints fix: edit data/backlog.md by hand and delete the exact text &#34;blocked-by: ghost&#34; from the record for task sp-dep, yet grep -c &#39;blocked-by: ghost&#39; data/backlog.md returns 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 record raw field is already in the snapshot JSON), or reword so the clause names the blocked-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 runs BOOT_OUT=$(&#34;$SCRIPT_DIR/fm-bootstrap.sh&#34; 2&gt;&amp;1) and prints it raw under the BOOTSTRAP section, so fm-backlog-lint: tasks-axi could not resolve task &lt;id&gt; in &lt;path&gt;; its archived-target edges are unchecked lands 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 the archived branch, so a record tasks-axi cannot resolve still gets a normal BACKLOG_STALE finding for the missing and done classes, 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 prints BACKLOG_STALE: task **bold-dep** has dangling blocked-by ghost ...; fix: run tasks-axi unblock **bold-dep** --by ghost, while running that exact command yields error: &#34;Invalid id \&#34;**bold-dep**\&#34; - ids must be slug-shaped ...&#34; / 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_token is carried through @tsv, which escapes tabs and backslashes, so a blocked-by: token separated by a tab is quoted with a literal two-character \t and is again not findable in the file - the narrow residual of the byte-exactness fix. Verified repro with config/backlog-backend=manual and the row - [ ] tab-dep - tab sep blocked-by:&lt;TAB&gt;ghost2 (repo: s) (kind: ship): the lint prints delete the blocked-by token &#34;blocked-by:\tghost2&#34; and grep -Fc -- &#39;blocked-by:\tghost2&#39; data/backlog.md returns 0, while the multi-space case in the same run returns 1. The snapshot's blocked-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 @base64 in the jq row and base64 -d after read), or unescape \t and \\ 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 - Moving probe_dependent_record ahead of the case runs tasks-axi show once 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 [ &#34;$target_class&#34; = live ] &amp;&amp; continue skips 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 the missing and done classes is to word the fix clause, so gate it behind a cheap local slug-shape test on the id (case &#34;$task&#34; 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 pins tasks-axi list's human-readable header byte-for-byte and assumes blocked_by is the last column, so any additive upstream change turns a working lint into a hard session-start error. grep -q &#39;^tasks\[[0-9][0-9]*\]{id,state,kind,repo,title,blocked_by}:$&#39; is fully anchored, and the awk extraction at lines 193-209 takes everything after the last ,&#34; or the last , - both break if tasks-axi inserts or reorders a field. The result is fm-backlog-lint: unexpected tasks-axi list schema on stderr with exit 1 at every session start, uncoded in the digest, whereas the show-based parse this replaced (sed -n &#39;s/^[[:space:]]*blocked_by:[[:space:]]*//p&#39;) tolerated added fields. fm_tasks_axi_compatible is a version floor, not a ceiling, so nothing prevents the drift. Separately, a listing row that fails the comma &lt; 2 or start == 0 guards is silently next-ed, which marks that task unresolvable with no signal - fail-safe, but invisible. Parse the header's brace list once to locate the blocked_by column 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: 0 branch 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 check
  • bin/fm-test-run.sh tests/fm-fleet-snapshot-view.test.sh - regression for the new --backlog-json reader mode
  • bin/fm-test-run.sh tests/fm-bootstrap.test.sh - regression for the session-start wiring
  • Manual CLI walkthrough: clean backlog silent -> reproduced archive retention rotation with tasks-axi add/hold/done + 10 fillers -> showed tasks-axi show archived-dependent --full (blocked: no) vs bin/fm-fleet-snapshot.sh --backlog-json (unresolved) -> bin/fm-backlog-lint.sh findings -> same lines via bin/fm-bootstrap.sh (exit 0) -> md5 before/after proving no mutation -> applied each printed tasks-axi unblock verbatim and confirmed silence
  • Manual CLI walkthrough 2: dangling class via hand edit (after showing tasks-axi refuses both --blocked-by never-existed and rm of a still-blocking task), config/backlog-backend=manual hand-edit fix clause with the quoted blocked-by token, and the BACKLOG_UNREADABLE coded diagnostic exiting 1 with no guessed stale edge
  • FM_HOME=~/coditan-firstmate bin/fm-backlog-lint.sh - live durable backlog run (617ms), plus per-edge verification of all 6 current edges against data/done-archive.md, tasks-axi show --full, and fm-fleet-snapshot.sh --backlog-json, with md5 of both durable files before/after
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: quote literal done args, silence indirect-trap SC2329
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

@Freudator86
Freudator86 merged commit 050cdce into main Jul 28, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant