doctrina-cli 0.11.0 → 0.13.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.
@@ -1,14 +1,17 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isFile } from "../lib/fs-ops.js";
3
+ import { exists, isFile, read, walk } from "../lib/fs-ops.js";
4
4
  import { flagBool } from "../lib/args.js";
5
5
  import { c } from "../lib/colors.js";
6
+ import { parseCapabilityFromDelta } from "./change.js";
7
+ import { printAdrCheckpoint } from "../lib/adr-guard.js";
6
8
  import * as analyze from "./analyze.js";
7
9
  import * as change from "./change.js";
8
10
  import * as verify from "./verify.js";
9
11
  import * as coverage from "./coverage.js";
10
12
  import * as trace from "./trace.js";
11
13
  import * as validate from "./validate.js";
14
+ import * as skill from "./skill.js";
12
15
 
13
16
  // One-command close (review 2026-06-27 passive-user feature #2). The work
14
17
  // playbook lists the closing sequence — analyze → apply → verify → coverage →
@@ -19,8 +22,7 @@ import * as validate from "./validate.js";
19
22
  // driver over the existing commands; it adds no new checks of its own.
20
23
 
21
24
  export async function run(positional, flags) {
22
- const id = positional[0];
23
- if (!id) {
25
+ if (positional.length === 0) {
24
26
  console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
25
27
  return 2;
26
28
  }
@@ -29,21 +31,72 @@ export async function run(positional, flags) {
29
31
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
30
32
  }
31
33
 
34
+ // Batch close (operator review 2026-07-19 §4.5): each id closes
35
+ // independently, worst exit code wins, so a 9-change implementation lot is
36
+ // one command, not nine.
37
+ let worst = 0;
38
+ for (const id of positional) {
39
+ if (positional.length > 1) {
40
+ console.log("");
41
+ console.log(c.bold(`════ close ${id}`));
42
+ }
43
+ worst = Math.max(worst, await closeOne(projectRoot, id, flags));
44
+ }
45
+ if (positional.length > 1) {
46
+ console.log("");
47
+ console.log(worst === 0
48
+ ? c.green("ok") + ` all ${positional.length} changes closed`
49
+ : c.red("fail") + " at least one close stopped — see above");
50
+ }
51
+ return worst;
52
+ }
53
+
54
+ async function closeOne(projectRoot, id, flags) {
32
55
  const force = flagBool(flags, "force", false);
33
- const strict = new Map([["strict", true]]);
34
56
  const archiveFlags = force ? new Map([["force", true]]) : new Map();
35
57
 
58
+ // Scope the coverage gate to the capabilities THIS change touches (its
59
+ // deltas), so one deliberately deferred spec elsewhere in the tree cannot
60
+ // block closing a change that never went near it (0.11.0 field review
61
+ // item 4). A change with no deltas (chore / metadata-only) falls back to
62
+ // the whole-tree gate — there is no narrower honest scope for it.
63
+ const touched = touchedCapabilities(projectRoot, id);
64
+ const coverageFlags = new Map([["strict", true]]);
65
+ let coverageRerun = "doctrina coverage --strict";
66
+ if (touched.length > 0) {
67
+ coverageFlags.set("only", touched.join(","));
68
+ coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
69
+ }
70
+
36
71
  // Each step: a label, the command that runs it, and the literal command to
37
72
  // rerun on failure. verify is conditional (skipped, with a loud note, when no
38
73
  // verify.json is declared — a project may not have wired the real gate yet).
39
74
  const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
40
75
  const steps = [
41
76
  { label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
77
+ // ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
78
+ // step was skippable in silence. Advisory — it names the accepted ADRs
79
+ // whose text cites the touched capabilities and the amend commands, but
80
+ // an ADR merely mentioning a capability is normal, so it never blocks.
81
+ {
82
+ label: "ADR checkpoint (advisory)",
83
+ rerun: "doctrina decision list",
84
+ run: async () => {
85
+ if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
86
+ console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
87
+ }
88
+ return 0;
89
+ },
90
+ },
42
91
  { label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
43
92
  verifyConfigured
44
93
  ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
45
94
  : { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
46
- { label: "coverage", rerun: "doctrina coverage --strict", run: () => coverage.run([], strict) },
95
+ {
96
+ label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
97
+ rerun: coverageRerun,
98
+ run: () => coverage.run([], coverageFlags),
99
+ },
47
100
  // trace is advisory (provenance is a warning, not a hard gate): report it,
48
101
  // never let it block the close.
49
102
  { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
@@ -51,7 +104,7 @@ export async function run(positional, flags) {
51
104
  { label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
52
105
  ];
53
106
 
54
- console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
107
+ console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → archive → validate"));
55
108
 
56
109
  for (let i = 0; i < steps.length; i++) {
57
110
  const step = steps[i];
@@ -78,25 +131,58 @@ export async function run(positional, flags) {
78
131
  }
79
132
  }
80
133
 
134
+ // Capture the lesson while it is fresh (operator review §4.7): surface the
135
+ // fix-shaped lessons not yet written as skills, right where the operator
136
+ // just proved they finish what they start. Advisory — a listing, never a
137
+ // gate, and a suggest failure never un-closes the change.
138
+ console.log("");
139
+ console.log(c.gray("──── skills (advisory)"));
140
+ try {
141
+ await skill.run(["suggest"], new Map());
142
+ } catch { /* advisory only */ }
143
+
81
144
  console.log("");
82
145
  console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
83
146
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
84
147
  return 0;
85
148
  }
86
149
 
150
+ // The capabilities this change's deltas target — the honest scope for its
151
+ // coverage gate. Read from the change folder's specs/**/delta.md files.
152
+ function touchedCapabilities(projectRoot, id) {
153
+ const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
154
+ const caps = new Set();
155
+ for (const deltaPath of walk(specsDir)) {
156
+ if (!deltaPath.endsWith("delta.md")) continue;
157
+ const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
158
+ if (cap) caps.add(cap);
159
+ }
160
+ return [...caps].sort();
161
+ }
162
+
87
163
  export const help = `
88
- Usage: doctrina close <id> [--force]
164
+ Usage: doctrina close <id...> [--force]
89
165
 
90
166
  Run the whole closing sequence for a change in one pass, stopping at the
91
167
  first failure with the exact command to rerun:
92
168
 
93
- analyze → change apply → verify → coverage --strict → trace → change
94
- archive → validate
169
+ analyze → ADR checkpoint (advisory) → change apply → verify → coverage
170
+ --strict (scoped to the change's touched capabilities) → trace → change
171
+ archive → validate → skill suggest (advisory)
172
+
173
+ The coverage gate is scoped to the capabilities the change's deltas touch,
174
+ so a deliberately deferred spec elsewhere cannot block an unrelated close;
175
+ a change with no deltas gates on the whole tree. verify is skipped (with a
176
+ note) when no .doctrina/verify.json is declared; trace, the ADR checkpoint
177
+ (accepted ADRs citing the touched capabilities — amend via \`decision
178
+ supersede\`, not silence), and the closing skill-suggest listing are
179
+ advisory (reports, never blockers). This is a driver over the existing
180
+ commands so the agent makes one call instead of nine and the human
181
+ approves once.
95
182
 
96
- verify is skipped (with a note) when no .doctrina/verify.json is declared;
97
- trace is advisory (a report, never a blocker). This is a driver over the
98
- existing commands — it adds no checks of its own — so the agent makes one
99
- call instead of seven and the human approves once.
183
+ Multiple ids close in sequence, each independently; the exit code is the
184
+ worst per-id result. Preview what close would refuse: \`doctrina change
185
+ check <id>\`.
100
186
 
101
187
  Options:
102
188
  --force Pass through to \`change archive\` (archive even if verification
@@ -3,7 +3,7 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
6
- import { listHeader } from "../lib/scan.js";
6
+ import { listHeader, parseDependsOn } from "../lib/scan.js";
7
7
  import { parseFrontmatter } from "./skill.js";
8
8
  import { flagBool, flagString } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
@@ -80,6 +80,15 @@ export async function run(positional, flags) {
80
80
  const specRel = `.doctrina/specs/${capability}/spec.md`;
81
81
  if (isFile(path.join(projectRoot, specRel))) {
82
82
  pushFile(specRel, `spec: ${capability}`);
83
+ // Pull the declared dependencies into the pack (the machine-readable
84
+ // **Depends on:** header): a task on `risk-map` that builds on
85
+ // `reporting` needs both truths in one read. One level deep,
86
+ // deterministic, no cycles (each spec is pushed at most once).
87
+ for (const dep of parseDependsOn(read(path.join(projectRoot, specRel)))) {
88
+ if (dep === capability) continue;
89
+ const depRel = `.doctrina/specs/${dep}/spec.md`;
90
+ if (isFile(path.join(projectRoot, depRel))) pushFile(depRel, `dependency of ${capability}`);
91
+ }
83
92
  } else {
84
93
  specMissing = true;
85
94
  }
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
6
+ import { specHeader } from "../lib/scan.js";
7
+ import { flagBool, flagString } from "../lib/args.js";
6
8
  import { c } from "../lib/colors.js";
7
9
 
8
10
  // Traceability report: how many acceptance criteria cite an artifact or
@@ -28,7 +30,21 @@ export async function run(_positional, flags) {
28
30
  const strict = flagBool(flags, "strict", false);
29
31
  const json = flagBool(flags, "json", false);
30
32
 
31
- const reports = collect(projectRoot);
33
+ // --only <cap,cap>: scope the report/gate to specific capabilities — the
34
+ // hook `doctrina close` uses so one deliberately deferred spec elsewhere in
35
+ // the tree cannot block closing a change that never touched it.
36
+ const onlyRaw = flagString(flags, "only");
37
+ const only = onlyRaw ? new Set(onlyRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null;
38
+
39
+ const reports = collect(projectRoot, { only });
40
+
41
+ // --run: execute the cited evidence instead of only checking it exists —
42
+ // promotes "the file is on disk" to "the proof passes". The runner is
43
+ // project-declared in .doctrina/verify.json ("evidence_runner": a command
44
+ // template with a {file} placeholder); the CLI never guesses a test runner.
45
+ if (flagBool(flags, "run", false)) {
46
+ return runEvidence(projectRoot, reports, strict);
47
+ }
32
48
 
33
49
  if (reports.length === 0) {
34
50
  if (json) {
@@ -43,19 +59,22 @@ export async function run(_positional, flags) {
43
59
  let totalCovered = 0;
44
60
  let totalDangling = 0;
45
61
  let totalConditional = 0;
62
+ let totalDeferred = 0;
46
63
  for (const rep of reports) {
47
64
  totalCriteria += rep.rows.length;
48
65
  totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
49
66
  totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
50
67
  totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
68
+ totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
51
69
  }
52
70
  const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
53
- const jsonClean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
71
+ // Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
72
+ const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
54
73
 
55
74
  if (json) {
56
75
  console.log(JSON.stringify({
57
- specs: reports.map((rep) => ({ capability: rep.cap, criteria: rep.rows })),
58
- summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, pct: jsonPct },
76
+ specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
77
+ summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
59
78
  }, null, 2));
60
79
  return jsonClean ? 0 : strict ? 1 : 0;
61
80
  }
@@ -67,15 +86,19 @@ export async function run(_positional, flags) {
67
86
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
68
87
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
69
88
  const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
89
+ const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
70
90
 
71
91
  const notes = [];
72
92
  if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
73
93
  if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
94
+ if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
74
95
  const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
75
96
  console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
76
97
  for (const r of rep.rows) {
77
98
  if (r.kind === "covered") continue;
78
- if (r.kind === "bare") {
99
+ if (r.kind === "deferred") {
100
+ console.log(` ${c.gray("○")} #${r.n} deferred — spec declares "Implementation: planned — <why>" (visible, not gated)`);
101
+ } else if (r.kind === "bare") {
79
102
  console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
80
103
  } else if (r.kind === "conditional") {
81
104
  console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
@@ -88,64 +111,141 @@ export async function run(_positional, flags) {
88
111
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
89
112
  console.log("");
90
113
  const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
91
- const clean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
92
- if (clean) {
93
- console.log(c.green("ok") + " " + summary);
94
- return 0;
95
- }
114
+ const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
96
115
  const extras = [];
97
116
  if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
98
117
  if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
118
+ if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
99
119
  const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
120
+ if (clean) {
121
+ console.log(c.green("ok") + " " + summary + extraSummary);
122
+ return 0;
123
+ }
100
124
  console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + extraSummary);
101
125
  // A report by default (exit 0); a gate under --strict (exit 1 for CI). A
102
126
  // conditional criterion fails the gate too: a skipped test is not proof.
127
+ // Deferred criteria never fail: the deferral is declared in the spec.
103
128
  return strict ? 1 : 0;
104
129
  }
105
130
 
131
+ // --run: execute every unique cited-and-resolving evidence file through the
132
+ // project-declared runner. Declared in .doctrina/verify.json as
133
+ // "evidence_runner": "python -m pytest {file}"
134
+ // ({file} is replaced per file). Exit 1 when any run fails (or under --strict
135
+ // when nothing is runnable). Deferred specs are skipped like everywhere else.
136
+ function runEvidence(projectRoot, reports, strict) {
137
+ const cfgPath = path.join(projectRoot, ".doctrina", "verify.json");
138
+ let runner = null;
139
+ if (isFile(cfgPath)) {
140
+ try { runner = JSON.parse(read(cfgPath))?.evidence_runner ?? null; } catch { runner = null; }
141
+ }
142
+ if (!runner || typeof runner !== "string" || !runner.includes("{file}")) {
143
+ console.error(c.red("error:") + " coverage --run needs an \"evidence_runner\" in .doctrina/verify.json");
144
+ console.error(c.gray("hint: ") + `add e.g. "evidence_runner": "python -m pytest {file}" (the {file} placeholder is required — the CLI never guesses a test runner)`);
145
+ return 1;
146
+ }
147
+
148
+ // Unique evidence files: cited by a non-deferred criterion, resolving on
149
+ // disk, and test-shaped (running a cited source artifact proves nothing).
150
+ const files = new Set();
151
+ for (const rep of reports) {
152
+ for (const r of rep.rows) {
153
+ if (rep.deferred || r.kind === "deferred") continue;
154
+ for (const token of r.evidence ?? []) files.add(token);
155
+ }
156
+ }
157
+
158
+ if (files.size === 0) {
159
+ console.log(c.gray("no runnable evidence found (no non-deferred criterion cites a resolving test file)"));
160
+ return strict ? 1 : 0;
161
+ }
162
+
163
+ console.log(c.bold("coverage --run") + c.gray(` — executing ${files.size} evidence file${files.size === 1 ? "" : "s"} via: ${runner}`));
164
+ let failed = 0;
165
+ for (const file of [...files].sort()) {
166
+ const cmd = runner.replaceAll("{file}", file);
167
+ console.log("");
168
+ console.log(c.gray(`──── ${cmd}`));
169
+ const res = spawnSync(cmd, { cwd: projectRoot, shell: true, stdio: "inherit" });
170
+ const ok = !res.error && res.status === 0;
171
+ console.log(ok ? c.green(`✓ ${file}`) : c.red(`✗ ${file}${res.error ? ` — ${res.error.message}` : ` (exit ${res.status})`}`));
172
+ if (!ok) failed += 1;
173
+ }
174
+ console.log("");
175
+ if (failed === 0) {
176
+ console.log(c.green("ok") + ` ${files.size}/${files.size} evidence runs passed — cited proof actually proves`);
177
+ return 0;
178
+ }
179
+ console.log(c.red("fail") + ` ${files.size - failed}/${files.size} evidence runs passed — a cited proof does not pass`);
180
+ return 1;
181
+ }
182
+
106
183
  // Per-spec criterion rows — the full classification behind both the report
107
184
  // and the --json output. Each row: { n, kind, missing?, skipped? }.
108
- export function collect(projectRoot) {
185
+ //
186
+ // A spec that declares a deliberate deferral — `Implementation: planned —
187
+ // <why>`, the exact escape hatch `validate` already honours — has its
188
+ // non-covered criteria remapped to kind "deferred": visible in every report,
189
+ // never a --strict failure. Declared debt and hidden debt stop being punished
190
+ // identically (0.11.0 field review item 4: one deferred capability poisoned
191
+ // the close of every unrelated change).
192
+ export function collect(projectRoot, { only = null } = {}) {
109
193
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
110
194
  const reports = [];
111
195
  if (isDir(specsDir)) {
112
196
  for (const cap of readdirSync(specsDir).sort()) {
197
+ if (only && !only.has(cap)) continue;
113
198
  const specPath = path.join(specsDir, cap, "spec.md");
114
199
  if (!isFile(specPath)) continue;
115
- const criteria = extractAcceptanceCriteria(read(specPath));
200
+ const text = read(specPath);
201
+ const criteria = extractAcceptanceCriteria(text);
116
202
  if (criteria.length === 0) continue;
117
- const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
118
- reports.push({ cap, specPath, rows });
203
+ const deferred = isDeclaredDeferral(text);
204
+ const rows = criteria.map((crit, i) => {
205
+ const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
206
+ if (deferred && row.kind !== "covered") {
207
+ return { ...row, kind: "deferred", was: row.kind };
208
+ }
209
+ return row;
210
+ });
211
+ reports.push({ cap, specPath, rows, deferred });
119
212
  }
120
213
  }
121
214
  return reports;
122
215
  }
123
216
 
217
+ // The declared-deferral escape hatch, matching validate's two-axis check:
218
+ // Implementation is "planned" WITH an explanatory note after the state word.
219
+ // A bare "planned" is an inventory claim, not a deferral, and gets no pass.
220
+ function isDeclaredDeferral(specText) {
221
+ const implRaw = specHeader(specText, "Implementation");
222
+ if (!implRaw) return false;
223
+ const tokens = implRaw.trim().split(/\s+/);
224
+ const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
225
+ return word === "planned" && tokens.length > 1;
226
+ }
227
+
124
228
  // Pure summary of coverage across the spec tree, for other commands
125
229
  // (`status`, `review`) that need the numbers without the report output.
230
+ // Deferred criteria (declared deferral, see collect) are counted separately
231
+ // and excluded from the problem counts, matching the gate semantics.
126
232
  export function summarize(projectRoot) {
127
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
128
- let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0;
233
+ let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
129
234
  const perCap = [];
130
- if (isDir(specsDir)) {
131
- for (const cap of readdirSync(specsDir).sort()) {
132
- const specPath = path.join(specsDir, cap, "spec.md");
133
- if (!isFile(specPath)) continue;
134
- const criteria = extractAcceptanceCriteria(read(specPath));
135
- if (criteria.length === 0) continue;
136
- const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
137
- const covered = rows.filter((r) => r.kind === "covered").length;
138
- const dangling = rows.filter((r) => r.kind === "dangling").length;
139
- const conditional = rows.filter((r) => r.kind === "conditional").length;
140
- totalCriteria += rows.length;
141
- totalCovered += covered;
142
- totalDangling += dangling;
143
- totalConditional += conditional;
144
- perCap.push({ cap, total: rows.length, covered, dangling, conditional });
145
- }
235
+ for (const rep of collect(projectRoot)) {
236
+ const covered = rep.rows.filter((r) => r.kind === "covered").length;
237
+ const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
238
+ const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
239
+ const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
240
+ totalCriteria += rep.rows.length;
241
+ totalCovered += covered;
242
+ totalDangling += dangling;
243
+ totalConditional += conditional;
244
+ totalDeferred += deferred;
245
+ perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, deferred });
146
246
  }
147
247
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
148
- return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, pct };
248
+ return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
149
249
  }
150
250
 
151
251
  // Pull the numbered items out of the "## Acceptance criteria" section.
@@ -206,11 +306,14 @@ function classify(criterion, n, projectRoot, specDir) {
206
306
  resolved.push({ token, isTest, skipped });
207
307
  }
208
308
  if (resolved.length === 0) return { kind: "dangling", n, missing };
309
+ // Runnable evidence: the resolving test-shaped citations, recorded so
310
+ // `coverage --run` can execute them (existence → passing proof).
311
+ const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
209
312
  // Real proof = a non-test artifact, or a test file whose suite runs. If the
210
313
  // only thing that resolves is a skipped test, the criterion is conditional.
211
314
  const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
212
- if (hasRealProof) return { kind: "covered", n };
213
- return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
315
+ if (hasRealProof) return { kind: "covered", n, evidence };
316
+ return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [] };
214
317
  }
215
318
 
216
319
  // A cited path is a test file when it sits under a tests directory or carries
@@ -265,7 +368,7 @@ function looksLikePath(s) {
265
368
  }
266
369
 
267
370
  export const help = `
268
- Usage: doctrina coverage [--strict]
371
+ Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
269
372
 
270
373
  Report how many acceptance criteria across .doctrina/specs/ cite an
271
374
  artifact or test that exists on disk. Each numbered criterion may cite
@@ -275,10 +378,23 @@ A criterion is covered when a cited path resolves to real proof,
275
378
  conditional when its only resolving proof is a test file whose suite is
276
379
  skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
277
380
  nothing), dangling when a cited path is missing on disk, and bare when
278
- nothing is cited. Read-only.
381
+ nothing is cited. A spec that declares a deliberate deferral —
382
+ \`Implementation: planned — <why>\`, the same escape hatch validate
383
+ honours — has its unproven criteria reported as DEFERRED: visible, but
384
+ never a --strict failure (declared debt is not hidden debt).
279
385
 
280
386
  Flags:
281
- --strict Exit 1 when any criterion is bare, dangling, or conditional
282
- (CI gate). Without it the command always exits 0 (a report).
283
- --json Emit per-spec criterion rows + summary as JSON.
387
+ --strict Exit 1 when any criterion is bare, dangling, or
388
+ conditional (CI gate). Deferred never fails. Without
389
+ it the command always exits 0 (a report).
390
+ --only <cap,cap> Scope the report/gate to specific capabilities
391
+ (\`doctrina close\` uses this so an unrelated deferred
392
+ spec cannot block a change's close).
393
+ --run Execute the cited evidence via the project-declared
394
+ "evidence_runner" in .doctrina/verify.json (a command
395
+ template with a {file} placeholder, e.g.
396
+ "python -m pytest {file}"). Exits 1 when any run
397
+ fails — promotes "the file exists" to "the proof
398
+ passes". The CLI never guesses a test runner.
399
+ --json Emit per-spec criterion rows + summary as JSON.
284
400
  `;
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
4
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
5
+ import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
5
6
  import { today } from "../lib/dates.js";
6
7
  import { cliVersion } from "../lib/version.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
@@ -108,10 +109,13 @@ export async function run(_positional, flags) {
108
109
  FRAMEWORK_VERSION: cliVersion(),
109
110
  };
110
111
 
111
- // Root AGENTS.md — with optional --from base content prepended.
112
+ // Root AGENTS.md — with optional --from base content prepended. The
113
+ // doctrina:surface block is regenerated from the installed catalog at init
114
+ // time, so a freshly scaffolded hub can never document a stale surface even
115
+ // if the static template lags a release (ADR 0015).
112
116
  const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
113
117
  const agentsTemplateBody = read(agentsTemplatePath);
114
- const projectAgents = substitute(agentsTemplateBody, tokens);
118
+ const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
115
119
  const finalAgents = fromAgentsContent
116
120
  ? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
117
121
  : projectAgents;
@@ -178,6 +182,15 @@ export async function run(_positional, flags) {
178
182
  return 0;
179
183
  }
180
184
 
185
+ // Replace the template's doctrina:surface span with the one generated from
186
+ // the running CLI's catalog; pass the text through untouched when the
187
+ // template carries no markers (older custom templates).
188
+ function refreshSurfaceBlock(text) {
189
+ const block = findSurfaceBlock(text);
190
+ if (!block) return text;
191
+ return text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
192
+ }
193
+
181
194
  // First non-empty line of the intake, stripped of Markdown heading marks
182
195
  // and clipped, as the one-line description token for the templates.
183
196
  function firstLineSummary(text) {