doctrina-cli 0.13.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 (66) 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 +66 -19
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  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 +139 -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 +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +8 -1
  34. package/src/commands/validate.js +58 -2
  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 +8 -1
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +1 -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 +239 -26
  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 +94 -9
  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/AGENTS.md.template +59 -53
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
@@ -6,6 +7,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
6
7
  import { specHeader } from "../lib/scan.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
8
9
  import { c } from "../lib/colors.js";
10
+ import { emitJson } from "../lib/json-out.js";
11
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
9
12
 
10
13
  // Traceability report: how many acceptance criteria cite an artifact or
11
14
  // test that actually exists on disk. Doctrina otherwise has no link
@@ -22,10 +25,19 @@ import { c } from "../lib/colors.js";
22
25
  // proves nothing — review G3: existence ≠ a passing test), "dangling" when
23
26
  // a cited path does not resolve, and "bare" when nothing is cited.
24
27
 
28
+ // Flags this command accepts. Declared HERE, with the command, so
29
+ // adding a command never requires editing the entrypoint — the gap that
30
+ // let six flags ship undeclared and silently swallow a positional (C3).
31
+ // This command builds its own JSON payload; the entrypoint must not
32
+ // wrap it in the generic envelope.
33
+ export const jsonNative = true;
34
+
35
+ export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
36
+
25
37
  export async function run(_positional, flags) {
26
38
  const projectRoot = process.cwd();
27
39
  if (!exists(path.join(projectRoot, ".doctrina"))) {
28
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
40
+ throw notADoctrinaProject();
29
41
  }
30
42
  const strict = flagBool(flags, "strict", false);
31
43
  const json = flagBool(flags, "json", false);
@@ -48,7 +60,7 @@ export async function run(_positional, flags) {
48
60
 
49
61
  if (reports.length === 0) {
50
62
  if (json) {
51
- console.log(JSON.stringify({ specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } }, null, 2));
63
+ emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } });
52
64
  return 0;
53
65
  }
54
66
  console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
