doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -1,27 +1,28 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
- import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
6
- import * as idx from "../lib/index-json.js";
7
- import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.js";
8
- import { cliVersion } from "../lib/version.js";
9
- import { summarize as coverageSummary } from "./coverage.js";
10
- import { summarize as traceSummary } from "./trace.js";
11
- import { flagBool } from "../lib/args.js";
4
+ import { exists } from "../lib/fs-ops.js";
5
+ import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
12
6
  import { c } from "../lib/colors.js";
13
7
  import { emitJson } from "../lib/json-out.js";
14
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
9
+ import { collectSnapshot, collectStatus, collectReportWindow } from "../lib/snapshot.js";
10
+ import { renderView, VIEWS } from "../lib/views.js";
11
+ import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
12
+ import { cliVersion } from "../lib/version.js";
13
+ import { suggest } from "../lib/suggest.js";
15
14
 
16
15
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
17
16
  // dashboard that answers "where do things stand?" in a single read, so neither
18
17
  // the human nor the agent has to run validate + coverage + trace + next
19
- // separately. Strictly read-only; never mutates and always exits 0. It reports
20
- // the gate signals it can compute cheaply (index drift, the framework stamp,
21
- // coverage/trace ratios, whether verify is configured) and the artifact counts;
22
- // `doctrina validate` / `verify` remain the full, authoritative gates.
23
-
24
- const CONFIG_REL = ".doctrina/verify.json";
18
+ // separately. Strictly read-only; never mutates and always exits 0.
19
+ //
20
+ // Since change 0037 this is also the HOME of the other read-only views:
21
+ // `--view prime|handoff|report` renders the same snapshot in a different
22
+ // shape. `prime`, `handoff` and `report` remain as their own commands (they
23
+ // are what AGENTS.md tells an agent to run) and delegate straight here, so
24
+ // there is one collector, one set of formatters, and no way for the four to
25
+ // report different numbers (audit finding F7).
25
26
 
26
27
  // Flags this command accepts. Declared HERE, with the command, so
27
28
  // adding a command never requires editing the entrypoint — the gap that
@@ -30,7 +31,10 @@ const CONFIG_REL = ".doctrina/verify.json";
30
31
  // wrap it in the generic envelope.
31
32
  export const jsonNative = true;
32
33
 
33
- export const flags = { boolean: ["json"], string: [] };
34
+ export const flags = { boolean: ["json"], string: ["view", "since"] };
35
+
36
+ // The snapshot's views, plus the one draft that is not a view of it.
37
+ const STATUS_VIEWS = [...VIEWS, "agent-changelog"];
34
38
 
