Skip to content

TECH — Ledger mutation CLI (ID-35.3)

Task: ID-35. This Subtask: {35.3} TECH — wiring (vendor) + schema reuse + error envelope + promote orchestration + vendor-drift guard. Predecessors: RESEARCH, PRODUCT.

Maps each PRODUCT invariant to a Proposed change against verified, installed code. Migration plan: zero — net-new CLI + net-new vendored modules.


scripts/ledger-cli.ts # entry point: argv parse → dispatch → envelope (PRODUCT §2,3)
lib/ledger/
atomic-write.ts # VENDORED verbatim from task-view v0.2.0 (no schema deps)
detect-schema.ts # VENDORED, imports rewired → @/lib/validation/*
patch-apply.ts # VENDORED, imports rewired
record-mutate.ts # VENDORED, imports rewired
README.md # vendoring provenance + re-vendor procedure
__tests__/scripts/ledger-cli.test.ts # unit per subcommand (temp fixtures)
__tests__/scripts/ledger-cli-integration.test.ts # round-trip dogfood (PRODUCT §4)
.github/workflows/task-view-vendor-drift.yml # EXTENDED: watch + diff lib/ledger primitives

Not vendored: mirror-generator.ts (RESEARCH §4 — mirrors owned by regen-mirrors.sh), ledger-transaction.ts (its 2-file orchestration becomes thin CLI glue, §4 below, so every vendored file stays byte-faithful for clean drift-checking).


The vendored primitives import @task-view/schemas/{task-list,roadmap,backlog}. KH already vendors those schemas at @/lib/validation/{task-list-schema,roadmap-schema,backlog-schema} exporting the identical symbols (verified RESEARCH §3). Rewire is a mechanical specifier swap:

task-view importKH rewire
@task-view/schemas/task-listTaskListSchema, TaskList, TaskSchema, SubtaskSchema, Task, Subtask@/lib/validation/task-list-schema
@task-view/schemas/roadmapRoadmapSchema, Roadmap, RoadmapThemeSchema@/lib/validation/roadmap-schema
@task-view/schemas/backlogBacklogSchema, BacklogDocument, BacklogItemSchema, BacklogItem@/lib/validation/backlog-schema

No .max() or shape change — the CLI inherits the ID-34 soft-warning discipline for free because parseTaskListWithWarnings lives in the same KH schema module (PRODUCT inv 13).


Modelled on scripts/ast-dataflow-cli.ts. One discriminated result type:

type CliResult =
| { ok: true; subcommand: string; result: unknown; warnings?: string[]; mirrorStale?: boolean }
| { ok: false; subcommand: string; error: string; detail?: string; issues?: ZodIssue[] };
  • success → JSON.stringify(result) to stdout, exit 0.
  • error → JSON.stringify(result) to stderr, exit 1.
  • --pretty → human lines; same exit codes.
  • The single dispatch wrapper try/catches every subcommand, so an unexpected throw still yields {ok:false, error:"internal", detail} + exit 1 (never an unframed stack trace).

No silent corrupt write (inv 2): the write helper is detectSchema → structuredClone → applyPatches|insertRecord|removeRecord → on ok → JSON.stringify(parsed,null,2) → atomicWriteFile. The mutation primitives re-parse via Zod before returning ok, so a schema failure surfaces as {ok:false} and atomicWriteFile is never reached.


§4 Promote orchestration (PRODUCT inv 11) — thin CLI glue over vendored primitives

Section titled “§4 Promote orchestration (PRODUCT inv 11) — thin CLI glue over vendored primitives”

promoteTransaction is not vendored (it couples to mirror-generator.ts). Its algorithm is reimplemented as ~25 lines of CLI glue that reuse the vendored primitives, preserving the documented ledger-transaction.ts semantics verbatim (validate-first → stage-both → commit-last, ADD side first):

