doctrina-cli 0.13.0 → 0.14.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 (66) 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 +66 -19
  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 +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  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 +14 -2
  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 +16 -6
  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/upgrade.js +8 -1
  34. package/src/commands/validate.js +58 -2
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +8 -1
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +1 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +239 -26
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +94 -9
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/AGENTS.md.template +59 -53
@@ -1,17 +1,24 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { spawnSync } from "node:child_process";
5
6
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
6
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
7
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
10
  import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
  import { suggest } from "../lib/suggest.js";
13
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
14
 
13
15
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
14
16
 
17
+ // Flags this command accepts. Declared HERE, with the command, so
18
+ // adding a command never requires editing the entrypoint — the gap that
19
+ // let six flags ship undeclared and silently swallow a positional (C3).
20
+ export const flags = { boolean: ["json", "force", "write"], string: ["since"] };
21
+
15
22
  export async function run(positional, flags) {
16
23
  const sub = positional[0];
17
24
  if (!SUBCOMMANDS.includes(sub)) {
@@ -81,6 +88,7 @@ function skillSuggest(args, flags) {
81
88
 
82
89
  // Two deterministic sources, deduped by slug. `seen` guards against a lesson
83
90
  // appearing twice (e.g. a fix committed and later archived as a change).
91
+ /** @type {SkillCandidate[]} */
84
92
  const candidates = [];
85
93
  const seen = new Set();
86
94
 
@@ -93,6 +101,7 @@ function skillSuggest(args, flags) {
93
101
  if (!FIX_SHAPED.test(id)) continue;
94
102
  const slug = skillSlug(id);
95
103
  if (seen.has(slug)) continue;
104
+ /** @type {SkillCandidate} */
96
105
  const cand = { id, slug, source: "change", from: name };
97
106
  if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
98
107
  const proposal = path.join(archiveDir, name, "proposal.md");
@@ -198,6 +207,7 @@ function gitFixCommits(projectRoot, { since, limit }) {
198
207
  const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
199
208
  if (r.error || r.status !== 0 || !r.stdout) return [];
200
209
 
210
+ /** @type {SkillCandidate[]} */
201
211
  const out = [];
202
212
  const seen = new Set();
203
213
  for (const record of r.stdout.split("\x1e")) {
@@ -280,11 +290,11 @@ function skillNew(args, flags) {
280
290
  return 1;
281
291
  }
282
292
 
283
- const templatesDir = locateTemplatesDir();
284
- const tplPath = path.join(templatesDir, "skill.md.template");
285
- const body = substitute(read(tplPath), { SKILL_NAME: name });
293
+ const tpl = readTemplate(projectRoot, "skill.md.template");
294
+ const body = substitute(tpl.body, { SKILL_NAME: name });
286
295
  write(targetPath, body, { force });
287
- console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
296
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}` +
297
+ (tpl.source === "project" ? c.gray(" (project template)") : ""));
288
298
 
289
299
  const date = today();
290
300
  const index = idx.load(projectRoot);
@@ -395,7 +405,7 @@ function parseFrontmatter(text, key) {
395
405
 
396
406
  function ensureDoctrinaProject(projectRoot) {
397
407
  if (!exists(path.join(projectRoot, ".doctrina"))) {
398
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
408
+ throw notADoctrinaProject();
399
409
  }
400
410
  }
401
411
 
@@ -1,8 +1,9 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
5
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
6
7
  import { specHeader, deriveIndex } from "../lib/scan.js";
7
8
  import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
8
9
  import * as idx from "../lib/index-json.js";
@@ -10,9 +11,15 @@ import { today } from "../lib/dates.js";
10
11
  import { flagBool, flagString } from "../lib/args.js";
11
12
  import { c } from "../lib/colors.js";
12
13
  import { suggest } from "../lib/suggest.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
13
15
 
14
16
  const SUBCOMMANDS = ["new", "list", "set"];
15
17
 
18
+ // Flags this command accepts. Declared HERE, with the command, so
19
+ // adding a command never requires editing the entrypoint — the gap that
20
+ // let six flags ship undeclared and silently swallow a positional (C3).
21
+ export const flags = { boolean: ["json", "bug", "force"], string: ["bump", "criterion", "implementation", "status", "version"] };
22
+
16
23
  export async function run(positional, flags) {
17
24
  const sub = positional[0];
18
25
  if (!SUBCOMMANDS.includes(sub)) {
@@ -44,13 +51,13 @@ export async function run(positional, flags) {
44
51
  return 1;
45
52
  }
46
53
 
47
- const templatesDir = locateTemplatesDir();
48
54
  const tplName = bug ? "spec-bug.md.template" : "spec.md.template";
49
- const tplPath = path.join(templatesDir, tplName);
55
+ const tpl = readTemplate(projectRoot, tplName);
50
56
  const date = today();
51
- const body = substitute(read(tplPath), { CAPABILITY: capability, DATE: date });
57
+ const body = substitute(tpl.body, { CAPABILITY: capability, DATE: date });
52
58
  write(targetPath, body, { force });
53
- console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
59
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}` +
60
+ (tpl.source === "project" ? c.gray(" (project template)") : ""));
54
61
 
55
62
  const index = idx.load(projectRoot);
56
63
  // Two axes: a fresh capability scaffold is a draft document for a
@@ -197,7 +204,7 @@ function specSet(args, flags) {
197
204
 
198
205
  function ensureDoctrinaProject(projectRoot) {
199
206
  if (!exists(path.join(projectRoot, ".doctrina"))) {
200
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
207
+ throw notADoctrinaProject();
201
208
  }
202
209
  }
203
210
 
@@ -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";
@@ -9,6 +10,8 @@ import { summarize as coverageSummary } from "./coverage.js";
9
10
  import { summarize as traceSummary } from "./trace.js";
10
11
  import { flagBool } from "../lib/args.js";
11
12
  import { c } from "../lib/colors.js";
13
+ import { emitJson } from "../lib/json-out.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
15
 
13
16
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
14
17
  // dashboard that answers "where do things stand?" in a single read, so neither
@@ -20,16 +23,25 @@ import { c } from "../lib/colors.js";
20
23
 
21
24
  const CONFIG_REL = ".doctrina/verify.json";
22
25
 
26
+ // Flags this command accepts. Declared HERE, with the command, so
27
+ // adding a command never requires editing the entrypoint — the gap that
28
+ // let six flags ship undeclared and silently swallow a positional (C3).
29
+ // This command builds its own JSON payload; the entrypoint must not
30
+ // wrap it in the generic envelope.
31
+ export const jsonNative = true;
32
+
33
+ export const flags = { boolean: ["json"], string: [] };
34
+
23
35
  export async function run(_positional, flags) {
24
36
  const projectRoot = process.cwd();
25
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
26
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
38
+ throw notADoctrinaProject();
27
39
  }
28
40
 
29
41
  const s = collectStatus(projectRoot);
30
42
 
31
43
  if (flagBool(flags, "json", false)) {
32
- console.log(JSON.stringify(s, null, 2));
44
+ emitJson("status", s);
33
45
  return 0;
34
46
  }
35
47
 
@@ -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
 
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { exists } from "../lib/fs-ops.js";
@@ -7,6 +8,7 @@ import { cliVersion } from "../lib/version.js";
7
8
  import { flagBool } from "../lib/args.js";
8
9
  import { c } from "../lib/colors.js";
9
10
  import { today } from "../lib/dates.js";
11
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
10
12
  import * as templates from "./templates.js";
11
13
  import * as validate from "./validate.js";
12
14
 
@@ -20,10 +22,15 @@ import * as validate from "./validate.js";
20
22
  // step. Preview by default; --write applies. Never rewrites user content
21
23
  // (the same additive-only bound as `templates update`).
22
24
 
25
+ // Flags this command accepts. Declared HERE, with the command, so
26
+ // adding a command never requires editing the entrypoint — the gap that
27
+ // let six flags ship undeclared and silently swallow a positional (C3).
28
+ export const flags = { boolean: ["json", "write"], string: [] };
29
+
23
30
  export async function run(_positional, flags) {
24
31
  const projectRoot = process.cwd();
25
32
  if (!exists(path.join(projectRoot, ".doctrina"))) {
26
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
+ throw notADoctrinaProject();
27
34
  }
28
35
  const writeMode = flagBool(flags, "write", false);
29
36
  const running = cliVersion();