doctrina-cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +120 -17
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +38 -3
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +201 -8
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. package/templates/spec.md.template +22 -0
@@ -1,14 +1,21 @@
1
+ // @ts-check
2
+ import { getSection } from "../lib/doc-model.js";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { readdirSync } from "node:fs";
4
6
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
7
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
6
8
  import { specHeader } from "../lib/scan.js";
7
9
  import * as idx from "../lib/index-json.js";
8
10
  import { today } from "../lib/dates.js";
9
11
  import { flagBool } from "../lib/args.js";
10
12
  import { c } from "../lib/colors.js";
11
13
  import { suggest } from "../lib/suggest.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
15
+ import {
16
+ parseTable, parseRuntimeDeclaration, checkWiring, checkEmptySemantics,
17
+ checkEnums, checkSelectors,
18
+ } from "../lib/runtime.js";
12
19
 
13
20
  // Contracts are the first-class home for the integration/runtime surface
14
21
  // no single capability owns: the port map, the env/dependency contract,
@@ -20,6 +27,11 @@ import { suggest } from "../lib/suggest.js";
20
27
 
21
28
  const SUBCOMMANDS = ["new", "list", "check"];
22
29
 
30
+ // Flags this command accepts. Declared HERE, with the command, so
31
+ // adding a command never requires editing the entrypoint — the gap that
32
+ // let six flags ship undeclared and silently swallow a positional (C3).
33
+ export const flags = { boolean: ["json", "force"], string: [] };
34
+
23
35
  export async function run(positional, flags) {
24
36
  const sub = positional[0];
25
37
  switch (sub) {
@@ -55,11 +67,12 @@ function contractNew(args, flags) {
55
67
  return 1;
56
68
  }
57
69
 
58
- const templatesDir = locateTemplatesDir();
70
+ const tpl = readTemplate(projectRoot, "contract.md.template");
59
71
  const date = today();
60
- const body = substitute(read(path.join(templatesDir, "contract.md.template")), { CONTRACT_NAME: name, DATE: date });
72
+ const body = substitute(tpl.body, { CONTRACT_NAME: name, DATE: date });
61
73
  write(targetPath, body, { force });
62
- console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
74
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}` +
75
+ (tpl.source === "project" ? c.gray(" (project template)") : ""));
63
76
 
64
77
  const index = idx.load(projectRoot);
65
78
  idx.addContract(index, {
@@ -73,6 +86,7 @@ function contractNew(args, flags) {
73
86
  console.log(c.green("indexed") + ` contract "${name}"`);
74
87
  console.log("");
75
88
  console.log("Fill in the Ports / Environment / Interfaces tables, then run " + c.cyan("doctrina contract check") + ".");
89
+ console.log(c.gray("Declaring Wiring and Selectors rows is what makes the RUNTIME half checkable — without them, check can only verify the prose."));
76
90
  return 0;
77
91
  }
78
92
 
@@ -135,7 +149,7 @@ function contractCheck(args, _flags) {
135
149
  console.log(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
136
150
 
137
151
  // 1. Port collisions — two services must not claim the same port.
138
- const ports = parseTable(sectionOf(text, "Ports"));
152
+ const ports = parseTable(getSection(text, "Ports"));
139
153
  if (ports) {
140
154
  const portCol = colIndex(ports.headers, "port");
141
155
  const svcCol = colIndex(ports.headers, "service");
@@ -155,7 +169,7 @@ function contractCheck(args, _flags) {
155
169
 
156
170
  // 2. Environment drift — every declared variable must exist in
157
171
  // .env.example (when present), so code, example, and infra agree.
158
- const env = parseTable(sectionOf(text, "Environment"));
172
+ const env = parseTable(getSection(text, "Environment"));
159
173
  if (env && envExample !== null) {
160
174
  const varCol = colIndex(env.headers, "variable");
161
175
  for (const row of env.rows) {
@@ -170,13 +184,42 @@ function contractCheck(args, _flags) {
170
184
  }
171
185
 
172
186
  // 3. Referenced capability specs must exist.
173
- for (const refCap of referencedCapabilities(sectionOf(text, "References"))) {
187
+ for (const refCap of referencedCapabilities(getSection(text, "References"))) {
174
188
  const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
175
189
  if (!isFile(specPath)) {
176
190
  console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
177
191
  errors += 1;
178
192
  }
179
193
  }
194
+
195
+ // 4. The RUNTIME half: a declaration is only worth what binds it to the
196
+ // running system. These checks read the contract's Wiring, Selectors
197
+ // and Values declarations and hold the implementation to them — the
198
+ // "I set the secret in GitHub and nothing happened" class, the
199
+ // default that an empty value never triggers, the enum nothing
200
+ // validates, and the selector that matches zero cases and exits 0.
201
+ // Every section is optional, so a contract written before they
202
+ // existed checks exactly as it did before.
203
+ const decl = parseRuntimeDeclaration(text);
204
+ for (const f of [
205
+ ...checkWiring(projectRoot, decl),
206
+ ...checkEmptySemantics(projectRoot, decl),
207
+ ...checkEnums(projectRoot, decl),
208
+ ...checkSelectors(projectRoot, decl),
209
+ ]) {
210
+ const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
211
+ console.log(mark + f.message + c.gray(` [${f.code}]`));
212
+ console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
213
+ if (f.level === "error") errors += 1;
214
+ else warnings += 1;
215
+ }
216
+
217
+ if (decl.wiring.length + decl.selectors.length === 0) {
218
+ // Silence here is not proof of correctness — it is proof that nothing
219
+ // was declared. Saying so is what stops a green check from being read
220
+ // as "the wiring is verified".
221
+ console.log(c.gray(" · no Wiring or Selectors declared — the runtime surface is unchecked"));
222
+ }
180
223
  }
181
224
 
182
225
  console.log("");
@@ -189,37 +232,6 @@ function contractCheck(args, _flags) {
189
232
  return errors === 0 ? 0 : 1;
190
233
  }
191
234
 
192
- // Return the body of a "## <name>" section, up to the next "## " heading.
193
- function sectionOf(text, name) {
194
- const lines = text.split(/\r?\n/);
195
- const head = new RegExp(`^##\\s+${name}\\b`, "i");
196
- let inSection = false;
197
- const out = [];
198
- for (const line of lines) {
199
- if (/^##\s+/.test(line)) {
200
- inSection = head.test(line);
201
- continue;
202
- }
203
- if (inSection) out.push(line);
204
- }
205
- return out.join("\n");
206
- }
207
-
208
- // Parse a GitHub-flavoured Markdown table into { headers, rows }.
209
- function parseTable(text) {
210
- const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
211
- if (lines.length < 2) return null;
212
- const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cl) => cl.trim());
213
- const headers = cells(lines[0]);
214
- const rows = [];
215
- for (let i = 1; i < lines.length; i++) {
216
- const row = cells(lines[i]);
217
- if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue; // separator row
218
- rows.push(row);
219
- }
220
- return { headers, rows };
221
- }
222
-
223
235
  function colIndex(headers, name) {
224
236
  const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
225
237
  return i < 0 ? 0 : i;
@@ -234,7 +246,7 @@ function referencedCapabilities(text) {
234
246
 
235
247
  function ensureDoctrinaProject(projectRoot) {
236
248
  if (!exists(path.join(projectRoot, ".doctrina"))) {
237
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
249
+ throw notADoctrinaProject();
238
250
  }
239
251
  }
240
252
 
@@ -247,8 +259,29 @@ the port map, the environment contract, and API/WS/event interfaces.
247
259
  Subcommands:
248
260
  new <name> Scaffold .doctrina/contracts/<name>.md and index it
249
261
  list One line per contract: id, status, last updated
250
- check [<name>] Verify the mechanically checkable parts: no two
251
- services share a port, every declared env var exists
252
- in .env.example, and every referenced spec exists.
253
- Exits 1 on errors (port collisions, missing specs).
262
+ check [<name>] Verify the mechanically checkable parts.
263
+
264
+ Structure: no two services share a port, every
265
+ declared env var exists in .env.example, and every
266
+ referenced spec exists.
267
+
268
+ Runtime — the declaration held to the implementation:
269
+ RT01/02 a variable declared with origin vars or
270
+ secrets must be exported by an env: block
271
+ in the workflow the Wiring row names, from
272
+ the origin and name it declares.
273
+ RT03 its consumer must not give it a default
274
+ that only applies when ABSENT: CI injects
275
+ the empty string, so the default never
276
+ fires (textual lint).
277
+ RT04 a declared Values enum must hold in
278
+ .env.example, and the consumer should
279
+ validate it.
280
+ RT05 every declared selector must match at
281
+ least one target, or a run dispatched on
282
+ it executes nothing and still exits 0.
283
+
284
+ No CI system or test runner is parsed natively: each
285
+ check reads the glob, pattern and origin the contract
286
+ declares. Exits 1 on errors, 0 on warnings only.
254
287
  `;
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
@@ -6,6 +7,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
6
7
  import { specHeader } from "../lib/scan.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
8
9
  import { c } from "../lib/colors.js";
10
+ import { emitJson } from "../lib/json-out.js";
11
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
9
12
 
10
13
  // Traceability report: how many acceptance criteria cite an artifact or
11
14
  // test that actually exists on disk. Doctrina otherwise has no link
@@ -22,10 +25,19 @@ import { c } from "../lib/colors.js";
22
25
  // proves nothing — review G3: existence ≠ a passing test), "dangling" when
23
26
  // a cited path does not resolve, and "bare" when nothing is cited.
24
27
 
28
+ // Flags this command accepts. Declared HERE, with the command, so
29
+ // adding a command never requires editing the entrypoint — the gap that
30
+ // let six flags ship undeclared and silently swallow a positional (C3).
31
+ // This command builds its own JSON payload; the entrypoint must not
32
+ // wrap it in the generic envelope.
33
+ export const jsonNative = true;
34
+
35
+ export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
36
+
25
37
  export async function run(_positional, flags) {
26
38
  const projectRoot = process.cwd();
27
39
  if (!exists(path.join(projectRoot, ".doctrina"))) {
28
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
40
+ throw notADoctrinaProject();
29
41
  }
30
42
  const strict = flagBool(flags, "strict", false);
31
43
  const json = flagBool(flags, "json", false);
@@ -48,7 +60,7 @@ export async function run(_positional, flags) {
48
60
 
49
61
  if (reports.length === 0) {
50
62
  if (json) {
51
- console.log(JSON.stringify({ specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } }, null, 2));
63
+ emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } });
52
64
  return 0;
53
65
  }
54
66
  console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
@@ -59,23 +71,26 @@ export async function run(_positional, flags) {
59
71
  let totalCovered = 0;
60
72
  let totalDangling = 0;
61
73
  let totalConditional = 0;
74
+ let totalUnguarded = 0;
62
75
  let totalDeferred = 0;
63
76
  for (const rep of reports) {
64
77
  totalCriteria += rep.rows.length;
65
78
  totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
66
79
  totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
67
80
  totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
81
+ totalUnguarded += rep.rows.filter((r) => r.kind === "unguarded").length;
68
82
  totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
69
83
  }
70
84
  const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
71
85
  // Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
72
- const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
86
+ const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
87
+ && totalConditional === 0 && totalUnguarded === 0;
73
88
 
74
89
  if (json) {
75
- console.log(JSON.stringify({
90
+ emitJson("coverage", {
76
91
  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 },
78
- }, null, 2));
92
+ summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, unguarded: totalUnguarded, deferred: totalDeferred, pct: jsonPct },
93
+ });
79
94
  return jsonClean ? 0 : strict ? 1 : 0;
80
95
  }
81
96
 
@@ -86,10 +101,12 @@ export async function run(_positional, flags) {
86
101
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
87
102
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
88
103
  const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
104
+ const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
89
105
  const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
90
106
 
91
107
  const notes = [];
92
108
  if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
109
+ if (unguarded > 0) notes.push(c.yellow(`${unguarded} unguarded`));
93
110
  if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
94
111
  if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
95
112
  const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
@@ -102,6 +119,8 @@ export async function run(_positional, flags) {
102
119
  console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
103
120
  } else if (r.kind === "conditional") {
104
121
  console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
122
+ } else if (r.kind === "unguarded") {
123
+ console.log(` ${c.red("✗")} #${r.n} orchestration claim is not fail-closed: ${r.reason}`);
105
124
  } else {
106
125
  console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
107
126
  }
@@ -111,9 +130,11 @@ export async function run(_positional, flags) {
111
130
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
112
131
  console.log("");
113
132
  const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
114
- const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
133
+ const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
134
+ && totalConditional === 0 && totalUnguarded === 0;
115
135
  const extras = [];
116
136
  if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
137
+ if (totalUnguarded > 0) extras.push(`${totalUnguarded} unguarded`);
117
138
  if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
118
139
  if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
119
140
  const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
@@ -236,13 +257,14 @@ export function summarize(projectRoot) {
236
257
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
237
258
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
238
259
  const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
260
+ const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
239
261
  const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
240
262
  totalCriteria += rep.rows.length;
241
263
  totalCovered += covered;
242
264
  totalDangling += dangling;
243
265
  totalConditional += conditional;
244
266
  totalDeferred += deferred;
245
- perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, deferred });
267
+ perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, unguarded, deferred });
246
268
  }
