doctrina-cli 0.12.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 (68) 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 +76 -19
  5. package/src/commands/change.js +341 -44
  6. package/src/commands/clarify.js +40 -19
  7. package/src/commands/close.js +119 -11
  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 +152 -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 +277 -40
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +23 -12
  34. package/src/commands/validate.js +93 -3
  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 +69 -7
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +47 -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 +314 -3
  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 +228 -3
  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/.gitkeep +0 -0
  67. package/templates/AGENTS.md.template +71 -75
  68. package/templates/change/spec-delta.md.template +13 -7
@@ -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,7 +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 } from "../lib/templates.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";
5
14
  import { flagBool } from "../lib/args.js";
6
15
  import { c } from "../lib/colors.js";
7
16
  import { suggest } from "../lib/suggest.js";
@@ -27,6 +36,11 @@ const PRODUCT_SECTIONS = [
27
36
  ];
28
37
  const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
29
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
+
30
44
  export async function run(positional, flags) {
31
45
  const sub = positional[0];
32
46
  if (!SUBCOMMANDS.includes(sub)) {
@@ -56,11 +70,97 @@ function updateTemplates(flags) {
56
70
 
57
71
  const plan = [];
58
72
 
73
+ // AGENTS.md command-surface block: the one CLI-OWNED span of the file
74
+ // (marker-delimited, generated from the command catalog). Three cases:
75
+ // markers present but stale -> regenerate the span; a legacy hand-written
76
+ // "## Doctrina command surface" section without markers -> replace that
77
+ // section with the managed block (the section was CLI-scaffolded content
78
+ // to begin with — this is the one sanctioned exception to additive-only,
79
+ // see ADR 0015); neither -> append the block. This is what makes
80
+ // `doctrina upgrade --write` actually refresh AGENTS.md when the CLI
81
+ // gains commands, instead of only bumping the stamp.
82
+ const agentsMdPath = path.join(projectRoot, "AGENTS.md");
83
+ if (isFile(agentsMdPath)) {
84
+ const text = read(agentsMdPath);
85
+ const block = findSurfaceBlock(text);
86
+ const fresh = surfaceBlock();
87
+ if (block) {
88
+ if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
89
+ plan.push({
90
+ describe: ["AGENTS.md: regenerate the doctrina:surface block (stale vs installed CLI)"],
91
+ preview: () => diffPreview(text.slice(block.start, block.end), fresh),
92
+ apply() {
93
+ const cur = read(agentsMdPath);
94
+ const b = findSurfaceBlock(cur);
95
+ if (b) write(agentsMdPath, cur.slice(0, b.start) + fresh + cur.slice(b.end), { force: true });
96
+ },
97
+ });
98
+ }
99
+ } else {
100
+ const legacy = findLegacySurfaceSection(text);
101
+ const anchors = templateAnchors();
102
+ plan.push({
103
+ describe: [legacy
104
+ ? "AGENTS.md: replace the hand-written \"## Doctrina command surface\" section with the generated doctrina:surface block"
105
+ : `AGENTS.md: insert the generated doctrina:surface command block${anchors.after ? ` after "${anchors.after}"` : ""} (agents discover commands through this file)`],
106
+ preview: () => fresh,
107
+ apply() {
108
+ const cur = read(agentsMdPath);
109
+ const l = findLegacySurfaceSection(cur);
110
+ if (l) {
111
+ // The legacy section already sits where the CLI put it, so
112
+ // replacing it in place preserves the layout.
113
+ write(agentsMdPath, cur.slice(0, l.start) + fresh + "\n\n" + cur.slice(l.end), { force: true });
114
+ } else {
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 });
151
+ }
152
+ },
153
+ });
154
+ }
155
+ }
156
+
59
157
  // Markdown files: append stub sections for missing recommended headings.
