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
@@ -2,24 +2,32 @@
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, checkChangelogImpact } 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, sequenceLabels, stepRerun } from "../lib/gates.js";
15
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
16
+ import { refuseChangeRef } from "../lib/project.js";
12
17
  import * as analyze from "./analyze.js";
13
18
  import * as change from "./change.js";
14
19
  import * as verify from "./verify.js";
15
20
  import * as coverage from "./coverage.js";
16
21
  import * as trace from "./trace.js";
17
22
  import * as validate from "./validate.js";
23
+ import * as indexRebuild from "./index-rebuild.js";
24
+ import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
25
+ import * as review from "./review.js";
18
26
  import * as skill from "./skill.js";
19
27
 
20
28
  // 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
29
+ // playbook lists the closing sequence — analyze → apply → runtime → verify →
30
+ // coverage → trace → archive → validate — and the agent runs it step by step, which is
23
31
  // exactly where a gate gets skipped. `close` runs the whole sequence in one
24
32
  // pass, in-process, stopping at the first failure with the exact command to
25
33
  // rerun, so the agent makes one call and the human approves once. It is a
@@ -61,6 +69,16 @@ export async function run(positional, flags) {
61
69
  }
62
70
 
63
71
  async function closeOne(projectRoot, id, flags) {
72
+ const refused = refuseChangeRef(id);
73
+ if (refused !== null) return refused;
74
+ // A change id that does not resolve is the USAGE class, before any step
75
+ // is sequenced (change 0114) — every sibling that takes a change id
76
+ // answers 2; `close` answered 1 through the analyze step it ran first.
77
+ if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
78
+ console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
79
+ console.error(c.gray("hint: ") + "open changes: `doctrina next`");
80
+ return EXIT.USAGE;
81
+ }
64
82
  const force = flagBool(flags, "force", false);
65
83
  const archiveFlags = force ? new Map([["force", true]]) : new Map();
66
84
  // Set by the docs gate when it fails, so a --force close can record the
@@ -82,67 +100,256 @@ async function closeOne(projectRoot, id, flags) {
82
100
  coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
83
101
  }
84
102
 
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).
103
+ // How each declared step actually runs, keyed by the step id in
104
+ // SEQUENCES.close. The DECLARATION owns which steps exist, in what order,
105
+ // and how hard each bites (lib/gates.js, audit finding F1); this table owns
106
+ // only how to execute one in-process. A step declared with no runner here
107
+ // falls back to spawning its declared `argv`, so a gate added to the
108
+ // sequence appears in `close` — and in `doctor` and the CI action —
109
+ // without a second edit; a drift test pins the two together.
110
+ //
111
+ // Each runner returns an exit code and may replace the step's label or
112
+ // rerun line with a context-aware one (the coverage scope, the --force
113
+ // archive), which is rendering, not sequencing.
88
114
  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()) },
