doctrina-cli 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- import { getHeader, setHeader } from "../lib/doc-model.js";
2
+ import { unwrittenSections, getHeader, setHeader } from "../lib/doc-model.js";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { exists, read, relPath, write } from "../lib/fs-ops.js";
@@ -9,7 +9,7 @@ import * as idx from "../lib/index-json.js";
9
9
  import { today, slugify, padNumber } from "../lib/dates.js";
10
10
  import { c } from "../lib/colors.js";
11
11
  import { suggest } from "../lib/suggest.js";
12
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
13
  import { flagBool } from "../lib/args.js";
14
14
  import { parseAdrScope, decisionEntry } from "../lib/scan.js";
15
15
 
@@ -100,7 +100,7 @@ function decisionSupersede(args) {
100
100
  const oldFile = files.find((f) => path.basename(f).startsWith(`${padded}-`));
101
101
  if (!oldFile) {
102
102
  console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
103
- return 1;
103
+ return EXIT.USAGE;
104
104
  }
105
105
  const oldText = read(oldFile);
106
106
  const oldStatusValue = getHeader(oldText, "Status");
@@ -113,6 +113,14 @@ function decisionSupersede(args) {
113
113
  console.error(c.red("error:") + ` ADR ${padded} is already superseded`);
114
114
  return 1;
115
115
  }
116
+ // Only a decision that HELD can be superseded (change 0109). A proposed
117
+ // ADR was never a rule; superseding it builds a chain of decisions that
118
+ // never applied. A proposal that fell is edited to `rejected`, or deleted.
119
+ if (oldStatus.trim().toLowerCase() !== "accepted") {
120
+ console.error(c.red("error:") + ` ADR ${padded} is "${oldStatus.trim()}", not "accepted" — only an accepted decision is superseded`);
121
+ console.error(c.gray("hint: ") + "a proposal that fell is not superseded: set its Status: to rejected, or delete the file");
122
+ return 1;
123
+ }
116
124
 
117
125
  // Title for new ADR is read from argv after the target number, or prompted.
118
126
  const title = args.slice(1).join(" ").trim();
@@ -120,6 +128,13 @@ function decisionSupersede(args) {
120
128
  console.error(c.red("error:") + " supply the new ADR title as the second argument");
121
129
  return 2;
122
130
  }
131
+ // A title that is only digits is the argument order swapped, not a
132
+ // decision (change 0109): `supersede 0001 0002` made an ADR titled "0002".
133
+ if (/^\d+$/.test(title)) {
134
+ console.error(c.red("error:") + ` "${title}" is a number, not a title`);
135
+ console.error(c.gray("hint: ") + "the grammar is `doctrina decision supersede <number> \"<title>\"` — the target first, then the new ADR's title");
136
+ return 2;
137
+ }
123
138
 
124
139
  // Create the new ADR using the same path the `new` flow uses.
125
140
  const next = nextDecisionNumber(projectRoot);
@@ -137,8 +152,20 @@ function decisionSupersede(args) {
137
152
  });
138
153
  // Inject Supersedes link
139
154
  body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
155
+ // Carry the predecessor's Scope. A successor built from the bare template
156
+ // had none, so it was GLOBAL: refining a decision that governed one
157
+ // capability, then accepting the refinement, loaded it into every
158
+ // capability's pack — ones the original never governed — and nothing said
159
+ // so. A replacement decides the same subject; its scope starts where the
160
+ // predecessor's was, written into the file where the author can widen it
161
+ // on purpose (ADR 0022: a pack is retrieval, not a dump).
162
+ const inheritedScope = parseAdrScope(oldText);
163
+ if (inheritedScope.length > 0) body = insertScopeHeader(body, inheritedScope) ?? body;
140
164
  write(newPath, body, { force: false });
141
165
  console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
166
+ if (inheritedScope.length > 0) {
167
+ console.log(c.gray("scope: ") + `inherited from ${padded} — ${inheritedScope.join(", ")} (edit the Scope: header to change it)`);
168
+ }
142
169
 
143
170
  // Mutate ONLY the Status: and Superseded by: headers of the old ADR.
144
171
  // Both go through the document model, so an ADR written with a slightly