1. stat both files → base mtimes (RESEARCH OQ-35-6).
2. load + detectSchema both ledgers (task-list, backlog).
3. insertRecord(taskList, taskRecord) → on !ok, abort (nothing staged) + exit 1.
4. removeRecord(backlog, sourceBacklogId) → on !ok, abort + exit 1.
5. stageAtomicWrite(taskListPath, serialised insert result) ┐ both durable temps;
stageAtomicWrite(backlogPath, serialised remove result) ┘ originals untouched.
6. commitStagedWrite(taskList) // ADD side FIRST (async)
renameSync(backlog tmp→target) // REMOVE side — SYNC, no microtask yield (sub-µs window)
on STAGE error → abortStagedWrite(both) + exit 1.
on COMMIT error → NO rollback + exit 1 (commit-failed): the first rename may already
have committed; reverting could crash mid-way and compound the inconsistency. The
ADD-first ordering keeps the worst case a benign duplicate (matches the
ledger-transaction.ts "we do NOT attempt automatic rollback" comment).

A pre-commit failure leaves BOTH ledgers pristine (only orphaned temps). The ADD-first ordering means a kill in the two-rename window yields a benign transient duplicate, never a lost update — identical to the source. The glue is documented inline with a pointer to ../task-view/packages/server/ledger-transaction.ts as the algorithm of record.

This keeps the genuinely-hard parts (record mutation + Zod re-parse + atomic staged writes) as the shipped, vendored primitives; only the 2-file sequencing is CLI code — matching the “thin wrapper” mandate.


§5 Mirror integration (PRODUCT inv 14, 15)

Section titled “§5 Mirror integration (PRODUCT inv 14, 15)”

The CLI never writes a .md mirror. After a successful mutation:

  • print {mirrorStale:true} in the envelope + a stderr reminder: mirrors stale — run bash scripts/regen-mirrors.sh before committing (CI ledger-mirror-parity gates on it).
  • --regen-mirrors → spawn bash scripts/regen-mirrors.sh (inherit stdio); surface its exit code in the envelope. Off by default (it re-clones task-view; network-bound).

§6 Vendor-drift guard extension (PRODUCT inv 16 — parent-ratified OQ-35-5)

Section titled “§6 Vendor-drift guard extension (PRODUCT inv 16 — parent-ratified OQ-35-5)”

Extend .github/workflows/task-view-vendor-drift.yml:

  1. Trigger: add lib/ledger/*.ts to the paths: filter so editing a vendored primitive runs the reminder.
  2. Diff: add a second comparison loop. The four schema files diff against published release assets (existing, KH→task-view direction). The primitives diff in the task-view→KH direction: shallow-clone task-view @ TASK_VIEW_TAG (the same v0.2.0-task-view tag the existing job + regen-mirrors.sh use) and diff packages/server/<name> vs lib/ledger/<name> through the existing normalise() (which already strips imports + header comments — exactly the rewire difference). Emit a ::warning:: on substantive body drift. Non-blocking (matches the existing job).

Vendored set checked: atomic-write.ts, detect-schema.ts, patch-apply.ts, record-mutate.ts. (Each is byte-faithful modulo imports → normalise() yields zero drift when in sync.)


§7 Coverage matrix (PRODUCT inv → change → test)

Section titled “§7 Coverage matrix (PRODUCT inv → change → test)”
invchangetest
1,4§3 envelope + dispatch wrappereach subcommand emits valid envelope + exit code
2§3 re-parse-before-writeschema-invalid mutation: file unchanged, exit 1
3vendored atomicWriteFilewrite path uses it; no raw write to canonical
5--dry-run short-circuit before writemtime/bytes unchanged
6flip via applyPatches + enumvalid flip ok; bad status exit 1
7append-journal read-append-writeprior details intact + new block
8add-subtask + superRefinevalid ok; cross-Task dep exit 1
9insertRecordfresh ok; dup exit 1
10removeRecordremove ok; absent not-found exit 1
11§4 promote gluesuccess removes+inserts; invalid leaves both unchanged
12show read-onlyprints record; mtime unchanged
13parseTaskListWithWarnings on writeover-budget mutation warns, exit 0
14,15§5 reminder; no commit/mirrorreminder present; no git/mirror write
16§6 workflow extensionpath filter + diff loop include lib/ledger

New T-OQ: none beyond RESEARCH’s (all defaulted/ratified). Migration plan: zero.