60
- for (const [rel, sections] of [
158
+ /** @type {Array<[string, string[]]>} */
159
+ const markdownTargets = [
61
160
  ["AGENTS.md", AGENTS_SECTIONS],
62
161
  [path.join(".doctrina", "product.md"), PRODUCT_SECTIONS],
63
- ]) {
162
+ ];
163
+ for (const [rel, sections] of markdownTargets) {
64
164
  const filePath = path.join(projectRoot, rel);
65
165
  if (!isFile(filePath)) continue;
66
166
  const text = read(filePath);
@@ -89,7 +189,7 @@ function updateTemplates(flags) {
89
189
  if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
90
190
  if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
91
191
  if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
92
- const categories = ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"];
192
+ const categories = ARTIFACT_CATEGORIES;
93
193
  for (const cat of categories) {
94
194
  if (idx.artifacts && idx.artifacts[cat] === undefined) {
95
195
  describe.push(`index.json: add empty artifact category "${cat}"`);
@@ -120,8 +220,16 @@ function updateTemplates(flags) {
120
220
  return 0;
121
221
  }
122
222
 
123
- for (const s of steps) {
124
- 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
+ }
125
233
  }
126
234
  console.log("");
127
235
  if (!writeMode) {
@@ -135,27 +243,32 @@ function updateTemplates(flags) {
135
243
  }
136
244
 
137
245
  function listTemplates() {
138
- const dir = locateTemplatesDir();
139
- const files = walk(dir).filter((f) => !f.endsWith(".gitkeep"));
140
- if (files.length === 0) {
141
- 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");
142
254
  return 1;
143
255
  }
144
- console.log(c.bold(`Templates shipped by the installed Doctrina CLI:`));
256
+
257
+ console.log(c.bold("Templates resolved for this project:"));
145
258
  console.log("");
146
- let maxLen = 0;
147
- const rows = [];
148
- for (const f of files) {
149
- const rel = relPath(dir, f);
150
- const lines = lineCount(f);
151
- rows.push({ rel, lines });
152
- if (rel.length > maxLen) maxLen = rel.length;
153
- }
259
+ const width = Math.max(...rows.map((r) => r.relativePath.length));
154
260
  for (const r of rows) {
155
- 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}`);
156
265
  }
157
266
  console.log("");
158
- 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
+ }
159
272
  return 0;
160
273
  }
161
274
 
@@ -166,7 +279,16 @@ function checkTemplates() {
166
279
  console.error(c.gray("hint: ") + "run `doctrina init` first");
167
280
  return 1;
168
281
  }
282
+ const { findings, ok } = collectFindings(projectRoot);
283
+
284
+ return report(findings, ok);
285
+ }
169
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) {
170
292
  const findings = [];
171
293
  const ok = [];
172
294
 
@@ -176,10 +298,19 @@ function checkTemplates() {
176
298
  const text = read(agentsPath);
177
299
  for (const heading of AGENTS_SECTIONS) {
178
300
  if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
179
- 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" });
302
+ }
303
+ // Command-surface block: present and current vs the installed catalog.
304
+ const block = findSurfaceBlock(text);
305
+ if (!block) {
306
+ findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
307
+ } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
308
+ findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina templates update --write" });
309
+ } else {
310
+ ok.push("AGENTS.md: doctrina:surface block current");
180
311
  }
181
312
  } else {
182
- findings.push("AGENTS.md missing at project root");
313
+ findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
183
314
  }
184
315
 
185
316
  // product.md sections
@@ -188,10 +319,58 @@ function checkTemplates() {
188
319
  const text = read(productPath);
189
320
  for (const heading of PRODUCT_SECTIONS) {
190
321
  if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
191
- 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" });
323
+ }
324
+ } else {
325
+ findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
326
+ }
327
+
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
+ });
350
+ }
351
+ }
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
+ });
192
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
+ });
193
372
  } else {
194
- findings.push(".doctrina/product.md missing");
373
+ ok.push(`surface block within budget (${surfaceLines}/${SURFACE_LINE_BUDGET} lines)`);
195
374
  }
196
375
 
197
376
  // index.json schema fields
@@ -201,21 +380,31 @@ function checkTemplates() {
201
380
  const idx = JSON.parse(read(indexPath));
202
381
  for (const field of INDEX_FIELDS) {
203
382
  if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
204
- 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" });
205
384
  }
206
385
  if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
207
- 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 });
208
387
  }
209
388
  } catch (err) {
210
- findings.push(`.doctrina/index.json failed to parse: ${err.message}`);
389
+ findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
211
390
  }
212
391
  } else {
213
- findings.push(".doctrina/index.json missing");
392
+ findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
214
393
  }
215
394
 
216
395
  // Output
396
+ return { findings, ok };
397
+ }
398
+
399
+ function report(findings, ok) {
217
400
  for (const o of ok) console.log(c.green("✓ ") + o);
218
- 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
+ }
219
408
  console.log("");
220
409
 
221
410
  if (findings.length === 0) {
@@ -223,10 +412,53 @@ function checkTemplates() {
223
412
  return 0;
224
413
  }
225
414
  console.log(c.red("fail") + ` ${findings.length} recommendation${findings.length === 1 ? "" : "s"}`);
226
- console.log(c.gray("hint: ") + "this command is read-only; review and add the missing sections by hand");
227
415
  return 1;
228
416
  }
229
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
+
433
+ // Whitespace/EOL-insensitive comparison for the surface block, so a CRLF
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
+
446
+ function normalizeBlock(s) {
447
+ return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
448
+ }
449
+
450
+ // A pre-marker AGENTS.md's hand-written surface section: from the
451
+ // "## Doctrina command surface" heading up to (excluding) the next "## "
452
+ // heading. The trailing "Continuous: ..." paragraph the old template put
453
+ // after the bullets belongs to the section body, so it is replaced too.
454
+ function findLegacySurfaceSection(text) {
455
+ const head = text.match(/^##\s+Doctrina command surface.*$/m);
456
+ if (!head) return null;
457
+ const after = text.slice(head.index + head[0].length);
458
+ const next = after.match(/^##\s+/m);
459
+ return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
460
+ }
461
+
230
462
  function hasHeading(text, heading) {
231
463
  // Match an exact h2 heading at line start (case-insensitive for the body
232
464
  // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
@@ -245,15 +477,20 @@ Subcommands:
245
477
  their relative paths and line counts.
246
478
  check Compare the current project's AGENTS.md, product.md,
247
479
  and index.json against the recommended sections and
248
- schema fields shipped in this CLI version. Reports any
249
- recommended section that is missing. Read-only; never
250
- modifies any files. Exits 1 when recommendations exist,
251
- 0 when the project follows the current template shape.
252
- update Additive-only fixer for what check reports: appends
253
- missing recommended sections as stubs and adds missing
254
- index.json fields. Preview by default (exits 1 while
255
- updates are pending); pass --write to apply. Never
256
- rewrites or removes existing content.
480
+ schema fields shipped in this CLI version — including
481
+ whether the AGENTS.md doctrina:surface block (the
482
+ CLI-owned, generated command catalog) is present and
483
+ current. Read-only; never modifies any files. Exits 1
484
+ when recommendations exist, 0 otherwise.
485
+ update Fixer for what check reports: appends missing
486
+ recommended sections as stubs, adds missing index.json
487
+ fields, and regenerates the AGENTS.md doctrina:surface
488
+ block from the installed command catalog. Preview by
489
+ default (exits 1 while updates are pending); pass
490
+ --write to apply. Additive-only outside the surface
491
+ block: the marker-delimited span is the one CLI-owned
492
+ region it rewrites (ADR 0015); your content is never
493
+ touched.
257
494
 
258
495
  Distinct from \`doctrina validate\`: validate answers "is this a
259
496
  well-formed Doctrina tree?"; templates check answers "does this
@@ -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