doctrina-cli 0.12.0 → 0.14.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 (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +76 -19
  5. package/src/commands/change.js +341 -44
  6. package/src/commands/clarify.js +40 -19
  7. package/src/commands/close.js +119 -11
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +152 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +14 -2
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +16 -6
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +277 -40
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +23 -12
  34. package/src/commands/validate.js +93 -3
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +69 -7
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +47 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +314 -3
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +228 -3
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/.gitkeep +0 -0
  67. package/templates/AGENTS.md.template +71 -75
  68. package/templates/change/spec-delta.md.template +13 -7
@@ -1,17 +1,29 @@
1
+ // @ts-check
2
+ import { getSection, getHeader } from "../lib/doc-model.js";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
- import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
5
+ import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
4
6
  import { diffLines, formatUnified } from "../lib/diff.js";
5
7
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
6
8
  import * as idx from "../lib/index-json.js";
7
9
  import { deriveIndex } from "../lib/scan.js";
8
10
  import { extractOps, applyOps } from "../lib/spec-ops.js";
11
+ import { printAdrCheckpoint } from "../lib/adr-guard.js";
12
+ import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
9
13
  import { today } from "../lib/dates.js";
10
14
  import { flagBool, flagString } from "../lib/args.js";
11
15
  import { c } from "../lib/colors.js";
12
16
  import { suggest } from "../lib/suggest.js";
17
+ import { confirm, isInteractive } from "../lib/prompt.js";
18
+ import { EXIT } from "../lib/exit-codes.js";
19
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
13
20
 
14
- const SUBCOMMANDS = ["new", "apply", "archive", "diff", "abandon"];
21
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
22
+
23
+ // Flags this command accepts. Declared HERE, with the command, so
24
+ // adding a command never requires editing the entrypoint — the gap that
25
+ // 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"] };
15
27
 
16
28
  export async function run(positional, flags) {
17
29
  const sub = positional[0];
@@ -19,13 +31,17 @@ export async function run(positional, flags) {
19
31
  case "new":
20
32
  return changeNew(positional.slice(1), flags);
21
33
  case "apply":
22
- return changeApply(positional.slice(1), flags);
34
+ return forEachId(positional.slice(1), "apply", (id) => changeApply([id], flags));
23
35
  case "archive":
24
- return changeArchive(positional.slice(1), flags);
36
+ return forEachId(positional.slice(1), "archive", (id) => changeArchive([id], flags));
37
+ case "check":
38
+ return forEachId(positional.slice(1), "check", (id) => changeCheck(id));
39
+ case "tick":
40
+ return changeTick(positional.slice(1), flags);
25
41
  case "diff":
26
42
  return changeDiff(positional.slice(1), flags);
27
43
  case "abandon":
28
- return changeAbandon(positional.slice(1), flags);
44
+ return await changeAbandon(positional.slice(1), flags);
29
45
  default:
30
46
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
31
47
  const guess = suggest(sub, SUBCOMMANDS);
@@ -36,6 +52,63 @@ export async function run(positional, flags) {
36
52
  }
37
53
  }
38
54
 
55
+ // 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).
59
+ function enforceTransition(projectRoot, id, changeDir, transition, flags) {
60
+ const force = flagBool(flags, "force", false);
61
+ const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
62
+ if (ok) return true;
63
+
64
+ const label = TRANSITIONS[transition].label;
65
+ if (!force) {
66
+ console.error(c.red("error:") + ` refusing to ${transition} "${id}" — ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}:`);
67
+ for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
68
+ const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
69
+ console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
70
+ return false;
71
+ }
72
+ console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
73
+ for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
74
+ recordForcedGap(projectRoot, id, transition, blockers);
75
+ return true;
76
+ }
77
+
78
+ // Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
79
+ // accept multiple ids so a backlog closes without a bash loop. Each id runs
80
+ // independently — one failure does not stop the rest — and the exit code is
81
+ // the worst one seen. `new` and `abandon` stay single-id on purpose (creation
82
+ // wants a title; abandonment is destructive and deserves one deliberate call).
83
+ async function forEachId(ids, name, one) {
84
+ if (ids.length === 0) {
85
+ console.error(c.red("error:") + ` change ${name} requires at least one <id>`);
86
+ return 2;
87
+ }
88
+ let worst = 0;
89
+ for (const id of ids) {
90
+ if (ids.length > 1) {
91
+ console.log("");
92
+ console.log(c.bold(`──── change ${name} ${id}`));
93
+ }
94
+ let code;
95
+ try {
96
+ code = await one(id);
97
+ } catch (err) {
98
+ console.error(c.red("error:") + ` ${err.message}`);
99
+ code = 1;
100
+ }
101
+ worst = Math.max(worst, code);
102
+ }
103
+ if (ids.length > 1) {
104
+ console.log("");
105
+ console.log(worst === 0
106
+ ? c.green("ok") + ` all ${ids.length} changes passed ${name}`
107
+ : c.red("fail") + ` at least one change failed ${name}`);
108
+ }
109
+ return worst;
110
+ }
111
+
39
112
  function changeNew(args, flags) {
40
113
  const id = args[0];
41
114
  const title = args.slice(1).join(" ").trim();
@@ -107,7 +180,7 @@ function changeNew(args, flags) {
107
180
  return 0;
108
181
  }
109
182
 
110
- function changeApply(args, _flags) {
183
+ function changeApply(args, flags) {
111
184
  const id = args[0];
112
185
  if (!id) {
113
186
  console.error(c.red("error:") + " change apply requires <id>");
@@ -122,6 +195,30 @@ function changeApply(args, _flags) {
122
195
  return 1;
123
196
  }
124
197
 
198
+ // Gate parity (C6). `apply` used to mutate specs with no preconditions,
199
+ // so `analyze` could exit 1 on a change and `apply` would write it anyway
200
+ // and exit 0 — through the very analyze → apply path the docs prescribe.
201
+ // The preconditions now come from the shared map, so what guards a
202
+ // transition does not depend on which command drove it.
203
+ if (!enforceTransition(projectRoot, id, changeDir, "apply", flags)) return 1;
204
+
205
+ // Applying twice is silent corruption. The ops verbs are ADDITIVE —
206
+ // `append-requirement` appends, `bump-version` bumps — so a second pass
207
+ // duplicates every requirement and criterion the delta carries and bumps
208
+ // the version again. `apply` stamps the proposal `applied` on success, so
209
+ // that stamp is the guard. (Found by `close` re-running apply on an
210
+ // already-applied change and doubling 15 requirements on this repo.)
211
+ const proposalPath = path.join(changeDir, "proposal.md");
212
+ const alreadyApplied = isFile(proposalPath)
213
+ && (getHeader(read(proposalPath), "Status") ?? "").trim().toLowerCase() === "applied";
214
+ if (alreadyApplied && !flagBool(flags, "force", false)) {
215
+ console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
216
+ console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
217
+ console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
218
+ console.log(c.gray("only after reverting the specs to their pre-apply state."));
219
+ return 0;
220
+ }
221
+
125
222
  const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
126
223
  const date = today();
127
224
  let errors = 0;
@@ -246,6 +343,193 @@ function changeApply(args, _flags) {
246
343
  return errors > 0 ? 1 : 0;
247
344
  }
248
345
 
346
+ // Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
347
+ // apply, and archive would refuse, listed BEFORE any of them runs, with the
348
+ // remediation next to each finding. Read-only — the per-change `doctor`.
349
+ async function changeCheck(id) {
350
+ const projectRoot = process.cwd();
351
+ ensureDoctrinaProject(projectRoot);
352
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
353
+ if (!isDir(changeDir)) {
354
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
355
+ return 1;
356
+ }
357
+
358
+ let failures = 0;
359
+
360
+ // 1. Structural analysis — same checks analyze runs before an apply.
361
+ console.log(c.gray("──── 1/3 structure (analyze)"));
362
+ const analyze = await import("./analyze.js");
363
+ if ((await analyze.run([id], new Map())) !== 0) failures += 1;
364
+
365
+ // 2. Ops dry-run: execute every MODIFIED delta's ops block against the
366
+ // target spec in memory. An op that would fail at apply time (missing
367
+ // header, no such criterion, unknown verb) is reported here, not days
368
+ // later; a delta with no ops block is flagged as a manual merge so the
369
+ // close is planned around it instead of surprised by it.
370
+ console.log(c.gray("──── 2/3 ops dry-run (what apply would do)"));
371
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
372
+ let opsFindings = 0;
373
+ for (const deltaPath of deltaFiles) {
374
+ const text = read(deltaPath);
375
+ if (parseOperation(text) !== "MODIFIED") continue;
376
+ const capability = parseCapabilityFromDelta(text, deltaPath);
377
+ const rel = relPath(projectRoot, deltaPath);
378
+ const ops = extractOps(text);
379
+ if (ops.length === 0) {
380
+ console.log(c.yellow("⚠ ") + `${rel}: no ops block — apply will print a manual-merge pointer`);
381
+ continue;
382
+ }
383
+ const targetPath = path.join(projectRoot, ".doctrina", "specs", capability ?? "", "spec.md");
384
+ if (!capability || !exists(targetPath)) {
385
+ console.log(c.red("✗ ") + `${rel}: MODIFIED targets missing spec`);
386
+ opsFindings += 1;
387
+ continue;
388
+ }
389
+ const result = applyOps(read(targetPath), ops);
390
+ if (result.errors.length > 0) {
391
+ console.log(c.red("✗ ") + `${rel}: ${result.errors.length} op error${result.errors.length === 1 ? "" : "s"} (apply would refuse):`);
392
+ for (const e of result.errors) console.log(` - ${e}`);
393
+ opsFindings += 1;
394
+ } else {
395
+ console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
396
+ }
397
+ }
398
+ if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
399
+ if (opsFindings > 0) failures += 1;
400
+
401
+ // 3. Archive gate preview: what archive will refuse, listed with the fix.
402
+ console.log(c.gray("──── 3/3 archive gate"));
403
+ // Preview through the SAME map the transitions enforce, so `check` can
404
+ // never disagree with what `apply` / `archive` will actually do (C6).
405
+ const archiveGate = checkTransition(projectRoot, changeDir, "archive");
406
+ if (archiveGate.ok) {
407
+ console.log(c.green("✓ ") + `archive gate clear (${archiveGate.gates.join(" + ")})`);
408
+ } else {
409
+ failures += 1;
410
+ for (const b of archiveGate.blockers) console.log(c.red("✗ ") + `[${b.gate}] ${b.message}`);
411
+ console.log(c.gray(" check them off as they land, or in bulk: ") + c.cyan(`doctrina change tick ${id} --all`));
412
+ }
413
+
414
+ // Advisory (never gates): accepted ADRs citing the touched capabilities.
415
+ const caps = [...new Set(deltaFiles.map((p) => parseCapabilityFromDelta(read(p), p)).filter(Boolean))].sort();
416
+ console.log("");
417
+ printAdrCheckpoint(projectRoot, caps, { c });
418
+
419
+ console.log("");
420
+ if (failures === 0) {
421
+ console.log(c.green("ok") + ` ${id} is ready to close: ` + c.cyan(`doctrina close ${id}`));
422
+ return 0;
423
+ }
424
+ console.log(c.red("fail") + ` ${failures} area${failures === 1 ? "" : "s"} would block the close — fix them first`);
425
+ return 1;
426
+ }
427
+
428
+ // Bulk checkbox marking (operator review 2026-07-19 §3.5: "marcar checkbox
429
+ // não tem comando nenhum" — it was sed/Python by hand). With no args it
430
+ // LISTS the unchecked boxes with ordinals; `tick <id> 2 5` checks those;
431
+ // `tick <id> --all` checks everything (tasks.md + the proposal's
432
+ // ## Verification). Ticking is a claim of completion — the honest gate is
433
+ // still archive/verify; this only removes the mechanical friction.
434
+ function changeTick(args, flags) {
435
+ const id = args[0];
436
+ if (!id) {
437
+ console.error(c.red("error:") + " change tick requires <id> [ordinals... | --all]");
438
+ return 2;
439
+ }
440
+ const projectRoot = process.cwd();
441
+ ensureDoctrinaProject(projectRoot);
442
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
443
+ if (!isDir(changeDir)) {
444
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
445
+ return 1;
446
+ }
447
+
448
+ // Unified ordinal space over both files, in reading order: every unchecked
449
+ // `- [ ]` in tasks.md, then in the proposal's ## Verification section.
450
+ const files = [
451
+ { label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
452
+ { label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
453
+ ];
454
+ const boxes = [];
455
+ for (const f of files) {
456
+ 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
+ }
473
+ }
474
+ }
475
+
476
+ if (boxes.length === 0) {
477
+ console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
478
+ return 0;
479
+ }
480
+
481
+ 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}:`));
486
+ console.log("");
487
+ for (let i = 0; i < boxes.length; i++) {
488
+ const label = isPlaceholder(boxes[i])
489
+ ? 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}]`)}`);
492
+ }
493
+ 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`));
495
+ return 0;
496
+ }
497
+
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;
503
+ }
504
+ }
505
+ // Ticking an empty scaffold placeholder is a meaningless claim — it is how
506
+ // a hollow change games the archive gate. Refuse (all-or-nothing) and name
507
+ // the fix; analyze/close hard-fail on the placeholders regardless.
508
+ const empties = picked.filter((n) => isPlaceholder(boxes[n - 1]));
509
+ if (empties.length > 0) {
510
+ console.error(c.red("error:") + ` box${empties.length === 1 ? "" : "es"} ${empties.join(", ")} ${empties.length === 1 ? "is a" : "are"} scaffold placeholder${empties.length === 1 ? "" : "s"} with no text — nothing was ticked`);
511
+ console.error(c.gray("hint: ") + "write the real task on each line (or delete it), then tick");
512
+ return 2;
513
+ }
514
+
515
+ // Group edits per file so each file is read/written once.
516
+ const byPath = new Map();
517
+ for (const n of picked) {
518
+ const box = boxes[n - 1];
519
+ if (!byPath.has(box.file.path)) byPath.set(box.file.path, []);
520
+ byPath.get(box.file.path).push(box);
521
+ }
522
+ for (const [filePath, hits] of byPath) {
523
+ const lines = read(filePath).split(/\r?\n/);
524
+ for (const box of hits) {
525
+ lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
526
+ }
527
+ write(filePath, lines.join("\n"), { force: true });
528
+ }
529
+ console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
530
+ return 0;
531
+ }
532
+
249
533
  function changeArchive(args, flags) {
250
534
  const id = args[0];
251
535
  if (!id) {
@@ -266,22 +550,12 @@ function changeArchive(args, flags) {
266
550
  // task (including closing steps) or any declared verification item is
267
551
  // still unchecked. This is the difference between "boxes marked" and
268
552
  // "verification passed" the framework was faulted for collapsing.
269
- // --force is the escape hatch: it archives anyway and records the gap.
270
- const force = flagBool(flags, "force", false);
271
- const blockers = collectArchiveBlockers(changeDir);
272
- if (blockers.length > 0) {
273
- if (!force) {
274
- console.error(c.red("error:") + ` refusing to archive "${id}" — verification incomplete:`);
275
- for (const b of blockers) console.error(` - ${b}`);
276
- console.error(
277
- c.gray("hint: ") +
278
- "finish and check the items, or pass --force to archive anyway (records the gap)",
279
- );
280
- return 1;
281
- }
282
- console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
283
- for (const b of blockers) console.log(c.yellow(" - ") + b);
284
- }
553
+ // Preconditions from the shared map (C6): structure AND verification.
554
+ // Reaching archive directly used to skip the structural check that the
555
+ // `close` path always ran first, so the same state was guarded
556
+ // differently depending on the route taken. --force is the escape hatch
557
+ // for both, and records the gap.
558
+ if (!enforceTransition(projectRoot, id, changeDir, "archive", flags)) return 1;
285
559
 
286
560
  const date = today();
287
561
  const archiveName = `${date}-${id}`;
@@ -369,7 +643,7 @@ function changeArchive(args, flags) {
369
643
  // and record the abandonment in the ledger so the history shows the dead end
370
644
  // instead of a silent gap. This is the missing inverse of `change new` — the
371
645
  // review had to delete a stray folder and hand-edit index.json (G6).
372
- function changeAbandon(args, flags) {
646
+ async function changeAbandon(args, flags) {
373
647
  const id = args[0];
374
648
  if (!id) {
375
649
  console.error(c.red("error:") + " change abandon requires <id>");
@@ -384,6 +658,31 @@ function changeAbandon(args, flags) {
384
658
  return 1;
385
659
  }
386
660
 
661
+ // Consent before destruction (audit item C9). This deletes work with no
662
+ // undo — the ledger line is good practice and is not a substitute for
663
+ // being asked. Off a terminal there is nobody to ask, so `--force` is
664
+ // required: silence is not consent.
665
+ const force = flagBool(flags, "force", false);
666
+ if (!force) {
667
+ const doomed = walk(changeDir);
668
+ console.log(c.yellow("About to delete") + ` ${relPath(projectRoot, changeDir)} — ${doomed.length} file${doomed.length === 1 ? "" : "s"}:`);
669
+ for (const f of doomed.slice(0, 10)) console.log(` ${c.gray(relPath(changeDir, f))}`);
670
+ if (doomed.length > 10) console.log(c.gray(` … and ${doomed.length - 10} more`));
671
+ console.log(c.gray("This cannot be undone (the change is deleted, not archived)."));
672
+
673
+ if (!isInteractive()) {
674
+ console.error("");
675
+ console.error(c.red("error:") + " refusing to delete without confirmation");
676
+ console.error(c.gray("hint: ") + `re-run with ${c.cyan("--force")} to abandon non-interactively`);
677
+ return EXIT.USAGE;
678
+ }
679
+ const ok = await confirm(`Abandon "${id}"?`, { defaultYes: false, whenNonInteractive: false });
680
+ if (!ok) {
681
+ console.log(c.gray("aborted — nothing was deleted"));
682
+ return EXIT.OK;
683
+ }
684
+ }
685
+
387
686
  const reason = flagString(flags, "reason") ?? "";
388
687
  const date = today();
389
688
 
@@ -552,32 +851,15 @@ function collectArchiveBlockers(changeDir) {
552
851
 
553
852
  const proposalPath = path.join(changeDir, "proposal.md");
554
853
  if (exists(proposalPath)) {
555
- const n = countUnchecked(extractSection(read(proposalPath), "Verification"));
854
+ const n = countUnchecked(getSection(read(proposalPath), "Verification"));
556
855
  if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
557
856
  }
558
857
  return blockers;
559
858
  }
560
859
 
561
- // Return the body of a "## <name>" section (lines after the heading up to
562
- // the next "## " heading). Empty string when the section is absent.
563
- function extractSection(text, name) {
564
- const lines = text.split(/\r?\n/);
565
- const head = new RegExp(`^##\\s+${name}\\b`, "i");
566
- let inSection = false;
567
- const out = [];
568
- for (const line of lines) {
569
- if (/^##\s+/.test(line)) {
570
- inSection = head.test(line);
571
- continue;
572
- }
573
- if (inSection) out.push(line);
574
- }
575
- return out.join("\n");
576
- }
577
-
578
860
  function ensureDoctrinaProject(projectRoot) {
579
861
  if (!exists(path.join(projectRoot, ".doctrina"))) {
580
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
862
+ throw notADoctrinaProject();
581
863
  }
582
864
  }
583
865
 
@@ -589,7 +871,7 @@ Subcommands:
589
871
  (--chore / --no-spec: a spec-less change for infra /
590
872
  docs / build that still gets a proposal + ledger;
591
873
  --design: also scaffold design.md, opt-in)
592
- apply <id> Apply spec deltas: ADDED writes the full body (it
874
+ apply <id...> Apply spec deltas: ADDED writes the full body (it
593
875
  also REPLACES a target that is still the untouched
594
876
  \`spec new\` scaffold — the canonical new-capability
595
877
  flow; real content refuses), REMOVED deletes,
@@ -597,19 +879,34 @@ Subcommands:
597
879
  (set-header / bump-version / set-criterion / ...),
598
880
  MODIFIED without one prints a manual-merge pointer.
599
881
  On any spec write the index is rebuilt from the tree.
600
- archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
882
+ 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
884
+ checks + every ops block executed in memory against
885
+ its target + the archive gate preview + an advisory
886
+ list of accepted ADRs citing the touched capabilities.
887
+ Everything close would refuse, listed BEFORE it runs.
888
+ tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
889
+ ## Verification) with ordinals; tick the given ones,
890
+ or every one with --all. No args = list only.
601
891
  abandon <id> Delete an open change folder and its index entry, and
602
892
  record the abandonment in the ledger ([--reason "..."]).
603
893
  diff <id> Preview every spec delta: line diff for MODIFIED,
604
894
  summary for ADDED/REMOVED. Read-only.
605
895
 
896
+ apply / archive / check accept multiple ids (batch close of a backlog); the
897
+ exit code is the worst per-id result.
898
+
606
899
  A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
607
900
  \`\`\`ops
608
901
  set-header Implementation: verified — durable adapter (\`src/db.ts\`)
609
902
  bump-version minor
610
903
  set-criterion 1: verified
611
904
  append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
905
+ append-requirement event: When <trigger>, the system shall <action>.
906
+ replace-requirement ubiquitous 2: The system shall <action>.
612
907
  \`\`\`
908
+ append-* ops resolve numbering/position at apply time, so concurrent open
909
+ changes appending to the same spec cannot collide.
613
910
 
614
911
  Options:
615
912
  --force Overwrite existing files (where applicable)
@@ -1,9 +1,11 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
6
+ import { flagBool, flagString } from "../lib/args.js";
6
7
  import { c } from "../lib/colors.js";
8
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
7
9
 
8
10
  // Two lexicons, one linter (0.11.0 field review item 3: an English-only
9
11
  // clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
@@ -78,16 +80,35 @@ function detectLang(text) {
78
80
  return pt > en ? "pt" : "en";
79
81
  }
80
82
 
81
- function rulesFor(projectRoot, text) {
82
- const declared = projectLanguage(projectRoot);
83
- const lang = declared ?? detectLang(text);
83
+ function rulesFor(projectRoot, text, forcedLang = null) {
84
+ const lang = forcedLang ?? projectLanguage(projectRoot) ?? detectLang(text);
84
85
  return lang === "pt" ? RULES_PT : RULES_EN;
85
86
  }
86
87
 
88
+ // --lang pt|en wins over the project config and the per-file heuristic
89
+ // (operator review 2026-07-19 §4.8: on a PT-BR project with no config.json,
90
+ // a file whose stopword count tips English gets the EN lexicon and "some" —
91
+ // the verb *sumir* — is flagged as the vague quantifier). One flag, zero
92
+ // guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
93
+ function forcedLang(flags) {
94
+ const raw = flagString(flags, "lang");
95
+ if (!raw || raw === true) return null;
96
+ const lang = String(raw).toLowerCase();
97
+ if (lang.startsWith("pt")) return "pt";
98
+ if (lang.startsWith("en")) return "en";
99
+ console.error(c.yellow("warn:") + ` unknown --lang "${raw}" (use pt|en) — falling back to detection`);
100
+ return null;
101
+ }
102
+
103
+ // Flags this command accepts. Declared HERE, with the command, so
104
+ // adding a command never requires editing the entrypoint — the gap that
105
+ // let six flags ship undeclared and silently swallow a positional (C3).
106
+ export const flags = { boolean: ["json", "all"], string: ["lang"] };
107
+
87
108
  export async function run(positional, flags) {
88
109
  const projectRoot = process.cwd();
89
110
 
90
- if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
111
+ if (flagBool(flags, "all", false)) return clarifyAll(projectRoot, forcedLang(flags));
91
112
 
92
113
  const target = positional[0];
93
114
  if (!target) {
@@ -103,7 +124,7 @@ export async function run(positional, flags) {
103
124
  console.log(`clarify ${relPath(projectRoot, fullPath)}`);
104
125
  console.log("");
105
126
 
106
- const smells = scanFile(fullPath);
127
+ const smells = scanFile(fullPath, projectRoot, forcedLang(flags));
107
128
  for (const s of smells) {
108
129
  console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
109
130
  }
@@ -122,9 +143,9 @@ export async function run(positional, flags) {
122
143
  // and clarify each. ADRs are immutable and the archive is history, so
123
144
  // neither is included; AGENTS.md is dense command text, a different
124
145
  // register from prose specs.
125
- function clarifyAll(projectRoot) {
146
+ function clarifyAll(projectRoot, lang = null) {
126
147
  if (!exists(path.join(projectRoot, ".doctrina"))) {
127
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
148
+ throw notADoctrinaProject();
128
149
  }
129
150
  const files = [];
130
151
  const productPath = path.join(projectRoot, ".doctrina", "product.md");
@@ -149,7 +170,7 @@ function clarifyAll(projectRoot) {
149
170
 
150
171
  let total = 0;
151
172
  for (const f of files) {
152
- const smells = scanFile(f);
173
+ const smells = scanFile(f, projectRoot, lang);
153
174
  if (smells.length === 0) continue;
154
175
  total += smells.length;
155
176
  const rel = relPath(projectRoot, f);
@@ -167,10 +188,10 @@ function clarifyAll(projectRoot) {
167
188
  return 1;
168
189
  }
169
190
 
170
- function scanFile(fullPath, projectRoot = process.cwd()) {
191
+ function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
171
192
  const text = read(fullPath);
172
193
  const skippedRanges = collectSkippedRanges(text);
173
- const rules = rulesFor(projectRoot, text);
194
+ const rules = rulesFor(projectRoot, text, lang);
174
195
  const smells = [];
175
196
 
176
197
  // Per-line rules: regex pass with skip-range filter. A line carrying the
@@ -257,20 +278,20 @@ function findAcceptanceLine(text) {
257
278
  }
258
279
 
259
280
  export const help = `
260
- Usage: doctrina clarify <path>
261
- doctrina clarify --all
281
+ Usage: doctrina clarify <path> [--lang pt|en]
282
+ doctrina clarify --all [--lang pt|en]
262
283
 
263
284
  Read a Markdown file and report weasel words, vague quantifiers,
264
285
  placeholders, and empty Acceptance criteria sections. Exits 0 when
265
286
  no smells are found, 1 otherwise. Matches inside fenced code
266
287
  blocks, HTML comments, and inline backtick spans are skipped.
267
288
 
268
- Language-aware: declare it in .doctrina/config.json
269
- ({ "language": "pt-BR" }) or let a per-file stopword count decide.
270
- Portuguese mode uses a PT lexicon (bare TODO is the pronoun, only
271
- "TODO:" is a marker; "some"/"several" are not smells). A line ending
272
- in <!-- clarify:ok --> is author-accepted and never flagged — the
273
- escape hatch for a false positive.
289
+ Language-aware: --lang pt|en forces the lexicon; otherwise
290
+ .doctrina/config.json ({ "language": "pt-BR" }) decides, else a
291
+ per-file stopword count. Portuguese mode uses a PT lexicon (bare
292
+ TODO is the pronoun, only "TODO:" is a marker; "some"/"several" are
293
+ not smells). A line ending in <!-- clarify:ok --> is author-accepted
294
+ and never flagged — the escape hatch for a false positive.
274
295
 
275
296
  With --all, every living document is scanned in one pass:
276
297
  product.md, capability specs, open changes, and skills. ADRs