@@ -72,10 +84,10 @@ export async function run(_positional, flags) {
72
84
  const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
73
85
 
74
86
  if (json) {
75
- console.log(JSON.stringify({
87
+ emitJson("coverage", {
76
88
  specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
77
89
  summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
78
- }, null, 2));
90
+ });
79
91
  return jsonClean ? 0 : strict ? 1 : 0;
80
92
  }
81
93
 
@@ -1,16 +1,26 @@
1
+ // @ts-check
2
+ import { getHeader, setHeader } from "../lib/doc-model.js";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { exists, read, relPath, write } from "../lib/fs-ops.js";
4
6
  import { walk } from "../lib/fs-ops.js";
5
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
7
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
6
8
  import * as idx from "../lib/index-json.js";
7
9
  import { today, slugify, padNumber } from "../lib/dates.js";
8
10
  import { c } from "../lib/colors.js";
9
11
  import { suggest } from "../lib/suggest.js";
12
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
13
+ import { flagBool } from "../lib/args.js";
14
+ import { parseAdrScope, decisionEntry } from "../lib/scan.js";
10
15
 
11
- const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list"];
16
+ const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list", "scope"];
12
17
 
13
- export async function run(positional, _flags) {
18
+ // Flags this command accepts. Declared HERE, with the command, so
19
+ // adding a command never requires editing the entrypoint — the gap that
20
+ // let six flags ship undeclared and silently swallow a positional (C3).
21
+ export const flags = { boolean: ["json", "write"], string: [] };
22
+
23
+ export async function run(positional, cmdFlags) {
14
24
  const sub = positional[0];
15
25
  switch (sub) {
16
26
  case "new":
@@ -21,6 +31,8 @@ export async function run(positional, _flags) {
21
31
  return decisionAccept(positional.slice(1));
22
32
  case "land":
23
33
  return decisionLand(positional.slice(1));
34
+ case "scope":
35
+ return decisionScope(positional.slice(1), cmdFlags);
24
36
  case "list":
25
37
  return decisionList();
26
38
  default:
@@ -52,8 +64,7 @@ function decisionNew(args) {
52
64
  return 1;
53
65
  }
54
66
 
55
- const templatesDir = locateTemplatesDir();
56
- const tpl = read(path.join(templatesDir, "decision.md.template"));
67
+ const tpl = readTemplate(projectRoot, "decision.md.template").body;
57
68
  const body = substitute(tpl, {
58
69
  DECISION_NUMBER: next,
59
70
  DECISION_TITLE: title,
@@ -63,14 +74,11 @@ function decisionNew(args) {
63
74
  write(targetPath, body, { force: false });
64
75
  console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
65
76
 
77
+ // Register through the SAME deriver the index rebuild uses. Assembling a
78
+ // look-alike entry here is how a field added to the deriver turns every
79
+ // freshly created ADR into index drift the moment it is written.
66
80
  const index = idx.load(projectRoot);
67
- idx.addDecision(index, {
68
- id: next,
69
- path: `.doctrina/decisions/${filename}`,
70
- title,
71
- status: "proposed",
72
- date,
73
- });
81
+ idx.addDecision(index, decisionEntry(body, filename, null, date));
74
82
  idx.touch(index, date);
75
83
  idx.save(projectRoot, index);
76
84
  console.log(c.green("indexed") + ` decision ${next}`);
@@ -95,12 +103,12 @@ function decisionSupersede(args) {
95
103
  return 1;
96
104
  }
97
105
  const oldText = read(oldFile);
98
- const oldStatusMatch = oldText.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
99
- if (!oldStatusMatch) {
106
+ const oldStatusValue = getHeader(oldText, "Status");
107
+ if (oldStatusValue === null) {
100
108
  console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, oldFile)} has no Status: header`);
101
109
  return 1;
102
110
  }
103
- const oldStatus = oldStatusMatch[1].trim();
111
+ const oldStatus = oldStatusValue;
104
112
  if (oldStatus.toLowerCase().startsWith("superseded")) {
105
113
  console.error(c.red("error:") + ` ADR ${padded} is already superseded`);
106
114
  return 1;
@@ -132,23 +140,17 @@ function decisionSupersede(args) {
132
140
  write(newPath, body, { force: false });
133
141
  console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
134
142
 
135
- // Mutate ONLY the Status: and Superseded by: headers of the old ADR
136
- const updated = oldText
137
- .replace(/^(-\s+\*\*Status:\*\*)\s+.+$/m, `$1 superseded by ${next}`)
138
- .replace(/^(-\s+\*\*Superseded by:\*\*)\s+.+$/m, `$1 ${next}`);
143
+ // Mutate ONLY the Status: and Superseded by: headers of the old ADR.
144
+ // Both go through the document model, so an ADR written with a slightly
145
+ // off header form is still updated rather than silently skipped (M3).
146
+ const withStatus = setHeader(oldText, "Status", `superseded by ${next}`) ?? oldText;
147
+ const updated = setHeader(withStatus, "Superseded by", String(next)) ?? withStatus;
139
148
  write(oldFile, updated, { force: true });
140
149
  console.log(c.green("updated") + ` ${relPath(projectRoot, oldFile)} status -> superseded by ${next}`);
141
150
 
142
151
  // Update index
143
152
  const index = idx.load(projectRoot);
144
- idx.addDecision(index, {
145
- id: next,
146
- path: `.doctrina/decisions/${filename}`,
147
- title,
148
- status: "proposed",
149
- date,
150
- supersedes: padded,
151
- });
153
+ idx.addDecision(index, decisionEntry(body, filename, null, date));
152
154
  idx.updateDecision(index, padded, () => ({
153
155
  status: `superseded by ${next}`,
154
156
  superseded_by: next,
@@ -176,19 +178,19 @@ function decisionAccept(args) {
176
178
  return 1;
177
179
  }
178
180
  const text = read(file);
179
- const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
180
- if (!statusMatch) {
181
+ const statusValue = getHeader(text, "Status");
182
+ if (statusValue === null) {
181
183
  console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
182
184
  return 1;
183
185
  }
184
- const status = statusMatch[1].trim().toLowerCase();
186
+ const status = statusValue.toLowerCase();
185
187
  if (status !== "proposed") {
186
- console.error(c.red("error:") + ` ADR ${padded} is "${statusMatch[1].trim()}", not "proposed" — nothing to accept`);
188
+ console.error(c.red("error:") + ` ADR ${padded} is "${statusValue}", not "proposed" — nothing to accept`);
187
189
  return 1;
188
190
  }
189
191
 
190
192
  // Mutate ONLY the Status: header; the body stays immutable.
191
- write(file, text.replace(/^(-\s+\*\*Status:\*\*)\s+.+$/m, "$1 accepted"), { force: true });
193
+ write(file, setHeader(text, "Status", "accepted") ?? text, { force: true });
192
194
  console.log(c.green("accepted") + ` ${relPath(projectRoot, file)}`);
193
195
 
194
196
  const date = today();
@@ -224,16 +226,16 @@ function decisionLand(args) {
224
226
  return 1;
225
227
  }
226
228
  const text = read(file);
227
- const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
228
- if (!statusMatch) {
229
+ const statusValue = getHeader(text, "Status");
230
+ if (statusValue === null) {
229
231
  console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
230
232
  return 1;
231
233
  }
232
- const status = statusMatch[1].trim().toLowerCase();
234
+ const status = statusValue.toLowerCase();
233
235
  if (status !== "accepted") {
234
236
  console.error(
235
237
  c.red("error:") +
236
- ` ADR ${padded} is "${statusMatch[1].trim()}", not "accepted" — accept it before recording that it landed`,
238
+ ` ADR ${padded} is "${statusValue}", not "accepted" — accept it before recording that it landed`,
237
239
  );
238
240
  return 1;
239
241
  }
@@ -251,7 +253,7 @@ function decisionLand(args) {
251
253
  } else if (/^-\s+\*\*Evidence:\*\*.*$/m.test(text)) {
252
254
  updated = text.replace(/^(-\s+\*\*Evidence:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
253
255
  } else {
254
- updated = text.replace(/^(-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
256
+ updated = text.replace(/^(\s*-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
255
257
  }
256
258
  write(file, updated, { force: true });
257
259
  console.log(c.green("landed") + ` ${relPath(projectRoot, file)} on ${date}`);
@@ -286,11 +288,11 @@ function decisionList() {
286
288
  if (!m) continue;
287
289
  const text = read(f);
288
290
  const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
289
- const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
291
+ const listedStatus = getHeader(text, "Status");
290
292
  const dateMatch = text.match(/^-\s+\*\*Date:\*\*\s+(\S+)/m);
291
293
  rows.push({
292
294
  id: m[1],
293
- status: statusMatch ? statusMatch[1].trim() : "?",
295
+ status: listedStatus ?? "?",
294
296
  date: dateMatch ? dateMatch[1] : "—",
295
297
  title: titleMatch ? titleMatch[1].trim() : path.basename(f),
296
298
  });
@@ -309,6 +311,127 @@ function decisionList() {
309
311
  return 0;
310
312
  }
311
313
 
314
+ // Propose — or, with --write, apply — the capability scope of an ADR.
315
+ //
316
+ // An unscoped ADR is GLOBAL: it loads into every context pack, forever,
317
+ // because ADRs are immutable and never retire. That is what makes the
318
+ // default read pack grow with the project's age instead of with the task
319
+ // at hand. Scoping is the fix, but only if it gets adopted, and nobody
320
+ // hand-annotates forty immutable documents.
321
+ //
322
+ // So the scope is SUGGESTED from evidence the tree already holds: the
323
+ // archived change that introduced an ADR records which specs it touched
324
+ // (`changes_archive[].specs_affected`), and the ADR's own text names the
325
+ // capabilities it governs. Both are hints. The agent or human confirms —
326
+ // the tool proposes, it never decides (ADR 0005).
327
+ function decisionScope(args, cmdFlags) {
328
+ const projectRoot = process.cwd();
329
+ ensureDoctrinaProject(projectRoot);
330
+ const doWrite = flagBool(cmdFlags, "write", false);
331
+
332
+ const index = idx.load(projectRoot);
333
+ const capabilities = new Set((index.artifacts.specs ?? []).map((s) => s.id));
334
+ const archive = index.artifacts.changes_archive ?? [];
335
+ const wanted = args[0] ? padNumber(parseInt(args[0], 10)) : null;
336
+
337
+ const rows = [];
338
+ for (const dec of index.artifacts.decisions ?? []) {
339
+ if (wanted && dec.id !== wanted) continue;
340
+ const file = path.join(projectRoot, dec.path);
341
+ if (!exists(file)) continue;
342
+ const text = read(file);
343
+ const declared = parseAdrScope(text);
344
+
345
+ // Signal 1 — the archived change that cites this ADR, and the specs
346
+ // that change touched. Strongest evidence: it is what actually moved.
347
+ const fromLedger = new Set();
348
+ for (const ch of archive) {
349
+ if (!archivedChangeCites(projectRoot, ch, dec.id)) continue;
350
+ for (const s of ch.specs_affected ?? []) {
351
+ if (capabilities.has(s.capability)) fromLedger.add(s.capability);
352
+ }
353
+ }
354
+ // Signal 2 — capability ids the ADR's own text names, and only as a
355
+ // FALLBACK. Capability ids are ordinary words (`cli`, `core`, `docs`),
356
+ // so on a mature tree this signal matches nearly every ADR and a scope
357
+ // of "everything" is the same as no scope at all. The ledger records
358
+ // what actually moved; text only guesses. When the ledger has an
359
+ // answer, it is the answer.
360
+ const fromText = new Set();
361
+ if (fromLedger.size === 0) {
362
+ for (const cap of capabilities) {
363
+ const escaped = cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
364
+ if (new RegExp(`(^|[^a-z0-9-])${escaped}([^a-z0-9-]|$)`, "im").test(text)) {
365
+ fromText.add(cap);
366
+ }
367
+ }
368
+ }
369
+ const viaLedger = fromLedger.size > 0;
370
+ const suggested = [...(viaLedger ? fromLedger : fromText)].sort();
371
+ rows.push({ dec, file, text, declared, suggested, viaLedger });
372
+ }
373
+
374
+ if (rows.length === 0) {
375
+ console.log(c.gray(wanted ? `no ADR ${wanted}` : "no ADRs found in .doctrina/decisions/"));
376
+ return 0;
377
+ }
378
+
379
+ console.log(c.bold("ADR scope") + c.gray(" — an unscoped ADR is global: it loads into every pack"));
380
+ console.log("");
381
+ let wrote = 0;
382
+ for (const r of rows) {
383
+ const have = r.declared.length > 0
384
+ ? c.green(r.declared.join(", "))
385
+ : c.gray("(global)");
386
+ console.log(` ${c.cyan(r.dec.id)} ${String(r.dec.title ?? "").slice(0, 48).padEnd(48)} ${have}`);
387
+ if (r.declared.length > 0 || r.suggested.length === 0) continue;
388
+ const via = r.viaLedger ? "ledger" : "text — confirm before writing";
389
+ console.log(` ${c.yellow("suggest:")} ${r.suggested.join(", ")} ${c.gray(`(${via})`)}`);
390
+ if (!doWrite) continue;
391
+ const updated = insertScopeHeader(r.text, r.suggested);
392
+ if (updated) {
393
+ write(r.file, updated, { force: true });
394
+ wrote += 1;
395
+ console.log(` ${c.green("written")}`);
396
+ }
397
+ }
398
+
399
+ console.log("");
400
+ if (wrote > 0) {
401
+ console.log(c.green("ok") + ` scoped ${wrote} ADR${wrote === 1 ? "" : "s"}`);
402
+ console.log(c.gray("next: ") + c.cyan("doctrina index rebuild") + c.gray(" then review — a scope too narrow"));
403
+ console.log(c.gray(" hides a decision from the pack that needs it."));
404
+ return 0;
405
+ }
406
+ const open = rows.filter((r) => r.declared.length === 0);
407
+ const actionable = open.filter((r) => r.suggested.length > 0).length;
408
+ console.log(c.gray(`${open.length} of ${rows.length} unscoped (global)`)
409
+ + (actionable > 0 ? c.gray(`, ${actionable} with a suggestion`) : ""));
410
+ if (!doWrite && actionable > 0) {
411
+ console.log(c.gray("apply: ") + c.cyan("doctrina decision scope --write"));
412
+ }
413
+ return 0;
414
+ }
415
+
416
+ // Does an archived change cite this ADR anywhere in its folder?
417
+ function archivedChangeCites(projectRoot, archived, adrId) {
418
+ if (!archived.path) return false;
419
+ const dir = path.join(projectRoot, archived.path);
420
+ for (const f of walk(dir)) {
421
+ if (f.endsWith(".md") && read(f).includes(`ADR ${adrId}`)) return true;
422
+ }
423
+ return false;
424
+ }
425
+
426
+ // Place `- **Scope:** a, b` in the metadata block, right after Status —
427
+ // where a reader looks for what a decision applies to.
428
+ function insertScopeHeader(text, scope) {
429
+ if (getHeader(text, "Scope") !== null) return null;
430
+ const line = `- **Scope:** ${scope.join(", ")}`;
431
+ const updated = text.replace(/^(-[ \t]+\*\*Status:\*\*.*)$/m, `$1\n${line}`);
432
+ return updated === text ? null : updated;
433
+ }
434
+
312
435
  function nextDecisionNumber(projectRoot) {
313
436
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
314
437
  const files = walk(adrDir);
@@ -325,7 +448,7 @@ function nextDecisionNumber(projectRoot) {
325
448
 
326
449
  function ensureDoctrinaProject(projectRoot) {
327
450
  if (!exists(path.join(projectRoot, ".doctrina"))) {
328
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
451
+ throw notADoctrinaProject();
329
452
  }
330
453
  }
331
454
 
@@ -345,4 +468,13 @@ Subcommands:
345
468
  and rewrite the target's Status: header
346
469
  list One line per ADR: number, status, date,
347
470
  title. Read-only.
471
+ scope [<number>] [--write] Show which capabilities each ADR governs
472
+ and propose one for the unscoped, from the
473
+ archived change that cites it. An unscoped
474
+ ADR is global: it loads into every context
475
+ pack. --write applies the suggestions.
476
+
477
+ Options:
478
+ --write scope: apply the suggested scopes
479
+ --json Machine-readable output
348
480
  `;
@@ -1,10 +1,13 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { exists } from "../lib/fs-ops.js";
6
7
  import { collectStatus } from "./status.js";
8
+ import { collectFindings } from "./templates.js";
7
9
  import { c } from "../lib/colors.js";
10
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
8
11
 
9
12
  // Aggregate diagnostic: the one command to run when "something looks wrong"
10
13
  // and you do not know which gate to ask. It sequences the existing checks —
@@ -15,10 +18,15 @@ import { c } from "../lib/colors.js";
15
18
 
16
19
  const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
17
20
 
21
+ // Flags this command accepts. Declared HERE, with the command, so
22
+ // adding a command never requires editing the entrypoint — the gap that
23
+ // let six flags ship undeclared and silently swallow a positional (C3).
24
+ export const flags = { boolean: ["json"], string: [] };
25
+
18
26
  export async function run(_positional, _flags) {
19
27
  const projectRoot = process.cwd();
20
28
  if (!exists(path.join(projectRoot, ".doctrina"))) {
21
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
29
+ throw notADoctrinaProject();
22
30
  }
23
31
 
24
32
  console.log(c.bold("Doctrina doctor") + c.gray(" — aggregate diagnostic (read-only):"));
@@ -84,12 +92,35 @@ export async function run(_positional, _flags) {
84
92
  if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
85
93
  else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
86
94
 
87
- // 5. Template shape vs the running CLI's recommendation.
88
- const tmpl = runSelf(["templates", "check"], projectRoot);
89
- if (tmpl.status === 0) row("ok", "templates", "project follows the current template shape");
90
- else {
91
- row("warn", "templates", "recommended sections/fields are missing", "doctrina templates update (--write applies)");
92
- warningsTotal += 1;
95
+ // 5. Template shape vs the running CLI's recommendation. The row reports
96
+ // the ACTUAL findings and the remedy each one names, rather than
97
+ // assuming every failure is a missing section — an adapter pointer
98
+ // break was reported as "recommended sections/fields are missing" and
99
+ // sent the user to `templates update`, which does not touch adapters
100
+ // and so could never clear it (C2).
101
+ {
102
+ let tmplFindings = [];
103
+ try {
104
+ tmplFindings = collectFindings(projectRoot).findings;
105
+ } catch {
106
+ tmplFindings = [];
107
+ }
108
+ if (tmplFindings.length === 0) {
109
+ row("ok", "templates", "project follows the current template shape");
110
+ } else {
111
+ // Group by remedy so the row names what actually fixes what.
112
+ const byRemedy = new Map();
113
+ for (const f of tmplFindings) {
114
+ const key = f.remedy ?? "(manual repair)";
115
+ byRemedy.set(key, (byRemedy.get(key) ?? 0) + 1);
116
+ }
117
+ const summary = tmplFindings.length === 1
118
+ ? tmplFindings[0].message
119
+ : `${tmplFindings.length} template findings`;
120
+ const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
121
+ row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
122
+ warningsTotal += 1;
123
+ }
93
124
  }
94
125
 
95
126
  // 6. Verify configuration (the real build gate must exist to be run).
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { exists } from "../lib/fs-ops.js";
@@ -6,6 +7,7 @@ import { computeActions } from "./next.js";
6
7
  import { openChanges } from "./prime.js";
7
8
  import { today } from "../lib/dates.js";
8
9
  import { c } from "../lib/colors.js";
10
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
9
11
 
10
12
  // Session handoff note: everything the NEXT session (a fresh agent, a
11
13
  // teammate, tomorrow's you) needs to resume without re-reading the tree —
@@ -16,10 +18,15 @@ import { c } from "../lib/colors.js";
16
18
  // a stored file: a saved note goes stale the moment work continues, the
17
19
  // tree never does (no new home for facts).
18
20
 
21
+ // Flags this command accepts. Declared HERE, with the command, so
22
+ // adding a command never requires editing the entrypoint — the gap that
23
+ // let six flags ship undeclared and silently swallow a positional (C3).
24
+ export const flags = { boolean: ["json"], string: [] };
25
+
19
26
  export async function run(_positional, _flags) {
20
27
  const projectRoot = process.cwd();
21
28
  if (!exists(path.join(projectRoot, ".doctrina"))) {
22
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
29
+ throw notADoctrinaProject();
23
30
  }
24
31
 
25
32
  const s = collectStatus(projectRoot);
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { chmodSync } from "node:fs";
@@ -9,6 +10,11 @@ import { suggest } from "../lib/suggest.js";
9
10
 
10
11
  const SUBCOMMANDS = ["install"];
11
12
 
13
+ // Flags this command accepts. Declared HERE, with the command, so
14
+ // adding a command never requires editing the entrypoint — the gap that
15
+ // let six flags ship undeclared and silently swallow a positional (C3).
16
+ export const flags = { boolean: ["json", "force"], string: [] };
17
+
12
18
  export async function run(positional, flags) {
13
19
  const sub = positional[0];
14
20
  if (!SUBCOMMANDS.includes(sub)) {
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { exists } from "../lib/fs-ops.js";
@@ -8,9 +9,15 @@ import { today } from "../lib/dates.js";
8
9
  import { flagBool } from "../lib/args.js";
9
10
  import { c } from "../lib/colors.js";
10
11
  import { suggest } from "../lib/suggest.js";
12
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
11
13
 
12
14
  const SUBCOMMANDS = ["rebuild"];
13
15
 
16
+ // Flags this command accepts. Declared HERE, with the command, so
17
+ // adding a command never requires editing the entrypoint — the gap that
18
+ // let six flags ship undeclared and silently swallow a positional (C3).
19
+ export const flags = { boolean: ["json", "check"], string: [] };
20
+
14
21
  export async function run(positional, flags) {
15
22
  const sub = positional[0];
16
23
  if (sub !== "rebuild") {
@@ -28,7 +35,7 @@ function rebuild(flags) {
28
35
  const check = flagBool(flags, "check", false);
29
36
  const projectRoot = process.cwd();
30
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
31
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
38
+ throw notADoctrinaProject();
32
39
  }
33
40
 
34
41
  let current = null;