doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -1,29 +1,32 @@
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";
6
6
  import { diffLines, formatUnified } from "../lib/diff.js";
7
7
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
8
8
  import * as idx from "../lib/index-json.js";
9
- import { deriveIndex } from "../lib/scan.js";
9
+ import { archivedChangeEntry, deriveIndex } from "../lib/scan.js";
10
10
  import { extractOps, applyOps } from "../lib/spec-ops.js";
11
11
  import { printAdrCheckpoint } from "../lib/adr-guard.js";
12
12
  import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
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
- import { notADoctrinaProject } from "../lib/exit-codes.js";
20
+ import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
21
+ import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
22
+ import { changeNew } from "../lib/change-ops.js";
20
23
 
21
- const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
24
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "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,11 +38,10 @@ 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
- case "diff":
42
- return changeDiff(positional.slice(1), flags);
43
45
  case "abandon":
44
46
  return await changeAbandon(positional.slice(1), flags);
45
47
  default:
@@ -53,13 +55,23 @@ export async function run(positional, flags) {
53
55
  }
54
56
 
55
57
  // 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).
58
+ // One implementation, so every driver of a transition refuses — and
59
+ // explains, and forces — identically (C6).
60
+ //
61
+ // Returns `{ ok, forced }`. `forced` carries the blockers that were waved
62
+ // through, for the caller to record ONCE THE TRANSITION HAS ACTUALLY
63
+ // HAPPENED — see `recordForcedGap` below. This used to record here, at the
64
+ // moment the gate was overridden, which wrote history for an event that had
65
+ // not occurred yet and often never would: `change apply --force` on a
66
+ // change with an unappliable ops block logged "forced apply past 3
67
+ // blockers" while the apply wrote nothing, the spec stayed byte-identical
68
+ // and the proposal stayed `proposed` (third audit, finding 3). The ledger
69
+ // is the readable source of what happened to the tree; an attempt that
70
+ // changed nothing did not happen to it.
59
71
  function enforceTransition(projectRoot, id, changeDir, transition, flags) {
60
72
  const force = flagBool(flags, "force", false);
61
73
  const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
62
- if (ok) return true;
74
+ if (ok) return { ok: true, forced: null };
63
75
 
64
76
  const label = TRANSITIONS[transition].label;
65
77
  if (!force) {
@@ -67,12 +79,11 @@ function enforceTransition(projectRoot, id, changeDir, transition, flags) {
67
79
  for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
68
80
  const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
69
81
  console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
70
- return false;
82
+ return { ok: false, forced: null };
71
83
  }
72
84
  console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
73
85
  for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
74
- recordForcedGap(projectRoot, id, transition, blockers);
75
- return true;
86
+ return { ok: true, forced: blockers };
76
87
  }
77
88
 
78
89
  // Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
@@ -87,6 +98,8 @@ async function forEachId(ids, name, one) {
87
98
  }
88
99
  let worst = 0;
89
100
  for (const id of ids) {
101
+ const refused = refuseChangeRef(id);
102
+ if (refused !== null) { worst = Math.max(worst, refused); continue; }
90
103
  if (ids.length > 1) {
91
104
  console.log("");
92
105
  console.log(c.bold(`──── change ${name} ${id}`));
@@ -95,6 +108,14 @@ async function forEachId(ids, name, one) {
95
108
  try {
96
109
  code = await one(id);
97
110
  } catch (err) {
111
+ // A TYPED error is a condition of the RUN, not a verdict on this id:
112
+ // "not a Doctrina project" is equally true of every id, and the class
113
+ // it carries is the whole point of the contract. Flattening it to 1
114
+ // here made `change apply|archive|check` answer "fix your work and
115
+ // retry" where `change new`, one switch arm away, answered "run the
116
+ // setup command first" — same condition, same sentence, two codes.
117
+ // The entrypoint owns the mapping and prints the remedy; let it.
118
+ if (err && typeof err.exitCode === "number") throw err;
98
119
  console.error(c.red("error:") + ` ${err.message}`);
99
120
  code = 1;
100
121
  }
@@ -109,76 +130,6 @@ async function forEachId(ids, name, one) {
109
130
  return worst;
110
131
  }
111
132
 
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
133
 
183
134
  function changeApply(args, flags) {
184
135
  const id = args[0];
@@ -192,7 +143,7 @@ function changeApply(args, flags) {
192
143
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
193
144
  if (!isDir(changeDir)) {
194
145
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
195
- return 1;
146
+ return EXIT.USAGE;
196
147
  }
197
148
 
198
149
  // Gate parity (C6). `apply` used to mutate specs with no preconditions,
@@ -200,7 +151,8 @@ function changeApply(args, flags) {
200
151
  // and exit 0 — through the very analyze → apply path the docs prescribe.
201
152
  // The preconditions now come from the shared map, so what guards a
202
153
  // transition does not depend on which command drove it.
203
- if (!enforceTransition(projectRoot, id, changeDir, "apply", flags)) return 1;
154
+ const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
155
+ if (!applyGate.ok) return 1;
204
156
 
205
157
  // Applying twice is silent corruption. The ops verbs are ADDITIVE —
206
158
  // `append-requirement` appends, `bump-version` bumps — so a second pass
@@ -334,6 +286,12 @@ function changeApply(args, flags) {
334
286
  }
335
287
 
336
288
  console.log("");
289
+ // The gap is history only once the transition happened. An apply that
290
+ // errored wrote nothing and left the proposal `proposed`, so there is
291
+ // nothing for the ledger to record (change 0096).
292
+ if (applyGate.forced && errors === 0) {
293
+ recordForcedGap(projectRoot, id, "apply", applyGate.forced);
294
+ }
337
295
  console.log(c.bold("Apply summary:") + ` ${writes} written, ${manual} manual, ${errors} errors.`);
338
296
  if (manual > 0) {
339
297
  console.log(`Resolve manual merges, then run ${c.cyan(`doctrina change archive ${id}`)}.`);
@@ -346,19 +304,19 @@ function changeApply(args, flags) {
346
304
  // Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
347
305
  // apply, and archive would refuse, listed BEFORE any of them runs, with the
348
306
  // remediation next to each finding. Read-only — the per-change `doctor`.
349
- async function changeCheck(id) {
307
+ async function changeCheck(id, { verbose = false } = {}) {
350
308
  const projectRoot = process.cwd();
351
309
  ensureDoctrinaProject(projectRoot);
352
310
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
353
311
  if (!isDir(changeDir)) {
354
312
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
355
- return 1;
313
+ return EXIT.USAGE;
356
314
  }
357
315
 
358
316
  let failures = 0;
359
317
 
360
318
  // 1. Structural analysis — same checks analyze runs before an apply.
361
- console.log(c.gray("──── 1/3 structure (analyze)"));
319
+ console.log(c.gray("──── 1/3 structure"));
362
320
  const analyze = await import("./analyze.js");
363
321
  if ((await analyze.run([id], new Map())) !== 0) failures += 1;
364
322
 
@@ -398,6 +356,15 @@ async function changeCheck(id) {
398
356
  if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
399
357
  if (opsFindings > 0) failures += 1;
400
358
 
359
+ // --verbose: the per-delta preview the removed `change diff` printed. The dry-run
360
+ // above says whether the ops WOULD apply; this says what the file would
361
+ // look like afterwards, which is the question the separate command existed
362
+ // to answer (change 0049).
363
+ if (verbose && deltaFiles.length > 0) {
364
+ console.log(c.gray("──── deltas in full (--verbose)"));
365
+ if (printDeltaPreview(projectRoot, changeDir, deltaFiles) > 0) failures += 1;
366
+ }
367
+
401
368
  // 3. Archive gate preview: what archive will refuse, listed with the fix.
402
369
  console.log(c.gray("──── 3/3 archive gate"));
403
370
  // Preview through the SAME map the transitions enforce, so `check` can
@@ -437,12 +404,14 @@ function changeTick(args, flags) {
437
404
  console.error(c.red("error:") + " change tick requires <id> [ordinals... | --all]");
438
405
  return 2;
439
406
  }
407
+ const refused = refuseChangeRef(id);
408
+ if (refused !== null) return refused;
440
409
  const projectRoot = process.cwd();
441
410
  ensureDoctrinaProject(projectRoot);
442
411
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
443
412
  if (!isDir(changeDir)) {
444
413
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
445
- return 1;
414
+ return EXIT.USAGE;
446
415
  }
447
416
 
448
417
  // Unified ordinal space over both files, in reading order: every unchecked
@@ -451,57 +420,73 @@ function changeTick(args, flags) {
451
420
  { label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
452
421
  { label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
453
422
  ];
423
+ // STABLE ordinals (change 0104): every box in reading order, checked or
424
+ // not, so a number means the same box on every invocation. The ordinals
425
+ // used to run over the UNCHECKED boxes only, and renumbered after each
426
+ // tick: `tick 1`, `tick 2`, `tick 3`, `tick 4` in four calls ticked task 1,
427
+ // then "Apply the change", "Update index.json" and "acceptance criteria
428
+ // are met" — the closing steps and the proposal's Verification claims —
429
+ // while tasks 2-4 stayed open. A box is a claim; the number that names
430
+ // it cannot move.
454
431
  const boxes = [];
455
432
  for (const f of files) {
456
433
  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
- }
434
+ // One box grammar, from the document model (change 0067).
435
+ for (const box of parseChecklist(read(f.path), { section: f.section })) {
436
+ boxes.push({ file: f, lineIndex: box.line, text: box.text, placeholder: box.placeholder, checked: box.checked });
473
437
  }
474
438
  }
439
+ const open = boxes.filter((b) => !b.checked);
475
440
 
476
- if (boxes.length === 0) {
441
+ if (open.length === 0) {
477
442
  console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
478
443
  return 0;
479
444
  }
480
445
 
481
446
  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}:`));
447
+ const rawOrdinals = args.slice(1);
448
+ const isPlaceholder = (box) => box.placeholder;
449
+ if (!all && rawOrdinals.length === 0) {
450
+ 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
451
  console.log("");
487
452
  for (let i = 0; i < boxes.length; i++) {
488
- const label = isPlaceholder(boxes[i])
453
+ const b = boxes[i];
454
+ const label = isPlaceholder(b)
489
455
  ? 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}]`)}`);
456
+ : b.text;
457
+ const state = b.checked ? c.green("[x]") : "[ ]";
458
+ const line = ` ${String(i + 1).padStart(3)}. ${state} ${label} ${c.gray(`[${b.file.label}]`)}`;
459
+ console.log(b.checked ? c.gray(line) : line);
492
460
  }
493
461
  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`));
462
+ const sample = open.slice(0, 2).map((b) => boxes.indexOf(b) + 1).join(" ");
463
+ console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} ${sample}`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
495
464
  return 0;
496
465
  }
497
466
 
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;
467
+ let picked;
468
+ if (all) {
469
+ picked = open.map((b) => boxes.indexOf(b) + 1);
470
+ } else {
471
+ picked = [];
472
+ for (const raw of rawOrdinals) {
473
+ const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
474
+ if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
475
+ console.error(c.red("error:") + ` no box "${raw}" (a number 1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
476
+ return 2;
477
+ }
478
+ picked.push(n);
503
479
  }
504
480
  }
481
+ // A box already ticked is a no-op, named: ticking it twice is not a
482
+ // second claim, and refusing would punish the stable numbering.
483
+ const already = picked.filter((n) => boxes[n - 1].checked);
484
+ for (const n of already) console.log(c.gray(`note: box ${n} is already ticked — left as is`));
485
+ picked = picked.filter((n) => !boxes[n - 1].checked);
486
+ if (picked.length === 0) {
487
+ console.log(c.green("ok") + " nothing to tick");
488
+ return 0;
489
+ }
505
490
  // Ticking an empty scaffold placeholder is a meaningless claim — it is how
506
491
  // a hollow change games the archive gate. Refuse (all-or-nothing) and name
507
492
  // the fix; analyze/close hard-fail on the placeholders regardless.
@@ -542,12 +527,12 @@ function changeArchive(args, flags) {
542
527
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
543
528
  if (!isDir(changeDir)) {
544
529
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
545
- return 1;
530
+ return EXIT.USAGE;
546
531
  }
547
532
 
548
533
  // Verification gate. "Done" is a claim until it is checked. Archiving is
549
534
  // the act of declaring a change finished, so it must refuse while any
550
- // task (including closing steps) or any declared verification item is
535
+ // task or any declared verification item is
551
536
  // still unchecked. This is the difference between "boxes marked" and
552
537
  // "verification passed" the framework was faulted for collapsing.
553
538
  // Preconditions from the shared map (C6): structure AND verification.
@@ -555,7 +540,8 @@ function changeArchive(args, flags) {
555
540
  // `close` path always ran first, so the same state was guarded
556
541
  // differently depending on the route taken. --force is the escape hatch
557
542
  // for both, and records the gap.
558
- if (!enforceTransition(projectRoot, id, changeDir, "archive", flags)) return 1;
543
+ const archiveGate = enforceTransition(projectRoot, id, changeDir, "archive", flags);
544
+ if (!archiveGate.ok) return 1;
559
545
 
560
546
  const date = today();
561
547
  const archiveName = `${date}-${id}`;
@@ -585,6 +571,13 @@ function changeArchive(args, flags) {
585
571
 
586
572
  move(changeDir, archiveDir);
587
573
  console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
574
+ // The move happened, so a waved-through blocker is now part of the
575
+ // history and the ledger says so (change 0096). Recorded here rather than
576
+ // at the gate, so a forced transition that then failed leaves no claim
577
+ // behind it.
578
+ if (archiveGate.forced) {
579
+ recordForcedGap(projectRoot, id, "archive", archiveGate.forced);
580
+ }
588
581
 
589
582
  // Collect affected specs from delta files for the index entry
590
583
  const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
@@ -599,40 +592,22 @@ function changeArchive(args, flags) {
599
592
  // Title from proposal
600
593
  const proposal = path.join(archiveDir, "proposal.md");
601
594
  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();
595
+ // One owner for the H1's grammar (ADR 0021): the id contains hyphens,
596
+ // and every copy of this parse got that wrong in a different way.
597
+ title = parseChangeTitle(read(proposal)) ?? title;
609
598
  }
610
599
 
611
600
  // Append a one-line summary to the archive ledger so history stays
612
601
  // scannable without opening folders (the archive is out of the default
613
602
  // 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)}`);
603
+ const ledgerFile = appendLedgerLine(projectRoot, archivedLine(id, title, specsAffected, date));
604
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
626
605
 
627
606
  const index = idx.load(projectRoot);
628
- idx.moveChangeToArchive(index, id, {
629
- id,
630
- title,
631
- path: `.doctrina/changes/archive/${archiveName}`,
632
- status: "applied",
633
- applied: date,
634
- specs_affected: specsAffected,
635
- });
607
+ idx.moveChangeToArchive(index, id, archivedChangeEntry(
608
+ exists(proposal) ? read(proposal) : "",
609
+ { id, archiveName, applied: date, specsAffected, title },
610
+ ));
636
611
  idx.touch(index, date);
637
612
  idx.save(projectRoot, index);
638
613
  console.log(c.green("indexed") + " change archived");
@@ -649,13 +624,15 @@ async function changeAbandon(args, flags) {
649
624
  console.error(c.red("error:") + " change abandon requires <id>");
650
625
  return 2;
651
626
  }
627
+ const refused = refuseChangeRef(id);
628
+ if (refused !== null) return refused;
652
629
  const projectRoot = process.cwd();
653
630
  ensureDoctrinaProject(projectRoot);
654
631
 
655
632
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
656
633
  if (!isDir(changeDir)) {
657
634
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
658
- return 1;
635
+ return EXIT.USAGE;
659
636
  }
660
637
 
661
638
  // Consent before destruction (audit item C9). This deletes work with no
@@ -690,16 +667,8 @@ async function changeAbandon(args, flags) {
690
667
  console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
691
668
 
692
669
  // 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)}`);
670
+ const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, reason, date));
671
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
703
672
 
704
673
  // Drop the change entry; rebuild from the tree so nothing drifts.
705
674
  const current = idx.load(projectRoot);
@@ -710,27 +679,15 @@ async function changeAbandon(args, flags) {
710
679
  return 0;
711
680
  }
712
681
 
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
-
682
+ // The per-delta preview: what applying this delta would do to its target.
683
+ // ADDED reports the body it would write, REMOVED the spec it would delete,
684
+ // MODIFIED a line diff against the current spec.
685
+ //
686
+ // `change check --verbose` prints this after its ops dry-run. It was also
687
+ // the whole of `change diff`, until that alias was retired (changes 0049,
688
+ // 0175) — the one renderer is what made the merge honest. Returns the number of
689
+ // deltas it could not read.
690
+ export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
734
691
  let errors = 0;
735
692
  for (const deltaPath of deltaFiles) {
736
693
  const rel = relPath(changeDir, deltaPath);
@@ -773,27 +730,7 @@ function changeDiff(args, _flags) {
773
730
  console.log(out);
774
731
  }
775
732
  }
776
-
777
- console.log("");
778
- return errors > 0 ? 1 : 0;
779
- }
780
-
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;
733
+ return errors;
797
734
  }
798
735
 
799
736
  function extractDeltaBody(text) {
@@ -803,65 +740,6 @@ function extractDeltaBody(text) {
803
740
  return text.slice(idxSep + 5).replace(/^\n+/, "");
804
741
  }
805
742
 
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
-
838
- // Reasons a change is not finished enough to archive. Counts unchecked
839
- // GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
840
- // closing steps) and in the proposal's "## Verification" section. Returns
841
- // a list of human-readable blocker strings; empty means clear to archive.
842
- function collectArchiveBlockers(changeDir) {
843
- const blockers = [];
844
- const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
845
-
846
- const tasksPath = path.join(changeDir, "tasks.md");
847
- if (exists(tasksPath)) {
848
- const n = countUnchecked(read(tasksPath));
849
- if (n > 0) blockers.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
850
- }
851
-
852
- const proposalPath = path.join(changeDir, "proposal.md");
853
- if (exists(proposalPath)) {
854
- const n = countUnchecked(getSection(read(proposalPath), "Verification"));
855
- if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
856
- }
857
- return blockers;
858
- }
859
-
860
- function ensureDoctrinaProject(projectRoot) {
861
- if (!exists(path.join(projectRoot, ".doctrina"))) {
862
- throw notADoctrinaProject();
863
- }
864
- }
865
743
 
866
744
  export const help = `
867
745
  Usage: doctrina change <subcommand> [args]
@@ -880,7 +758,7 @@ Subcommands:
880
758
  MODIFIED without one prints a manual-merge pointer.
881
759
  On any spec write the index is rebuilt from the tree.
882
760
  archive <id...> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
883
- check <id...> Pre-close dry-run, read-only: analyze's structural
761
+ check <id...> Pre-close dry-run, read-only: the structural
884
762
  checks + every ops block executed in memory against
885
763
  its target + the archive gate preview + an advisory
886
764
  list of accepted ADRs citing the touched capabilities.
@@ -890,8 +768,6 @@ Subcommands:
890
768
  or every one with --all. No args = list only.
891
769
  abandon <id> Delete an open change folder and its index entry, and
892
770
  record the abandonment in the ledger ([--reason "..."]).
893
- diff <id> Preview every spec delta: line diff for MODIFIED,
894
- summary for ADDED/REMOVED. Read-only.
895
771
 
896
772
  apply / archive / check accept multiple ids (batch close of a backlog); the
897
773
  exit code is the worst per-id result.
@@ -917,4 +793,5 @@ Options:
917
793
 
918
794
  // Re-export parsers so scan.js (index rebuild) can reuse them, and the
919
795
  // scaffold so `work` can open a change without duplicating the logic.
920
- export { parseOperation, parseCapabilityFromDelta, changeNew, isUntouchedScaffold };
796
+ export { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
797
+ export { changeNew } from "../lib/change-ops.js";