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,8 +1,16 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
4
- import { locateTemplatesDir, loadTemplateTree } from "../lib/templates.js";
5
- import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
5
+ import { locateTemplatesDir, listResolvedTemplates } from "../lib/templates.js";
6
+ import { adapterFiles, isHubPointer, listAdapterNames } from "../lib/adapters.js";
7
+ import {
8
+ surfaceBlock, findSurfaceBlock, surfaceAnchors, placeSurfaceBlock,
9
+ COMMAND_META, COMMAND_NAMES, SURFACE_LINE_BUDGET, surfaceMarkdown,
10
+ agentChangelogBlock, findAgentChangelogBlock,
11
+ } from "../lib/commands.js";
12
+ import { cliVersion } from "../lib/version.js";
13
+ import { ARTIFACT_CATEGORIES } from "../lib/index-json.js";
6
14
  import { flagBool } from "../lib/args.js";
7
15
  import { c } from "../lib/colors.js";
8
16
  import { suggest } from "../lib/suggest.js";
@@ -28,6 +36,11 @@ const PRODUCT_SECTIONS = [
28
36
  ];
29
37
  const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
30
38
 
39
+ // Flags this command accepts. Declared HERE, with the command, so
40
+ // adding a command never requires editing the entrypoint — the gap that
41
+ // let six flags ship undeclared and silently swallow a positional (C3).
42
+ export const flags = { boolean: ["json", "write"], string: [] };
43
+
31
44
  export async function run(positional, flags) {
32
45
  const sub = positional[0];
33
46
  if (!SUBCOMMANDS.includes(sub)) {
@@ -75,6 +88,7 @@ function updateTemplates(flags) {
75
88
  if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
76
89
  plan.push({
77
90
  describe: ["AGENTS.md: regenerate the doctrina:surface block (stale vs installed CLI)"],
91
+ preview: () => diffPreview(text.slice(block.start, block.end), fresh),
78
92
  apply() {
79
93
  const cur = read(agentsMdPath);
80
94
  const b = findSurfaceBlock(cur);
@@ -84,17 +98,56 @@ function updateTemplates(flags) {
84
98
  }
85
99
  } else {
86
100
  const legacy = findLegacySurfaceSection(text);
101
+ const anchors = templateAnchors();
87
102
  plan.push({
88
103
  describe: [legacy
89
104
  ? "AGENTS.md: replace the hand-written \"## Doctrina command surface\" section with the generated doctrina:surface block"
90
- : "AGENTS.md: append the generated doctrina:surface command block (agents discover commands through this file)"],
105
+ : `AGENTS.md: insert the generated doctrina:surface command block${anchors.after ? ` after "${anchors.after}"` : ""} (agents discover commands through this file)`],
106
+ preview: () => fresh,
91
107
  apply() {
92
108
  const cur = read(agentsMdPath);
93
109
  const l = findLegacySurfaceSection(cur);
94
110
  if (l) {
111
+ // The legacy section already sits where the CLI put it, so
112
+ // replacing it in place preserves the layout.
95
113
  write(agentsMdPath, cur.slice(0, l.start) + fresh + "\n\n" + cur.slice(l.end), { force: true });
96
114
  } else {
97
- write(agentsMdPath, cur + (cur.endsWith("\n") ? "" : "\n") + "\n" + fresh + "\n", { force: true });
115
+ // Neither block nor legacy section: PLACE it at the canonical
116
+ // position instead of appending, so `upgrade` and `init` produce
117
+ // the same layout (C4).
118
+ write(agentsMdPath, placeSurfaceBlock(cur, fresh, anchors), { force: true });
119
+ }
120
+ },
121
+ });
122
+ }
123
+ }
124
+
125
+ // The agent-facing changelog (M2): three to six lines stating only what
126
+ // alters agent behaviour in this version. An agent reading AGENTS.md after
127
+ // an upgrade learns what is new without being told to look — reported from
128
+ // real use, where a new command shipped and the driving LLM never knew.
129
+ if (isFile(agentsMdPath)) {
130
+ const cur = read(agentsMdPath);
131
+ const fresh = agentChangelogBlock(cliVersion());
132
+ const existing = findAgentChangelogBlock(cur);
133
+ if (fresh && (!existing || normalizeBlock(cur.slice(existing.start, existing.end)) !== normalizeBlock(fresh))) {
134
+ plan.push({
135
+ describe: [`AGENTS.md: ${existing ? "refresh" : "add"} the "What changed in ${cliVersion()}" block for agents`],
136
+ preview: () => fresh,
137
+ apply() {
138
+ const now = read(agentsMdPath);
139
+ const found = findAgentChangelogBlock(now);
140
+ if (found) {
141
+ write(agentsMdPath, now.slice(0, found.start) + fresh + now.slice(found.end), { force: true });
142
+ return;
143
+ }
144
+ // Place it immediately after the surface block, so "what exists"
145
+ // and "what just changed" are read together.
146
+ const sb = findSurfaceBlock(now);
147
+ if (sb) {
148
+ write(agentsMdPath, now.slice(0, sb.end) + "\n\n" + fresh + now.slice(sb.end), { force: true });
149
+ } else {
150
+ write(agentsMdPath, now.replace(/\s+$/, "") + "\n\n" + fresh + "\n", { force: true });
98
151
  }
99
152
  },
100
153
  });
@@ -102,10 +155,12 @@ function updateTemplates(flags) {
102
155
  }
103
156
 
104
157
  // Markdown files: append stub sections for missing recommended headings.
105
- for (const [rel, sections] of [
158
+ /** @type {Array<[string, string[]]>} */
159
+ const markdownTargets = [
106
160
  ["AGENTS.md", AGENTS_SECTIONS],
107
161
  [path.join(".doctrina", "product.md"), PRODUCT_SECTIONS],
108
- ]) {
162
+ ];
163
+ for (const [rel, sections] of markdownTargets) {
109
164
  const filePath = path.join(projectRoot, rel);
110
165
  if (!isFile(filePath)) continue;
111
166
  const text = read(filePath);
@@ -134,7 +189,7 @@ function updateTemplates(flags) {
134
189
  if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
135
190
  if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
136
191
  if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
137
- const categories = ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"];
192
+ const categories = ARTIFACT_CATEGORIES;
138
193
  for (const cat of categories) {
139
194
  if (idx.artifacts && idx.artifacts[cat] === undefined) {
140
195
  describe.push(`index.json: add empty artifact category "${cat}"`);
@@ -165,8 +220,16 @@ function updateTemplates(flags) {
165
220
  return 0;
166
221
  }
167
222
 
168
- for (const s of steps) {
169
- console.log((writeMode ? c.green("update ") : c.yellow("would ")) + s);
223
+ for (const p of plan) {
224
+ for (const s of p.describe) {
225
+ console.log((writeMode ? c.green("update ") : c.yellow("would ")) + s);
226
+ }
227
+ // A one-line "would append" told the operator nothing about what was
228
+ // about to land in the file they read first. In preview, show the
229
+ // content and where it goes (C4).
230
+ if (!writeMode && p.preview) {
231
+ for (const line of p.preview().split("\n")) console.log(c.gray(" │ ") + line);
232
+ }
170
233
  }
171
234
  console.log("");
172
235
  if (!writeMode) {
@@ -180,27 +243,32 @@ function updateTemplates(flags) {
180
243
  }
181
244
 
182
245
  function listTemplates() {
183
- const dir = locateTemplatesDir();
184
- const files = walk(dir).filter((f) => !f.endsWith(".gitkeep"));
185
- if (files.length === 0) {
186
- console.error(c.red("error:") + " no templates found at " + dir);
246
+ // Resolution is a CHAIN (M1): a template under the project's
247
+ // `.doctrina/templates/` wins over the bundled copy, per file. Printing
248
+ // WHERE each one resolved from is what turns the override from a hidden
249
+ // behaviour into a usable one.
250
+ const projectRoot = process.cwd();
251
+ const rows = listResolvedTemplates(isDir(path.join(projectRoot, ".doctrina")) ? projectRoot : null);
252
+ if (rows.length === 0) {
253
+ console.error(c.red("error:") + " no templates found");
187
254
  return 1;
188
255
  }
189
- console.log(c.bold(`Templates shipped by the installed Doctrina CLI:`));
256
+
257
+ console.log(c.bold("Templates resolved for this project:"));
190
258
  console.log("");
191
- let maxLen = 0;
192
- const rows = [];
193
- for (const f of files) {
194
- const rel = relPath(dir, f);
195
- const lines = lineCount(f);
196
- rows.push({ rel, lines });
197
- if (rel.length > maxLen) maxLen = rel.length;
198
- }
259
+ const width = Math.max(...rows.map((r) => r.relativePath.length));
199
260
  for (const r of rows) {
200
- console.log(` ${r.rel.padEnd(maxLen + 2)}${String(r.lines).padStart(4)} lines`);
261
+ const source = r.source === "project"
262
+ ? c.cyan("project") + (r.overrides ? c.gray(" (overrides bundled)") : "")
263
+ : c.gray("bundled");
264
+ console.log(` ${r.relativePath.padEnd(width + 2)}${String(lineCount(r.path)).padStart(4)} lines ${source}`);
201
265
  }
202
266
  console.log("");
203
- console.log(c.gray(`${rows.length} templates · location: ${dir}`));
267
+ const overridden = rows.filter((r) => r.source === "project").length;
268
+ console.log(c.gray(`${rows.length} templates · ${overridden} from this project's .doctrina/templates/`));
269
+ if (overridden === 0) {
270
+ console.log(c.gray("Drop a file there with the same relative path to override one — see docs/en/templates.md."));
271
+ }
204
272
  return 0;
205
273
  }
206
274
 
@@ -211,7 +279,16 @@ function checkTemplates() {
211
279
  console.error(c.gray("hint: ") + "run `doctrina init` first");
212
280
  return 1;
213
281
  }
282
+ const { findings, ok } = collectFindings(projectRoot);
283
+
284
+ return report(findings, ok);
285
+ }
214
286
 
287
+ // The findings `templates check` reports, as structured records with an
288
+ // executable remedy. Exported so a test can seed each finding, run the
289
+ // remedy it names, and assert the finding clears — a remedy that cannot
290
+ // resolve its own finding is not a remedy (C2).
291
+ export function collectFindings(projectRoot) {
215
292
  const findings = [];
216
293
  const ok = [];
217
294
 
@@ -221,19 +298,19 @@ function checkTemplates() {
221
298
  const text = read(agentsPath);
222
299
  for (const heading of AGENTS_SECTIONS) {
223
300
  if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
224
- else findings.push(`AGENTS.md missing recommended section "${heading}"`);
301
+ else findings.push({ message: `AGENTS.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
225
302
  }
226
303
  // Command-surface block: present and current vs the installed catalog.
227
304
  const block = findSurfaceBlock(text);
228
305
  if (!block) {
229
- findings.push("AGENTS.md has no doctrina:surface block — agents discover commands through this file (`templates update --write` adds it)");
306
+ findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
230
307
  } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
231
- findings.push("AGENTS.md doctrina:surface block is stale vs the installed CLI (`templates update --write` or `doctrina upgrade --write` regenerates it)");
308
+ findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina templates update --write" });
232
309
  } else {
233
310
  ok.push("AGENTS.md: doctrina:surface block current");
234
311
  }
235
312
  } else {
236
- findings.push("AGENTS.md missing at project root");
313
+ findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
237
314
  }
238
315
 
239
316
  // product.md sections
@@ -242,37 +319,59 @@ function checkTemplates() {
242
319
  const text = read(productPath);
243
320
  for (const heading of PRODUCT_SECTIONS) {
244
321
  if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
245
- else findings.push(`.doctrina/product.md missing recommended section "${heading}"`);
322
+ else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
246
323
  }
247
324
  } else {
248
- findings.push(".doctrina/product.md missing");
325
+ findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
249
326
  }
250
327
 
251
- // Installed agent adapters: every adapter is a thin POINTER at AGENTS.md
252
- // (that is why one `upgrade --write` refresh of the hub reaches every
253
- // installed agent). Verify the contract holds for each adapter file the
254
- // project actually has: it must still reference AGENTS.md. The shipped
255
- // adapter template tree is the inventory, so a new adapter is covered
256
- // automatically.
257
- try {
258
- const templatesDir = locateTemplatesDir();
259
- const adaptersRoot = path.join(templatesDir, "adapters");
260
- if (isDir(adaptersRoot)) {
261
- for (const agent of walk(adaptersRoot).map((f) => relPath(adaptersRoot, f))) {
262
- const posix = agent.replace(/\\/g, "/");
263
- if (posix.endsWith("README.md") || posix.endsWith(".gitkeep")) continue;
264
- const rel = posix.split("/").slice(1).join("/").replace(/\.template$/, "");
265
- if (!rel) continue;
266
- const installed = path.join(projectRoot, rel);
267
- if (!isFile(installed)) continue; // not installed — nothing to check
268
- if (read(installed).includes("AGENTS.md")) {
269
- ok.push(`adapter ${rel}: points at AGENTS.md`);
270
- } else {
271
- findings.push(`adapter ${rel} no longer references AGENTS.md — agents loading it will miss the hub (restore the pointer, e.g. re-run \`doctrina init --agent <name> --force\`)`);
272
- }
328
+ // Installed agent adapters: a HUB POINTER file must still reference
329
+ // AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
330
+ // every installed agent). Only files whose template declares the
331
+ // {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
332
+ // hub through its parent pointer file and is not checked (C2).
333
+ for (const name of listAdapterNames(projectRoot)) {
334
+ const spec = adapterFiles(projectRoot, name);
335
+ if (!spec) continue;
336
+ for (const file of spec.files) {
337
+ const installed = path.join(projectRoot, file.relativePath);
338
+ if (!isFile(installed)) continue; // not installed — nothing to check
339
+ if (!isHubPointer(file)) continue; // command shim, not a pointer
340
+ if (read(installed).includes("AGENTS.md")) {
341
+ ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
342
+ } else {
343
+ findings.push({
344
+ message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
345
+ // Executable and verified: `adapter add --force` rewrites exactly
346
+ // this file from its template. A remedy that cannot resolve the
347
+ // finding it is attached to is not a remedy (C2).
348
+ remedy: `doctrina adapter add ${name} --force`,
349
+ });
273
350
  }
274
351
  }
275
- } catch { /* no templates dir (unusual install) — skip the adapter pass */ }
352
+ }
353
+
354
+ // M2: a command that cannot state its trigger has not earned a place on
355
+ // the surface. The block is an agent's only discovery surface, so both
356
+ // fields are required and the block has a declared size budget.
357
+ for (const name of COMMAND_NAMES) {
358
+ const meta = COMMAND_META[name];
359
+ if (!meta || !meta.purpose || !meta.when) {
360
+ findings.push({
361
+ message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
362
+ remedy: null,
363
+ });
364
+ }
365
+ }
366
+ const surfaceLines = surfaceMarkdown().split("\n").length;
367
+ if (surfaceLines > SURFACE_LINE_BUDGET) {
368
+ findings.push({
369
+ message: `the generated surface block is ${surfaceLines} lines (budget ${SURFACE_LINE_BUDGET}) — cut commands rather than raising the budget`,
370
+ remedy: null,
371
+ });
372
+ } else {
373
+ ok.push(`surface block within budget (${surfaceLines}/${SURFACE_LINE_BUDGET} lines)`);
374
+ }
276
375
 
277
376
  // index.json schema fields
278
377
  const indexPath = path.join(projectRoot, ".doctrina", "index.json");
@@ -281,21 +380,31 @@ function checkTemplates() {
281
380
  const idx = JSON.parse(read(indexPath));
282
381
  for (const field of INDEX_FIELDS) {
283
382
  if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
284
- else findings.push(`.doctrina/index.json missing field "${field}"`);
383
+ else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
285
384
  }
286
385
  if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
287
- findings.push(`.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`);
386
+ findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
288
387
  }
289
388
  } catch (err) {
290
- findings.push(`.doctrina/index.json failed to parse: ${err.message}`);
389
+ findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
291
390
  }
292
391
  } else {
293
- findings.push(".doctrina/index.json missing");
392
+ findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
294
393
  }
295
394
 
296
395
  // Output
396
+ return { findings, ok };
397
+ }
398
+
399
+ function report(findings, ok) {
297
400
  for (const o of ok) console.log(c.green("✓ ") + o);
298
- for (const f of findings) console.log(c.yellow("✗ ") + f);
401
+ for (const f of findings) {
402
+ console.log(c.yellow("✗ ") + f.message);
403
+ // Every finding names the command that RESOLVES it, or says plainly
404
+ // that repair is manual. A remedy the CLI cannot execute and verify is
405
+ // not a remedy — a test runs each one and asserts the finding clears.
406
+ console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
407
+ }
299
408
  console.log("");
300
409
 
301
410
  if (findings.length === 0) {
@@ -303,12 +412,37 @@ function checkTemplates() {
303
412
  return 0;
304
413
  }
305
414
  console.log(c.red("fail") + ` ${findings.length} recommendation${findings.length === 1 ? "" : "s"}`);
306
- console.log(c.gray("hint: ") + "this command is read-only; review and add the missing sections by hand");
307
415
  return 1;
308
416
  }
309
417
 
418
+ // Line-level preview of what regenerating the block changes: only the
419
+ // lines that differ, so a stale-catalog refresh reads as the two or three
420
+ // commands that moved rather than as twenty unchanged lines.
421
+ function diffPreview(before, after) {
422
+ const a = before.split("\n");
423
+ const b = after.split("\n");
424
+ const aSet = new Set(a);
425
+ const bSet = new Set(b);
426
+ const out = [
427
+ ...a.filter((line) => !bSet.has(line)).map((line) => `- ${line}`),
428
+ ...b.filter((line) => !aSet.has(line)).map((line) => `+ ${line}`),
429
+ ];
430
+ return out.length > 0 ? out.join("\n") : "(no textual change)";
431
+ }
432
+
310
433
  // Whitespace/EOL-insensitive comparison for the surface block, so a CRLF
311
434
  // checkout is not eternally "stale".
435
+
436
+ // The canonical position, read from the shipped template — the single
437
+ // place the intended layout is defined.
438
+ function templateAnchors() {
439
+ try {
440
+ return surfaceAnchors(read(path.join(locateTemplatesDir(), "AGENTS.md.template")));
441
+ } catch {
442
+ return { after: null, before: null };
443
+ }
444
+ }
445
+
312
446
  function normalizeBlock(s) {
313
447
  return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
314
448
  }
@@ -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";
@@ -5,6 +6,8 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
5
6
  import { specHeader } from "../lib/scan.js";
6
7
  import { flagBool } from "../lib/args.js";
7
8
  import { c } from "../lib/colors.js";
9
+ import { emitJson } from "../lib/json-out.js";
10
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
8
11
 
9
12
  // Intent-provenance report (ADR 0006). `coverage` proves a criterion has a
10
13
  // test; `trace` proves a capability traces to a stated intent. Together they
@@ -26,10 +29,19 @@ import { c } from "../lib/colors.js";
26
29
 
27
30
  const ANCHOR_RE = /[A-Z]+\d+/g;
28
31
 
32
+ // Flags this command accepts. Declared HERE, with the command, so
33
+ // adding a command never requires editing the entrypoint — the gap that
34
+ // let six flags ship undeclared and silently swallow a positional (C3).
35
+ // This command builds its own JSON payload; the entrypoint must not
36
+ // wrap it in the generic envelope.
37
+ export const jsonNative = true;
38
+
39
+ export const flags = { boolean: ["json", "strict"], string: [] };
40
+
29
41
  export async function run(_positional, flags) {
30
42
  const projectRoot = process.cwd();
31
43
  if (!exists(path.join(projectRoot, ".doctrina"))) {
32
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
44
+ throw notADoctrinaProject();
33
45
  }
34
46
  const strict = flagBool(flags, "strict", false);
35
47
  const json = flagBool(flags, "json", false);
@@ -43,7 +55,7 @@ export async function run(_positional, flags) {
43
55
  // The feature is unused: do not nag a project that never opted in.
44
56
  if (anchors.length === 0 && !anyRealizes) {
45
57
  if (json) {
46
- console.log(JSON.stringify({ anchors: [], dangling: [], untraceable: [], summary: summarize(projectRoot) }, null, 2));
58
+ emitJson("trace", { anchors: [], dangling: [], untraceable: [], summary: summarize(projectRoot) });
47
59
  return 0;
48
60
  }
49
61
  console.log(
@@ -77,7 +89,7 @@ export async function run(_positional, flags) {
77
89
  if (json) {
78
90
  const rows = anchors.map((a) => ({ id: a.id, realizedBy: (realizedBy.get(a.id) ?? []).sort() }));
79
91
  const clean = rows.every((r) => r.realizedBy.length > 0) && dangling.length === 0 && untraceable.length === 0;
80
- console.log(JSON.stringify({ anchors: rows, dangling, untraceable, summary: summarize(projectRoot) }, null, 2));
92
+ emitJson("trace", { anchors: rows, dangling, untraceable, summary: summarize(projectRoot) });
81
93
  return clean ? 0 : strict ? 1 : 0;
82
94
  }
83
95