@@ -175,7 +202,7 @@ function decisionAccept(args) {
175
202
  const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
176
203
  if (!file) {
177
204
  console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
178
- return 1;
205
+ return EXIT.USAGE;
179
206
  }
180
207
  const text = read(file);
181
208
  const statusValue = getHeader(text, "Status");
@@ -189,13 +216,38 @@ function decisionAccept(args) {
189
216
  return 1;
190
217
  }
191
218
 
219
+ // An accepted ADR is IMMUTABLE, becomes a standing rule in `prime --rules`,
220
+ // and loads into every context pack it is scoped to (ADR 0022). Accepting
221
+ // one whose body is still the template was accepting a decision nobody had
222
+ // written down (change 0065). `analyze` has had exactly this guard for a
223
+ // change proposal since the beginning; the more consequential document was
224
+ // the one without it.
225
+ const unwritten = unwrittenSections(text, ["Context", "Decision", "Consequences"], {
226
+ template: "decision.md.template",
227
+ });
228
+ if (unwritten.length > 0) {
229
+ console.error(c.red("error:") +
230
+ ` ADR ${padded} still carries the template in ${unwritten.map((x) => `## ${x}`).join(", ")}`);
231
+ console.error(c.gray("hint: ") +
232
+ `write the decision before accepting it — an accepted ADR is immutable, becomes a ` +
233
+ `standing rule, and loads into every pack it governs (${relPath(projectRoot, file)})`);
234
+ return 1;
235
+ }
236
+
192
237
  // Mutate ONLY the Status: header; the body stays immutable.
193
238
  write(file, setHeader(text, "Status", "accepted") ?? text, { force: true });
194
239
  console.log(c.green("accepted") + ` ${relPath(projectRoot, file)}`);
195
240
 
196
241
  const date = today();
197
242
  const index = idx.load(projectRoot);
198
- idx.updateDecision(index, padded, () => ({ status: "accepted" }));
243
+ // Re-derive the whole entry, not just the status. Between `decision new` and
244
+ // `decision accept` the author writes the body — which is now REQUIRED, so
245
+ // it is the normal flow, not the exception — and the entry's summary and
246
+ // scope are derived from that body. Updating only the status left the index
247
+ // holding the scaffold's summary, and `validate` reported drift the author
248
+ // had no reason to expect (change 0065).
249
+ const accepted = decisionEntry(read(file), path.basename(file), null, date);
250
+ idx.updateDecision(index, padded, () => accepted);
199
251
  idx.touch(index, date);
200
252
  idx.save(projectRoot, index);
201
253
  console.log(c.green("indexed") + ` decision ${padded} -> accepted`);
@@ -223,7 +275,7 @@ function decisionLand(args) {
223
275
  const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
224
276
  if (!file) {
225
277
  console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
226
- return 1;
278
+ return EXIT.USAGE;
227
279
  }
228
280
  const text = read(file);
229
281
  const statusValue = getHeader(text, "Status");
@@ -372,7 +424,14 @@ function decisionScope(args, cmdFlags) {
372
424
  }
373
425
 
374
426
  if (rows.length === 0) {
375
- console.log(c.gray(wanted ? `no ADR ${wanted}` : "no ADRs found in .doctrina/decisions/"));
427
+ // A number that names no ADR is a wrong invocation, not an empty tree:
428
+ // reporting it as success was the one place in this family that
429
+ // APPROVED an unresolvable reference (change 0093).
430
+ if (wanted) {
431
+ console.error(c.red("error:") + ` no ADR ${wanted} under .doctrina/decisions/`);
432
+ return EXIT.USAGE;
433
+ }
434
+ console.log(c.gray("no ADRs found in .doctrina/decisions/"));
376
435
  return 0;
377
436
  }
378
437
 
@@ -1,24 +1,40 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import { exists } from "../lib/fs-ops.js";
7
- import { collectStatus } from "./status.js";
8
- import { collectFindings } from "./templates.js";
4
+ import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
5
+ import { collectStatus } from "../lib/snapshot.js";
6
+ import { collectFindings, surfaceBudget } from "../lib/templates-model.js";
9
7
  import { c } from "../lib/colors.js";
10
8
  import { flagBool } from "../lib/args.js";
11
9
  import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
10
+ import { sequence, stepRerun } from "../lib/gates.js";
12
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { agentsMdBudget, collectValidation } from "../lib/validation-model.js";
13
+ import { collectIndexDrift } from "../lib/scan.js";
14
+ import { collectReproducibility } from "../lib/reproducibility.js";
15
+ import { configRows, loadConfig } from "../lib/config.js";
16
+ import { USAGE_ENV, summarise } from "../lib/usage.js";
17
+ import { OPERATIONS } from "../lib/commands.js";
13
18
 
19
+ import { coverageLabel } from "../lib/views.js";
20
+
21
+ // How many never-invoked operations the usage row names before pointing at
22
+ // `metrics --commands` for the rest. The spec states this number, so it is
23
+ // named here rather than repeated as a literal in three places.
24
+ const UNUSED_OPS_LISTED = 8;
14
25
  // Aggregate diagnostic: the one command to run when "something looks wrong"
15
26
  // and you do not know which gate to ask. It sequences the existing checks —
16
27
  // validate, index drift, clean-checkout lint, template shape — and reports
17
28
  // each with its exact remediation command. A DRIVER over existing commands
18
29
  // (like `close`): it adds no checks of its own, so it can never disagree
19
30
  // with the authoritative gates it fronts.
20
-
21
- const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
31
+ //
32
+ // Every row is a COLLECTION read in this process (change 0045). Three of them
33
+ // used to be a `spawnSync` of this same binary with `--json`, whose output
34
+ // this command then parsed — two integration styles inside one command, three
35
+ // extra Node processes per run, and a failure mode — the report that never
36
+ // arrived — that existed only because of the choice. A driver calls its
37
+ // collectors.
22
38
 
23
39
  // Flags this command accepts. Declared HERE, with the command, so
24
40
  // adding a command never requires editing the entrypoint — the gap that
@@ -43,73 +59,125 @@ export async function run(_positional, _flags) {
43
59
  if (state === "fail") failures += 1;
44
60
  };
45
61
 
46
- // 1. Structural gate (validate), machine-read so the counts are exact.
47
- const v = runSelf(["validate", "--json"], projectRoot);
48
- let vParsed = null;
49
- try {
50
- vParsed = JSON.parse(v.stdout);
51
- } catch {
52
- vParsed = null;
53
- }
54
- if (!vParsed) {
55
- row("fail", "validate", "did not produce a report", "doctrina validate");
56
- } else if (!vParsed.ok) {
57
- row("fail", "validate", `${vParsed.errors.length} error${vParsed.errors.length === 1 ? "" : "s"}, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (then `doctrina validate --fix` for index drift)");
58
- for (const e of vParsed.errors.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e}`);
59
- } else if (vParsed.warnings.length > 0) {
60
- warningsTotal += vParsed.warnings.length;
61
- row("warn", "validate", `0 errors, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (warnings listed there)");
62
- } else {
63
- row("ok", "validate", "all structural checks pass");
64
- }
62
+ // status is read once and shared: three rows below are views of the same
63
+ // collection, and re-deriving it per row would make them disagree.
64
+ const s = collectStatus(projectRoot);
65
65
 
66
- // 2. Index ↔ tree drift.
67
- const drift = runSelf(["index", "rebuild", "--check"], projectRoot);
68
- if (drift.status === 0) row("ok", "index", "index.json matches the tree");
69
- else row("fail", "index", "index.json has drifted from the tree", "doctrina validate --fix (or `doctrina index rebuild`)");
66
+ // How each declared diagnostic row renders, keyed by the step id in
67
+ // SEQUENCES.doctor. As in `close`, the DECLARATION owns which rows exist
68
+ // and in what order (lib/gates.js, audit finding F1); this table owns only
69
+ // how one row is computed. A step with no reporter here falls back to
70
+ // running its declared argv and reporting the exit code, so a gate added to
71
+ // the sequence shows up as a row without a second edit.
72
+ const reporters = {
70
73
 
71
- // 3. Evidence and provenance ratios (cheap summaries; strict gates confirm).
72
- const s = collectStatus(projectRoot);
73
- if (s.coverage.totalCriteria === 0) {
74
- row("warn", "coverage", "no acceptance criteria declared yet", "write criteria with evidence — see `doctrina coverage`");
75
- warningsTotal += 1;
76
- } else if (s.coverage.pct === 100 && s.coverage.totalDangling === 0 && s.coverage.totalConditional === 0) {
77
- row("ok", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)`);
78
- } else {
79
- row("warn", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria}${s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : ""}${s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : ""})`, "doctrina coverage (cite the missing evidence)");
80
- warningsTotal += 1;
81
- }
82
- if (s.trace.anchors === 0) {
83
- row("warn", "trace", "no intent anchors declared in product.md", "tag bullets `- [SC1] ...`, add `**Realizes:**` to specs");
84
- warningsTotal += 1;
85
- } else if (s.trace.realized === s.trace.anchors && s.trace.untraceable === 0 && s.trace.dangling === 0) {
86
- row("ok", "trace", `${s.trace.realized}/${s.trace.anchors} anchors realized`);
87
- } else {
88
- row("warn", "trace", `${s.trace.realized}/${s.trace.anchors} realized, ${s.trace.dropped} dropped, ${s.trace.untraceable} untraceable`, "doctrina trace");
89
- warningsTotal += 1;
90
- }
74
+ // 1. Structural gate (validate), machine-read so the counts are exact.
75
+ validate: () => {
76
+ // Read-only: `doctor` never passes fix — a diagnostic that repaired the
77
+ // tree while reporting on it could not be run to find out what is wrong.
78
+ const { errors, warnings } = collectValidation(projectRoot);
79
+ if (errors.length > 0) {
80
+ row("fail", "validate", `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, "doctrina validate (then `doctrina validate --fix` for index drift)");
81
+ for (const e of errors.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e}`);
82
+ } else if (warnings.length > 0) {
83
+ warningsTotal += warnings.length;
84
+ row("warn", "validate", `0 errors, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, "doctrina validate (warnings listed there)");
85
+ } else {
86
+ row("ok", "validate", "all structural checks pass");
87
+ }
88
+ },
91
89
 
