TECH — Ledger mutation CLI (ID-35.3)
TECH — Ledger mutation CLI (ID-35.3)
Section titled “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.
§1 Module layout
Section titled “§1 Module layout”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 primitivesNot 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).
§2 Schema reuse (zero new schema code)
Section titled “§2 Schema reuse (zero new schema code)”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 import | KH rewire |
|---|---|
@task-view/schemas/task-list → TaskListSchema, TaskList, TaskSchema, SubtaskSchema, Task, Subtask | @/lib/validation/task-list-schema |
@task-view/schemas/roadmap → RoadmapSchema, Roadmap, RoadmapThemeSchema | @/lib/validation/roadmap-schema |
@task-view/schemas/backlog → BacklogSchema, 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).
§3 Error envelope (PRODUCT inv 1, 2, 4)
Section titled “§3 Error envelope (PRODUCT inv 1, 2, 4)”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→ spawnbash 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:
- Trigger: add
lib/ledger/*.tsto thepaths:filter so editing a vendored primitive runs the reminder. - 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 samev0.2.0-task-viewtag the existing job +regen-mirrors.shuse) and diffpackages/server/<name>vslib/ledger/<name>through the existingnormalise()(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)”| inv | change | test |
|---|---|---|
| 1,4 | §3 envelope + dispatch wrapper | each subcommand emits valid envelope + exit code |
| 2 | §3 re-parse-before-write | schema-invalid mutation: file unchanged, exit 1 |
| 3 | vendored atomicWriteFile | write path uses it; no raw write to canonical |
| 5 | --dry-run short-circuit before write | mtime/bytes unchanged |
| 6 | flip via applyPatches + enum | valid flip ok; bad status exit 1 |
| 7 | append-journal read-append-write | prior details intact + new block |
| 8 | add-subtask + superRefine | valid ok; cross-Task dep exit 1 |
| 9 | insertRecord | fresh ok; dup exit 1 |
| 10 | removeRecord | remove ok; absent not-found exit 1 |
| 11 | §4 promote glue | success removes+inserts; invalid leaves both unchanged |
| 12 | show read-only | prints record; mtime unchanged |
| 13 | parseTaskListWithWarnings on write | over-budget mutation warns, exit 0 |
| 14,15 | §5 reminder; no commit/mirror | reminder present; no git/mirror write |
| 16 | §6 workflow extension | path filter + diff loop include lib/ledger |
New T-OQ: none beyond RESEARCH’s (all defaulted/ratified). Migration plan: zero.