doctrina-cli 0.9.0 → 0.11.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 (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/completion.js +116 -0
  4. package/src/commands/constitution.js +138 -0
  5. package/src/commands/context.js +118 -22
  6. package/src/commands/coverage.js +41 -16
  7. package/src/commands/doctor.js +135 -0
  8. package/src/commands/handoff.js +94 -0
  9. package/src/commands/init.js +12 -1
  10. package/src/commands/next.js +32 -16
  11. package/src/commands/prime.js +118 -0
  12. package/src/commands/report.js +142 -0
  13. package/src/commands/show.js +173 -0
  14. package/src/commands/status.js +70 -40
  15. package/src/commands/trace.js +15 -2
  16. package/src/commands/validate.js +97 -1
  17. package/src/commands/why.js +132 -36
  18. package/src/index.js +12 -41
  19. package/src/lib/commands.js +115 -0
  20. package/src/lib/criteria.js +76 -0
  21. package/src/lib/index-json.js +1 -0
  22. package/src/lib/scan.js +7 -0
  23. package/templates/AGENTS.md.template +3 -2
  24. package/templates/README.md +7 -1
  25. package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
  26. package/templates/adapters/claude/.claude/commands/doctrina-context.md.template +13 -0
  27. package/templates/adapters/claude/.claude/commands/doctrina-next.md.template +13 -0
  28. package/templates/adapters/claude/.claude/commands/doctrina-status.md.template +12 -0
  29. package/templates/adapters/claude/.claude/commands/doctrina-why.md.template +12 -0
  30. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +17 -0
  31. package/templates/adapters/cursor/.cursor/commands/doctrina-context.md.template +10 -0
  32. package/templates/adapters/cursor/.cursor/commands/doctrina-next.md.template +10 -0
  33. package/templates/adapters/cursor/.cursor/commands/doctrina-status.md.template +10 -0
  34. package/templates/adapters/cursor/.cursor/commands/doctrina-why.md.template +9 -0
  35. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +12 -0
  36. package/templates/doctrina/index.json.template +3 -0
@@ -0,0 +1,173 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
5
+ import { parseAcceptanceCriteria } from "../lib/criteria.js";
6
+ import { suggest } from "../lib/suggest.js";
7
+ import { c } from "../lib/colors.js";
8
+
9
+ // Point reads. An agent that needs ONE requirement re-reads a whole spec —
10
+ // hundreds of lines for a two-line fact. `show` resolves a compact reference
11
+ // to exactly the lines that own it:
12
+ //
13
+ // doctrina show cli-R12 twelfth requirement of the cli spec (file order)
14
+ // doctrina show gates-C3 acceptance criterion 3 (the spec's own numbering)
15
+ // doctrina show 0007 ADR 0007, whole decision (already a bounded read)
16
+ // doctrina show cli the spec's header block + Purpose only
17
+ //
18
+ // R-refs are positional (file order across the EARS sections), so they shift
19
+ // when a requirement is inserted above — cite them for point reads and
20
+ // conversation, not as immutable identifiers. C-refs use the criteria's own
21
+ // explicit numbers and are as stable as the spec keeps them.
22
+
23
+ export async function run(positional, _flags) {
24
+ const ref = positional[0];
25
+ const projectRoot = process.cwd();
26
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
27
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
28
+ }
29
+ if (!ref) {
30
+ console.error(c.red("error:") + " show requires a reference");
31
+ console.error(c.gray("hint: ") + "`doctrina show cli-R12` (requirement), `doctrina show cli-C3` (criterion), `doctrina show 0007` (ADR), `doctrina show cli` (spec header + purpose)");
32
+ return 2;
33
+ }
34
+
35
+ // ADR by number.
36
+ if (/^\d{4}$/.test(ref)) return showAdr(projectRoot, ref);
37
+
38
+ const m = ref.match(/^([a-z][a-z0-9-]*)-([RC])(\d+)$/);
39
+ if (m) return showSpecItem(projectRoot, m[1], m[2], Number(m[3]));
40
+
41
+ if (/^[a-z][a-z0-9-]*$/.test(ref)) return showSpecHead(projectRoot, ref);
42
+
43
+ console.error(c.red("error:") + ` unrecognised reference "${ref}"`);
44
+ console.error(c.gray("hint: ") + "use <cap>-R<n>, <cap>-C<n>, an ADR number (0007), or a capability name");
45
+ return 2;
46
+ }
47
+
48
+ function showAdr(projectRoot, num) {
49
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
50
+ const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
51
+ if (!file) {
52
+ console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
53
+ return 1;
54
+ }
55
+ const full = path.join(dir, file);
56
+ console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
57
+ console.log(read(full).trimEnd());
58
+ return 0;
59
+ }
60
+
61
+ function loadSpec(projectRoot, cap) {
62
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
63
+ const specPath = path.join(specsDir, cap, "spec.md");
64
+ if (isFile(specPath)) return { specPath, text: read(specPath) };
65
+ const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
66
+ console.error(c.red("error:") + ` no spec for capability "${cap}"`);
67
+ const guess = suggest(cap, known);
68
+ if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
69
+ else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
70
+ return null;
71
+ }
72
+
73
+ function showSpecHead(projectRoot, cap) {
74
+ const spec = loadSpec(projectRoot, cap);
75
+ if (!spec) return 1;
76
+ const lines = spec.text.split(/\r?\n/);
77
+ const out = [];
78
+ let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
79
+ for (const line of lines) {
80
+ const isH2 = /^##\s+/.test(line);
81
+ if (mode === "head" && isH2) mode = /^##\s+Purpose\b/i.test(line) ? "purpose" : "seek";
82
+ else if (mode === "seek" && isH2 && /^##\s+Purpose\b/i.test(line)) mode = "purpose";
83
+ else if (mode === "purpose" && isH2) break;
84
+
85
+ if (mode === "head" || (mode === "purpose")) out.push(line);
86
+ }
87
+ console.log(c.gray(`— ${relPath(projectRoot, spec.specPath)} (header + purpose; \`doctrina context ${cap}\` for the full spec) —`));
88
+ console.log(out.join("\n").trimEnd());
89
+ return 0;
90
+ }
91
+
92
+ function showSpecItem(projectRoot, cap, kind, n) {
93
+ const spec = loadSpec(projectRoot, cap);
94
+ if (!spec) return 1;
95
+
96
+ if (kind === "C") {
97
+ const criteria = parseAcceptanceCriteria(spec.text);
98
+ const crit = criteria.find((r) => r.n === n);
99
+ if (!crit) {
100
+ console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
101
+ return 1;
102
+ }
103
+ console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
104
+ console.log(`${n}. ${crit.body}`);
105
+ if (crit.proofPaths.length > 0) {
106
+ console.log(c.gray(` evidence: ${crit.proofPaths.join(", ")}`));
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ const reqs = parseRequirements(spec.text);
112
+ const req = reqs[n - 1];
113
+ if (!req) {
114
+ console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
115
+ return 1;
116
+ }
117
+ console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
118
+ console.log(req.text);
119
+ return 0;
120
+ }
121
+
122
+ // Top-level bullets of the "## Requirements (EARS)" section, in file order,
123
+ // each with its `### <grammar>` sub-section name and continuation lines.
124
+ export function parseRequirements(text) {
125
+ const lines = text.split(/\r?\n/);
126
+ const out = [];
127
+ let inSection = false;
128
+ let sub = "";
129
+ let cur = null;
130
+ const flush = () => {
131
+ if (cur) out.push({ section: cur.section, text: cur.lines.join("\n").trimEnd() });
132
+ cur = null;
133
+ };
134
+ for (const line of lines) {
135
+ if (/^##\s+/.test(line)) {
136
+ flush();
137
+ inSection = /^##\s+Requirements\b/i.test(line);
138
+ sub = "";
139
+ continue;
140
+ }
141
+ if (!inSection) continue;
142
+ const subMatch = line.match(/^###\s+(.+)$/);
143
+ if (subMatch) {
144
+ flush();
145
+ sub = subMatch[1].trim();
146
+ continue;
147
+ }
148
+ if (/^-\s+/.test(line)) {
149
+ flush();
150
+ cur = { section: sub || "Requirements", lines: [line] };
151
+ } else if (cur && (line.startsWith(" ") || line.trim() === "")) {
152
+ cur.lines.push(line);
153
+ } else {
154
+ flush();
155
+ }
156
+ }
157
+ flush();
158
+ return out;
159
+ }
160
+
161
+ export const help = `
162
+ Usage: doctrina show <ref>
163
+
164
+ Point-read one artifact fragment instead of a whole file:
165
+
166
+ doctrina show cli-R12 requirement 12 of the cli spec (file order)
167
+ doctrina show cli-C3 acceptance criterion 3 (the spec's numbering)
168
+ doctrina show 0007 ADR 0007 (the whole decision)
169
+ doctrina show cli the spec's header block + Purpose only
170
+
171
+ R-refs are positional (they shift when a requirement is inserted above);
172
+ C-refs use the criteria's own numbers. Read-only.
173
+ `;
@@ -7,6 +7,7 @@ import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.j
7
7
  import { cliVersion } from "../lib/version.js";
8
8
  import { summarize as coverageSummary } from "./coverage.js";
9
9
  import { summarize as traceSummary } from "./trace.js";
10
+ import { flagBool } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
 
12
13
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
@@ -19,53 +20,47 @@ import { c } from "../lib/colors.js";
19
20
 
20
21
  const CONFIG_REL = ".doctrina/verify.json";
21
22
 
22
- export async function run(_positional, _flags) {
23
+ export async function run(_positional, flags) {
23
24
  const projectRoot = process.cwd();
24
25
  if (!exists(path.join(projectRoot, ".doctrina"))) {
25
26
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
26
27
  }
27
28
 
28
- let index = null;
29
- try {
30
- index = idx.load(projectRoot);
31
- } catch {
32
- index = null;
29
+ const s = collectStatus(projectRoot);
30
+
31
+ if (flagBool(flags, "json", false)) {
32
+ console.log(JSON.stringify(s, null, 2));
33
+ return 0;
33
34
  }
34
35
 
35
- const project = index?.project ?? path.basename(projectRoot);
36
- const stamp = index?.framework_version ?? "—";
37
- const cli = cliVersion();
38
- console.log(c.bold("Doctrina status") + c.gray(` — ${project} (framework ${stamp} / CLI ${cli})`));
36
+ console.log(c.bold("Doctrina status") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
39
37
  console.log("");
40
38
 
41
39
  // --- Gates ---
42
40
  console.log(c.bold(" Gates"));
43
- // Index drift (the silent rot validate now errors on).
44
41
  let driftLine;
45
- if (!index) {
42
+ if (s.indexState === "missing") {
46
43
  driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
47
- } else if (indexesMatch(deriveIndex(projectRoot, index), index)) {
44
+ } else if (s.indexState === "in-sync") {
48
45
  driftLine = c.green("in sync");
49
46
  } else {
50
47
  driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
51
48
  }
52
49
  console.log(` ${"index".padEnd(11)} ${driftLine}`);
53
50
 
54
- // Framework stamp divergence.
55
- const stampLine = stamp === cli
51
+ const stampLine = s.stamp === s.cli
56
52
  ? c.green("current")
57
- : c.yellow(`${stamp} (CLI ${cli})`) + c.gray(" — `doctrina index rebuild`");
53
+ : c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
58
54
  console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
59
55
 
60
- // Coverage and trace ratios.
61
- const cov = coverageSummary(projectRoot);
56
+ const cov = s.coverage;
62
57
  const covExtra = cov.totalDangling + cov.totalConditional > 0
63
58
  ? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
64
59
  : "";
65
60
  const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
66
61
  console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
67
62
 
68
- const tr = traceSummary(projectRoot);
63
+ const tr = s.trace;
69
64
  const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
70
65
  ? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
71
66
  : "";
@@ -73,40 +68,72 @@ export async function run(_positional, _flags) {
73
68
  const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
74
69
  console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
75
70
 
76
- // Verify config presence (running it is the slow, authoritative gate).
77
- const verifyCfg = path.join(projectRoot, CONFIG_REL);
78
71
  let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
79
- if (isFile(verifyCfg)) {
80
- try {
81
- const cfg = JSON.parse(read(verifyCfg));
82
- const n = Array.isArray(cfg?.checks) ? cfg.checks.length : 0;
83
- verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
84
- } catch {
85
- verifyLine = c.red("invalid JSON");
86
- }
72
+ if (s.verify.invalid) {
73
+ verifyLine = c.red("invalid JSON");
74
+ } else if (s.verify.configured) {
75
+ const n = s.verify.checks;
76
+ verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
87
77
  }
88
78
  console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
89
79
 
90
80
  // --- Work ---
91
81
  console.log("");
92
82
  console.log(c.bold(" Work"));
93
- const specs = countSpecs(projectRoot);
94
- const implBreak = Object.entries(specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
95
- console.log(` ${"specs".padEnd(11)} ${specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
96
- console.log(` ${"changes".padEnd(11)} ${specs.openChanges} open`);
97
- const adr = countDecisions(projectRoot);
83
+ const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
84
+ console.log(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
85
+ console.log(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
98
86
  const adrNotes = [];
99
- if (adr.proposed > 0) adrNotes.push(c.yellow(`${adr.proposed} proposed`));
100
- if (adr.bare > 0) adrNotes.push(c.yellow(`${adr.bare} unproven`));
101
- console.log(` ${"decisions".padEnd(11)} ${adr.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
102
- const skills = countSkills(projectRoot);
103
- console.log(` ${"skills".padEnd(11)} ${skills}`);
87
+ if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
88
+ if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
89
+ console.log(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
90
+ console.log(` ${"skills".padEnd(11)} ${s.skills}`);
104
91
 
105
92
  console.log("");
106
93
  console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
107
94
  return 0;
108
95
  }
109
96
 
97
+ // Pure, render-free status snapshot — the data behind the dashboard, shared
98
+ // with `prime`, `handoff`, `doctor`, and the `--json` output so every consumer
99
+ // reads the same numbers.
100
+ export function collectStatus(projectRoot) {
101
+ let index = null;
102
+ try {
103
+ index = idx.load(projectRoot);
104
+ } catch {
105
+ index = null;
106
+ }
107
+ let indexState = "missing";
108
+ if (index) {
109
+ indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
110
+ }
111
+
112
+ const verifyCfg = path.join(projectRoot, CONFIG_REL);
113
+ let verify = { configured: false, checks: 0, invalid: false };
114
+ if (isFile(verifyCfg)) {
115
+ try {
116
+ const cfg = JSON.parse(read(verifyCfg));
117
+ verify = { configured: true, checks: Array.isArray(cfg?.checks) ? cfg.checks.length : 0, invalid: false };
118
+ } catch {
119
+ verify = { configured: true, checks: 0, invalid: true };
120
+ }
121
+ }
122
+
123
+ return {
124
+ project: index?.project ?? path.basename(projectRoot),
125
+ stamp: index?.framework_version ?? null,
126
+ cli: cliVersion(),
127
+ indexState,
128
+ coverage: coverageSummary(projectRoot),
129
+ trace: traceSummary(projectRoot),
130
+ verify,
131
+ specs: countSpecs(projectRoot),
132
+ decisions: countDecisions(projectRoot),
133
+ skills: countSkills(projectRoot),
134
+ };
135
+ }
136
+
110
137
  function countSpecs(projectRoot) {
111
138
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
112
139
  const impl = {};
@@ -165,13 +192,16 @@ function countSkills(projectRoot) {
165
192
  }
166
193
 
167
194
  export const help = `
168
- Usage: doctrina status
195
+ Usage: doctrina status [--json]
169
196
 
170
197
  Print a one-glance health dashboard for the .doctrina/ project: the gate
171
198
  signals (index drift, framework stamp, coverage %, trace anchors, whether
172
199
  verify is configured) and the artifact counts (specs by implementation
173
200
  state, open changes, decisions, skills). Read-only; always exits 0.
174
201
 
202
+ Flags:
203
+ --json Emit the snapshot as JSON (stable shape for agents and CI).
204
+
175
205
  It is a fast summary, not the authoritative gate: run \`doctrina validate\`
176
206
  and \`doctrina verify\` for the full structural and build checks, and
177
207
  \`doctrina next\` for the recommended next action.
@@ -32,6 +32,7 @@ export async function run(_positional, flags) {
32
32
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
33
  }
34
34
  const strict = flagBool(flags, "strict", false);
35
+ const json = flagBool(flags, "json", false);
35
36
 
36
37
  const anchors = collectAnchors(projectRoot); // [{ id }], in document order
37
38
  const specs = collectSpecs(projectRoot); // [{ cap, status, realizes: [id] }]
@@ -41,6 +42,10 @@ export async function run(_positional, flags) {
41
42
 
42
43
  // The feature is unused: do not nag a project that never opted in.
43
44
  if (anchors.length === 0 && !anyRealizes) {
45
+ if (json) {
46
+ console.log(JSON.stringify({ anchors: [], dangling: [], untraceable: [], summary: summarize(projectRoot) }, null, 2));
47
+ return 0;
48
+ }
44
49
  console.log(
45
50
  c.gray(
46
51
  "no intent-provenance markers found — tag product.md bullets with `[SC1]` " +
@@ -69,6 +74,13 @@ export async function run(_positional, flags) {
69
74
  .filter((s) => s.realizes === null && s.status === "active")
70
75
  .map((s) => s.cap);
71
76
 
77
+ if (json) {
78
+ const rows = anchors.map((a) => ({ id: a.id, realizedBy: (realizedBy.get(a.id) ?? []).sort() }));
79
+ 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));
81
+ return clean ? 0 : strict ? 1 : 0;
82
+ }
83
+
72
84
  console.log(c.bold("Trace") + c.gray(" — product intent → capability provenance:"));
73
85
  console.log("");
74
86
 
@@ -183,6 +195,7 @@ trace reports three provenance breaks:
183
195
  untraceable spec an active spec with no Realizes: header
184
196
 
185
197
  Read-only. Exits 0 as a report; with --strict, exits 1 when any break
186
- exists (CI gate). It checks that the link exists and is complete — not
187
- that a criterion is faithful to the intent (that stays a human/LLM call).
198
+ exists (CI gate). --json emits anchors/dangling/untraceable + summary
199
+ as JSON. It checks that the link exists and is complete — not that a
200
+ criterion is faithful to the intent (that stays a human/LLM call).
188
201
  `;
@@ -11,6 +11,13 @@ import { c } from "../lib/colors.js";
11
11
  import { parseFrontmatter } from "./skill.js";
12
12
  import { checkEars, isEarsSpec } from "../lib/ears.js";
13
13
  import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
14
+ import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
15
+ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
16
+
17
+ // AGENTS.md is treated as a maintained doctrina-command catalog only once it
18
+ // documents at least this many real commands; below it, the file defers to
19
+ // `doctrina --help` and is never nagged about omissions.
20
+ const CATALOG_THRESHOLD = 8;
14
21
 
15
22
  export async function run(_positional, flags) {
16
23
  const projectRoot = process.cwd();
@@ -25,6 +32,46 @@ export async function run(_positional, flags) {
25
32
  const lines = lineCount(agentsMd);
26
33
  if (lines > 200) errors.push(`AGENTS.md is ${lines} lines (>200, hard limit)`);
27
34
  else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
35
+
36
+ // 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
37
+ // first, so the doctrina commands it documents must match the real CLI.
38
+ // Two drifts are flagged: a reference to a command that does not exist
39
+ // (a typo or a removed command — the agent will try to run it and fail),
40
+ // and a maintained catalog that has fallen behind the CLI (commands the
41
+ // agent never discovers because the hub never names them). validate only
42
+ // checked AGENTS.md *size* before, so the hub could rot while staying
43
+ // green; this closes that gap.
44
+ const agentsText = read(agentsMd);
45
+ const referenced = referencedCommands(agentsText);
46
+ const known = new Set(COMMAND_NAMES);
47
+ // Reverse drift is always flagged — a dangling command reference misleads
48
+ // the agent regardless of how the file is structured.
49
+ for (const cmd of [...referenced].sort()) {
50
+ if (!known.has(cmd)) {
51
+ warnings.push(
52
+ `AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
53
+ `(typo or removed command — agents reading the hub will try to run it)`,
54
+ );
55
+ }
56
+ }
57
+ // Forward drift (omitted commands) is flagged only when the file presents
58
+ // itself as an exhaustive catalog: it documents many commands AND does not
59
+ // defer to `doctrina --help`. A file that defers is declaring its list
60
+ // illustrative (the shipped template does this), so its omissions are
61
+ // intentional and stay silent.
62
+ const documented = [...referenced].filter((cmd) => known.has(cmd));
63
+ const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
64
+ if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
65
+ const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd));
66
+ if (missing.length > 0) {
67
+ warnings.push(
68
+ `AGENTS.md documents the doctrina command surface but omits ` +
69
+ `${missing.length} command${missing.length === 1 ? "" : "s"} ` +
70
+ `(${missing.join(", ")}) — agents reading the hub will not discover ` +
71
+ `them (add them, or defer to \`doctrina --help\` for the full list)`,
72
+ );
73
+ }
74
+ }
28
75
  }
29
76
 
30
77
  // 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
@@ -306,6 +353,23 @@ export async function run(_positional, flags) {
306
353
  }
307
354
  }
308
355
  }
356
+
357
+ // 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
358
+ // criterion that asserts [verified] but cites no proof path is a
359
+ // claim with nothing behind it — the dishonest-green case. `coverage`
360
+ // reports every bare criterion; this names the specific combo
361
+ // (claims verified, proves nothing) in the always-run gate. It reads
362
+ // through the shared multi-line parser, so a proof cited on a
363
+ // continuation line still counts (no false positive).
364
+ for (const crit of parseAcceptanceCriteria(text)) {
365
+ if (isVerified(crit) && crit.proofPaths.length === 0) {
366
+ warnings.push(
367
+ `${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
368
+ `[verified] but cites no proof path — self-certified (cite the file/test in ` +
369
+ `backticks, or drop the marker; \`doctrina coverage\`)`,
370
+ );
371
+ }
372
+ }
309
373
  }
310
374
  }
311
375
  }
@@ -435,7 +499,35 @@ export async function run(_positional, flags) {
435
499
  }
436
500
  }
437
501
 
502
+ // 16. Bilingual docs parity. Projects that keep an EN-source docs tree
503
+ // with a PT mirror (docs/en + docs/pt) get filename-parity checking:
504
+ // a file added in one language and missing in the other is invisible
505
+ // to every other gate, and the mirror silently falls behind. Only the
506
+ // deterministic half (file sets) is checked — content staleness stays
507
+ // a human judgement. Projects without both trees never see this.
508
+ const docsEn = path.join(projectRoot, "docs", "en");
509
+ const docsPt = path.join(projectRoot, "docs", "pt");
510
+ if (isDir(docsEn) && isDir(docsPt)) {
511
+ const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
512
+ const enSet = mdSet(docsEn);
513
+ const ptSet = mdSet(docsPt);
514
+ for (const f of [...enSet].sort()) {
515
+ if (!ptSet.has(f)) {
516
+ warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
517
+ }
518
+ }
519
+ for (const f of [...ptSet].sort()) {
520
+ if (!enSet.has(f)) {
521
+ warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
522
+ }
523
+ }
524
+ }
525
+
438
526
  // Output
527
+ if (flagBool(flags, "json", false)) {
528
+ console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
529
+ return errors.length === 0 ? 0 : 1;
530
+ }
439
531
  for (const w of warnings) console.log(c.yellow("warn: ") + w);
440
532
  for (const e of errors) console.log(c.red("error: ") + e);
441
533
 
@@ -549,9 +641,13 @@ function isLikelyPath(s) {
549
641
  }
550
642
 
551
643
  export const help = `
552
- Usage: doctrina validate
644
+ Usage: doctrina validate [--fix] [--json]
553
645
 
554
646
  Run schema, artifact-existence, and structural checks against the
555
647
  .doctrina/ tree in the current working directory. Exits 0 if no errors
556
648
  (warnings allowed), 1 otherwise.
649
+
650
+ Flags:
651
+ --fix Rebuild index.json from the tree before checking (heals drift).
652
+ --json Emit { ok, errors, warnings } as JSON (stable shape for agents/CI).
557
653
  `;