Ledger-CLI v2 — gap + fix analysis (spec of record)
Ledger-CLI v2 — gap + fix analysis (spec of record)
Section titled “Ledger-CLI v2 — gap + fix analysis (spec of record)”Status: Authoritative research spec. Authored: {35.12} RESEARCH round (S271, Task Planner).
Scope: scripts/ledger-cli.ts v2 — command coverage, input ergonomics, write-time
validation across all three workflow ledgers (task-list.json, product-roadmap.json,
product-backlog.json) uniformly.
Primary input (do not re-derive): docs/research/ledger-cli-dogfooding-s270.md (S270 dogfooding audit; archived to the knowledge-hub-archive repo at audits/ledger-cli-dogfooding-s270.md).
Companion: PLAN.md — the {35.13}+ Subtask breakdown derived from this analysis.
0. North star (Liam directive)
Section titled “0. North star (Liam directive)”Make over-budget / wrong-shape ledger writes impossible at source — reject-before-write,
zero retries — across all three ledgers uniformly. Not warned-after-the-fact. The S270
author overshot the 250-char subtask-description budget 3× in one session (789→280→213 on
{20.29}; 264→239 on {35.12}) because nothing stopped the attempt; the only signal was a soft
warning buried in a ~135 KB whole-ledger warning dump on stderr. An agent must not be able to even
attempt a bad write.
The fix is a complete CLI command surface — so an agent never hand-writes node -e / heredoc
shell for any ledger op. The S270 author repeatedly hit zsh’s ! history-expansion gotcha
(!Number.isNaN, !r.error → \! syntax errors, 3+ times) because the missing commands forced
ad-hoc scripting. Closing the command surface removes that entire error class at source.
1. What is solid — do NOT redo
Section titled “1. What is solid — do NOT redo”The {35.11} scoped-write + the OQ-LS-2 escaping serialise() fix made add-subtask a clean
+20-line append: no whole-file key reorder, byte-stable siblings.
- Serialisation foundation is solid.
escapeSerialise()/scopedSerialise()/escapeNonAscii()inlib/ledger/scoped-serialise.tsproduce minimal diffs (verified 1-line on the live ledger) with on-disk\uXXXXescaping + Zod-canonical key order. Every fix below builds on this; none redoes it. - The vendored patch primitives are solid.
applyPatches/insertRecord/removeRecord(lib/ledger/patch-apply.ts,record-mutate.ts) each re-parse the whole document through the matching Zod schema before any byte is written — a schema-violating mutation surfaces as{ok:false}andatomicWriteFileis never reached. - The atomic-write + promote-transaction discipline is solid (
atomic-write.ts, thepromote2-file stage→commit glue).
Every gap is command coverage / input ergonomics / write-time-validation, never byte-format.
1.1 The key structural lever the fix exploits
Section titled “1.1 The key structural lever the fix exploits”patch-apply.ts permits a field iff it is declared in the record type’s Zod .shape
(TASK_KNOWN_FIELDS = new Set(Object.keys(TaskSchema.shape)), and the same for subtask / theme /
backlog-item). Two consequences the fix wave depends on:
- Adding
titletoBacklogItemSchemaautomatically makesupdate-backlog <id> title <value>work — no walker change needed;BACKLOG_ITEM_KNOWN_FIELDSpicks it up from the shape. - The keyset guard is load-bearing for backlog specifically:
BacklogItemSchemais NOT.strict()(it is a plainz.object+ a.superRefinefor unique-ids), so a typo’d field would otherwise be silently stripped by Zod and the patch would no-op withok:true. The keyset rejects unknown fields as awalk-error. This must be preserved — any newupdate-*/setcommand routes through the keyset-guarded walker, never a raw object merge.
2. The five S270 dogfooding gaps (+ the audit-surfaced drop guard) — prevent-at-source fix designs
Section titled “2. The five S270 dogfooding gaps (+ the audit-surfaced drop guard) — prevent-at-source fix designs”Gaps 1–5 are the S270 dogfooding gaps. Gap 6 (§2.6) is a separate, highest-severity guard surfaced by a parallel backlog-integrity audit — not a dogfooding friction point but the most severe wrong-shape write (a silently dropped record). It sits at the same write-gate layer as the budget pre-check and composes with it.
| # | Gap | Prevent-at-source fix |
|---|---|---|
| 1 | No update-subtask command. No CLI path to edit a subtask field at all (the single biggest gap). | Add update-subtask <taskId.subId> <field> <value> — mirror of update-backlog, routing through the keyset-guarded applyPatches walker. |
| 2 | add-subtask / create-backlog do not auto-assign id. Author hand-set id:29/id:30 and backlog id:185. Wrong guess = silent dup or gap. | CLI-layer auto-id: default max(existingId)+1; --id <n> to override. Lives in the CLI (not the vendored primitive). |
| 3 | Budget breaches not enforced on the mutated record. add-subtask accepted a 789-char description returning ok:true. | CLI-layer pre-write budget check on the new/mutated record’s fields; reject (exit 1) unless --force; print the offending field + length + budget inline, scoped to the changed record — never a whole-ledger dump. |
| 4 | Raw-JSON-on-argv input. Multi-line details (newlines/quotes) can’t pass safely on argv; author wrote a node builder → temp file → cat. | Add --file <path> and stdin (-) JSON input for every record-creating command, plus named-flag entry (--title --status --depends …) so common shapes need no JSON at all. |
| 5 | Mirror regen is opt-in (--regen-mirrors); edits leave mirrors stale → CI red. | Regen the affected mirror by default; --no-regen-mirrors to opt out (e.g. batch edits). Fail-loud if regen errors. |
| 6 | Whole-file re-serialise can silently DROP/duplicate a record (audit-surfaced; 0 historical, unguarded). The most severe wrong-shape write. | CLI-layer record-set-preservation gate (§2.6): assert post-write id-set + count == pre-write under the intended delta; mismatch → reject record-set-violation, write nothing. Wraps every mutating path. |
2.1 Gap 1 — update-subtask (HIGHEST VALUE)
Section titled “2.1 Gap 1 — update-subtask (HIGHEST VALUE)”Design. update-subtask <taskId.subId> <field> <value> (dotted id, mirroring the
${task.id}.${subtask.id} prose convention). Resolves to FieldPath
['tasks', taskId, 'subtasks', String(subId), field], builds a single FieldPatch, runs
fieldPatchMutation + commitMutation with --scoped available exactly as flip-subtask does
today. The keyset guard (SUBTASK_KNOWN_FIELDS) rejects unknown fields. Value parsing mirrors
update-backlog: try JSON.parse(value) (for dependencies arrays / nullable fields), else treat
as a bare string. Budget pre-check (§2.3) applies to description / testStrategy.
Why this and not a generic set. A generic set <ledger> <id> <fieldPath...> <value> is the
broader prize (§5.1), but update-subtask is the named, agent-discoverable command the dogfooding
called out by name; it should ship as a thin alias even once a generic set exists, because agents
reach for the specific verb.
2.2 Gap 2 — auto-id
Section titled “2.2 Gap 2 — auto-id”Design. A CLI-layer nextId(detected, collection) helper computes
max(existingIds.map(Number)) + 1 (ids are bare-digit strings for tasks/themes/items, bare
integers for subtasks — nextId returns the right primitive type per collection). On a
record-creating command (add-subtask, open-task, create-backlog, and a new roadmap
theme-create), if the supplied body omits id (named-flag path) or --id is absent, inject
nextId(...). --id <n> forces an explicit id (still duplicate-checked by insertRecord).
Why CLI-layer, not the primitive. insertRecord is vendored byte-faithful and intentionally
rejects duplicate id rather than auto-assigning (it must round-trip task-view). Auto-id is a KH
ergonomics concern → it lives in scripts/ledger-cli.ts, leaving the vendored primitive untouched
and task-view-vendor-drift.yml-safe.
Edge case — gappy id ranges. Live backlog ids span 17–185 with gaps (149 items). max+1 is the
correct semantics (monotonic, never reuses a freed id); the fix does NOT try to fill gaps.
2.3 Gap 3 — write-time budget enforcement (the core of the north star)
Section titled “2.3 Gap 3 — write-time budget enforcement (the core of the north star)”Design. A unified budget registry (§4.1) maps (ledger, recordKind, field) → budget. On any
mutating command, after the mutation is applied in memory but before atomicWriteFile, the CLI
checks the budgeted fields of the single changed record against the registry:
- Default: reject. Over-budget → emit
{ok:false, error:'budget-exceeded', detail:'<field> is <N> chars (budget <B>) on <ledger> <id>'}to stderr, exit 1, write nothing. --forcedowngrades the rejection to the existing soft warning and proceeds (escape hatch for the rare legitimate over-budget, e.g. the {34.8} relocate-not-delete sweep).- The message is scoped to the changed record (one line, names field + actual + budget) — never
the ~135 KB whole-ledger
parseTaskListWithWarningsdump.
Critical constraint — enforcement is CLI-layer, NOT a schema .max(). Per
task-list-discipline.md §3, there are no hard length
caps on any text field: a z.string().max(N) would (a) reject the live over-budget ledger at
parse time and (b) diverge the vendored lib/validation/*-schema.ts from task-view’s source, which
task-view-vendor-drift.yml watches. So the schema stays cap-free; the CLI is the gate. This is
the exact “prevent-at-source” lever: the schema can hold over-budget data (so the live ledger
never breaks and a --force write still parses), but the CLI refuses to author it.
Subtask.details is intentionally unbudgeted (append-only journal home) — the registry must
not budget it.
2.4 Gap 4 — --file / stdin / named flags
Section titled “2.4 Gap 4 — --file / stdin / named flags”Design. Three input modes for record-creating commands, in precedence order:
- Positional JSON (today’s behaviour) — retained for back-compat.
--file <path>— read JSON from a file (the author’s manual temp-file workaround, made first-class).-reads stdin.- Named flags —
--title,--description,--status,--depends 1,2,--priority, etc. Build the record object from flags; auto-id fillsid; sensible defaults fill required structural fields (e.g. subtaskdependencies: [],details: '',testStrategy: null).
Named flags are the ergonomic win for the common case; --file/stdin is the escape for multi-line
details. Both eliminate argv JSON-escaping and the zsh ! hazard.
Arg parsing. The CLI hand-rolls parseArgs today and there is no commander/yargs/minimist
dependency (verified). Node >=22 is pinned, so node:util’s parseArgs is available if a
richer parser is wanted — but extending the existing hand-rolled loop with typed value-flags is
lower-risk and keeps zero new deps. Recommendation: extend the existing parser (add a
value-flag set so --title X consumes X), do not add a dependency.
2.5 Gap 5 — mirror regen default-on
Section titled “2.5 Gap 5 — mirror regen default-on”Design. Flip the default: commitMutation / promote call maybeRegenMirrors(true) unless
--no-regen-mirrors is passed. regen-mirrors.sh already runs all three ledgers idempotently and
is the CI-parity source of truth; running it after a write keeps docs/reference/{tasks,roadmap, backlog}/ in sync so ledger-mirror-parity stays green. If regen exits non-zero, fail loud
(surface the non-zero status as a CLI warning to stderr; the write already succeeded so this is a
post-write alert, not a rollback).
Cost note. regen-mirrors.sh clones task-view@TASK_VIEW_TAG (cached) + regenerates all three
mirror dirs. For a batch of many edits in one session, --no-regen-mirrors on all-but-the-last
avoids redundant regens. Default-on is correct for the single-edit common case that caused the
stale-mirror friction.
2.6 Record-set preservation — the drop guard (HIGHEST SEVERITY — parent directive)
Section titled “2.6 Record-set preservation — the drop guard (HIGHEST SEVERITY — parent directive)”The vector. A parallel backlog-integrity audit identified the whole-file re-serialise path
(serialise() / escapeSerialise() → atomicWriteFile, and the scopedSerialise parse→mutate→
re-emit round-trip) as the single biggest accidental record-DROP vector: latent and unguarded.
0 historical drops, but nothing prevents one. A dropped (or silently duplicated) record is the
most severe wrong-shape write there is — strictly worse than an over-budget field, because the
loss is silent: Zod re-validates the survivors and passes, the mirror regen happily renders the
smaller set, and the only trace is a record that ceased to exist. The budget pre-check (§2.3) does
nothing for this — it inspects the changed record’s fields, not the integrity of the whole
collection.
The guard. Before any mutating path commits a byte, assert the post-write collection’s id-set and count equal the pre-write set transformed by the intended delta:
| Mutation kind | Intended delta on the id-set |
|---|---|
field-edit (flip-*, update-*, append-journal) | ZERO — id-set + count identical pre/post |
create (add-subtask, open-task, create-backlog, create-theme) | +1 — exactly the one new id added, nothing else changed |
delete (delete-backlog) | −1 — exactly the one id removed, nothing else changed |
promote (cross-ledger) | task-list +1 (the new Task id) AND backlog −1 (the source item id), each asserted against its own ledger |
| backfill (bulk field-edit) | ZERO — id-set + count identical (only title values added; §6.4 / {35.22}) |
On any mismatch → reject (exit 1, write nothing), error record-set-violation with a detail
naming the expected-vs-actual id-set diff (the unexpectedly-missing id(s) and/or
unexpectedly-present id(s)). So a reorder / escaping / parse / clone bug that silently drops or
duplicates a record cannot reach disk.
Design. A CLI-layer assertion assertRecordSet(beforeIds, afterIds, expectedDelta) computed
from the in-memory collections at the write gate:
beforeIds= the id-set of the relevant collection read atloadLedgertime (the parsed-original for scoped writes;detected.datafor whole-file writes — both before mutation).afterIds= the id-set of the bytes about to be written, derived by parsing the serialised output (NOT the in-memorydetected.data) so the guard catches a serialise-side defect, not just a logic-side one. For scoped writes this is thescopedSerialiseoutput text; for whole-file it is theserialise()/escapeSerialise()output text. Parse it once, extract ids, compare.expectedDelta∈ {+id,−id,∅} per the table above;promoteruns the assertion twice (once per ledger).
It wraps every write path — commitMutation (scoped and whole-file) and the promote two-file
stage→commit — at the same layer as the §2.3 budget pre-check, and composes with, does not
replace, it: budget gate checks the changed record’s field lengths; record-set gate checks the
whole collection’s membership. Both must pass before atomicWriteFile / commitStagedWrite.
Why parse-the-output, not trust the in-memory doc. The drop vector is specifically the
serialise step (key-reorder / \uXXXX escaping / JSON.stringify of a Zod-reparsed clone). If the
guard only compared in-memory id-sets it would miss a serialiser that emitted malformed JSON or
dropped an array element. Deriving afterIds from the bytes-to-be-written closes that gap — the
guard validates the actual on-disk shape one step before it lands. (Cost: one extra JSON.parse of
the output string per write — negligible vs the safety.)
Relationship to existing validation. applyPatches / insertRecord / removeRecord already
re-parse through Zod before write, but Zod validates shape, not set-preservation — it cannot
know that the collection should still contain id “143” after a field-edit. record-set-violation
is the missing membership invariant. It is also the structural backstop for the whole §2 north star:
the most catastrophic wrong-shape write (a vanished record) becomes impossible at source.
3. The deps number-vs-string decision — DECIDED: functional-keep
Section titled “3. The deps number-vs-string decision — DECIDED: functional-keep”ROOT of the asymmetry. subtask.id is a number but task.id is a string
(task-list-schema.ts L99–100 vs L63–67), so
dependencies types simply follow their referent id types: Task dependencies = string[]
(L110), Subtask dependencies = number[] (L83). Task also has no details / testStrategy
field (L139 .strict() comment). The real asymmetry is the id types, not the deps.
Empirical verification against the TM schema (decisive). Run against
docs/reference/example-tm-tasks.json (the canonical
TM master export, SHA pinned in taskmaster-schema-reference.md):
| Field | Type observed | Example |
|---|---|---|
Task id | string | "153" |
Subtask id | number | 1 |
Task dependencies[] | string[] | ["152"] |
Subtask dependencies[] | number[] | [1], [7, 8] |
Task details / testStrategy | empty string (structurally required, functionally empty) | "" |
taskmaster-schema-reference.md §3.3 states this
verbatim: “Mixed-type ids in the same JSON file are an empirical Taskmaster characteristic that any
consumer (parser, importer, validator) must handle. Strictly speaking the JSON Schema for Subtask
dependencies is array of integer, while Task dependencies is array of string.” §2 confirms
Task-level details/testStrategy are “structurally required but functionally empty”.
DECISION: functional-keep. The asymmetry is a Taskmaster mandate, not a KH oversight. The KH schema faithfully mirrors TM. Unifying would:
- Diverge
lib/validation/*-schema.tsfrom task-view’s vendored schema (watched bytask-view-vendor-drift.yml) and break TM round-trip compatibility. - Require a migration touching the task-view fork’s renderer/editor and any other ledger consumer — a cross-Task blast radius for zero functional gain.
The actual problem is discoverability, not the types. Agents guess the types every session
because nothing documents them at the point of use. The fix is the schema/--help subcommand
(§5.2) that prints, per record kind, each field’s name + type + budget — so an agent reads
subtask.dependencies: number[] / task.dependencies: string[] instead of guessing. No type
change; no migration.
OPEN QUESTION (cross-Task — for the orchestrator to escalate, NOT decided here). If Liam later wants id-type unification regardless of the TM round-trip cost, that is a separate Task: it touches the task-view fork (ID-20 owns it),
lib/validation/*,lib/ledger/*walkers, every mirror, and any external consumer. It must NOT be folded into this fix wave. Flagged so the orchestrator can route it; this spec recommends do not unify.
4. All three ledgers — uniform command surface
Section titled “4. All three ledgers — uniform command surface”Every gap applies to roadmap + backlog, not just task-list. Current non-status field-edit coverage:
| Ledger | Record create | Field edit (non-status) | Auto-id | Budget enforce | --file/stdin |
|---|---|---|---|---|---|
| task-list (task) | open-task | — (no Task field-editor) | ✗ | ✗ | ✗ |
| task-list (subtask) | add-subtask | MISSING (gap 1) | ✗ | ✗ | ✗ |
| roadmap (theme) | MISSING | MISSING (no roadmap editor at all) | ✗ | ✗ | ✗ |
| backlog (item) | create-backlog | update-backlog | ✗ | ✗ | ✗ |
Confirmed gaps to close for parity:
update-subtask(gap 1) and a Task field-editor (update-task <taskId> <field> <value>— e.g.status_note,description,priority; status already hasflip-task).- Roadmap has no editor at all. Add
update-roadmap <themeId> <field> <value>(mirror ofupdate-backlog) andcreate-theme(mirror ofcreate-backlog, with auto-id). Live roadmap has 11 themes (under the 12 soft ceiling). - Auto-id on
add-subtask/open-task/create-backlog/create-theme. - Budget enforcement on every mutating command, gated by the unified registry.
--file/stdin + named flags on every record-creating command.
After this, an agent never hand-writes node/shell for any ledger op — every record kind has create + field-edit + read, with auto-id, input ergonomics, and write-time budget gating.
4.1 Unified budget registry
Section titled “4.1 Unified budget registry”Today FIELD_BUDGETS (L207) covers only
task-list (taskDescription 1500, taskStatusNote 300, subtaskDescription 250,
subtaskTestStrategy 300). Roadmap and backlog have no budget constants. The fix introduces a
single registry keyed by (ledger, recordKind, field), reusing the existing task-list numbers and
adding roadmap + backlog entries (including the new backlog title — §6). Sole consumers today are
parseTaskListWithWarnings and scripts/ledger-sweep-s269.ts; both keep working (the registry
re-exports or supersets the existing constant). The CLI budget pre-check (§2.3) reads this registry.
Home for the registry. To stay vendor-drift-safe, the budget numbers must NOT be .max() on the
vendored schemas. Options: (a) extend FIELD_BUDGETS in task-list-schema.ts to cover all three
(it already lives in a vendored file but is KH-added warning-only data, not a schema constraint —
the file is watched but the warning logic is already a documented KH delta); or (b) a new
lib/validation/ledger-budgets.ts that the schemas + CLI both import. Recommendation: (b) — a
dedicated KH-authored module avoids enlarging the vendored-file delta and gives one clear import for
the CLI, the three parse*WithWarnings helpers, and the schema/--help output. The TECH/PLAN
executor confirms the exact home; either is correct.
5. Anything-missed sweep + discoverability
Section titled “5. Anything-missed sweep + discoverability”5.1 Generic set / get (broader than the named commands)
Section titled “5.1 Generic set / get (broader than the named commands)”The named commands (update-subtask, update-task, update-roadmap) close the dogfooding gaps.
A generic set <ledger> <id-or-dotted-id> <field> <value> would subsume them and a
get <ledger> <id> [field] would extend show to single-field reads (the dogfooding wanted a
query/get beyond show). Recommendation: ship the named commands (they are what agents
reach for and what the dogfooding named); add get as a small read-side win; treat a fully generic
set as optional polish (the named verbs already route through the same keyset-guarded walker, so a
generic set is a thin dispatcher over them if later wanted). Do not block the wave on it.
5.2 Discoverability — schema / --help subcommand (the “prevent guessing” Liam named)
Section titled “5.2 Discoverability — schema / --help subcommand (the “prevent guessing” Liam named)”add-subtask --help today returns only the bare usage line (the global USAGE; there is no
per-subcommand help dispatch — --help parses as a flag and the command falls through to
missing-args). Add a schema [ledger|recordKind] subcommand that prints, per record kind, every
field’s name + type + budget + required/optional, sourced from the Zod .shape + the budget
registry + the status/priority enums in lib/validation/work-status.ts. This is the single highest-
leverage fix for the recurring id/deps-type guessing (§3): an agent reads
subtask.dependencies: number[] (sibling-only) / task.dependencies: string[] /
subtask.description: string ≤250 directly. Per-subcommand --help (e.g. add-subtask --help)
should print that command’s flags + the target record’s schema slice.
5.3 Other ergonomics / consistency gaps found in the sweep
Section titled “5.3 Other ergonomics / consistency gaps found in the sweep”update-backlog’s value-parse heuristic is silent. It doesJSON.parse(value)then falls back to a bare string on throw. A string value that happens to be valid JSON (e.g."true","123","[1]") is silently coerced to a non-string. The newupdate-*commands should make the intent explicit (e.g.--jsonflag, or type-aware coercion driven by the field’s schema type from the registry) soupdate-backlog 100 description "123"stays the string"123". Surface this; the field-type-aware coercion (drive parse by the Zod field type) is the clean fix.- Unknown flags are silently ignored (
parseArgsdrops--fooit doesn’t recognise). With the new value-flags this becomes a typo trap (--titel Xsilently dropped → required field missing → confusing schema error). The v2 parser should reject unknown flags (exit 1, list known flags) for the named-flag commands. show/getis the only read path. Alist <ledger> [--status X]to enumerate ids would cut thejqreach-throughs the dogfooding used to findmax(id). Optional; auto-id (§2.2) removes the main reason agents ran thosejqqueries.- No dry-run diff.
--dry-runprints the post-mutation document, not a diff. A scoped diff preview (the changed lines only) would let an agent confirm a write before committing. Optional polish; the budget pre-check + scoped-write already make blind writes safe.
6. Backlog title field — DECIDED by Liam (the field WILL be added)
Section titled “6. Backlog title field — DECIDED by Liam (the field WILL be added)”Not researched whether to add it — only how. Confirmed live data: 149 backlog items, 0 with
a title field; description median 125 / mean 182 / max 971 chars (item 184);
description currently doubles as the H1 heading in docs/reference/backlog/N.md mirrors (verified:
item 100’s mirror renders # 100: <full description>).
6.1 Field shape
Section titled “6.1 Field shape”title: z.string().min(1).optional()onBacklogItemSchema. Optional, because all 149 existing items lack it and the schema must keep parsing the live ledger before backfill completes. After backfill, a soft-warn (not a hard require) can flag missing titles. Mirrors thedetails/testStrategyoptional-field precedent already in the schema (L141, L147).- Budget:
≤ 80 chars(short noun-phrase heading, same class asSubtask.title~40–80 andTask.title~30–60). Added to the unified budget registry (§4.1) and enforced by the CLI pre-check. - Position: first field after
id(so it reads as the heading), matching the Task/Subtask convention wheretitleprecedesdescription. descriptionstays required (min(1)) — it becomes the one-sentence summary under the title, exactly as Task/Subtaskdescriptionrelates to theirtitle.
Schema safety: BacklogItemSchema is NOT .strict(), so adding an optional field is
non-breaking against existing data and against the patch-apply keyset (which reads the shape →
update-backlog <id> title <value> works automatically, §1.1). Adding title does trip the
non-blocking task-view-vendor-drift.yml reminder (the schema is vendored) — see §7.
6.2 CLI surfacing
Section titled “6.2 CLI surfacing”create-backlogaccepts--title(named-flag path) /title(JSON/--filepath).update-backlog <id> title <value>works via the keyset walker once the schema has the field (no walker change).schema backlog/create-backlog --helpliststitlewith its budget.
6.3 Mirror rendering — OUT OF SCOPE for this wave (task-view fork)
Section titled “6.3 Mirror rendering — OUT OF SCOPE for this wave (task-view fork)”The N.md mirror H1 is rendered by task-view’s mirror-generator.ts (NOT vendored; regen via
regen-mirrors.sh cloning task-view@TASK_VIEW_TAG). Rendering title as the H1 (with
description as the sub-line) requires a task-view fork release — ID-20 owns the fork and is
working it in parallel; this wave does NOT edit it. Until the fork ships, the mirror H1 keeps
rendering description; the canonical JSON carries title and the CLI surfaces it. PLAN.md notes
this explicitly. (Adding the schema field alone is enough for the JSON-side win; the viewer
catches up on the next fork release.)
6.4 Backfill approach (do NOT over-engineer)
Section titled “6.4 Backfill approach (do NOT over-engineer)”149 items, 0 titled. Backfill = read each item’s description, write a concise ≤80-char title
(the description’s heading-essence), set it via update-backlog <id> title "<title>". Per Liam:
split across 3–4 parallel executors on disjoint id-ranges — e.g. ids sorted then quartered
(~37–38 items each). Each executor runs update-backlog per item in its range, then the orchestrator
runs regen-mirrors.sh once at the end. This is one PLAN Subtask flagged “fan out across 3–4
executors, disjoint id-ranges” — the orchestrator does the fan-out. Not over-engineered: no
title-generation heuristic, no LLM batch; an executor reads and writes a short heading per item.
Sequencing: backfill depends on (a) the title schema field landing and (b) update-backlog
budget-enforcing title (so a >80-char title is rejected at source during backfill — dogfooding the
fix on itself).
7. Out-of-scope / vendor-drift notes (must inform the fix)
Section titled “7. Out-of-scope / vendor-drift notes (must inform the fix)”task-view-vendor-drift.ymlis non-blocking. Addingtitletobacklog-schema.ts(a vendored schema) trips its::warning::re-vendor reminder. This is expected and acceptable — do not plan any task-view fork edits to silence it. The schema delta is documented; the warning is advisory.scoped-serialise.tsis KH-authored, NOT vendored — must never be added to the vendor-drift primitive-diff list. The fix builds on it; it does not modify the escaping contract.serialise()conformance is sensitive (escapes all non-ASCII →\uXXXX, Zod-canonical key order). New record-creating paths must continue to useescapeSerialise/scopedSerialisefor minimal diffs; the budget pre-check happens on the in-memory record before serialisation, so it does not touch the byte format.- No schema
.max()caps anywhere (§2.3) — enforcement is CLI-layer only, preserving live-ledger parseability and vendor parity. - Mirror generator + id-type unification both touch the task-view fork (ID-20) — both are out-of-scope; the second is the §3 cross-Task Open Question.
8. Verification block (OQ-3 — pre-ratification empirical check)
Section titled “8. Verification block (OQ-3 — pre-ratification empirical check)”Date: 27/05/2026. Author: {35.12} Planner.
| Symbol / claim | Pin | Checked against | Result |
|---|---|---|---|
TM Task id = string, Subtask id = number, deps follow referent | n/a (data file) | docs/reference/example-tm-tasks.json (jq) | PRESENT — Task "153" (string), Subtask 1 (number), Task deps ["152"] (string[]), Subtask deps [1] (number[]). Confirms §3 functional-keep. |
TM Task details/testStrategy functionally empty | n/a (data file) | example-tm-tasks.json (jq) | PRESENT — details:"", testStrategy:"" on Task 153. Confirms Task has no load-bearing details. |
| Backlog: 149 items, 0 titled, desc median 125 / max 971 | live | product-backlog.json (jq) | PRESENT — 149 items, 0 with title, median 125, mean 182, max 971 (item 184), 35 items >250. Confirms §6. |
| Backlog id range gappy, max 185 | live | product-backlog.json (jq) | PRESENT — ids 17–185, 149 items (non-contiguous). Confirms §2.2 max+1 semantics. |
| ID-35 subtasks 1–12 contiguous → next subId 13 | live | task-list.json (jq) | PRESENT — 12 subtasks, ids 1–12. Confirms PLAN starts at {35.13}. |
patch-apply keyset = Object.keys(Schema.shape); backlog NOT .strict() | source | lib/ledger/patch-apply.ts L44–49, backlog-schema.ts L156 | PRESENT — keyset guard confirmed; BacklogSchema uses .superRefine not .strict(). Confirms §1.1 lever. |
add-subtask --help returns bare usage (no per-cmd help) | source | scripts/ledger-cli.ts L862–869 | PRESENT — only global USAGE on --help/-h/none; no per-subcommand dispatch. Confirms §5.2. |
Zod pin (no .max() proposed; .min/.optional/.superRefine in use) | ^4.4.3 | package.json; schemas in use | PRESENT — Zod 4; .min(1), .optional(), .superRefine already used. Fix adds title: z.string().min(1).optional() only — no novel API. |
| No commander/yargs/minimist; Node ≥22 (parseArgs avail) | package.json | jq | PRESENT — no arg-parser dep; Node >=22 <23. Confirms §2.4 (extend hand-rolled parser, zero new deps). |
No ABSENT / SIGNATURE_DRIFT / BEHAVIOUR_DRIFT. All cited claims verified against the pinned
data files and source. Spec is empirically grounded; safe to ratify.
9. Fix-wave summary (full breakdown in PLAN.md)
Section titled “9. Fix-wave summary (full breakdown in PLAN.md)”The wave closes ALL gaps in one pass, ordered foundation → commands → title → discoverability →
mirror-default. The two write-gate guards — record-set preservation (§2.6) and budget enforcement
(§2.3) — are foundation: they land before the new commands so every create/delete/update/backfill
path commits through both. The shared hotspots are scripts/ledger-cli.ts (most command
subtasks serialise on it) and lib/validation/ schemas + budget registry (foundation). The
title backfill is one fan-out Subtask, itself gated by the §2.6 id-set invariant (149 records, max
id 185, gaps legitimate). See PLAN.md for the {35.13}+ TM-shape Subtask records,
sibling-only deps, and the parallel-vs-serialise grouping.