115
+ const runners = {
116
+ analyze: { run: () => analyze.run([id], new Map()) },
117
+
91
118
  // ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
92
119
  // step was skippable in silence. Advisory — it names the accepted ADRs
93
120
  // whose text cites the touched capabilities and the amend commands, but
94
121
  // an ADR merely mentioning a capability is normal, so it never blocks.
95
- {
96
- label: "ADR checkpoint (advisory)",
97
- rerun: "doctrina decision list",
122
+ "adr-checkpoint": {
98
123
  run: async () => {
99
124
  if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
100
- console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
125
+ // "No ADR cites these capabilities" is conformance only when there
126
+ // ARE accepted ADRs to cite them. With none on disk the sentence is
127
+ // vacuously true, and printing it as `ok` reads as a decision
128
+ // checked rather than a checkbox with nothing behind it (0089).
129
+ const accepted = acceptedDecisionCount(projectRoot);
130
+ if (accepted === 0) {
131
+ console.log(c.gray("· no accepted ADR in the tree — nothing to check the change against"));
132
+ } else {
133
+ console.log(c.green("ok") + ` no accepted ADR cites the touched capabilities (${accepted} checked)`);
134
+ }
135
+ }
136
+ return 0;
137
+ },
138
+ },
139
+
140
+ // The review runs against the working tree, which for a close IS the
141
+ // change's diff: the work is done and not yet archived. Its exit code is
142
+ // discarded on purpose — the step is declared advisory, and an advisory
143
+ // step that could still move the close's result would be advisory in name
144
+ // only. A crash is caught by the loop and reported the same way.
145
+ review: {
146
+ run: async () => {
147
+ await review.run([], new Map());
148
+ return 0;
149
+ },
150
+ },
151
+
152
+ apply: { run: () => change.run(["apply", id], new Map()) },
153
+
154
+ // The RUNTIME gate (audit finding F2). RT01–RT05 live in lib/runtime.js
155
+ // and no default driver ran them: `close` did not, `validate` only under
156
+ // --runtime, and the published action not at all — so the one class of
157
+ // break every structural gate is blind to (the declaration that no
158
+ // longer matches the running system) reached production green. It sits
159
+ // after `apply`, because the deltas the apply just merged are what may
160
+ // have moved the surface the contract describes. A driver over
161
+ // lib/runtime.js: the same findings `contract check`, `triage` and
162
+ // `doctor` render, so the five can never disagree.
163
+ //
164
+ // Severity decides the level: an `error` blocks the close, a `warn` is
165
+ // reported and the close continues. A project with no contracts — or
166
+ // with contracts that declare no Wiring/Selectors rows — is not passing,
167
+ // it is UNCHECKED, and saying so is what stops silence from reading as
168
+ // proof.
169
+ runtime: {
170
+ run: async () => {
171
+ const { findings, contracts, declared } = collectRuntimeFindings(projectRoot);
172
+ if (contracts === 0) {
173
+ console.log(c.gray("· no contracts — nothing declares a runtime surface"));
174
+ return 0;
175
+ }
176
+ for (const f of findings) {
177
+ const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
178
+ console.log(mark + `${f.contract}: ${f.message}` + c.gray(` [${f.code}]`));
179
+ console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
180
+ }
181
+ const errs = findings.filter((f) => f.level === "error").length;
182
+ if (errs > 0) {
183
+ 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`);
184
+ return 1;
185
+ }
186
+ if (declared === 0) {
187
+ console.log(c.yellow("warn: ") +
188
+ `${contracts} contract${contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`);
189
+ return 0;
190
+ }
191
+ const warns = findings.length;
192
+ console.log(c.green("ok") + ` ${declared} declared row${declared === 1 ? " holds" : "s hold"}` +
193
+ (warns > 0 ? c.gray(`; ${warns} advisory finding${warns === 1 ? "" : "s"} above`) : ""));
194
+ return 0;
195
+ },
196
+ },
197
+
198
+ // The Implementation header, PROPOSED rather than remembered (audit
199
+ // finding F10). The `work` playbook asked the agent twice to advance a
200
+ // field whose correct value coverage had already computed one file over.
201
+ // Advisory and non-mutating by design: the close prints the `set-header`
202
+ // op for the capabilities this change touched, and a human — or
203
+ // `spec set --implementation auto` — applies it. A gate that rewrote the
204
+ // claim it checks would be marking its own homework.
205
+ implementation: {
206
+ run: async () => {
207
+ const scope = touched.length > 0 ? new Set(touched) : null;
208
+ const derived = derivedImplementations(projectRoot, { only: scope });
209
+ const proposals = [];
210
+ for (const [cap, row] of derived) {
211
+ const specPath = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
212
+ if (!isFile(specPath)) continue;
213
+ const mismatch = implementationMismatch(specHeader(read(specPath), "Implementation"), row.derived);
214
+ if (mismatch) proposals.push({ cap, row, mismatch });
215
+ }
216
+ if (derived.size === 0) {
217
+ // "Every touched spec matches" over ZERO specs is vacuously true.
218
+ // A chore in a project with no capability spec reached this and was
219
+ // told its implementation headers were in order (change 0089).
220
+ console.log(c.gray("· no spec to check — the change touches no capability"));
221
+ return 0;
222
+ }
223
+ if (proposals.length === 0) {
224
+ console.log(c.green("ok") + ` every touched spec's Implementation header matches its coverage (${derived.size} checked)`);
225
+ return 0;
226
+ }
227
+ for (const { cap, row, mismatch } of proposals) {
228
+ console.log(c.yellow(" ! ") + `${cap}: Implementation is "${mismatch.written}" but ` +
229
+ `${row.covered}/${row.total} criteria have resolving proof`);
230
+ console.log(` ${c.gray("delta op: ")}${c.cyan(mismatch.op)}`);
231
+ console.log(` ${c.gray("or: ")}${c.cyan(`doctrina spec set ${cap} --implementation auto`)}`);
101
232
  }
102
233
  return 0;
103
234
  },
104
235
  },
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
- {
236
+
237
+ // verify is conditional: skipped with a loud note when no verify.json is
238
+ // declared, because a project may not have wired the real gate yet.
239
+ verify: verifyConfigured
240
+ ? { run: () => verify.run([], new Map()) }
241
+ : { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
242
+
243
+ coverage: {
110
244
  label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
111
245
  rerun: coverageRerun,
112
- run: () => coverage.run([], coverageFlags),
246
+ run: async () => {
247
+ const code = await coverage.run([], coverageFlags);
248
+ reportDependentCoverage(projectRoot, touched);
249
+ return code;
250
+ },
113
251
  },
252
+
114
253
  // trace is advisory (provenance is a warning, not a hard gate): report it,
115
254
  // never let it block the close.
116
- { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
255
+ trace: { run: async () => { await trace.run([], new Map()); return 0; } },
256
+
117
257
  // Docs ship inside the change (D2): a change that alters a documented
118
258
  // surface — a command, a flag, an exit code — closes only with the
119
259
  // documentation that describes it. A blocking gate, because a docs
120
260
  // phase scheduled after the work never happens; --force is the same
121
261
  // 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",
262
+ docs: {
126
263
  run: async () => {
127
- const r = checkDocsImpact(projectRoot, path.join(projectRoot, ".doctrina", "changes", id));
264
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
265
+ const r = checkDocsImpact(projectRoot, changeDir);
128
266
  if (r.ok) {
129
267
  console.log(c.green("ok") + ` ${r.reason}`);
130
- return 0;
268
+ // Documentation says how it works; the changelog says that it
269
+ // changed. Asking only the first is how thirteen changes closed in
270
+ // one session with an empty `## [Unreleased]` behind them.
271
+ const log = checkChangelogImpact(projectRoot, changeDir);
272
+ if (log.ok) {
273
+ console.log(c.green("ok") + ` ${log.reason}`);
274
+ return 0;
275
+ }
276
+ console.error(c.red("error:") + ` this change ${log.reason}:`);
277
+ for (const s of log.signals) console.error(` - ${s}`);
278
+ console.error(c.gray("hint: ") + "add an entry under the changelog's unreleased heading" +
279
+ ", or pass --force to close anyway (records the gap)");
280
+ docsGap = log;
281
+ return 1;
131
282
  }
132
283
  console.error(c.red("error:") + ` this change ${r.reason}:`);
133
284
  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)");
285
+ // The remedy comes out of the project being checked, never out of
286
+ // Doctrina's own repository (change 0058): an adopting project with
287
+ // no `docs/` was being told to write English AND Portuguese and to
288
+ // read a skill it does not have.
289
+ console.error(c.gray("hint: ") + docsRemedy(projectRoot) + ".");
290
+ // THE SECOND ANSWER, NAMED WHERE IT IS NEEDED.
291
+ //
292
+ // The gate reads names, and a name is all it can see: a change that
293
+ // explains an effect, or a Scope boundaries line saying what it does
294
+ // NOT touch, reads exactly like one that alters the command. When the
295
+ // only remedy on offer was `--force`, an author looking at a false
296
+ // positive took the worst of the two exits — the ledger fills with
297
+ // gaps that were never gaps, and a real one stops standing out.
298
+ //
299
+ // The declaration exists for precisely this (change 0139) and was
300
+ // named nowhere the author would be looking.
301
+ console.error(c.gray(" ") + "if the names above are only MENTIONED, say so on the record: " +
302
+ "add `- **Documented surface:** n/a — <why>` to the proposal.");
303
+ console.error(c.gray(" ") + "or pass --force to close anyway (records the gap).");
137
304
  docsGap = r;
138
305
  return 1;
139
306
  },
140
307
  },
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
308
 
145
- console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → docs → archive → validate"));
309
+ archive: {
310
+ rerun: `doctrina change archive ${id}${force ? " --force" : ""}`,
311
+ run: () => change.run(["archive", id], archiveFlags),
312
+ },
313
+
314
+ // In-process, like every other runner here: the close starts no subprocess
315
+ // of its own binary, and a criterion holds it to that.
316
+ "index-drift": { run: () => indexRebuild.run(["rebuild"], new Map([["check", true]])) },
317
+
318
+ validate: { run: () => validate.run([], new Map()) },
319
+ };
320
+
321
+ const steps = sequence("close").map((step) => {
322
+ const runner = runners[step.id] ?? {};
323
+ return {
324
+ id: step.id,
325
+ label: runner.label ?? step.label,
326
+ rerun: runner.rerun ?? stepRerun(step, id),
327
+ forceable: step.level === "forceable",
328
+ advisory: step.level === "advisory",
329
+ skip: runner.skip,
330
+ // A step declared with no in-process runner is a gap in THIS file, and
331
+ // it is reported as one — the same answer `doctor` gives (change 0060).
332
+ // The close used to shell out to its own binary here instead, which was
333
+ // the second integration style change 0045 removed from `doctor`; every
334
+ // declared step has a runner and a test holds it that way, so the
335
+ // fallback was a second answer to a question nobody could reach.
336
+ run: runner.run ?? (() => missingRunner(step)),
337
+ };
338
+ });
339
+
340
+ // The banner is the declaration read aloud, so it cannot fall behind the
341
+ // sequence it announces the way a hand-maintained string did.
342
+ console.log(c.bold(`Closing change ${id}`) +
343
+ c.gray(` — ${sequence("close").map((s) => s.label.replace(" (advisory)", "")).join(" → ")}`));
344
+
345
+ // What the closing line is allowed to claim. A step that was SKIPPED was
346
+ // not performed, and the conclusion used to say "verified, archived, and
347
+ // validated" as a fixed string — including on a close whose own step 7 had
348
+ // just printed `skip no .doctrina/verify.json`. The final line is the one
349
+ // sentence a human reads before approving; a word it did not earn is the
350
+ // most expensive claim in the tree (change 0089).
351
+ const ran = new Set();
352
+ const skippedSteps = [];
146
353
 
147
354
  for (let i = 0; i < steps.length; i++) {
148
355
  const step = steps[i];
@@ -150,6 +357,7 @@ async function closeOne(projectRoot, id, flags) {
150
357
  console.log(c.gray(`──── ${i + 1}/${steps.length} ${step.label}`));
151
358
  if (step.skip) {
152
359
  console.log(c.yellow("skip ") + step.skip);
360
+ skippedSteps.push(step);
153
361
  continue;
154
362
  }
155
363
  let code;
@@ -160,6 +368,13 @@ async function closeOne(projectRoot, id, flags) {
160
368
  console.error(c.red("error:") + ` ${err.message}`);
161
369
  }
162
370
  if (code !== 0) {
371
+ // The declared level decides, not the runner: an advisory step reports
372
+ // and the close carries on, which is what "advisory" means in
373
+ // lib/gates.js and what the surfaces must agree on.
374
+ if (step.advisory) {
375
+ console.log(c.yellow("warn:") + ` "${step.label}" reported findings (advisory — the close continues)`);
376
+ continue;
377
+ }
163
378
  // A forceable gate under --force warns and continues, matching how
164
379
  // `change archive --force` handles incomplete verification: the gap
165
380
  // is recorded, not hidden.
@@ -174,14 +389,17 @@ async function closeOne(projectRoot, id, flags) {
174
389
  console.log(` ${c.cyan(`doctrina close ${id}`)}`);
175
390
  return 1;
176
391
  }
392
+ ran.add(step.id);
177
393
  }
178
394
 
179
395
  // Record a forced docs gap in the ledger, so history shows the change
180
396
  // shipped without its documentation rather than showing nothing.
181
397
  if (docsGap) {
182
- const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
398
+ const ledgerPath = ledgerFile(projectRoot);
183
399
  if (isFile(ledgerPath)) {
184
- appendFileSync(ledgerPath, ` - docs gap: ${id} closed with --force; ${docsGap.signals.join("; ")} documented nowhere\n`);
400
+ // Written in the ledger's own entry grammar (change 0046): a waived
401
+ // gate no reader can find is the same as an unrecorded one.
402
+ appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
185
403
  console.log(c.yellow("ledger") + " recorded the docs gap");
186
404
  }
187
405
  }
@@ -197,13 +415,74 @@ async function closeOne(projectRoot, id, flags) {
197
415
  } catch { /* advisory only */ }
198
416
 
199
417
  console.log("");
200
- console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
418
+ console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
201
419
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
202
420
  return 0;
203
421
  }
204
422
 
423
+ // A step the sequence declares and this file does not implement. The gate map
424
+ // is the single source of truth for what a close runs (ADR 0017), so a step
425
+ // with no runner here is a defect in this file — not a reason to start a
426
+ // second process and not a reason to pass silently. `doctor` reaches the same
427
+ // conclusion for its own reporters; the two drivers now give one answer.
428
+ // The closing sentence, built from the steps that actually ran.
429
+ //
430
+ // Each word names one step: `verify` -> verified, `archive` -> archived,
431
+ // `validate` -> validated. A step that was skipped loses its word and is
432
+ // named instead, so the line can never claim more than the run performed.
433
+ function closingClaim(ran, skippedSteps) {
434
+ const words = [
435
+ ["verify", "verified"],
436
+ ["archive", "archived"],
437
+ ["validate", "validated"],
438
+ ].filter(([stepId]) => ran.has(stepId)).map(([, word]) => word);
439
+
440
+ const claimed = words.length === 0
441
+ ? ""
442
+ : words.length === 1
443
+ ? ` — ${words[0]}.`
444
+ : ` — ${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}.`;
445
+
446
+ const skipped = skippedSteps.map((s) => s.label.replace(" (advisory)", ""));
447
+ if (skipped.length === 0) return claimed || " — no gate ran.";
448
+ return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
449
+ }
450
+
451
+ function missingRunner(step) {
452
+ console.error(c.red("error:") +
453
+ ` "${step.label}" is declared in the close sequence but has no runner here`);
454
+ if (step.argv) {
455
+ console.error(c.gray("hint: ") + `run it directly: doctrina ${step.argv.join(" ")}`);
456
+ }
457
+ return 1;
458
+ }
459
+
205
460
  // The capabilities this change's deltas target — the honest scope for its
206
461
  // coverage gate. Read from the change folder's specs/**/delta.md files.
462
+ // The capabilities that DECLARE a dependency on what this change touched,
463
+ // with their coverage — advisory, never part of the verdict (change 0046).
464
+ //
465
+ // The scope exists because one deliberately deferred spec elsewhere in the
466
+ // tree must not block a change that never went near it, and widening the gate
467
+ // to dependents would give that problem straight back. What a closing agent
468
+ // actually needs is the pointer: this change moved ground something else
469
+ // stands on, and here is how well that something is proven today.
470
+ function reportDependentCoverage(projectRoot, touched) {
471
+ if (touched.length === 0) return;
472
+ const dependents = dependentsOf(projectRoot, touched);
473
+ if (dependents.length === 0) return;
474
+ const byCap = new Map(summarize(projectRoot).perCap.map((r) => [r.cap, r]));
475
+ console.log(c.gray(" dependents of the touched capabilities (advisory — not part of this gate):"));
476
+ for (const dep of dependents) {
477
+ const row = byCap.get(dep.capability);
478
+ const proof = row && row.total > 0
479
+ ? `${row.covered}/${row.total} criteria proven` + (row.dangling ? `, ${row.dangling} dangling` : "")
480
+ : "no acceptance criteria";
481
+ console.log(` ${c.cyan(dep.capability)} depends on ${dep.dependsOn.join(", ")} — ${proof}` +
482
+ c.gray(` (doctrina why ${dep.capability})`));
483
+ }
484
+ }
485
+
207
486
  function touchedCapabilities(projectRoot, id) {
208
487
  const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
209
488
  const caps = new Set();
@@ -215,24 +494,49 @@ function touchedCapabilities(projectRoot, id) {
215
494
  return [...caps].sort();
216
495
  }
217
496
 
497
+ // The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
498
+ // to be typed out in this string, which is how the help came to name ten
499
+ // steps of thirteen — review, implementation, docs and the index-drift check
500
+ // were added over time and the paragraph was not.
501
+ const SEQUENCE = wrapArrows(sequenceLabels("close"), 70, " ");
502
+
503
+ function wrapArrows(labels, width, indent) {
504
+ const lines = [];
505
+ let line = "";
506
+ for (const label of labels) {
507
+ const piece = line === "" ? label : `${line} → ${label}`;
508
+ if (piece.length + indent.length > width && line !== "") {
509
+ lines.push(indent + line + " →");
510
+ line = label;
511
+ } else line = piece;
512
+ }
513
+ if (line !== "") lines.push(indent + line);
514
+ return lines.join("\n");
515
+ }
516
+
218
517
  export const help = `
219
518
  Usage: doctrina close <id...> [--force]
220
519
 
221
520
  Run the whole closing sequence for a change in one pass, stopping at the
222
521
  first failure with the exact command to rerun:
223
522
 
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)
523
+ ${SEQUENCE}
524
+
525
+ then a closing skill-suggest listing (advisory).
227
526
 
228
527
  The coverage gate is scoped to the capabilities the change's deltas touch,
229
528
  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
529
+ a change with no deltas gates on the whole tree. The runtime gate holds the
530
+ contracts' declared wiring, enums and selectors to the implementation (the
531
+ RT01-RT05 checks \`contract check\` renders): an error blocks, a warning is
532
+ reported and the close continues, and a project with no contracts — or with
533
+ contracts declaring no rows — is reported UNCHECKED rather than passing.
534
+ verify is skipped (with a note) when no .doctrina/verify.json is declared;
535
+ trace, the ADR checkpoint
232
536
  (accepted ADRs citing the touched capabilities — amend via \`decision
233
537
  supersede\`, not silence), and the closing skill-suggest listing are
234
538
  advisory (reports, never blockers). This is a driver over the existing
235
- commands so the agent makes one call instead of nine and the human
539
+ commands so the agent makes one call instead of thirteen and the human
236
540
  approves once.
237
541
 
238
542
  Multiple ids close in sequence, each independently; the exit code is the
@@ -1,15 +1,21 @@
1
1
  // @ts-check
2
- import { OPERATIONS, COMMAND_NAMES } from "../lib/commands.js";
2
+ import { DEPRECATED, OPERATIONS } from "../lib/commands.js";
3
3
  import { c } from "../lib/colors.js";
4
4
 
5
5
  // Shell completions, generated from the same OPERATIONS catalog that feeds
6
6
  // `--help` and the docs drift tests — so the completion script can never
7
7
  // know a different surface than the CLI ships. Static output by design:
8
8
  // paste it into your shell config once; regenerate after upgrading.
9
+ //
10
+ // Deprecated operations are left out (change 0188): they still run, with a
11
+ // notice, for a script that already types them — but tab completion is how
12
+ // a person discovers the surface, and it should offer the replacement only.
13
+ const LIVE = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
14
+ const COMMAND_NAMES = [...new Set(LIVE.map(([op]) => op.split(" ")[0]))];
9
15
 
10
16
  function subcommandMap() {
11
17
  const map = new Map();
12
- for (const [op] of OPERATIONS) {
18
+ for (const [op] of LIVE) {
13
19
  const [cmd, sub] = op.split(" ");
14
20
  if (!sub) continue;
15
21
  if (!map.has(cmd)) map.set(cmd, []);