doctrina-cli 0.14.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > CLI for the Doctrina framework. The package is published as
4
4
  > `doctrina-cli`; the executable it installs is `doctrina`.
5
5
 
6
- **Status:** v0.14.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.15.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -4,6 +4,7 @@ import process from "node:process";
4
4
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
5
  import { c } from "../lib/colors.js";
6
6
  import { isUntouchedScaffold } from "./change.js";
7
+ import { collectBudgets } from "../lib/runtime.js";
7
8
 
8
9
  // Flags this command accepts. Declared HERE, with the command, so
9
10
  // adding a command never requires editing the entrypoint — the gap that
@@ -167,9 +168,64 @@ export function collectAnalysis(projectRoot, changeDir) {
167
168
  }
168
169
  }
169
170
 
171
+ // BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
172
+ // a preference — and the tempting fix for "the output blew the limit" is
173
+ // to raise the limit. That buys headroom by truncating what mattered
174
+ // instead of sending less, and it is the discussion that comes back every
175
+ // quarter because nothing ever recorded that it had been settled. Raising
176
+ // an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
177
+ // resolve an overflow is the move this refuses.
178
+ for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
179
+
180
+ return results;
181
+ }
182
+
183
+ // Compare the ceilings a change's proposal/deltas propose against the ones
184
+ // the contracts declare today. Text-scanned: a change states a new value in
185
+ // prose long before any code moves, which is exactly when it should be
186
+ // argued about.
187
+ function checkBudgetRaises(projectRoot, changeDir) {
188
+ const results = [];
189
+ let declared;
190
+ try {
191
+ declared = collectBudgets(projectRoot);
192
+ } catch {
193
+ return results;
194
+ }
195
+ if (declared.size === 0) return results;
196
+
197
+ const texts = [];
198
+ for (const f of walk(changeDir)) {
199
+ if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
200
+ }
201
+
202
+ for (const [name, budget] of declared) {
203
+ if (budget.direction !== "output") continue;
204
+ for (const { rel, text } of texts) {
205
+ // "<limit> ... <number>" on one line: the shape a proposal uses when
206
+ // it restates a ceiling ("raise ai-summary to 2000").
207
+ const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
208
+ for (const m of text.matchAll(re)) {
209
+ const proposed = Number.parseInt(m[1], 10);
210
+ if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
211
+ results.push(fail(
212
+ `${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
213
+ `(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
214
+ `truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
215
+ ));
216
+ break;
217
+ }
218
+ }
219
+ }
170
220
  return results;
171
221
  }
172
222
 
223
+ // A limit name comes from a contract table cell, so it is user text and
224
+ // must not be spliced into a pattern unescaped.
225
+ function escapeRe(s) {
226
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
+ }
228
+
173
229
  function pass(msg) {
174
230
  return { kind: "pass", line: c.green("✓ ") + msg };
175
231
  }
@@ -235,18 +235,35 @@ export async function run(positional, cmdFlags) {
235
235
  // 6. Skills are on-demand: list name + description + trigger, never the
236
236
  // body. The `when:` line is what lets an agent fire the right skill
237
237
  // without loading any of them.
238
+ // RANKED by the query (change 0029). Alphabetical order is the wrong
239
+ // order for a list whose whole job is "fire the right one": on a
240
+ // diagnostic task the agent would read four specs and still not learn
241
+ // that CI injects an empty string, because the skill that says so sat
242
+ // at the bottom under `z`. The TRIGGER is what a task matches against,
243
+ // so it is weighted above the description — that is what `when:` is
244
+ // for. A skill with no query to rank on keeps alphabetical order.
238
245
  const onDemand = [];
239
246
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
240
247
  for (const f of walk(skillsDir)) {
241
248
  if (!f.endsWith(".md")) continue;
242
249
  const text = read(f);
250
+ const name = parseFrontmatter(text, "name") ?? path.basename(f, ".md");
251
+ const description = parseFrontmatter(text, "description") ?? "<missing description>";
252
+ const when = parseFrontmatter(text, "when") ?? null;
243
253
  onDemand.push({
244
254
  rel: relPath(projectRoot, f),
245
- name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
246
- description: parseFrontmatter(text, "description") ?? "<missing description>",
247
- when: parseFrontmatter(text, "when") ?? null,
255
+ name,
256
+ description,
257
+ when,
258
+ // The trigger is the emphasis field, so a term matching `when:`
259
+ // outranks the same term buried in the body.
260
+ rank: relevance(`${when ?? ""} ${description} ${name}`, terms, `${when ?? ""} ${name}`),
248
261
  });
249
262
  }
263
+ if (terms.length > 0) {
264
+ onDemand.sort((a, b) => compareRank(b.rank, a.rank) || a.name.localeCompare(b.name));
265
+ }
266
+ const skillsMatched = onDemand.filter((s) => s.rank[1] > 0).length;
250
267
 
251
268
  // Fit the pack to the budget. This mutates items in place (degrading) and
252
269
  // returns what it had to give up, so the report can name it.
@@ -278,11 +295,21 @@ export async function run(positional, cmdFlags) {
278
295
  reportBudget(totalTokens, budget, fit, (msg) => console.log(msg));
279
296
  if (onDemand.length > 0) {
280
297
  console.log("");
281
- console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):"));
298
+ const heading = skillsMatched > 0
299
+ ? c.bold("On-demand skills") + c.gray(` — ${skillsMatched} match this task; READ THESE FIRST:`)
300
+ : c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):");
301
+ console.log(heading);
282
302
  for (const s of onDemand) {
283
- console.log(` ${c.cyan(s.name.padEnd(26))} ${s.description}`);
303
+ // A skill whose trigger the task actually matches is marked, so the
304
+ // list stops being a uniform inventory the reader has to scan.
305
+ const hit = s.rank[1] > 0 ? c.green("→ ") : " ";
306
+ console.log(` ${hit}${c.cyan(s.name.padEnd(26))} ${s.description}`);
284
307
  if (s.when) console.log(` ${" ".repeat(26)} ${c.gray(`when: ${s.when}`)}`);
285
308
  }
309
+ if (skillsMatched > 0) {
310
+ console.log("");
311
+ console.log(c.gray(" A matching skill is procedural memory for THIS task — read it before the specs above."));
312
+ }
286
313
  }
287
314
  }
288
315
 
@@ -12,6 +12,10 @@ import { flagBool } from "../lib/args.js";
12
12
  import { c } from "../lib/colors.js";
13
13
  import { suggest } from "../lib/suggest.js";
14
14
  import { notADoctrinaProject } from "../lib/exit-codes.js";
15
+ import {
16
+ parseTable, parseRuntimeDeclaration, checkWiring, checkEmptySemantics,
17
+ checkEnums, checkSelectors,
18
+ } from "../lib/runtime.js";
15
19
 
16
20
  // Contracts are the first-class home for the integration/runtime surface
17
21
  // no single capability owns: the port map, the env/dependency contract,
@@ -82,6 +86,7 @@ function contractNew(args, flags) {
82
86
  console.log(c.green("indexed") + ` contract "${name}"`);
83
87
  console.log("");
84
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."));
85
90
  return 0;
86
91
  }
87
92
 
@@ -186,6 +191,35 @@ function contractCheck(args, _flags) {
186
191
  errors += 1;
187
192
  }
188
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
+ }
189
223
  }
190
224
 
191
225
  console.log("");
@@ -198,21 +232,6 @@ function contractCheck(args, _flags) {
198
232
  return errors === 0 ? 0 : 1;
199
233
  }
200
234
 
201
- // Parse a GitHub-flavoured Markdown table into { headers, rows }.
202
- function parseTable(text) {
203
- const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
204
- if (lines.length < 2) return null;
205
- const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cl) => cl.trim());
206
- const headers = cells(lines[0]);
207
- const rows = [];
208
- for (let i = 1; i < lines.length; i++) {
209
- const row = cells(lines[i]);
210
- if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue; // separator row
211
- rows.push(row);
212
- }
213
- return { headers, rows };
214
- }
215
-
216
235
  function colIndex(headers, name) {
217
236
  const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
218
237
  return i < 0 ? 0 : i;
@@ -240,8 +259,29 @@ the port map, the environment contract, and API/WS/event interfaces.
240
259
  Subcommands:
241
260
  new <name> Scaffold .doctrina/contracts/<name>.md and index it
242
261
  list One line per contract: id, status, last updated
243
- check [<name>] Verify the mechanically checkable parts: no two
244
- services share a port, every declared env var exists
245
- in .env.example, and every referenced spec exists.
246
- 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.
247
287
  `;
@@ -71,22 +71,25 @@ export async function run(_positional, flags) {
71
71
  let totalCovered = 0;
72
72
  let totalDangling = 0;
73
73
  let totalConditional = 0;
74
+ let totalUnguarded = 0;
74
75
  let totalDeferred = 0;
75
76
  for (const rep of reports) {
76
77
  totalCriteria += rep.rows.length;
77
78
  totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
78
79
  totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
79
80
  totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
81
+ totalUnguarded += rep.rows.filter((r) => r.kind === "unguarded").length;
80
82
  totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
81
83
  }
82
84
  const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
83
85
  // Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
84
- const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
86
+ const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
87
+ && totalConditional === 0 && totalUnguarded === 0;
85
88
 
86
89
  if (json) {
87
90
  emitJson("coverage", {
88
91
  specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
89
- summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
92
+ summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, unguarded: totalUnguarded, deferred: totalDeferred, pct: jsonPct },
90
93
  });
91
94
  return jsonClean ? 0 : strict ? 1 : 0;
92
95
  }
@@ -98,10 +101,12 @@ export async function run(_positional, flags) {
98
101
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
99
102
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
100
103
  const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
104
+ const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
101
105
  const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
102
106
 
103
107
  const notes = [];
104
108
  if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
109
+ if (unguarded > 0) notes.push(c.yellow(`${unguarded} unguarded`));
105
110
  if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
106
111
  if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
107
112
  const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
@@ -114,6 +119,8 @@ export async function run(_positional, flags) {
114
119
  console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
115
120
  } else if (r.kind === "conditional") {
116
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}`);
117
124
  } else {
118
125
  console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
119
126
  }
@@ -123,9 +130,11 @@ export async function run(_positional, flags) {
123
130
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
124
131
  console.log("");
125
132
  const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
126
- const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
133
+ const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
134
+ && totalConditional === 0 && totalUnguarded === 0;
127
135
  const extras = [];
128
136
  if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
137
+ if (totalUnguarded > 0) extras.push(`${totalUnguarded} unguarded`);
129
138
  if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
130
139
  if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
131
140
  const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
@@ -248,13 +257,14 @@ export function summarize(projectRoot) {
248
257
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
249
258
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
250
259
  const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
260
+ const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
251
261
  const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
252
262
  totalCriteria += rep.rows.length;
253
263
  totalCovered += covered;
254
264
  totalDangling += dangling;
255
265
  totalConditional += conditional;
256
266
  totalDeferred += deferred;
257
- 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 });
258
268
  }
259
269
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
260
270
  return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
@@ -297,8 +307,84 @@ function extractAcceptanceCriteria(text) {
297
307
  return out.filter((s) => s.length > 0);
298
308
  }
299
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
+
300
379
  // Decide whether a single criterion is covered, conditional, dangling, or bare.
301
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
+
302
388
  const cited = extractBacktickPaths(criterion);
303
389
  if (cited.length === 0) return { kind: "bare", n };
304
390
  const missing = [];
@@ -390,15 +476,31 @@ A criterion is covered when a cited path resolves to real proof,
390
476
  conditional when its only resolving proof is a test file whose suite is
391
477
  skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
392
478
  nothing), dangling when a cited path is missing on disk, and bare when
393
- 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 —
394
496
  \`Implementation: planned — <why>\`, the same escape hatch validate
395
497
  honours — has its unproven criteria reported as DEFERRED: visible, but
396
498
  never a --strict failure (declared debt is not hidden debt).
397
499
 
398
500
  Flags:
399
- --strict Exit 1 when any criterion is bare, dangling, or
400
- conditional (CI gate). Deferred never fails. Without
401
- 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).
402
504
  --only <cap,cap> Scope the report/gate to specific capabilities
403
505
  (\`doctrina close\` uses this so an unrelated deferred
404
506
  spec cannot block a change's close).
@@ -7,6 +7,8 @@ import { exists } from "../lib/fs-ops.js";
7
7
  import { collectStatus } from "./status.js";
8
8
  import { collectFindings } from "./templates.js";
9
9
  import { c } from "../lib/colors.js";
10
+ import { flagBool } from "../lib/args.js";
11
+ import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
10
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
11
13
 
12
14
  // Aggregate diagnostic: the one command to run when "something looks wrong"
@@ -21,7 +23,7 @@ const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
21
23
  // Flags this command accepts. Declared HERE, with the command, so
22
24
  // adding a command never requires editing the entrypoint — the gap that
23
25
  // let six flags ship undeclared and silently swallow a positional (C3).
24
- export const flags = { boolean: ["json"], string: [] };
26
+ export const flags = { boolean: ["json", "env"], string: [] };
25
27
 
26
28
  export async function run(_positional, _flags) {
27
29
  const projectRoot = process.cwd();
@@ -123,7 +125,48 @@ export async function run(_positional, _flags) {
123
125
  }
124
126
  }
125
127
 
126
- // 6. Verify configuration (the real build gate must exist to be run).
128
+ // 6. The RUNTIME surface (change 0029). Every row above reads Markdown;
129
+ // this one reads what the Markdown CLAIMS about the running system and
130
+ // checks it — the wiring, the empty-vs-unset default, the enum, the
131
+ // selector that would match nothing. Still a driver: the checks belong
132
+ // to lib/runtime.js and `contract check` renders the same verdict.
133
+ {
134
+ const runtime = collectRuntimeFindings(projectRoot);
135
+ const errs = runtime.findings.filter((f) => f.level === "error");
136
+ const warns = runtime.findings.length - errs.length;
137
+ if (runtime.contracts === 0) {
138
+ row("ok", "runtime", "no contracts — nothing declares a runtime surface");
139
+ } else if (runtime.declared === 0) {
140
+ // Silence is not proof: an undeclared surface is UNCHECKED, and a row
141
+ // that read "ok" here would be the exact false confidence this
142
+ // command exists to prevent.
143
+ row("warn", "runtime", `${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`, "declare Wiring/Selectors rows, then `doctrina contract check`");
144
+ warningsTotal += 1;
145
+ } else if (errs.length > 0) {
146
+ row("fail", "runtime", `${errs.length} declaration${errs.length === 1 ? "" : "s"} do not hold`, "doctrina triage (each finding names its own fix)");
147
+ for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
148
+ } else if (warns > 0) {
149
+ row("warn", "runtime", `${runtime.declared} declared rows hold; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
150
+ warningsTotal += 1;
151
+ } else {
152
+ row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} hold`);
153
+ }
154
+ }
155
+
156
+ // 7. The local .env, opt-in. Off by default because it reads a file that
157
+ // is not committed and is none of CI's business; when asked for, it
158
+ // reports NAMES and enum membership only and never the value.
159
+ if (flagBool(_flags, "env", false)) {
160
+ const envFindings = checkLocalEnv(projectRoot);
161
+ if (envFindings.length === 0) {
162
+ row("ok", "local .env", "matches the declared names and enums (or no .env present)");
163
+ } else {
164
+ row("fail", "local .env", `${envFindings.length} finding${envFindings.length === 1 ? "" : "s"} — config would be rejected on boot`, "fix the named variables (values are never printed)");
165
+ for (const e of envFindings.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.message}`);
166
+ }
167
+ }
168
+
169
+ // 8. Verify configuration (the real build gate must exist to be run).
127
170
  if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
128
171
  else if (!s.verify.configured) {
129
172
  row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
@@ -158,9 +201,16 @@ Usage: doctrina doctor
158
201
 
159
202
  Aggregate diagnostic: run the structural gate (validate), the index
160
203
  drift check, the coverage/trace ratios, the clean-checkout lint
161
- (verify --clean), the template-shape check, and the verify-config
162
- presence — each reported with its exact remediation command.
204
+ (verify --clean), the template-shape check, the RUNTIME surface (the
205
+ declared wiring, enums and selectors, checked against the code and
206
+ workflows), and the verify-config presence — each reported with its
207
+ exact remediation command.
208
+
209
+ --env also check the local .env against the declared names and
210
+ enums. Reports membership only; a rejected value is never
211
+ printed, so this is safe to run and to paste.
163
212
 
164
- A driver over the existing commands; it adds no checks of its own.
213
+ A driver over the existing commands and lib/runtime.js; it adds no
214
+ checks of its own, so it can never disagree with the gates it fronts.
165
215
  Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
166
216
  `;
@@ -8,6 +8,7 @@ import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
8
8
  import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { emitJson } from "../lib/json-out.js";
11
+ import { collectRuntimeFindings } from "../lib/runtime.js";
11
12
 
12
13
  // Flags this command accepts. Declared HERE, with the command, so
13
14
  // adding a command never requires editing the entrypoint — the gap that
@@ -55,6 +56,26 @@ export async function run(_positional, flags) {
55
56
  export function computeActions(projectRoot) {
56
57
  const actions = [];
57
58
 
59
+ // RUNTIME FIRST (change 0029). A declared wiring that does not hold is
60
+ // not a queue item — it is the reason the last run lied. After a job goes
61
+ // green having executed nothing, "open a change on the observability
62
+ // capability" sends the agent to polish the Markdown of an empty-state
63
+ // message while the cause sits in a file no other action names. A broken
64
+ // declaration outranks every artifact chore below it.
65
+ try {
66
+ const runtime = collectRuntimeFindings(projectRoot);
67
+ const errs = runtime.findings.filter((f) => f.level === "error");
68
+ if (errs.length > 0) {
69
+ actions.push(
70
+ `doctrina triage — ${errs.length} runtime declaration${errs.length === 1 ? " does" : "s do"} not hold ` +
71
+ `(first: ${errs[0].code} ${errs[0].message.slice(0, 80)}${errs[0].message.length > 80 ? "…" : ""})`,
72
+ );
73
+ }
74
+ } catch {
75
+ // A malformed contract is `validate`'s finding to make, not a reason
76
+ // for `next` to fall over.
77
+ }
78
+
58
79
  // A pending intake is the very first thing to resolve: until it is
59
80
  // converted, product.md and the specs are still empty scaffolding.
60
81
  const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
@@ -178,7 +199,9 @@ export const help = `
178
199
  Usage: doctrina next [--json]
179
200
 
180
201
  Inspect the .doctrina/ tree and print the recommended next workflow
181
- actions in priority order: open changes (missing proposal, unchecked
202
+ actions in priority order: runtime declarations that no longer hold
203
+ (a broken wiring outranks every artifact chore — it is why the last
204
+ run lied), open changes (missing proposal, unchecked
182
205
  tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
183
206
  proposed status, accepted ADRs with nothing proving them (cite Evidence
184
207
  or run "decision land"), a skill-capture nudge when a past fix went