247
269
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
248
270
  return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
@@ -285,8 +307,84 @@ function extractAcceptanceCriteria(text) {
285
307
  return out.filter((s) => s.length > 0);
286
308
  }
287
309
 
310
+ // ORCHESTRATION criteria (change 0029). Coverage measures CITATION: a
311
+ // criterion is covered when it cites a file that exists (and, since G3, a
312
+ // test whose suite is not skipped). That is the right test for "this
313
+ // function behaves", and the wrong one for "the pipeline ran at all".
314
+ //
315
+ // A criterion like "absence of the report is explicit and the step does not
316
+ // fail" is satisfied, on paper, by a job that executed zero cases and
317
+ // printed a well-written empty state — the citation resolves, the suite is
318
+ // not skipped, and the only visible signal is a tidy message saying nothing
319
+ // happened. Coverage calls that proven.
320
+ //
321
+ // Marking a criterion `[orchestration]` says: the claim is that a RUN
322
+ // happened, so the proof must be a fail-closed one. It cites a verify check
323
+ // by name (`verify:<check>`), and that check must declare an `expect` guard
324
+ // — the thing that turns "exit 0" into "exit 0 having actually done
325
+ // something". A named check without a guard is UNGUARDED: the criterion is
326
+ // not proven, and says so, instead of quietly counting as covered.
327
+ const ORCHESTRATION_MARKER = /^orchestration\b/;
328
+
329
+ function citedVerifyChecks(criterion) {
330
+ const out = new Set();
331
+ for (const m of criterion.matchAll(/`verify:([A-Za-z0-9_.-]+)`/g)) out.add(m[1]);
332
+ return [...out];
333
+ }
334
+
335
+ function guardedVerifyChecks(projectRoot) {
336
+ const configPath = path.join(projectRoot, ".doctrina", "verify.json");
337
+ if (!isFile(configPath)) return null;
338
+ let config;
339
+ try {
340
+ config = JSON.parse(read(configPath));
341
+ } catch {
342
+ return null;
343
+ }
344
+ const map = new Map();
345
+ for (const ch of Array.isArray(config?.checks) ? config.checks : []) {
346
+ if (!ch?.name) continue;
347
+ const exp = ch.expect;
348
+ const guarded = Boolean(exp && typeof exp === "object"
349
+ && (typeof exp.fail_if_output_matches === "string" || typeof exp.require_output_matches === "string"));
350
+ map.set(ch.name, guarded);
351
+ }
352
+ return map;
353
+ }
354
+
355
+ function classifyOrchestration(criterion, n, projectRoot) {
356
+ const named = citedVerifyChecks(criterion);
357
+ if (named.length === 0) {
358
+ return { kind: "unguarded", n, reason: "cites no verify check — an orchestration claim is proven by a fail-closed check, cited as `verify:<check>`" };
359
+ }
360
+ const checks = guardedVerifyChecks(projectRoot);
361
+ if (checks === null) {
362
+ return { kind: "dangling", n, missing: named.map((x) => `verify:${x}`) };
363
+ }
364
+ const unknown = named.filter((x) => !checks.has(x));
365
+ if (unknown.length > 0) {
366
+ return { kind: "dangling", n, missing: unknown.map((x) => `verify:${x}`) };
367
+ }
368
+ const unguarded = named.filter((x) => !checks.get(x));
369
+ if (unguarded.length > 0) {
370
+ return {
371
+ kind: "unguarded",
372
+ n,
373
+ reason: `verify check${unguarded.length === 1 ? "" : "s"} ${unguarded.join(", ")} declare${unguarded.length === 1 ? "s" : ""} no "expect" guard — a check that exits 0 having run nothing would still pass it`,
374
+ };
375
+ }
376
+ return { kind: "covered", n, evidence: [] };
377
+ }
378
+
288
379
  // Decide whether a single criterion is covered, conditional, dangling, or bare.
