doctrina-cli 0.15.0 → 0.16.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 (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -2,24 +2,30 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { appendFileSync } from "node:fs";
5
- import { exists, isFile, read, walk } from "../lib/fs-ops.js";
5
+ import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
6
6
  import { flagBool } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
- import { parseCapabilityFromDelta } from "./change.js";
9
- import { printAdrCheckpoint } from "../lib/adr-guard.js";
10
- import { checkDocsImpact } from "../lib/docs-impact.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
8
+ import { parseCapabilityFromDelta } from "../lib/doc-model.js";
9
+ import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
10
+ import { docsRemedy, checkDocsImpact } from "../lib/docs-impact.js";
11
+ import { collectRuntimeFindings } from "../lib/runtime.js";
12
+ import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
13
+ import { specHeader, dependentsOf } from "../lib/scan.js";
14
+ import { sequence, stepRerun } from "../lib/gates.js";
15
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
16
  import * as analyze from "./analyze.js";
13
17
  import * as change from "./change.js";
14
18
  import * as verify from "./verify.js";
15
19
  import * as coverage from "./coverage.js";
16
20
  import * as trace from "./trace.js";
17
21
  import * as validate from "./validate.js";
22
+ import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
23
+ import * as review from "./review.js";
18
24
  import * as skill from "./skill.js";
19
25
 
20
26
  // One-command close (review 2026-06-27 passive-user feature #2). The work
21
- // playbook lists the closing sequence — analyze → apply → verify → coverage →
22
- // trace → archive → validate — and the agent runs it step by step, which is
27
+ // playbook lists the closing sequence — analyze → apply → runtime → verify →
28
+ // coverage → trace → archive → validate — and the agent runs it step by step, which is
23
29
  // exactly where a gate gets skipped. `close` runs the whole sequence in one
24
30
  // pass, in-process, stopping at the first failure with the exact command to
25
31
  // rerun, so the agent makes one call and the human approves once. It is a
@@ -61,6 +67,14 @@ export async function run(positional, flags) {
61
67
  }
62
68
 
63
69
  async function closeOne(projectRoot, id, flags) {
70
+ // A change id that does not resolve is the USAGE class, before any step
71
+ // is sequenced (change 0114) — every sibling that takes a change id
72
+ // answers 2; `close` answered 1 through the analyze step it ran first.
73
+ if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
74
+ console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
75
+ console.error(c.gray("hint: ") + "open changes: `doctrina next`");
76
+ return EXIT.USAGE;
77
+ }
64
78
  const force = flagBool(flags, "force", false);
65
79
  const archiveFlags = force ? new Map([["force", true]]) : new Map();
66
80
  // Set by the docs gate when it fails, so a --force close can record the
@@ -82,47 +96,166 @@ async function closeOne(projectRoot, id, flags) {
82
96
  coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
83
97
  }
84
98
 
85
- // Each step: a label, the command that runs it, and the literal command to
86
- // rerun on failure. verify is conditional (skipped, with a loud note, when no
87
- // verify.json is declared — a project may not have wired the real gate yet).
99
+ // How each declared step actually runs, keyed by the step id in
100
+ // SEQUENCES.close. The DECLARATION owns which steps exist, in what order,
101
+ // and how hard each bites (lib/gates.js, audit finding F1); this table owns
102
+ // only how to execute one in-process. A step declared with no runner here
103
+ // falls back to spawning its declared `argv`, so a gate added to the
104
+ // sequence appears in `close` — and in `doctor` and the CI action —
105
+ // without a second edit; a drift test pins the two together.
106
+ //
107
+ // Each runner returns an exit code and may replace the step's label or
108
+ // rerun line with a context-aware one (the coverage scope, the --force
109
+ // archive), which is rendering, not sequencing.
88
110
  const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
89
- const steps = [
90
- { label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
111
+ const runners = {
112
+ analyze: { run: () => analyze.run([id], new Map()) },
113
+
91
114
  // ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
92
115
  // step was skippable in silence. Advisory — it names the accepted ADRs
93
116
  // whose text cites the touched capabilities and the amend commands, but
94
117
  // an ADR merely mentioning a capability is normal, so it never blocks.
95
- {
96
- label: "ADR checkpoint (advisory)",
97
- rerun: "doctrina decision list",
118
+ "adr-checkpoint": {
98
119
  run: async () => {
99
120
  if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
100
- console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
121
+ // "No ADR cites these capabilities" is conformance only when there
122
+ // ARE accepted ADRs to cite them. With none on disk the sentence is
123
+ // vacuously true, and printing it as `ok` reads as a decision
124
+ // checked rather than a checkbox with nothing behind it (0089).
125
+ const accepted = acceptedDecisionCount(projectRoot);
126
+ if (accepted === 0) {
127
+ console.log(c.gray("· no accepted ADR in the tree — nothing to check the change against"));
128
+ } else {
129
+ console.log(c.green("ok") + ` no accepted ADR cites the touched capabilities (${accepted} checked)`);
130
+ }
101
131
  }
102
132
  return 0;
103
133
  },
104
134
  },
105
- { label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
106
- verifyConfigured
107
- ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
108
- : { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
109
- {
135
+
136
+ // The review runs against the working tree, which for a close IS the
137
+ // change's diff: the work is done and not yet archived. Its exit code is
138
+ // discarded on purpose — the step is declared advisory, and an advisory
139
+ // step that could still move the close's result would be advisory in name
140
+ // only. A crash is caught by the loop and reported the same way.
141
+ review: {
142
+ run: async () => {
143
+ await review.run([], new Map());
144
+ return 0;
145
+ },
146
+ },
147
+
148
+ apply: { run: () => change.run(["apply", id], new Map()) },
149
+
150
+ // The RUNTIME gate (audit finding F2). RT01–RT05 live in lib/runtime.js
151
+ // and no default driver ran them: `close` did not, `validate` only under
152
+ // --runtime, and the published action not at all — so the one class of
153
+ // break every structural gate is blind to (the declaration that no
154
+ // longer matches the running system) reached production green. It sits
155
+ // after `apply`, because the deltas the apply just merged are what may
156
+ // have moved the surface the contract describes. A driver over
157
+ // lib/runtime.js: the same findings `contract check`, `triage` and
158
+ // `doctor` render, so the five can never disagree.
159
+ //
160
+ // Severity decides the level: an `error` blocks the close, a `warn` is
161
+ // reported and the close continues. A project with no contracts — or
162
+ // with contracts that declare no Wiring/Selectors rows — is not passing,
163
+ // it is UNCHECKED, and saying so is what stops silence from reading as
164
+ // proof.
165
+ runtime: {
166
+ run: async () => {
167
+ const { findings, contracts, declared } = collectRuntimeFindings(projectRoot);
168
+ if (contracts === 0) {
169
+ console.log(c.gray("· no contracts — nothing declares a runtime surface"));
170
+ return 0;
171
+ }
172
+ for (const f of findings) {
173
+ const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
174
+ console.log(mark + `${f.contract}: ${f.message}` + c.gray(` [${f.code}]`));
175
+ console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
176
+ }
177
+ const errs = findings.filter((f) => f.level === "error").length;
178
+ if (errs > 0) {
179
+ console.error(c.red("error:") + ` ${errs} contract finding${errs === 1 ? "" : "s"} block${errs === 1 ? "s" : ""} the close — the same ${errs === 1 ? "one" : "ones"} \`doctrina contract check\` reports`);
180
+ return 1;
181
+ }
182
+ if (declared === 0) {
183
+ console.log(c.yellow("warn: ") +
184
+ `${contracts} contract${contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`);
185
+ return 0;
186
+ }
187
+ const warns = findings.length;
188
+ console.log(c.green("ok") + ` ${declared} declared row${declared === 1 ? " holds" : "s hold"}` +
189
+ (warns > 0 ? c.gray(`; ${warns} advisory finding${warns === 1 ? "" : "s"} above`) : ""));
190
+ return 0;
191
+ },
192
+ },
193
+
194
+ // The Implementation header, PROPOSED rather than remembered (audit
195
+ // finding F10). The `work` playbook asked the agent twice to advance a
196
+ // field whose correct value coverage had already computed one file over.
197
+ // Advisory and non-mutating by design: the close prints the `set-header`
198
+ // op for the capabilities this change touched, and a human — or
199
+ // `spec set --implementation auto` — applies it. A gate that rewrote the
200
+ // claim it checks would be marking its own homework.
201
+ implementation: {
202
+ run: async () => {
203
+ const scope = touched.length > 0 ? new Set(touched) : null;
204
+ const derived = derivedImplementations(projectRoot, { only: scope });
205
+ const proposals = [];
206
+ for (const [cap, row] of derived) {
207
+ const specPath = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
208
+ if (!isFile(specPath)) continue;
209
+ const mismatch = implementationMismatch(specHeader(read(specPath), "Implementation"), row.derived);
210
+ if (mismatch) proposals.push({ cap, row, mismatch });
211
+ }
212
+ if (derived.size === 0) {
213
+ // "Every touched spec matches" over ZERO specs is vacuously true.
214
+ // A chore in a project with no capability spec reached this and was
215
+ // told its implementation headers were in order (change 0089).
216
+ console.log(c.gray("· no spec to check — the change touches no capability"));
217
+ return 0;
218
+ }
219
+ if (proposals.length === 0) {
220
+ console.log(c.green("ok") + ` every touched spec's Implementation header matches its coverage (${derived.size} checked)`);
221
+ return 0;
222
+ }
223
+ for (const { cap, row, mismatch } of proposals) {
224
+ console.log(c.yellow(" ! ") + `${cap}: Implementation is "${mismatch.written}" but ` +
225
+ `${row.covered}/${row.total} criteria have resolving proof`);
226
+ console.log(` ${c.gray("delta op: ")}${c.cyan(mismatch.op)}`);
227
+ console.log(` ${c.gray("or: ")}${c.cyan(`doctrina spec set ${cap} --implementation auto`)}`);
228
+ }
229
+ return 0;
230
+ },
231
+ },
232
+
233
+ // verify is conditional: skipped with a loud note when no verify.json is
234
+ // declared, because a project may not have wired the real gate yet.
235
+ verify: verifyConfigured
236
+ ? { run: () => verify.run([], new Map()) }
237
+ : { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
238
+
239
+ coverage: {
110
240
  label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
111
241
  rerun: coverageRerun,
112
- run: () => coverage.run([], coverageFlags),
242
+ run: async () => {
243
+ const code = await coverage.run([], coverageFlags);
244
+ reportDependentCoverage(projectRoot, touched);
245
+ return code;
246
+ },
113
247
  },
248
+
114
249
  // trace is advisory (provenance is a warning, not a hard gate): report it,
115
250
  // never let it block the close.
116
- { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
251
+ trace: { run: async () => { await trace.run([], new Map()); return 0; } },
252
+
117
253
  // Docs ship inside the change (D2): a change that alters a documented
118
254
  // surface — a command, a flag, an exit code — closes only with the
119
255
  // documentation that describes it. A blocking gate, because a docs
120
256
  // phase scheduled after the work never happens; --force is the same
121
257
  // escape hatch archive offers, and records the gap in the ledger.
122
- {
123
- label: "docs",
124
- forceable: true,
125
- rerun: "edit docs/ (EN + PT), then rerun",
258
+ docs: {
126
259
  run: async () => {
127
260
  const r = checkDocsImpact(projectRoot, path.join(projectRoot, ".doctrina", "changes", id));
128
261
  if (r.ok) {
@@ -131,18 +264,57 @@ async function closeOne(projectRoot, id, flags) {
131
264
  }
132
265
  console.error(c.red("error:") + ` this change ${r.reason}:`);
133
266
  for (const s of r.signals) console.error(` - ${s}`);
134
- console.error(c.gray("hint: ") +
135
- "document it in docs/en/ AND docs/pt/ (the `keep-docs-en-pt-parity` skill), " +
136
- "or pass --force to close anyway (records the gap)");
267
+ // The remedy comes out of the project being checked, never out of
268
+ // Doctrina's own repository (change 0058): an adopting project with
269
+ // no `docs/` was being told to write English AND Portuguese and to
270
+ // read a skill it does not have.
271
+ console.error(c.gray("hint: ") + docsRemedy(projectRoot) +
272
+ ", or pass --force to close anyway (records the gap)");
137
273
  docsGap = r;
138
274
  return 1;
139
275
  },
140
276
  },
141
- { label: "archive", rerun: `doctrina change archive ${id}${force ? " --force" : ""}`, run: () => change.run(["archive", id], archiveFlags) },
142
- { label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
143
- ];
144
277
 
145
- console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → docs → archive → validate"));
278
+ archive: {
279
+ rerun: `doctrina change archive ${id}${force ? " --force" : ""}`,
280
+ run: () => change.run(["archive", id], archiveFlags),
281
+ },
282
+
283
+ validate: { run: () => validate.run([], new Map()) },
284
+ };
285
+
286
+ const steps = sequence("close").map((step) => {
287
+ const runner = runners[step.id] ?? {};
288
+ return {
289
+ id: step.id,
290
+ label: runner.label ?? step.label,
291
+ rerun: runner.rerun ?? stepRerun(step, id),
292
+ forceable: step.level === "forceable",
293
+ advisory: step.level === "advisory",
294
+ skip: runner.skip,
295
+ // A step declared with no in-process runner is a gap in THIS file, and
296
+ // it is reported as one — the same answer `doctor` gives (change 0060).
297
+ // The close used to shell out to its own binary here instead, which was
298
+ // the second integration style change 0045 removed from `doctor`; every
299
+ // declared step has a runner and a test holds it that way, so the
300
+ // fallback was a second answer to a question nobody could reach.
301
+ run: runner.run ?? (() => missingRunner(step)),
302
+ };
303
+ });
304
+
305
+ // The banner is the declaration read aloud, so it cannot fall behind the
306
+ // sequence it announces the way a hand-maintained string did.
307
+ console.log(c.bold(`Closing change ${id}`) +
308
+ c.gray(` — ${sequence("close").map((s) => s.label.replace(" (advisory)", "")).join(" → ")}`));
309
+
310
+ // What the closing line is allowed to claim. A step that was SKIPPED was
311
+ // not performed, and the conclusion used to say "verified, archived, and
312
+ // validated" as a fixed string — including on a close whose own step 7 had
313
+ // just printed `skip no .doctrina/verify.json`. The final line is the one
314
+ // sentence a human reads before approving; a word it did not earn is the
315
+ // most expensive claim in the tree (change 0089).
316
+ const ran = new Set();
317
+ const skippedSteps = [];
146
318
 
147
319
  for (let i = 0; i < steps.length; i++) {
148
320
  const step = steps[i];
@@ -150,6 +322,7 @@ async function closeOne(projectRoot, id, flags) {
150
322
  console.log(c.gray(`──── ${i + 1}/${steps.length} ${step.label}`));
151
323
  if (step.skip) {
152
324
  console.log(c.yellow("skip ") + step.skip);
325
+ skippedSteps.push(step);
153
326
  continue;
154
327
  }
155
328
  let code;
@@ -160,6 +333,13 @@ async function closeOne(projectRoot, id, flags) {
160
333
  console.error(c.red("error:") + ` ${err.message}`);
161
334
  }
162
335
  if (code !== 0) {
336
+ // The declared level decides, not the runner: an advisory step reports
337
+ // and the close carries on, which is what "advisory" means in
338
+ // lib/gates.js and what the surfaces must agree on.
339
+ if (step.advisory) {
340
+ console.log(c.yellow("warn:") + ` "${step.label}" reported findings (advisory — the close continues)`);
341
+ continue;
342
+ }
163
343
  // A forceable gate under --force warns and continues, matching how
164
344
  // `change archive --force` handles incomplete verification: the gap
165
345
  // is recorded, not hidden.
@@ -174,14 +354,17 @@ async function closeOne(projectRoot, id, flags) {
174
354
  console.log(` ${c.cyan(`doctrina close ${id}`)}`);
175
355
  return 1;
176
356
  }
357
+ ran.add(step.id);
177
358
  }
178
359
 
179
360
  // Record a forced docs gap in the ledger, so history shows the change
180
361
  // shipped without its documentation rather than showing nothing.
181
362
  if (docsGap) {
182
- const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
363
+ const ledgerPath = ledgerFile(projectRoot);
183
364
  if (isFile(ledgerPath)) {
184
- appendFileSync(ledgerPath, ` - docs gap: ${id} closed with --force; ${docsGap.signals.join("; ")} documented nowhere\n`);
365
+ // Written in the ledger's own entry grammar (change 0046): a waived
366
+ // gate no reader can find is the same as an unrecorded one.
367
+ appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
185
368
  console.log(c.yellow("ledger") + " recorded the docs gap");
186
369
  }
187
370
  }
@@ -197,13 +380,74 @@ async function closeOne(projectRoot, id, flags) {
197
380
  } catch { /* advisory only */ }
198
381
 
199
382
  console.log("");
200
- console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
383
+ console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
201
384
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
202
385
  return 0;
203
386
  }
204
387
 
388
+ // A step the sequence declares and this file does not implement. The gate map
389
+ // is the single source of truth for what a close runs (ADR 0017), so a step
390
+ // with no runner here is a defect in this file — not a reason to start a
391
+ // second process and not a reason to pass silently. `doctor` reaches the same
392
+ // conclusion for its own reporters; the two drivers now give one answer.
393
+ // The closing sentence, built from the steps that actually ran.
394
+ //
395
+ // Each word names one step: `verify` -> verified, `archive` -> archived,
396
+ // `validate` -> validated. A step that was skipped loses its word and is
397
+ // named instead, so the line can never claim more than the run performed.
398
+ function closingClaim(ran, skippedSteps) {
399
+ const words = [
400
+ ["verify", "verified"],
401
+ ["archive", "archived"],
402
+ ["validate", "validated"],
403
+ ].filter(([stepId]) => ran.has(stepId)).map(([, word]) => word);
404
+
405
+ const claimed = words.length === 0
406
+ ? ""
407
+ : words.length === 1
408
+ ? ` — ${words[0]}.`
409
+ : ` — ${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}.`;
410
+
411
+ const skipped = skippedSteps.map((s) => s.label.replace(" (advisory)", ""));
412
+ if (skipped.length === 0) return claimed || " — no gate ran.";
413
+ return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
414
+ }
415
+
416
+ function missingRunner(step) {
417
+ console.error(c.red("error:") +
418
+ ` "${step.label}" is declared in the close sequence but has no runner here`);
419
+ if (step.argv) {
420
+ console.error(c.gray("hint: ") + `run it directly: doctrina ${step.argv.join(" ")}`);
421
+ }
422
+ return 1;
423
+ }
424
+
205
425
  // The capabilities this change's deltas target — the honest scope for its
206
426
  // coverage gate. Read from the change folder's specs/**/delta.md files.
427
+ // The capabilities that DECLARE a dependency on what this change touched,
428
+ // with their coverage — advisory, never part of the verdict (change 0046).
429
+ //
430
+ // The scope exists because one deliberately deferred spec elsewhere in the
431
+ // tree must not block a change that never went near it, and widening the gate
432
+ // to dependents would give that problem straight back. What a closing agent
433
+ // actually needs is the pointer: this change moved ground something else
434
+ // stands on, and here is how well that something is proven today.
435
+ function reportDependentCoverage(projectRoot, touched) {
436
+ if (touched.length === 0) return;
437
+ const dependents = dependentsOf(projectRoot, touched);
438
+ if (dependents.length === 0) return;
439
+ const byCap = new Map(summarize(projectRoot).perCap.map((r) => [r.cap, r]));
440
+ console.log(c.gray(" dependents of the touched capabilities (advisory — not part of this gate):"));
441
+ for (const dep of dependents) {
442
+ const row = byCap.get(dep.capability);
443
+ const proof = row && row.total > 0
444
+ ? `${row.covered}/${row.total} criteria proven` + (row.dangling ? `, ${row.dangling} dangling` : "")
445
+ : "no acceptance criteria";
446
+ console.log(` ${c.cyan(dep.capability)} depends on ${dep.dependsOn.join(", ")} — ${proof}` +
447
+ c.gray(` (doctrina why ${dep.capability})`));
448
+ }
449
+ }
450
+
207
451
  function touchedCapabilities(projectRoot, id) {
208
452
  const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
209
453
  const caps = new Set();
@@ -221,14 +465,19 @@ Usage: doctrina close <id...> [--force]
221
465
  Run the whole closing sequence for a change in one pass, stopping at the
222
466
  first failure with the exact command to rerun:
223
467
 
224
- analyze → ADR checkpoint (advisory) → change apply → verify → coverage
225
- --strict (scoped to the change's touched capabilities) → trace → change
226
- archive → validate → skill suggest (advisory)
468
+ analyze → ADR checkpoint (advisory) → change apply → runtime → verify →
469
+ coverage --strict (scoped to the change's touched capabilities) → trace →
470
+ change archive → validate → skill suggest (advisory)
227
471
 
228
472
  The coverage gate is scoped to the capabilities the change's deltas touch,
229
473
  so a deliberately deferred spec elsewhere cannot block an unrelated close;
230
- a change with no deltas gates on the whole tree. verify is skipped (with a
231
- note) when no .doctrina/verify.json is declared; trace, the ADR checkpoint
474
+ a change with no deltas gates on the whole tree. The runtime gate holds the
475
+ contracts' declared wiring, enums and selectors to the implementation (the
476
+ RT01-RT05 checks \`contract check\` renders): an error blocks, a warning is
477
+ reported and the close continues, and a project with no contracts — or with
478
+ contracts declaring no rows — is reported UNCHECKED rather than passing.
479
+ verify is skipped (with a note) when no .doctrina/verify.json is declared;
480
+ trace, the ADR checkpoint
232
481
  (accepted ADRs citing the touched capabilities — amend via \`decision
233
482
  supersede\`, not silence), and the closing skill-suggest listing are
234
483
  advisory (reports, never blockers). This is a driver over the existing
@@ -6,6 +6,12 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { listHeader } from "../lib/scan.js";
7
7
  import { c } from "../lib/colors.js";
8
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
9
+ import { acceptedDecisions, productSection } from "../lib/constitution-model.js";
10
+ import { collectSnapshot } from "../lib/snapshot.js";
11
+ import { renderView } from "../lib/views.js";
12
+
13
+ // Read by the project snapshot as well; see lib/constitution-model.js.
14
+ export { acceptedDecisions, productSection } from "../lib/constitution-model.js";
9
15
 
10
16
  // The project's standing rules in one read — Spec Kit parity for its
11
17
  // `constitution.md`, but ASSEMBLED, not a new home for facts. Doctrina's
@@ -28,42 +34,11 @@ export async function run(_positional, _flags) {
28
34
  if (!exists(path.join(projectRoot, ".doctrina"))) {
29
35
  throw notADoctrinaProject();
30
36
  }
31
-
32
- const project = projectName(projectRoot);
33
- console.log(
34
- c.bold("Doctrina constitution") +
35
- c.gray(` — ${project} (standing rules: accepted decisions + non-goals)`),
36
- );
37
-
38
- // Principles — the accepted ADRs, by number. Every accepted decision is a
39
- // binding rule; superseded/withdrawn/proposed ADRs are not yet (or no longer)
40
- // in force and stay out.
41
- console.log("");
42
- console.log(c.bold(" Principles") + c.gray(" (immutable — supersede an ADR to change one)"));
43
- const adrs = acceptedDecisions(projectRoot);
44
- if (adrs.length === 0) {
45
- console.log(` ${c.gray("no accepted ADRs yet — record decisions with `doctrina decision new`")}`);
46
- } else {
47
- for (const a of adrs) console.log(` ${c.cyan("ADR " + a.id)} ${a.title}`);
48
- }
49
-
50
- // Non-goals — the explicit "what this project will not be", from product.md.
51
- console.log("");
52
- console.log(c.bold(" Non-goals") + c.gray(" (.doctrina/product.md)"));
53
- const nonGoals = productSection(projectRoot, "Non-goals");
54
- if (nonGoals.length === 0) {
55
- console.log(` ${c.gray("none declared — add a `## Non-goals` section to product.md")}`);
56
- } else {
57
- for (const g of nonGoals) console.log(` ${c.gray("•")} ${g}`);
58
- }
59
-
60
- console.log("");
61
- console.log(
62
- c.gray(
63
- ` ${adrs.length} accepted decision${adrs.length === 1 ? "" : "s"} · ` +
64
- `${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} · read-only`,
65
- ),
66
- );
37
+ // Deprecated (change 0049): the standing rules are a VIEW of the shared
38
+ // collection, and `prime --rules` renders it. This command prints the same
39
+ // lines — not a similar set, the same ones — so the deprecation is a
40
+ // rename with a warning rather than a loss.
41
+ for (const line of renderView("rules", collectSnapshot(projectRoot))) console.log(line);
67
42
  return 0;
68
43
  }
69
44
 
@@ -76,61 +51,6 @@ function projectName(projectRoot) {
76
51
  }
77
52
  }
78
53
 
79
- // Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
80
- // title read from the `# ADR NNNN — <title>` heading. Exported for `prime`,
81
- // which digests the same standing rules into the session primer.
82
- export function acceptedDecisions(projectRoot) {
83
- const dir = path.join(projectRoot, ".doctrina", "decisions");
84
- const out = [];
85
- if (!isDir(dir)) return out;
86
- for (const f of readdirSync(dir).sort()) {
87
- const m = f.match(/^(\d{4})-.*\.md$/);
88
- if (!m) continue;
89
- const text = read(path.join(dir, f));
90
- if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
91
- const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
92
- out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
93
- }
94
- return out;
95
- }
96
-
97
- // Bullets under a named `## <section>` of product.md, each accumulated across
98
- // its continuation lines so a wrapped bullet reads as one rule. Exported for
99
- // `prime`.
100
- export function productSection(projectRoot, name) {
101
- const p = path.join(projectRoot, ".doctrina", "product.md");
102
- if (!isFile(p)) return [];
103
- const lines = read(p).split(/\r?\n/);
104
- let inSection = false;
105
- const out = [];
106
- let cur = null;
107
- const flush = () => {
108
- if (cur) out.push(cur.replace(/\s+/g, " ").trim());
109
- cur = null;
110
- };
111
- for (const line of lines) {
112
- if (/^##\s+/.test(line)) {
113
- if (inSection) {
114
- flush();
115
- break;
116
- }
117
- if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
118
- continue;
119
- }
120
- if (!inSection) continue;
121
- const m = line.match(/^\s*[-*]\s+(.+)$/);
122
- if (m) {
123
- flush();
124
- cur = m[1];
125
- } else if (cur && line.trim() !== "") {
126
- cur += " " + line.trim();
127
- } else if (line.trim() === "") {
128
- flush();
129
- }
130
- }
131
- flush();
132
- return out;
133
- }
134
54
 
135
55
  export const help = `
136
56
  Usage: doctrina constitution
@@ -142,4 +62,7 @@ already own; it never writes.
142
62
 
143
63
  This is the Spec Kit \`constitution.md\` analogue: a single place to see the
144
64
  non-negotiables. To change one, supersede the ADR or edit product.md.
65
+
66
+ DEPRECATED: use \`doctrina prime --rules\`, which prints exactly these lines.
67
+ This name keeps working and will be removed in a future minor.
145
68
  `;