35
39
  export async function run(_positional, flags) {
36
40
  const projectRoot = process.cwd();
@@ -38,173 +42,48 @@ export async function run(_positional, flags) {
38
42
  throw notADoctrinaProject();
39
43
  }
40
44
 
41
- const s = collectStatus(projectRoot);
45
+ const view = flagString(flags, "view") ?? "dashboard";
46
+ if (!STATUS_VIEWS.includes(view)) {
47
+ console.error(c.red("error:") + ` unknown view "${view}"`);
48
+ const guess = suggest(view, STATUS_VIEWS);
49
+ console.error(c.gray("hint: ") + (guess ? `did you mean --view ${guess}?` : `available: ${STATUS_VIEWS.join(", ")}`));
50
+ return 2;
51
+ }
42
52
 
53
+ // --json always answers with the status shape, whatever the view: the
54
+ // envelope is a machine contract and must not change with a formatting flag.
43
55
  if (flagBool(flags, "json", false)) {
44
- emitJson("status", s);
56
+ emitJson("status", collectStatus(projectRoot));
45
57
  return 0;
46
58
  }
47
59
 
48
- console.log(c.bold("Doctrina status") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
49
- console.log("");
50
-
51
- // --- Gates ---
52
- console.log(c.bold(" Gates"));
53
- let driftLine;
54
- if (s.indexState === "missing") {
55
- driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
56
- } else if (s.indexState === "in-sync") {
57
- driftLine = c.green("in sync");
58
- } else {
59
- driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
60
- }
61
- console.log(` ${"index".padEnd(11)} ${driftLine}`);
62
-
63
- const stampLine = s.stamp === s.cli
64
- ? c.green("current")
65
- : c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
66
- console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
67
-
68
- const cov = s.coverage;
69
- const covExtra = cov.totalDangling + cov.totalConditional > 0
70
- ? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
71
- : "";
72
- const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
73
- console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
74
-
75
- const tr = s.trace;
76
- const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
77
- ? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
78
- : "";
79
- const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
80
- const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
81
- console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
82
-
83
- let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
84
- if (s.verify.invalid) {
85
- verifyLine = c.red("invalid JSON");
86
- } else if (s.verify.configured) {
87
- const n = s.verify.checks;
88
- verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
89
- }
90
- console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
91
-
92
- // --- Work ---
93
- console.log("");
94
- console.log(c.bold(" Work"));
95
- const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
96
- console.log(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
97
- console.log(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
98
- const adrNotes = [];
99
- if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
100
- if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
101
- console.log(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
102
- console.log(` ${"skills".padEnd(11)} ${s.skills}`);
103
-
104
- console.log("");
105
- console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
106
- return 0;
107
- }
108
-
109
- // Pure, render-free status snapshot — the data behind the dashboard, shared
110
- // with `prime`, `handoff`, `doctor`, and the `--json` output so every consumer
111
- // reads the same numbers.
112
- export function collectStatus(projectRoot) {
113
- let index = null;
114
- try {
115
- index = idx.load(projectRoot);
116
- } catch {
117
- index = null;
118
- }
119
- let indexState = "missing";
120
- if (index) {
121
- indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
122
- }
123
-
124
- const verifyCfg = path.join(projectRoot, CONFIG_REL);
125
- let verify = { configured: false, checks: 0, invalid: false };
126
- if (isFile(verifyCfg)) {
127
- try {
128
- const cfg = JSON.parse(read(verifyCfg));
129
- verify = { configured: true, checks: Array.isArray(cfg?.checks) ? cfg.checks.length : 0, invalid: false };
130
- } catch {
131
- verify = { configured: true, checks: 0, invalid: true };
132
- }
133
- }
134
-
135
- return {
136
- project: index?.project ?? path.basename(projectRoot),
137
- stamp: index?.framework_version ?? null,
138
- cli: cliVersion(),
139
- indexState,
140
- coverage: coverageSummary(projectRoot),
141
- trace: traceSummary(projectRoot),
142
- verify,
143
- specs: countSpecs(projectRoot),
144
- decisions: countDecisions(projectRoot),
145
- skills: countSkills(projectRoot),
146
- };
147
- }
148
-
149
- function countSpecs(projectRoot) {
150
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
151
- const impl = {};
152
- let total = 0;
153
- if (isDir(specsDir)) {
154
- for (const cap of readdirSync(specsDir).sort()) {
155
- const p = path.join(specsDir, cap, "spec.md");
156
- if (!isFile(p)) continue;
157
- total += 1;
158
- const implRaw = specHeader(read(p), "Implementation");
159
- if (implRaw) {
160
- const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
161
- impl[word] = (impl[word] ?? 0) + 1;
162
- }
163
- }
164
- }
165
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
166
- let openChanges = 0;
167
- if (isDir(changesDir)) {
168
- for (const e of readdirSync(changesDir)) {
169
- if (e === "archive" || e.startsWith(".")) continue;
170
- if (isDir(path.join(changesDir, e))) openChanges += 1;
60
+ let options = {};
61
+ if (view === "report" || view === "agent-changelog") {
62
+ const sinceRaw = flagString(flags, "since");
63
+ const days = parsePositiveInt(sinceRaw ?? "7");
64
+ if (days === null) {
65
+ console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
66
+ return 2;
171
67
  }
172
- }
173
- return { total, impl, openChanges };
174
- }
175
-
176
- function countDecisions(projectRoot) {
177
- const adrDir = path.join(projectRoot, ".doctrina", "decisions");
178
- let total = 0, proposed = 0, bare = 0;
179
- const isBare = (v) => {
180
- const t = (v ?? "").trim();
181
- return t === "" || t === "—" || t === "-";
182
- };
183
- if (isDir(adrDir)) {
184
- for (const f of walk(adrDir)) {
185
- if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
186
- total += 1;
187
- const text = read(f);
188
- const status = (listHeader(text, "Status") ?? "").toLowerCase();
189
- if (status === "proposed") proposed += 1;
190
- if (status === "accepted") {
191
- const evidence = listHeader(text, "Evidence");
192
- const landed = listHeader(text, "Landed");
193
- if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
194
- }
68
+ // A DRAFT of the block `upgrade --write` writes into AGENTS.md, proposed
69
+ // from what the archived changes said they touched. Not a view of the
70
+ // snapshot, so it renders here; authorship stays human (ADR 0005). With
71
+ // no --since the window is "since the last tag".
72
+ if (view === "agent-changelog") {
73
+ const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
74
+ for (const line of renderDraft(draft, cliVersion())) console.log(line);
75
+ return 0;
195
76
  }
77
+ options = collectReportWindow(projectRoot, days);
196
78
  }
197
- return { total, proposed, bare };
198
- }
199
79
 
200
- function countSkills(projectRoot) {
201
- const skillsDir = path.join(projectRoot, ".doctrina", "skills");
202
- if (!isDir(skillsDir)) return 0;
203
- return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
80
+ const snapshot = collectSnapshot(projectRoot);
81
+ for (const line of renderView(view, snapshot, options)) console.log(line);
82
+ return 0;
204
83
  }
205
84
 
206
85
  export const help = `
207
- Usage: doctrina status [--json]
86
+ Usage: doctrina status [--view <name>] [--since <days>] [--json]
208
87
 
209
88
  Print a one-glance health dashboard for the .doctrina/ project: the gate
210
89
  signals (index drift, framework stamp, coverage %, trace anchors, whether
@@ -212,7 +91,26 @@ verify is configured) and the artifact counts (specs by implementation
212
91
  state, open changes, decisions, skills). Read-only; always exits 0.
213
92
 
214
93
  Flags:
215
- --json Emit the snapshot as JSON (stable shape for agents and CI).
94
+ --view <name> Render a different shape of the same snapshot:
95
+ dashboard the default one-glance health board
96
+ prime the session primer (\`doctrina prime\`)
97
+ handoff the Markdown resume note (\`doctrina handoff\`)
98
+ report the period digest: gates, archived changes,
99
+ open work, and local git numbers
100
+ rules the standing rules (\`doctrina prime --rules\`)
101
+ agent-changelog
102
+ a DRAFT of the AGENTS.md "What changed" block:
103
+ one bullet per archived change that touched a
104
+ documented surface, newest first, at most five.
105
+ It proposes; a person cuts and rewrites.
106
+ --since <days> With --view report: the window (default 7). With
107
+ --view agent-changelog: the window (default: since the
108
+ last tag).
109
+ --json Emit the snapshot as JSON (stable shape for agents and CI).
110
+
111
+ The views read ONE collection of the tree, so they can never report
112
+ different numbers; \`prime\` and \`handoff\` are the same views under their
113
+ own names. \`doctrina report\` is deprecated: it is \`--view report\`.
216
114
 
217
115
  It is a fast summary, not the authoritative gate: run \`doctrina validate\`
218
116
  and \`doctrina verify\` for the full structural and build checks, and
@@ -13,28 +13,16 @@ import { cliVersion } from "../lib/version.js";
13
13
  import { ARTIFACT_CATEGORIES } from "../lib/index-json.js";
14
14
  import { flagBool } from "../lib/args.js";
15
15
  import { c } from "../lib/colors.js";
16
+ import { collectFindings, agentsSectionCost, AGENTS_SECTIONS, PRODUCT_SECTIONS, normalizeBlock, hasHeading } from "../lib/templates-model.js";
17
+
18
+ // The findings are collected in lib/templates-model.js, which `doctor` reads
19
+ // too (audit finding F7); this command renders and fixes them.
20
+ export { collectFindings } from "../lib/templates-model.js";
16
21
  import { suggest } from "../lib/suggest.js";
22
+ import { ensureDoctrinaProject } from "../lib/project.js";
17
23
 
18
24
  const SUBCOMMANDS = ["list", "check", "update"];
19
25
 
20
- // Recommended sections per file kind. Adopters whose files lack these
21
- // headings get a warning from `templates check`; they are recommendations,
22
- // not hard requirements (validate handles the hard requirements).
23
- const AGENTS_SECTIONS = [
24
- "## Stack",
25
- "## Commands",
26
- "## Repository structure",
27
- "## Conventions and boundaries",
28
- "## How to read context",
29
- ];
30
- const PRODUCT_SECTIONS = [
31
- "## Vision",
32
- "## Problem",
33
- "## Target users",
34
- "## Scope",
35
- "## Success criteria",
36
- ];
37
- const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
38
26
 
39
27
  // Flags this command accepts. Declared HERE, with the command, so
40
28
  // adding a command never requires editing the entrypoint — the gap that
@@ -63,12 +51,12 @@ export async function run(positional, flags) {
63
51
  function updateTemplates(flags) {
64
52
  const writeMode = flagBool(flags, "write", false);
65
53
  const projectRoot = process.cwd();
66
- if (!isDir(path.join(projectRoot, ".doctrina"))) {
67
- console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
68
- return 1;
69
- }
54
+ ensureDoctrinaProject(projectRoot);
70
55
 
71
56
  const plan = [];
57
+ // Updates this run declines to make, each with the reason and the number.
58
+ // Standing down is a RESULT, not a silence (change 0078).
59
+ const held = [];
72
60
 
73
61
  // AGENTS.md command-surface block: the one CLI-OWNED span of the file
74
62
  // (marker-delimited, generated from the command catalog). Three cases:
@@ -165,6 +153,20 @@ function updateTemplates(flags) {
165
153
  if (!isFile(filePath)) continue;
166
154
  const text = read(filePath);
167
155
  const missing = sections.filter((h) => !hasHeading(text, h));
156
+ // AGENTS.md has a declared line ceiling, and `analyze` refuses a change
157
+ // that resolves an overflow by raising it — so appending stubs that do
158
+ // not fit would trade one gate's recommendation for another's refusal,
159
+ // silently. The plan says what it would cost and stands down instead
160
+ // (change 0078); the check's remedy already names the cut to make first.
161
+ if (missing.length > 0 && rel === "AGENTS.md") {
162
+ const budget = agentsSectionCost(projectRoot, missing);
163
+ if (!budget.fits) {
164
+ held.push(`AGENTS.md: ${missing.length} stub section(s) not appended — they cost ` +
165
+ `${budget.cost} lines and only ${budget.slack} remain of the declared ceiling; ` +
166
+ `cut ${budget.over} line(s) of prose first`);
167
+ continue;
168
+ }
169
+ }
168
170
  if (missing.length > 0) {
169
171
  plan.push({
170
172
  describe: missing.map((h) => `${rel}: append stub section "${h}"`),
@@ -172,7 +174,7 @@ function updateTemplates(flags) {
172
174
  let updated = read(filePath);
173
175
  if (!updated.endsWith("\n")) updated += "\n";
174
176
  for (const h of missing) {
175
- updated += `\n${h}\n\n<!-- added by doctrina templates update — fill in -->\n`;
177
+ updated += `\n${h}\n\n<!-- added by doctrina upgrade — fill in -->\n`;
176
178
  }
177
179
  write(filePath, updated, { force: true });
178
180
  },
@@ -215,7 +217,16 @@ function updateTemplates(flags) {
215
217
  }
216
218
 
217
219
  const steps = plan.flatMap((p) => p.describe);
220
+ for (const reason of held) {
221
+ console.log(c.yellow("held ") + reason);
222
+ }
218
223
  if (steps.length === 0) {
224
+ if (held.length > 0) {
225
+ console.log("");
226
+ console.log(c.yellow("hold") + ` ${held.length} update${held.length === 1 ? "" : "s"} not applied — ` +
227
+ "make room first, then re-run");
228
+ return 0;
229
+ }
219
230
  console.log(c.green("ok") + " project already follows the current template shape");
220
231
  return 0;
221
232
  }
@@ -274,127 +285,12 @@ function listTemplates() {
274
285
 
275
286
  function checkTemplates() {
276
287
  const projectRoot = process.cwd();
277
- if (!isDir(path.join(projectRoot, ".doctrina"))) {
278
- console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
279
- console.error(c.gray("hint: ") + "run `doctrina init` first");
280
- return 1;
281
- }
288
+ ensureDoctrinaProject(projectRoot);
282
289
  const { findings, ok } = collectFindings(projectRoot);
283
290
 
284
291
  return report(findings, ok);
285
292
  }
286
293
 
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) {
292
- const findings = [];
293
- const ok = [];
294
-
295
- // AGENTS.md sections
296
- const agentsPath = path.join(projectRoot, "AGENTS.md");
297
- if (isFile(agentsPath)) {
298
- const text = read(agentsPath);
299
- for (const heading of AGENTS_SECTIONS) {
300
- if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${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");
311
- }
312
- } else {
313
- findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
314
- }
315
-
316
- // product.md sections
317
- const productPath = path.join(projectRoot, ".doctrina", "product.md");
318
- if (isFile(productPath)) {
319
- const text = read(productPath);
320
- for (const heading of PRODUCT_SECTIONS) {
321
- if (hasHeading(text, heading)) ok.push(`product.md: ${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
- });
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
- }
375
-
376
- // index.json schema fields
377
- const indexPath = path.join(projectRoot, ".doctrina", "index.json");
378
- if (isFile(indexPath)) {
379
- try {
380
- const idx = JSON.parse(read(indexPath));
381
- for (const field of INDEX_FIELDS) {
382
- if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
383
- else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
384
- }
385
- if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
386
- findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
387
- }
388
- } catch (err) {
389
- findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
390
- }
391
- } else {
392
- findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
393
- }
394
-
395
- // Output
396
- return { findings, ok };
397
- }
398
294
 
399
295
  function report(findings, ok) {
400
296
  for (const o of ok) console.log(c.green("✓ ") + o);
@@ -443,9 +339,6 @@ function templateAnchors() {
443
339
  }
444
340
  }
445
341
 
446
- function normalizeBlock(s) {
447
- return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
448
- }
449
342
 
450
343
  // A pre-marker AGENTS.md's hand-written surface section: from the
451
344
  // "## Doctrina command surface" heading up to (excluding) the next "## "
@@ -459,12 +352,6 @@ function findLegacySurfaceSection(text) {
459
352
  return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
460
353
  }
461
354
 
462
- function hasHeading(text, heading) {
463
- // Match an exact h2 heading at line start (case-insensitive for the body
464
- // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
465
- const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
466
- return new RegExp(`^${escaped}\\b`, "im").test(text);
467
- }
468
355
 
469
356
  export const help = `
470
357
  Usage: doctrina templates <subcommand>