doctrina-cli 0.13.0 → 0.15.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 (71) 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 +120 -17
  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 +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  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 +38 -3
  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 +201 -8
  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/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. package/templates/spec.md.template +22 -0
@@ -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,15 @@
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 { flagBool } from "../lib/args.js";
11
+ import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
12
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
8
13
 
9
14
  // Aggregate diagnostic: the one command to run when "something looks wrong"
10
15
  // and you do not know which gate to ask. It sequences the existing checks —
@@ -15,10 +20,15 @@ import { c } from "../lib/colors.js";
15
20
 
16
21
  const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
17
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", "env"], string: [] };
27
+
18
28
  export async function run(_positional, _flags) {
19
29
  const projectRoot = process.cwd();
20
30
  if (!exists(path.join(projectRoot, ".doctrina"))) {
21
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
31
+ throw notADoctrinaProject();
22
32
  }
23
33
 
24
34
  console.log(c.bold("Doctrina doctor") + c.gray(" — aggregate diagnostic (read-only):"));
@@ -84,15 +94,79 @@ export async function run(_positional, _flags) {
84
94
  if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
85
95
  else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
86
96
 
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;
97
+ // 5. Template shape vs the running CLI's recommendation. The row reports
98
+ // the ACTUAL findings and the remedy each one names, rather than
99
+ // assuming every failure is a missing section — an adapter pointer
100
+ // break was reported as "recommended sections/fields are missing" and
101
+ // sent the user to `templates update`, which does not touch adapters
102
+ // and so could never clear it (C2).
103
+ {
104
+ let tmplFindings = [];
105
+ try {
106
+ tmplFindings = collectFindings(projectRoot).findings;
107
+ } catch {
108
+ tmplFindings = [];
109
+ }
110
+ if (tmplFindings.length === 0) {
111
+ row("ok", "templates", "project follows the current template shape");
112
+ } else {
113
+ // Group by remedy so the row names what actually fixes what.
114
+ const byRemedy = new Map();
115
+ for (const f of tmplFindings) {
116
+ const key = f.remedy ?? "(manual repair)";
117
+ byRemedy.set(key, (byRemedy.get(key) ?? 0) + 1);
118
+ }
119
+ const summary = tmplFindings.length === 1
120
+ ? tmplFindings[0].message
121
+ : `${tmplFindings.length} template findings`;
122
+ const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
123
+ row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
124
+ warningsTotal += 1;
125
+ }
126
+ }
127
+
128
+ // 6. The RUNTIME surface (change 0029). Every row above reads Markdown;
129
+ // this one reads what the Markdown CLAIMS about the running system and
130
+ // checks it — the wiring, the empty-vs-unset default, the enum, the
131
+ // selector that would match nothing. Still a driver: the checks belong
132
+ // to lib/runtime.js and `contract check` renders the same verdict.
133
+ {
134
+ const runtime = collectRuntimeFindings(projectRoot);
135
+ const errs = runtime.findings.filter((f) => f.level === "error");
136
+ const warns = runtime.findings.length - errs.length;
137
+ if (runtime.contracts === 0) {
138
+ row("ok", "runtime", "no contracts — nothing declares a runtime surface");
139
+ } else if (runtime.declared === 0) {
140
+ // Silence is not proof: an undeclared surface is UNCHECKED, and a row
141
+ // that read "ok" here would be the exact false confidence this
142
+ // command exists to prevent.
143
+ row("warn", "runtime", `${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`, "declare Wiring/Selectors rows, then `doctrina contract check`");
144
+ warningsTotal += 1;
145
+ } else if (errs.length > 0) {
146
+ row("fail", "runtime", `${errs.length} declaration${errs.length === 1 ? "" : "s"} do not hold`, "doctrina triage (each finding names its own fix)");
147
+ for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
148
+ } else if (warns > 0) {
149
+ row("warn", "runtime", `${runtime.declared} declared rows hold; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
150
+ warningsTotal += 1;
151
+ } else {
152
+ row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} hold`);
153
+ }
93
154
  }
94
155
 
95
- // 6. Verify configuration (the real build gate must exist to be run).
156
+ // 7. The local .env, opt-in. Off by default because it reads a file that
157
+ // is not committed and is none of CI's business; when asked for, it
158
+ // reports NAMES and enum membership only and never the value.
159
+ if (flagBool(_flags, "env", false)) {
160
+ const envFindings = checkLocalEnv(projectRoot);
161
+ if (envFindings.length === 0) {
162
+ row("ok", "local .env", "matches the declared names and enums (or no .env present)");
163
+ } else {
164
+ row("fail", "local .env", `${envFindings.length} finding${envFindings.length === 1 ? "" : "s"} — config would be rejected on boot`, "fix the named variables (values are never printed)");
165
+ for (const e of envFindings.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.message}`);
166
+ }
167
+ }
168
+
169
+ // 8. Verify configuration (the real build gate must exist to be run).
96
170
  if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
97
171
  else if (!s.verify.configured) {
98
172
  row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
@@ -127,9 +201,16 @@ Usage: doctrina doctor
127
201
 
128
202
  Aggregate diagnostic: run the structural gate (validate), the index
129
203
  drift check, the coverage/trace ratios, the clean-checkout lint
130
- (verify --clean), the template-shape check, and the verify-config
131
- presence — each reported with its exact remediation command.
204
+ (verify --clean), the template-shape check, the RUNTIME surface (the
205
+ declared wiring, enums and selectors, checked against the code and
206
+ workflows), and the verify-config presence — each reported with its
207
+ exact remediation command.
208
+
209
+ --env also check the local .env against the declared names and
210
+ enums. Reports membership only; a rejected value is never
211
+ printed, so this is safe to run and to paste.
132
212
 
133
- A driver over the existing commands; it adds no checks of its own.
213
+ A driver over the existing commands and lib/runtime.js; it adds no
214
+ checks of its own, so it can never disagree with the gates it fronts.
134
215
  Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
135
216
  `;
@@ -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;