289
380
  function classify(criterion, n, projectRoot, specDir) {
381
+ // An orchestration criterion is judged on its GUARD, not on whether a
382
+ // cited file exists — the whole point is that existence proves nothing here.
383
+ const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
384
+ if (ORCHESTRATION_MARKER.test(marker)) {
385
+ return classifyOrchestration(criterion, n, projectRoot);
386
+ }
387
+
290
388
  const cited = extractBacktickPaths(criterion);
291
389
  if (cited.length === 0) return { kind: "bare", n };
292
390
  const missing = [];
@@ -378,15 +476,31 @@ A criterion is covered when a cited path resolves to real proof,
378
476
  conditional when its only resolving proof is a test file whose suite is
379
477
  skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
380
478
  nothing), dangling when a cited path is missing on disk, and bare when
381
- nothing is cited. A spec that declares a deliberate deferral —
479
+ nothing is cited.
480
+
481
+ ORCHESTRATION criteria. Citation is the right proof for "this function
482
+ behaves" and the wrong one for "the pipeline ran at all": a criterion
483
+ like "absence of the report is explicit and the step does not fail" is
484
+ satisfied on paper by a job that executed zero cases and printed a
485
+ well-written empty state. Mark such a criterion [orchestration] and cite
486
+ a verify check by name:
487
+
488
+ 3. [orchestration] the e2e suite actually executes scenarios —
489
+ verified by \`verify:e2e\`
490
+
491
+ That check must declare an "expect" guard (see \`doctrina verify --help\`).
492
+ A cited check with no guard is reported UNGUARDED and fails --strict: a
493
+ check that exits 0 having run nothing would otherwise satisfy the claim.
494
+
495
+ A spec that declares a deliberate deferral —
382
496
  \`Implementation: planned — <why>\`, the same escape hatch validate
383
497
  honours — has its unproven criteria reported as DEFERRED: visible, but
384
498
  never a --strict failure (declared debt is not hidden debt).
385
499
 
386
500
  Flags:
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).
501
+ --strict Exit 1 when any criterion is bare, dangling,
502
+ conditional, or unguarded (CI gate). Deferred never
503
+ fails. Without it the command always exits 0 (a report).
390
504
  --only <cap,cap> Scope the report/gate to specific capabilities
391
505
  (\`doctrina close\` uses this so an unrelated deferred
392
506
  spec cannot block a change's close).