Skip to content

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.


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.


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() in lib/ledger/scoped-serialise.ts produce minimal diffs (verified 1-line on the live ledger) with on-disk \uXXXX escaping + 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} and atomicWriteFile is never reached.
  • The atomic-write + promote-transaction discipline is solid (atomic-write.ts, the promote 2-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:

  1. Adding title to BacklogItemSchema automatically makes update-backlog <id> title <value> work — no walker change needed; BACKLOG_ITEM_KNOWN_FIELDS picks it up from the shape.
  2. The keyset guard is load-bearing for backlog specifically: BacklogItemSchema is NOT .strict() (it is a plain z.object + a .superRefine for unique-ids), so a typo’d field would otherwise be silently stripped by Zod and the patch would no-op with ok:true. The keyset rejects unknown fields as a walk-error. This must be preserved — any new update-*/set command 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.

#GapPrevent-at-source fix
1No 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.
2add-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).
3Budget 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.
4Raw-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.
5Mirror 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.
6Whole-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.

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.
  • --force downgrades 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 parseTaskListWithWarnings dump.

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:

  1. Positional JSON (today’s behaviour) — retained for back-compat.
  2. --file <path> — read JSON from a file (the author’s manual temp-file workaround, made first-class). - reads stdin.
  3. Named flags--title, --description, --status, --depends 1,2, --priority, etc. Build the record object from flags; auto-id fills id; sensible defaults fill required structural fields (e.g. subtask dependencies: [], 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.

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 kindIntended 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 at loadLedger time (the parsed-original for scoped writes; detected.data for 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-memory detected.data) so the guard catches a serialise-side defect, not just a logic-side one. For scoped writes this is the scopedSerialise output text; for whole-file it is the serialise()/escapeSerialise() output text. Parse it once, extract ids, compare.
  • expectedDelta ∈ {+id, −id, } per the table above; promote runs 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):

FieldType observedExample
Task idstring"153"
Subtask idnumber1
Task dependencies[]string[]["152"]
Subtask dependencies[]number[][1], [7, 8]
Task details / testStrategyempty 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.ts from task-view’s vendored schema (watched by task-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:

LedgerRecord createField edit (non-status)Auto-idBudget enforce--file/stdin
task-list (task)open-task— (no Task field-editor)
task-list (subtask)add-subtaskMISSING (gap 1)
roadmap (theme)MISSINGMISSING (no roadmap editor at all)
backlog (item)create-backlogupdate-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 has flip-task).
  • Roadmap has no editor at all. Add update-roadmap <themeId> <field> <value> (mirror of update-backlog) and create-theme (mirror of create-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.

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 does JSON.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 new update-* commands should make the intent explicit (e.g. --json flag, or type-aware coercion driven by the field’s schema type from the registry) so update-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 (parseArgs drops --foo it doesn’t recognise). With the new value-flags this becomes a typo trap (--titel X silently 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/get is the only read path. A list <ledger> [--status X] to enumerate ids would cut the jq reach-throughs the dogfooding used to find max(id). Optional; auto-id (§2.2) removes the main reason agents ran those jq queries.
  • No dry-run diff. --dry-run prints 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>).

  • title: z.string().min(1).optional() on BacklogItemSchema. 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 the details/testStrategy optional-field precedent already in the schema (L141, L147).
  • Budget: ≤ 80 chars (short noun-phrase heading, same class as Subtask.title ~40–80 and Task.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 where title precedes description.
  • description stays required (min(1)) — it becomes the one-sentence summary under the title, exactly as Task/Subtask description relates to their title.

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.

  • create-backlog accepts --title (named-flag path) / title (JSON/--file path).
  • update-backlog <id> title <value> works via the keyset walker once the schema has the field (no walker change).
  • schema backlog / create-backlog --help lists title with 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 releaseID-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.yml is non-blocking. Adding title to backlog-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.ts is 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 use escapeSerialise/scopedSerialise for 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 / claimPinChecked againstResult
TM Task id = string, Subtask id = number, deps follow referentn/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 emptyn/a (data file)example-tm-tasks.json (jq)PRESENTdetails:"", testStrategy:"" on Task 153. Confirms Task has no load-bearing details.
Backlog: 149 items, 0 titled, desc median 125 / max 971liveproduct-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 185liveproduct-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 13livetask-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()sourcelib/ledger/patch-apply.ts L44–49, backlog-schema.ts L156PRESENT — keyset guard confirmed; BacklogSchema uses .superRefine not .strict(). Confirms §1.1 lever.
add-subtask --help returns bare usage (no per-cmd help)sourcescripts/ledger-cli.ts L862–869PRESENT — 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.3package.json; schemas in usePRESENT — 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.jsonjqPRESENT — 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.