doctrina-cli 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- import { getSection, getHeader } from "../lib/doc-model.js";
2
+ import { parseChecklist, getSection, getHeader, parseChangeTitle } from "../lib/doc-model.js";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
@@ -13,17 +13,20 @@ import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gat
13
13
  import { today } from "../lib/dates.js";
14
14
  import { flagBool, flagString } from "../lib/args.js";
15
15
  import { c } from "../lib/colors.js";
16
+ import { appendLedgerLine, archivedLine, abandonedLine } from "../lib/ledger.js";
16
17
  import { suggest } from "../lib/suggest.js";
17
18
  import { confirm, isInteractive } from "../lib/prompt.js";
18
19
  import { EXIT } from "../lib/exit-codes.js";
19
20
  import { notADoctrinaProject } from "../lib/exit-codes.js";
21
+ import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
22
+ import { changeNew } from "../lib/change-ops.js";
20
23
 
21
24
  const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
22
25
 
23
26
  // Flags this command accepts. Declared HERE, with the command, so
24
27
  // adding a command never requires editing the entrypoint — the gap that
25
28
  // let six flags ship undeclared and silently swallow a positional (C3).
26
- export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec"], string: ["reason"] };
29
+ export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason"] };
27
30
 
28
31
  export async function run(positional, flags) {
29
32
  const sub = positional[0];
@@ -35,7 +38,8 @@ export async function run(positional, flags) {
35
38
  case "archive":
36
39
  return forEachId(positional.slice(1), "archive", (id) => changeArchive([id], flags));
37
40
  case "check":
38
- return forEachId(positional.slice(1), "check", (id) => changeCheck(id));
41
+ return forEachId(positional.slice(1), "check", (id) =>
42
+ changeCheck(id, { verbose: flagBool(flags, "verbose", false) }));
39
43
  case "tick":
40
44
  return changeTick(positional.slice(1), flags);
41
45
  case "diff":
@@ -53,13 +57,23 @@ export async function run(positional, flags) {
53
57
  }
54
58
 
55
59
  // Enforce a lifecycle transition's preconditions from the shared gate map.
56
- // Returns true when the transition may proceed. One implementation, so
57
- // every driver of a transition refuses — and explains, and forces —
58
- // identically (C6).
60
+ // One implementation, so every driver of a transition refuses — and
61
+ // explains, and forces — identically (C6).
62
+ //
63
+ // Returns `{ ok, forced }`. `forced` carries the blockers that were waved
64
+ // through, for the caller to record ONCE THE TRANSITION HAS ACTUALLY
65
+ // HAPPENED — see `recordForcedGap` below. This used to record here, at the
66
+ // moment the gate was overridden, which wrote history for an event that had
67
+ // not occurred yet and often never would: `change apply --force` on a
68
+ // change with an unappliable ops block logged "forced apply past 3
69
+ // blockers" while the apply wrote nothing, the spec stayed byte-identical
70
+ // and the proposal stayed `proposed` (third audit, finding 3). The ledger
71
+ // is the readable source of what happened to the tree; an attempt that
72
+ // changed nothing did not happen to it.
59
73
  function enforceTransition(projectRoot, id, changeDir, transition, flags) {
60
74
  const force = flagBool(flags, "force", false);
61
75
  const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
62
- if (ok) return true;
76
+ if (ok) return { ok: true, forced: null };
63
77
 
64
78
  const label = TRANSITIONS[transition].label;
65
79
  if (!force) {
@@ -67,12 +81,11 @@ function enforceTransition(projectRoot, id, changeDir, transition, flags) {
67
81
  for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
68
82
  const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
69
83
  console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
70
- return false;
84
+ return { ok: false, forced: null };
71
85
  }
72
86
  console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
73
87
  for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
74
- recordForcedGap(projectRoot, id, transition, blockers);
75
- return true;
88
+ return { ok: true, forced: blockers };
76
89
  }
77
90
 
78
91
  // Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
@@ -109,76 +122,6 @@ async function forEachId(ids, name, one) {
109
122
  return worst;
110
123
  }
111
124
 
112
- function changeNew(args, flags) {
113
- const id = args[0];
114
- const title = args.slice(1).join(" ").trim();
115
- if (!id) {
116
- console.error(c.red("error:") + " change new requires <id> and \"<title>\"");
117
- return 2;
118
- }
119
- if (!title) {
120
- console.error(c.red("error:") + " change new requires a title (quote it if it contains spaces)");
121
- return 2;
122
- }
123
-
124
- const force = flagBool(flags, "force", false);
125
- // A chore is a spec-less change (infra / docs / build / migration) — review
126
- // G9. It runs the full proposal → apply → archive → ledger lifecycle (so the
127
- // history shows it) without forcing a fake spec delta. The empty specs/ dir
128
- // is kept so `change apply`'s zero-delta path flips it to applied unchanged.
129
- const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
130
- const projectRoot = process.cwd();
131
- ensureDoctrinaProject(projectRoot);
132
-
133
- const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
134
- if (exists(changeDir) && !force) {
135
- console.error(c.red("error:") + ` change "${id}" already exists at ${relPath(projectRoot, changeDir)}`);
136
- return 1;
137
- }
138
-
139
- const templatesDir = locateTemplatesDir();
140
- const date = today();
141
- const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
142
-
143
- // design.md is opt-in (--design): in practice it scaffolded on every change
144
- // and stayed empty — nine changes out of nine in the 0.11.0 field review. A
145
- // change that needs a design doc asks for one; the rest stop carrying a
146
- // blank file through apply/archive/ledger.
147
- const wantDesign = flagBool(flags, "design", false);
148
- const tree = loadTemplateTree(templatesDir, "change");
149
- for (const entry of tree) {
150
- if (entry.relativePath === "spec-delta.md.template") continue;
151
- if (entry.relativePath === "design.md.template" && !wantDesign) continue;
152
- const written = materialiseEntry(entry, changeDir, tokens, { force });
153
- console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
154
- }
155
- mkdirp(path.join(changeDir, "specs"));
156
-
157
- // Stamp the proposal so a chore is honest in the artifact, not just the CLI.
158
- if (chore) {
159
- const proposalPath = path.join(changeDir, "proposal.md");
160
- if (exists(proposalPath)) {
161
- const txt = read(proposalPath);
162
- const updated = txt.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, "$1 (none — chore)");
163
- if (updated !== txt) write(proposalPath, updated, { force: true });
164
- }
165
- }
166
-
167
- const index = idx.load(projectRoot);
168
- idx.addChange(index, { id, title, path: `.doctrina/changes/${id}`, status: "proposed", opened: date });
169
- idx.touch(index, date);
170
- idx.save(projectRoot, index);
171
-
172
- console.log("");
173
- if (chore) {
174
- console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
175
- c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
176
- } else {
177
- console.log(c.bold("Change opened.") + " Add spec deltas under " +
178
- c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
179
- }
180
- return 0;
181
- }
182
125
 
183
126
  function changeApply(args, flags) {
184
127
  const id = args[0];
@@ -192,7 +135,7 @@ function changeApply(args, flags) {
192
135
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
193
136
  if (!isDir(changeDir)) {
194
137
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
195
- return 1;
138
+ return EXIT.USAGE;
196
139
  }
197
140
 
198
141
  // Gate parity (C6). `apply` used to mutate specs with no preconditions,
@@ -200,7 +143,8 @@ function changeApply(args, flags) {
200
143
  // and exit 0 — through the very analyze → apply path the docs prescribe.
201
144
  // The preconditions now come from the shared map, so what guards a
202
145
  // transition does not depend on which command drove it.
203
- if (!enforceTransition(projectRoot, id, changeDir, "apply", flags)) return 1;
146
+ const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
147
+ if (!applyGate.ok) return 1;
204
148
 
205
149
  // Applying twice is silent corruption. The ops verbs are ADDITIVE —
206
150
  // `append-requirement` appends, `bump-version` bumps — so a second pass
@@ -334,6 +278,12 @@ function changeApply(args, flags) {
334
278
  }
335
279
 
336
280
  console.log("");
281
+ // The gap is history only once the transition happened. An apply that
282
+ // errored wrote nothing and left the proposal `proposed`, so there is
283
+ // nothing for the ledger to record (change 0096).
284
+ if (applyGate.forced && errors === 0) {
285
+ recordForcedGap(projectRoot, id, "apply", applyGate.forced);
286
+ }
337
287
  console.log(c.bold("Apply summary:") + ` ${writes} written, ${manual} manual, ${errors} errors.`);
338
288
  if (manual > 0) {
339
289
  console.log(`Resolve manual merges, then run ${c.cyan(`doctrina change archive ${id}`)}.`);
@@ -346,13 +296,13 @@ function changeApply(args, flags) {
346
296
  // Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
347
297
  // apply, and archive would refuse, listed BEFORE any of them runs, with the
348
298
  // remediation next to each finding. Read-only — the per-change `doctor`.
349
- async function changeCheck(id) {
299
+ async function changeCheck(id, { verbose = false } = {}) {
350
300
  const projectRoot = process.cwd();
351
301
  ensureDoctrinaProject(projectRoot);
352
302
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
353
303
  if (!isDir(changeDir)) {
354
304
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
355
- return 1;
305
+ return EXIT.USAGE;
356
306
  }
357
307
 
358
308
  let failures = 0;
@@ -398,6 +348,15 @@ async function changeCheck(id) {
398
348
  if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
399
349
  if (opsFindings > 0) failures += 1;
400
350
 
351
+ // --verbose: the same per-delta preview `change diff` prints. The dry-run
352
+ // above says whether the ops WOULD apply; this says what the file would
353
+ // look like afterwards, which is the question the separate command existed
354
+ // to answer (change 0049).
355
+ if (verbose && deltaFiles.length > 0) {
356
+ console.log(c.gray("──── deltas in full (--verbose)"));
357
+ if (printDeltaPreview(projectRoot, changeDir, deltaFiles) > 0) failures += 1;
358
+ }
359
+
401
360
  // 3. Archive gate preview: what archive will refuse, listed with the fix.
402
361
  console.log(c.gray("──── 3/3 archive gate"));
403
362
  // Preview through the SAME map the transitions enforce, so `check` can
@@ -442,7 +401,7 @@ function changeTick(args, flags) {
442
401
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
443
402
  if (!isDir(changeDir)) {
444
403
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
445
- return 1;
404
+ return EXIT.USAGE;
446
405
  }
447
406
 
448
407
  // Unified ordinal space over both files, in reading order: every unchecked
@@ -451,57 +410,73 @@ function changeTick(args, flags) {
451
410
  { label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
452
411
  { label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
453
412
  ];
413
+ // STABLE ordinals (change 0104): every box in reading order, checked or
414
+ // not, so a number means the same box on every invocation. The ordinals
415
+ // used to run over the UNCHECKED boxes only, and renumbered after each
416
+ // tick: `tick 1`, `tick 2`, `tick 3`, `tick 4` in four calls ticked task 1,
417
+ // then "Apply the change", "Update index.json" and "acceptance criteria
418
+ // are met" — the closing steps and the proposal's Verification claims —
419
+ // while tasks 2-4 stayed open. A box is a claim; the number that names
420
+ // it cannot move.
454
421
  const boxes = [];
455
422
  for (const f of files) {
456
423
  if (!isFile(f.path)) continue;
457
- const lines = read(f.path).split(/\r?\n/);
458
- const inScope = f.section === null
459
- ? () => true
460
- : (() => {
461
- const scoped = new Set();
462
- let inSection = false;
463
- for (let i = 0; i < lines.length; i++) {
464
- if (/^##\s+/.test(lines[i])) inSection = new RegExp(`^##\\s+${f.section}\\b`, "i").test(lines[i]);
465
- else if (inSection) scoped.add(i);
466
- }
467
- return (i) => scoped.has(i);
468
- })();
469
- for (let i = 0; i < lines.length; i++) {
470
- if (/^\s*-\s*\[ \]/.test(lines[i]) && inScope(i)) {
471
- boxes.push({ file: f, lineIndex: i, text: lines[i].replace(/^\s*-\s*\[ \]\s*/, "") });
472
- }
424
+ // One box grammar, from the document model (change 0067).
425
+ for (const box of parseChecklist(read(f.path), { section: f.section })) {
426
+ boxes.push({ file: f, lineIndex: box.line, text: box.text, placeholder: box.placeholder, checked: box.checked });
473
427
  }
474
428
  }
429
+ const open = boxes.filter((b) => !b.checked);
475
430
 
476
- if (boxes.length === 0) {
431
+ if (open.length === 0) {
477
432
  console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
478
433
  return 0;
479
434
  }
480
435
 
481
436
  const all = flagBool(flags, "all", false);
482
- const ordinals = args.slice(1).map(Number);
483
- const isPlaceholder = (box) => box.text.trim() === "";
484
- if (!all && ordinals.length === 0) {
485
- console.log(c.bold(`Unchecked boxes in ${id}:`));
437
+ const rawOrdinals = args.slice(1);
438
+ const isPlaceholder = (box) => box.placeholder;
439
+ if (!all && rawOrdinals.length === 0) {
440
+ console.log(c.bold(`Boxes in ${id}`) + c.gray(` (${open.length} of ${boxes.length} unchecked; numbers are stable — a ticked box keeps its number):`));
486
441
  console.log("");
487
442
  for (let i = 0; i < boxes.length; i++) {
488
- const label = isPlaceholder(boxes[i])
443
+ const b = boxes[i];
444
+ const label = isPlaceholder(b)
489
445
  ? c.yellow("(scaffold placeholder — write the real task, or delete the line)")
490
- : boxes[i].text;
491
- console.log(` ${String(i + 1).padStart(3)}. ${label} ${c.gray(`[${boxes[i].file.label}]`)}`);
446
+ : b.text;
447
+ const state = b.checked ? c.green("[x]") : "[ ]";
448
+ const line = ` ${String(i + 1).padStart(3)}. ${state} ${label} ${c.gray(`[${b.file.label}]`)}`;
449
+ console.log(b.checked ? c.gray(line) : line);
492
450
  }
493
451
  console.log("");
494
- console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} 1 3`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
452
+ const sample = open.slice(0, 2).map((b) => boxes.indexOf(b) + 1).join(" ");
453
+ console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} ${sample}`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
495
454
  return 0;
496
455
  }
497
456
 
498
- const picked = all ? boxes.map((_, i) => i + 1) : ordinals;
499
- for (const n of picked) {
500
- if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
501
- console.error(c.red("error:") + ` no box #${n} (1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
502
- return 2;
457
+ let picked;
458
+ if (all) {
459
+ picked = open.map((b) => boxes.indexOf(b) + 1);
460
+ } else {
461
+ picked = [];
462
+ for (const raw of rawOrdinals) {
463
+ const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
464
+ if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
465
+ console.error(c.red("error:") + ` no box "${raw}" (a number 1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
466
+ return 2;
467
+ }
468
+ picked.push(n);
503
469
  }
504
470
  }
471
+ // A box already ticked is a no-op, named: ticking it twice is not a
472
+ // second claim, and refusing would punish the stable numbering.
473
+ const already = picked.filter((n) => boxes[n - 1].checked);
474
+ for (const n of already) console.log(c.gray(`note: box ${n} is already ticked — left as is`));
475
+ picked = picked.filter((n) => !boxes[n - 1].checked);
476
+ if (picked.length === 0) {
477
+ console.log(c.green("ok") + " nothing to tick");
478
+ return 0;
479
+ }
505
480
  // Ticking an empty scaffold placeholder is a meaningless claim — it is how
506
481
  // a hollow change games the archive gate. Refuse (all-or-nothing) and name
507
482
  // the fix; analyze/close hard-fail on the placeholders regardless.
@@ -542,7 +517,7 @@ function changeArchive(args, flags) {
542
517
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
543
518
  if (!isDir(changeDir)) {
544
519
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
545
- return 1;
520
+ return EXIT.USAGE;
546
521
  }
547
522
 
548
523
  // Verification gate. "Done" is a claim until it is checked. Archiving is
@@ -555,7 +530,8 @@ function changeArchive(args, flags) {
555
530
  // `close` path always ran first, so the same state was guarded
556
531
  // differently depending on the route taken. --force is the escape hatch
557
532
  // for both, and records the gap.
558
- if (!enforceTransition(projectRoot, id, changeDir, "archive", flags)) return 1;
533
+ const archiveGate = enforceTransition(projectRoot, id, changeDir, "archive", flags);
534
+ if (!archiveGate.ok) return 1;
559
535
 
560
536
  const date = today();
561
537
  const archiveName = `${date}-${id}`;
@@ -585,6 +561,13 @@ function changeArchive(args, flags) {
585
561
 
586
562
  move(changeDir, archiveDir);
587
563
  console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
564
+ // The move happened, so a waved-through blocker is now part of the
565
+ // history and the ledger says so (change 0096). Recorded here rather than
566
+ // at the gate, so a forced transition that then failed leaves no claim
567
+ // behind it.
568
+ if (archiveGate.forced) {
569
+ recordForcedGap(projectRoot, id, "archive", archiveGate.forced);
570
+ }
588
571
 
589
572
  // Collect affected specs from delta files for the index entry
590
573
  const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
@@ -599,30 +582,16 @@ function changeArchive(args, flags) {
599
582
  // Title from proposal
600
583
  const proposal = path.join(archiveDir, "proposal.md");
601
584
  if (exists(proposal)) {
602
- // First line may end in \r on Windows checkouts (autocrlf); split on
603
- // either ending so the title regex is not defeated by a stray \r.
604
- const firstLine = read(proposal).split(/\r?\n/, 1)[0] ?? "";
605
- // The id itself may contain hyphens (NNNN-slug), so match it as \S+
606
- // and split on the em-dash/hyphen separator that follows whitespace.
607
- const m = firstLine.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/);
608
- if (m) title = m[1].trim();
585
+ // One owner for the H1's grammar (ADR 0021): the id contains hyphens,
586
+ // and every copy of this parse got that wrong in a different way.
587
+ title = parseChangeTitle(read(proposal)) ?? title;
609
588
  }
610
589
 
611
590
  // Append a one-line summary to the archive ledger so history stays
612
591
  // scannable without opening folders (the archive is out of the default
613
592
  // read path; the ledger is the cheap way back in).
614
- const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
615
- if (!exists(ledgerPath)) {
616
- write(ledgerPath,
617
- "# Change ledger\n\n" +
618
- "One line per archived change, newest last. Appended by\n" +
619
- "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
620
- }
621
- const specsSummary = specsAffected.length > 0
622
- ? ` (specs: ${specsAffected.map((s) => `${s.capability} ${s.operation}`).join(", ")})`
623
- : "";
624
- write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — ${title}${specsSummary}\n`, { force: true });
625
- console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
593
+ const ledgerFile = appendLedgerLine(projectRoot, archivedLine(id, title, specsAffected, date));
594
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
626
595
 
627
596
  const index = idx.load(projectRoot);
628
597
  idx.moveChangeToArchive(index, id, {
@@ -655,7 +624,7 @@ async function changeAbandon(args, flags) {
655
624
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
656
625
  if (!isDir(changeDir)) {
657
626
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
658
- return 1;
627
+ return EXIT.USAGE;
659
628
  }
660
629
 
661
630
  // Consent before destruction (audit item C9). This deletes work with no
@@ -690,16 +659,8 @@ async function changeAbandon(args, flags) {
690
659
  console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
691
660
 
692
661
  // Ledger line so the abandonment is part of the visible history.
693
- const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
694
- if (!exists(ledgerPath)) {
695
- write(ledgerPath,
696
- "# Change ledger\n\n" +
697
- "One line per archived change, newest last. Appended by\n" +
698
- "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
699
- }
700
- const reasonSuffix = reason ? ` — ${reason}` : "";
701
- write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — abandoned${reasonSuffix}\n`, { force: true });
702
- console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
662
+ const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, reason, date));
663
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
703
664
 
704
665
  // Drop the change entry; rebuild from the tree so nothing drifts.
705
666
  const current = idx.load(projectRoot);
@@ -710,27 +671,15 @@ async function changeAbandon(args, flags) {
710
671
  return 0;
711
672
  }
712
673
 
713
- function changeDiff(args, _flags) {
714
- const id = args[0];
715
- if (!id) {
716
- console.error(c.red("error:") + " change diff requires <id>");
717
- return 2;
718
- }
719
- const projectRoot = process.cwd();
720
- ensureDoctrinaProject(projectRoot);
721
-
722
- const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
723
- if (!isDir(changeDir)) {
724
- console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
725
- return 1;
726
- }
727
-
728
- const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
729
- if (deltaFiles.length === 0) {
730
- console.log(c.gray("no spec deltas in this change; nothing to diff"));
731
- return 0;
732
- }
733
-
674
+ // The per-delta preview: what applying this delta would do to its target.
675
+ // ADDED reports the body it would write, REMOVED the spec it would delete,
676
+ // MODIFIED a line diff against the current spec.
677
+ //
678
+ // One renderer, two callers (change 0049): `change diff` is this and nothing
679
+ // else, and `change check --verbose` prints it after its ops dry-run — which
680
+ // is what makes the merge honest rather than a claim. Returns the number of
681
+ // deltas it could not read.
682
+ export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
734
683
  let errors = 0;
735
684
  for (const deltaPath of deltaFiles) {
736
685
  const rel = relPath(changeDir, deltaPath);
@@ -773,28 +722,35 @@ function changeDiff(args, _flags) {
773
722
  console.log(out);
774
723
  }
775
724
  }
725
+ return errors;
726
+ }
776
727
 
728
+ function changeDiff(args, _flags) {
729
+ const id = args[0];
730
+ if (!id) {
731
+ console.error(c.red("error:") + " change diff requires <id>");
732
+ return 2;
733
+ }
734
+ const projectRoot = process.cwd();
735
+ ensureDoctrinaProject(projectRoot);
736
+
737
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
738
+ if (!isDir(changeDir)) {
739
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
740
+ return EXIT.USAGE;
741
+ }
742
+
743
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
744
+ if (deltaFiles.length === 0) {
745
+ console.log(c.gray("no spec deltas in this change; nothing to diff"));
746
+ return 0;
747
+ }
748
+
749
+ const errors = printDeltaPreview(projectRoot, changeDir, deltaFiles);
777
750
  console.log("");
778
751
  return errors > 0 ? 1 : 0;
779
752
  }
780
753
 
781
- function parseOperation(text) {
782
- const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
783
- if (!m) return null;
784
- const op = m[1];
785
- if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
786
- return null;
787
- }
788
-
789
- function parseCapabilityFromDelta(text, deltaPath) {
790
- // Prefer the explicit header "# Spec Delta — capability: <name>"
791
- const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
792
- if (m) return m[1];
793
- // Fall back to the parent directory name of the delta file
794
- const parent = path.basename(path.dirname(deltaPath));
795
- if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
796
- return null;
797
- }
798
754
 
799
755
  function extractDeltaBody(text) {
800
756
  // The delta separates headers from the spec body with a `---` line.
@@ -803,37 +759,6 @@ function extractDeltaBody(text) {
803
759
  return text.slice(idxSep + 5).replace(/^\n+/, "");
804
760
  }
805
761
 
806
- // Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
807
- // check: render the shipped capability template for the same capability and
808
- // compare, ignoring the date-bearing "Last updated" line and whitespace
809
- // normalisation. When the template cannot be located (unusual installs),
810
- // fall back to the scaffold's own placeholder fingerprints — text no real
811
- // spec keeps. Used by `change apply` so an ADDED delta can replace a
812
- // scaffold (the canonical spec-new → delta flow) without ever clobbering a
813
- // spec that carries real content.
814
- function isUntouchedScaffold(specText, capability) {
815
- const normalize = (s) =>
816
- s.replace(/\r\n/g, "\n")
817
- .split("\n")
818
- .filter((line) => !/^\*\*Last updated:\*\*/.test(line))
819
- .join("\n")
820
- .trim();
821
- try {
822
- const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
823
- const rendered = read(tplPath)
824
- .replace(/\{\{CAPABILITY\}\}/g, capability)
825
- .replace(/\{\{DATE\}\}/g, "");
826
- if (normalize(rendered) === normalize(specText)) return true;
827
- } catch {
828
- // fall through to the fingerprint heuristic
829
- }
830
- // Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
831
- // section survive only in a scaffold nobody edited.
832
- return (
833
- specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
834
- /##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
835
- );
836
- }
837
762
 
838
763
  // Reasons a change is not finished enough to archive. Counts unchecked
839
764
  // GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
@@ -917,4 +842,5 @@ Options:
917
842
 
918
843
  // Re-export parsers so scan.js (index rebuild) can reuse them, and the
919
844
  // scaffold so `work` can open a change without duplicating the logic.
920
- export { parseOperation, parseCapabilityFromDelta, changeNew, isUntouchedScaffold };
845
+ export { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
846
+ export { changeNew } from "../lib/change-ops.js";