92
- // 4. Clean-checkout reproducibility lint (ADR 0008).
93
- const clean = runSelf(["verify", "--clean"], projectRoot);
94
- if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
95
- else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
96
-
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 {
90
+ // 2. Index ↔ tree drift.
91
+ index: () => {
92
+ const drift = collectIndexDrift(projectRoot);
93
+ if (drift.ok) row("ok", "index", "index.json matches the tree");
94
+ else row("fail", "index", "index.json has drifted from the tree", "doctrina validate --fix (or `doctrina index rebuild`)");
95
+ },
96
+
97
+ // The two budgets that are COUPLED, reported before either is breached
98
+ // (change 0072). The generated surface block lives inside AGENTS.md, so
99
+ // one command added to the catalog spends a line of both — and the
100
+ // AGENTS.md ceiling is declared OUTPUT, which means `analyze` refuses the
101
+ // raise-the-ceiling fix by design. The only remedy is to send less, and
102
+ // knowing that a week before the warning fires is the difference between
103
+ // choosing what to cut and cutting whatever is nearest.
104
+ budgets: () => {
105
+ // Both numbers come from their OWNER — `agentsMdBudget` and
106
+ // `surfaceBudget` — never from a second count here, so this row can
107
+ // never disagree with `validate` or `templates check` about a size
108
+ // they all report.
109
+ const agents = agentsMdBudget(projectRoot);
110
+ const surface = surfaceBudget(projectRoot);
111
+ // The coupling: the block is written INTO AGENTS.md, so the next
112
+ // command added to the catalog spends a line of each. What is left is
113
+ // therefore the smaller of the two slacks, not either one alone.
114
+ const headroom = Math.min(agents.slack, surface.slack);
115
+ const detail = `AGENTS.md ${agents.used}/${agents.soft} lines, ` +
116
+ `surface block ${surface.used}/${surface.budget}`;
117
+ const cost = `${headroom} line${headroom === 1 ? "" : "s"} of headroom — ` +
118
+ "one command added to the catalog spends a line of each";
119
+ if (agents.used > agents.soft || surface.used > surface.budget) {
120
+ row("warn", "budgets", `${detail} — over a declared ceiling`,
121
+ "cut prose from AGENTS.md or commands from the surface — both are OUTPUT budgets, so raising them is refused");
122
+ warningsTotal += 1;
123
+ } else if (headroom <= 1) {
124
+ row("warn", "budgets", `${detail} — ${cost}`,
125
+ "cut prose from AGENTS.md now, while there is still a choice about what goes");
126
+ warningsTotal += 1;
127
+ } else {
128
+ row("ok", "budgets", `${detail} · ${cost}`);
129
+ }
130
+ },
131
+
132
+ // 3. Evidence and provenance ratios (cheap summaries; strict gates confirm).
133
+ coverage: () => {
134
+ if (s.coverage.totalCriteria === 0) {
135
+ row("warn", "coverage", "no acceptance criteria declared yet", "write criteria with evidence — see `doctrina coverage`");
136
+ warningsTotal += 1;
137
+ } else if (s.coverage.pct === 100 && s.coverage.totalDangling === 0 && s.coverage.totalConditional === 0) {
138
+ row("ok", "coverage", coverageLabel(s.coverage));
139
+ } else {
140
+ row("warn", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria}${s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : ""}${s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : ""})`, "doctrina coverage (cite the missing evidence)");
141
+ warningsTotal += 1;
142
+ }
143
+ },
144
+
145
+ trace: () => {
146
+ if (s.trace.anchors === 0) {
147
+ row("warn", "trace", "no intent anchors declared in product.md", "tag bullets `- [SC1] ...`, add `**Realizes:**` to specs");
148
+ warningsTotal += 1;
149
+ } else if (s.trace.realized === s.trace.anchors && s.trace.untraceable === 0 && s.trace.dangling === 0) {
150
+ row("ok", "trace", `${s.trace.realized}/${s.trace.anchors} anchors realized`);
151
+ } else {
152
+ row("warn", "trace", `${s.trace.realized}/${s.trace.anchors} realized, ${s.trace.dropped} dropped, ${s.trace.untraceable} untraceable`, "doctrina trace");
153
+ warningsTotal += 1;
154
+ }
155
+ },
156
+
157
+ // 4. Clean-checkout reproducibility lint (ADR 0008).
158
+ "clean-checkout": () => {
159
+ const clean = collectReproducibility(projectRoot);
160
+ if (clean.findings.length === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
161
+ else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
162
+ },
163
+
164
+ // 5. Template shape vs the running CLI's recommendation. The row reports
165
+ // the ACTUAL findings and the remedy each one names, rather than
166
+ // assuming every failure is a missing section — an adapter pointer
167
+ // break was reported as "recommended sections/fields are missing" and
168
+ // sent the user to `templates update`, which does not touch adapters
169
+ // and so could never clear it (C2).
170
+ templates: () => {
171
+ let tmplFindings = [];
172
+ try {
173
+ tmplFindings = collectFindings(projectRoot).findings;
174
+ } catch {
175
+ tmplFindings = [];
176
+ }
177
+ if (tmplFindings.length === 0) {
178
+ row("ok", "templates", "project follows the current template shape");
179
+ return;
180
+ }
113
181
  // Group by remedy so the row names what actually fixes what.
114
182
  const byRemedy = new Map();
115
183
  for (const f of tmplFindings) {
@@ -120,59 +188,160 @@ export async function run(_positional, _flags) {
120
188
  ? tmplFindings[0].message
121
189
  : `${tmplFindings.length} template findings`;
122
190
  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)");
191
+ row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina upgrade (each finding names its own fix)");
124
192
  warningsTotal += 1;
125
- }
126
- }
193
+ },
127
194
 
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 row${runtime.declared === 1 ? " holds" : "s 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 ? " holds" : "s hold"}`);
153
- }
154
- }
195
+ // 6. The RUNTIME surface (change 0029). Every row above reads Markdown;
196
+ // this one reads what the Markdown CLAIMS about the running system and
197
+ // checks it — the wiring, the empty-vs-unset default, the enum, the
198
+ // selector that would match nothing. Still a driver: the checks belong
199
+ // to lib/runtime.js and `contract check` renders the same verdict.
200
+ runtime: () => {
201
+ const runtime = collectRuntimeFindings(projectRoot);
202
+ const errs = runtime.findings.filter((f) => f.level === "error");
203
+ const warns = runtime.findings.length - errs.length;
204
+ if (runtime.contracts === 0) {
205
+ row("ok", "runtime", "no contracts — nothing declares a runtime surface");
206
+ } else if (errs.length > 0) {
207
+ // An error is an error whether or not any Wiring/Selectors row was
208
+ // declared: the structural half (CT01-CT03, change 0103) fails a
209
+ // contract that declares no rows at all, and "unchecked" would
210
+ // hide it.
211
+ row("fail", "runtime", `${errs.length} finding${errs.length === 1 ? "" : "s"} do${errs.length === 1 ? "es" : ""} not hold`, "doctrina contract check (each finding names its own fix)");
212
+ for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
213
+ } else if (runtime.declared === 0) {
214
+ // Silence is not proof: an undeclared surface is UNCHECKED, and a row
215
+ // that read "ok" here would be the exact false confidence this
216
+ // command exists to prevent.
217
+ 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`");
218
+ warningsTotal += 1;
219
+ } else if (warns > 0) {
220
+ row("warn", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
221
+ warningsTotal += 1;
222
+ } else {
223
+ row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}`);
224
+ }
225
+ },
155
226
 
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
- }
227
+ // 7. The local .env, declared with `flag: "env"` so the sequence carries
228
+ // it and the loop skips it unless asked. Off by default because it
229
+ // reads a file that is not committed and is none of CI's business;
230
+ // when asked for, it reports NAMES and enum membership only, never the
231
+ // value.
232
+ "local-env": () => {
233
+ const envFindings = checkLocalEnv(projectRoot);
234
+ if (envFindings.length === 0) {
235
+ row("ok", "local .env", "matches the declared names and enums (or no .env present)");
236
+ } else {
237
+ 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)");
238
+ for (const e of envFindings.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.message}`);
239
+ }
240
+ },
241
+
242
+ // 8. Verify configuration (the real build gate must exist to be run).
243
+ "verify-config": () => {
244
+ if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
245
+ else if (!s.verify.configured) {
246
+ row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
247
+ warningsTotal += 1;
248
+ } else {
249
+ // Executed proof and signed proof are different claims (change 0039):
250
+ // a row that counts them together is the false confidence this
251
+ // command exists to prevent.
252
+ const so = s.verify.signoffs;
253
+ const stale = so ? so.expired + so.unverifiable + so.pending : 0;
254
+ const detail = `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"} declared` +
255
+ (so && so.manual > 0 ? `, ${so.manual} signed rather than executed` : "") +
256
+ " (run `doctrina verify` to execute)";
257
+ if (stale > 0) {
258
+ const parts = [];
259
+ if (so.expired) parts.push(`${so.expired} expired`);
260
+ if (so.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
261
+ if (so.pending) parts.push(`${so.pending} unsigned`);
262
+ row("warn", "verify config", `${detail} — ${parts.join(", ")}`,
263
+ "doctrina verify (each names the re-sign command)");
264
+ warningsTotal += 1;
265
+ } else {
266
+ row("ok", "verify config", detail);
267
+ }
268
+ }
269
+ },
168
270
 
169
- // 8. Verify configuration (the real build gate must exist to be run).
170
- if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
171
- else if (!s.verify.configured) {
172
- row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
271
+ // 9. What this project has CONFIGURED, and what is simply the default.
272
+ // Never a failure: it reports the effective value of each option and
273
+ // the file it came from, so "why is clarify using English here?" has
274
+ // an answer that does not involve reading the CLI's source.
275
+ config: () => {
276
+ const rows = configRows(projectRoot);
277
+ const set = rows.filter((r) => r.configured);
278
+ // The FILE first, then the values (change 0115): over an invalid
279
+ // config.json this row said "ok, all at their defaults" — true of the
280
+ // values, false of the file, on the same screen where the validate
281
+ // row failed it. A key the CLI does not know is named, not ignored.
282
+ const cfg = loadConfig(projectRoot);
283
+ const summary = set.length === 0
284
+ ? `all ${rows.length} options at their defaults`
285
+ : `${set.length} of ${rows.length} options configured`;
286
+ if (cfg.errors.length > 0) {
287
+ row("fail", "config", cfg.errors[0], "fix .doctrina/config.json, then `doctrina validate`");
288
+ } else if (cfg.unknown.length > 0) {
289
+ row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules");
290
+ warningsTotal += 1;
291
+ } else {
292
+ row("ok", "config", summary);
293
+ }
294
+ for (const r of rows) {
295
+ console.log(` ${" ".repeat(16)} ${c.gray("·")} ${r.option.padEnd(15)} ${r.value.padEnd(22)} ${c.gray(r.source)}`);
296
+ }
297
+ },
298
+
299
+ // 10. Which of the catalog's operations have never been reached for —
300
+ // but ONLY when the operator turned the log on. `lib/usage.js` was
301
+ // built to instrument the surface before shrinking it, and the only
302
+ // reader was a command someone had to think to type, so the decision
303
+ // it exists to inform kept being taken on opinion (audit finding
304
+ // F16). It reports and never writes: no log file is created here.
305
+ usage: () => {
306
+ const target = process.env[USAGE_ENV];
307
+ if (!target || !isFile(target)) return;
308
+ const catalog = OPERATIONS.map(([op]) => op);
309
+ const { samples, unused } = summarise(read(target), catalog);
310
+ if (samples === 0) {
311
+ row("ok", "usage", `${relPath(projectRoot, target)} is empty — nothing recorded yet`);
312
+ return;
313
+ }
314
+ const detail = `${samples} sample${samples === 1 ? "" : "s"}; ` +
315
+ `${unused.length} of ${catalog.length} operations never invoked`;
316
+ row("ok", "usage", detail);
317
+ // Zero samples for an operation is a CANDIDATE, never a verdict: a
318
+ // command reached for once a quarter and one nobody wants look
319
+ // identical over a week. ADR 0026: retiring one needs demonstrated
320
+ // redundancy, and this list is where you go looking for it.
321
+ for (const op of unused.slice(0, UNUSED_OPS_LISTED)) {
322
+ console.log(` ${" ".repeat(16)} ${c.gray("·")} never invoked: ${op}`);
323
+ }
324
+ if (unused.length > UNUSED_OPS_LISTED) {
325
+ console.log(` ${" ".repeat(16)} ${c.gray(`· … ${unused.length - UNUSED_OPS_LISTED} more — doctrina metrics --commands`)}`);
326
+ }
327
+ },
328
+ };
329
+
330
+ // The sequence, in the declared order. A step gated on a flag is skipped
331
+ // silently when that flag is absent. A step declared without a reporter is
332
+ // reported as UNCHECKED and names the command that answers it: this command
333
+ // reads collections, and a row it cannot compute here is a gap in this file,
334
+ // not something to shell out for. Silence would be the false confidence the
335
+ // whole diagnostic exists to prevent.
336
+ for (const step of sequence("doctor")) {
337
+ if (step.flag && !flagBool(_flags, step.flag, false)) continue;
338
+ const report = reporters[step.id];
339
+ if (report) {
340
+ report();
341
+ continue;
342
+ }
343
+ row("warn", step.label, "declared in the doctor sequence but has no reporter here", stepRerun(step));
173
344
  warningsTotal += 1;
174
- } else {
175
- row("ok", "verify config", `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"} declared (run \`doctrina verify\` to execute)`);
176
345
  }
177
346
 
178
347
  console.log("");
@@ -188,14 +357,6 @@ export async function run(_positional, _flags) {
188
357
  return 0;
189
358
  }
190
359
 
191
- function runSelf(args, cwd) {
192
- return spawnSync(process.execPath, [cliEntry, ...args], {
193
- cwd,
194
- encoding: "utf8",
195
- env: { ...process.env, NO_COLOR: "1" },
196
- });
197
- }
198
-
199
360
  export const help = `
200
361
  Usage: doctrina doctor
201
362
 
@@ -210,7 +371,9 @@ exact remediation command.
210
371
  enums. Reports membership only; a rejected value is never
211
372
  printed, so this is safe to run and to paste.
212
373
 
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.
215
- Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
374
+ A driver over the same collections the gates themselves render; it adds
375
+ no checks of its own, so it can never disagree with the gates it fronts,
376
+ and it runs entirely in this process — no subprocess, no re-parsing of
377
+ its own output. Read-only: it never repairs what it reports. Exits 1
378
+ when any area fails, 0 otherwise (warnings allowed).
216
379
  `;