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
@@ -0,0 +1,1381 @@
1
+ // @ts-check
2
+ // The structural gate's checks, as a collection rather than a printout.
3
+ //
4
+ // `validate` renders these; `doctor` reports them as one row of its
5
+ // diagnostic. Before this they were one function inside the command module,
6
+ // so the only way for another command to ask "does the tree validate?" was
7
+ // to spawn the CLI again and parse its own JSON back — two integration
8
+ // styles in one binary, and a "did not produce a report" failure path that
9
+ // existed only because of the choice (audit finding F4). ADR 0025: command
10
+ // modules render, shared logic lives here.
11
+ //
12
+ // The one impure part is deliberate: with `fix: true` the collection REPAIRS
13
+ // what it can (headers, a drifted index) and returns what it repaired, so the
14
+ // caller prints it. A collector that silently wrote files while pretending to
15
+ // be a read would be worse than one that says so in its options.
16
+ import path from "node:path";
17
+ import { readdirSync } from "node:fs";
18
+ import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
19
+ import { fold, terms } from "./lexicon.js";
20
+ import { intakeStatusError } from "./intake-model.js";
21
+ import * as idx from "./index-json.js";
22
+ import { SCHEMA_VERSION } from "./index-json.js";
23
+ import { cliVersion, newestVersion } from "./version.js";
24
+ import { today } from "./dates.js";
25
+ import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue, isScaffoldValue } from "./doc-model.js";
26
+ import { checkEars, isEarsSpec } from "./ears.js";
27
+ import { parseAdrScope, parseSourceGlobs, parseDependsOn, specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "./scan.js";
28
+ import { COMMAND_NAMES, referencedCommands, DEPRECATED } from "./commands.js";
29
+ import { parseAcceptanceCriteria, isVerified } from "./criteria.js";
30
+ import { parsePipeline, checkPipeline } from "./pipeline.js";
31
+ import { declaredBudget, collectRuntimeFindings, filesMatching } from "./runtime.js";
32
+ import { derivedImplementations, implementationMismatch } from "./coverage-model.js";
33
+ import { headerValueError, criterionMarkError } from "./spec-ops.js";
34
+ import { readLedger, ledgerPath as ledgerFile } from "./ledger.js";
35
+ import { loadConfig, SOURCES, CONFIG_REL, RULES_REL } from "./config.js";
36
+
37
+ // AGENTS.md is treated as a maintained doctrina-command catalog only once it
38
+ // documents at least this many real commands; below it, the file defers to
39
+ // `doctrina --help` and is never nagged about omissions.
40
+ const CATALOG_THRESHOLD = 8;
41
+
42
+ // The shipped ceiling for a project that declares no `agents-md-lines` budget.
43
+ // AGENTS.md is loaded into EVERY session, so its size is a tax on all work.
44
+ export const AGENTS_MD_SOFT_LIMIT = 150;
45
+ export const AGENTS_MD_HARD_MARGIN = 50;
46
+
47
+ /**
48
+ * The AGENTS.md size budget: what the project declares, what the file spends,
49
+ * and what is left.
50
+ *
51
+ * The ceiling had two homes — a literal here and the `agents-md-lines` row of
52
+ * the contract's Budgets table — which is how every count in this repository
53
+ * has ever drifted (change 0059). The contract is the declaration, so it wins;
54
+ * the literal below is the fallback for a project that declares nothing.
55
+ *
56
+ * @param {string} projectRoot
57
+ * @returns {{ used: number, soft: number, hard: number, slack: number, declared: boolean }}
58
+ */
59
+ export function agentsMdBudget(projectRoot) {
60
+ const file = path.join(projectRoot, "AGENTS.md");
61
+ const used = isFile(file) ? lineCount(file) : 0;
62
+ const { value: soft, declared } = declaredBudget(projectRoot, "agents-md-lines", AGENTS_MD_SOFT_LIMIT);
63
+ return { used, soft, hard: soft + AGENTS_MD_HARD_MARGIN, slack: soft - used, declared };
64
+ }
65
+
66
+
67
+ /**
68
+ * Run every structural check over the tree.
69
+ *
70
+ * @param {string} projectRoot
71
+ * @param {{fix?: boolean, runtime?: boolean}} [options]
72
+ * fix — repair what is mechanically repairable before reporting;
73
+ * runtime — also check the declared runtime surface (ADR 0023).
74
+ * @returns {{errors: string[], warnings: string[], fixes: string[]}}
75
+ */
76
+ export function collectValidation(projectRoot, { fix = false, runtime = false } = {}) {
77
+ const fixes = [];
78
+ const errors = [];
79
+ const warnings = [];
80
+
81
+ // 1. AGENTS.md
82
+ const agentsMd = path.join(projectRoot, "AGENTS.md");
83
+ if (!isFile(agentsMd)) {
84
+ errors.push("AGENTS.md missing at project root");
85
+ } else {
86
+ const budget = agentsMdBudget(projectRoot);
87
+ const lines = budget.used;
88
+ if (lines > budget.hard) errors.push(`AGENTS.md is ${lines} lines (>${budget.hard}, hard limit)`);
89
+ else if (lines > budget.soft) warnings.push(`AGENTS.md is ${lines} lines (>${budget.soft} soft limit)`);
90
+
91
+ // 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
92
+ // first, so the doctrina commands it documents must match the real CLI.
93
+ // Two drifts are flagged: a reference to a command that does not exist
94
+ // (a typo or a removed command — the agent will try to run it and fail),
95
+ // and a maintained catalog that has fallen behind the CLI (commands the
96
+ // agent never discovers because the hub never names them). validate only
97
+ // checked AGENTS.md *size* before, so the hub could rot while staying
98
+ // green; this closes that gap.
99
+ const agentsText = read(agentsMd);
100
+ const referenced = referencedCommands(agentsText);
101
+ const known = new Set(COMMAND_NAMES);
102
+ // Reverse drift is always flagged — a dangling command reference misleads
103
+ // the agent regardless of how the file is structured.
104
+ for (const cmd of [...referenced].sort()) {
105
+ if (!known.has(cmd)) {
106
+ warnings.push(
107
+ `AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
108
+ `(typo or removed command — agents reading the hub will try to run it)`,
109
+ );
110
+ }
111
+ }
112
+ // Forward drift (omitted commands) is flagged only when the file presents
113
+ // itself as an exhaustive catalog: it documents many commands AND does not
114
+ // defer to `doctrina --help`. A file that defers is declaring its list
115
+ // illustrative (the shipped template does this), so its omissions are
116
+ // intentional and stay silent.
117
+ const documented = [...referenced].filter((cmd) => known.has(cmd));
118
+ const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
119
+ if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
120
+ // A DEPRECATED command is absent from the generated block on purpose
121
+ // (change 0049): the block lists what to reach for, and a name that
122
+ // warns when used is not that. Counting it as a gap would make the
123
+ // deprecation permanently red.
124
+ const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd) && !DEPRECATED[cmd]);
125
+ if (missing.length > 0) {
126
+ warnings.push(
127
+ `AGENTS.md documents the doctrina command surface but omits ` +
128
+ `${missing.length} command${missing.length === 1 ? "" : "s"} ` +
129
+ `(${missing.join(", ")}) — agents reading the hub will not discover ` +
130
+ `them (add them, or defer to \`doctrina --help\` for the full list)`,
131
+ );
132
+ }
133
+ }
134
+ }
135
+
136
+ // 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
137
+ // size caps as the root file.
138
+ for (const nested of findNestedAgentsMd(projectRoot)) {
139
+ const rel = relPath(projectRoot, nested);
140
+ const lines = lineCount(nested);
141
+ if (lines > 200) errors.push(`${rel} is ${lines} lines (>200, hard limit)`);
142
+ else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft limit)`);
143
+ }
144
+
145
+ // 2. product.md
146
+ const productMd = path.join(projectRoot, ".doctrina", "product.md");
147
+ if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
148
+
149
+ // 2b. intake.md's status, when there is one.
150
+ //
151
+ // `next` branches on this value to decide whether the bootstrap is done,
152
+ // and the playbook has the agent write it BY HAND — so it is the one
153
+ // control value in the tree that nothing read back. Anything but the two
154
+ // declared words meant "pending" in silence: "convertido" typed in a
155
+ // Portuguese project, or an empty value left by a botched edit, both left
156
+ // `next` asking forever for a bootstrap that had already happened.
157
+ // A spec's Status gets an enum and an error; so does this one.
158
+ const intakeMd = path.join(projectRoot, ".doctrina", "intake.md");
159
+ if (isFile(intakeMd)) {
160
+ const badStatus = intakeStatusError(read(intakeMd));
161
+ if (badStatus) errors.push(`.doctrina/intake.md: ${badStatus}`);
162
+ }
163
+
164
+ // Header repair (M3). One grammar means a non-canonical header can be
165
+ // REWRITTEN, not just reported: `- **Status**: x` becomes
166
+ // `- **Status:** x`, and a spec written with list-style headers is
167
+ // normalised to the bare-bold form its kind uses. Content is never
168
+ // touched — only the header's own punctuation and style.
169
+ const doFix = fix;
170
+ {
171
+ const artifacts = [];
172
+ const dot = path.join(projectRoot, ".doctrina");
173
+ for (const f of walk(dot)) {
174
+ if (!f.endsWith(".md")) continue;
175
+ const rel = relPath(projectRoot, f).replace(/\\/g, "/");
176
+ if (rel.includes("/changes/archive/")) continue; // history is immutable
177
+ artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
178
+ }
179
+ let repairedFiles = 0;
180
+ for (const a of artifacts) {
181
+ const text = read(a.file);
182
+ const bad = nonConformingHeaders(text, a.kind);
183
+ if (bad.length === 0) continue;
184
+ if (doFix) {
185
+ const { text: fixed, repaired } = repairHeaders(text, a.kind);
186
+ if (repaired > 0 && fixed !== text) {
187
+ write(a.file, fixed, { force: true });
188
+ repairedFiles += 1;
189
+ }
190
+ } else {
191
+ for (const h of bad) {
192
+ warnings.push(
193
+ `${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
194
+ );
195
+ }
196
+ }
197
+ }
198
+ if (doFix && repairedFiles > 0) {
199
+ fixes.push(`normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
200
+ }
201
+ }
202
+
203
+ // 3. index.json
204
+ let index = null;
205
+ try {
206
+ index = idx.load(projectRoot);
207
+ } catch (err) {
208
+ errors.push(err.message);
209
+ }
210
+
211
+ if (index) {
212
+ // --fix (F5): regenerate the index from the tree before validating, so a
213
+ // drifted index is repaired rather than reported. A full rebuild — it also
214
+ // absorbs orphans and migrates the framework stamp, matching
215
+ // `index rebuild`. Subsequent checks then see the repaired index.
216
+ if (fix) {
217
+ const derived = deriveIndex(projectRoot, index);
218
+ // Never rewound (change 0116): a stamp AHEAD of this CLI is a tree
219
+ // managed by a newer release, and the remedy is to upgrade the CLI,
220
+ // not to rewrite the stamp backwards on every commit.
221
+ const stamp = newestVersion(index.framework_version, cliVersion());
222
+ const staleStamp = (index.framework_version ?? null) !== stamp;
223
+ if (!indexesMatch(derived, index) || staleStamp) {
224
+ derived.framework_version = stamp;
225
+ derived.last_updated = today();
226
+ idx.save(projectRoot, derived);
227
+ fixes.push("rebuilt .doctrina/index.json from the tree");
228
+ index = derived;
229
+ }
230
+ }
231
+
232
+ if (index.$schema_version !== SCHEMA_VERSION) {
233
+ warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
234
+ }
235
+ // framework_version stamp divergence (3.6). The index records which CLI
236
+ // manages it; a stamp behind (or absent vs.) the running CLI means the
237
+ // tree may predate the current schema. `index rebuild` migrates it.
238
+ const runningVersion = cliVersion();
239
+ if (!index.framework_version) {
240
+ warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
241
+ } else if (newestVersion(index.framework_version, runningVersion) !== runningVersion) {
242
+ warnings.push(
243
+ `index.json framework_version is "${index.framework_version}", ahead of the running CLI ` +
244
+ `${runningVersion} — this tree is managed by a newer release; upgrade the CLI (the stamp is never rewound)`,
245
+ );
246
+ } else if (index.framework_version !== runningVersion) {
247
+ warnings.push(
248
+ `index.json framework_version is "${index.framework_version}" but the running CLI is ` +
249
+ `${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
250
+ );
251
+ }
252
+ if (!index.artifacts) {
253
+ errors.push("index.json missing artifacts object");
254
+ } else {
255
+ // 4. Every referenced artifact path must exist
256
+ const allArtifactPaths = [];
257
+ if (index.artifacts.product?.path) allArtifactPaths.push(index.artifacts.product.path);
258
+ for (const s of index.artifacts.specs ?? []) allArtifactPaths.push(s.path);
259
+ for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
260
+ for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
261
+ for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
262
+ for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
263
+ for (const rel of allArtifactPaths) {
264
+ const full = path.join(projectRoot, rel);
265
+ if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
266
+ }
267
+
268
+ // 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
269
+ // source of truth about whether the project is OK, so an indexed
270
+ // artifact whose recorded metadata (version, implementation, status,
271
+ // ...) no longer matches its file is an ERROR here — not a
272
+ // pass-with-warning that only `index rebuild --check` would catch
273
+ // (the "validate says OK while next says drift" footgun). Presence
274
+ // drift (orphan on disk / missing file) stays the orphan warning and
275
+ // the missing-artifact error above; this targets the metadata that
276
+ // silently lies. `validate --fix` (above) regenerates instead.
277
+ const derived = deriveIndex(projectRoot, index);
278
+ for (const line of metadataDrift(index, derived)) {
279
+ errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
280
+ }
281
+ }
282
+ }
283
+
284
+ // 5. ADRs have parseable Status: header
285
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
286
+ // Track which files claim each NNNN so a merge-time collision (two branches
287
+ // both allocating the same number) becomes a loud error rather than one ADR
288
+ // silently shadowing the other (C2).
289
+ const adrByNumber = new Map();
290
+ if (isDir(adrDir)) {
291
+ for (const f of walk(adrDir)) {
292
+ if (!f.endsWith(".md")) continue;
293
+ const base = path.basename(f);
294
+ const numMatch = base.match(/^(\d{4})-/);
295
+ const num = numMatch ? numMatch[1] : null;
296
+ if (num) {
297
+ if (!adrByNumber.has(num)) adrByNumber.set(num, []);
298
+ adrByNumber.get(num).push(relPath(projectRoot, f));
299
+ }
300
+ // ADR filenames must be NNNN-slug.md (four digits). The entire ADR
301
+ // toolchain — decision new/accept/supersede, index derivation, and
302
+ // the orphan check below — keys off this shape; a file like
303
+ // "ADR-001-foo.md" is silently invisible to all of them.
304
+ if (base !== "README.md" && !/^\d{4}-.+\.md$/.test(base)) {
305
+ errors.push(
306
+ `${relPath(projectRoot, f)} has a non-canonical ADR filename ` +
307
+ `(expected four digits + slug, e.g. "0001-jwt-algorithm.md"; ` +
308
+ `the decision and index commands silently ignore other shapes)`,
309
+ );
310
+ }
311
+ const text = read(f);
312
+ if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
313
+ errors.push(
314
+ `${relPath(projectRoot, f)} missing or malformed Status: header ` +
315
+ `(expected a Markdown list item exactly like "- **Status:** accepted")`,
316
+ );
317
+ }
318
+
319
+ // 5b. ADR evidence drift. A decision is only true if reality reflects
320
+ // it; an accepted ADR that points at nothing — or at a file that
321
+ // no longer exists — is the classic "ADR decrees X, the code
322
+ // never did X". Two header fields anchor a decision to reality:
323
+ // **Evidence:** (cited at any point) and **Landed:** (the
324
+ // non-mutating "this is now implemented and verified here" stamp,
325
+ // written by `doctrina decision land`; 3.4). Both are checked for
326
+ // dangling citations; an accepted ADR is only flagged bare when
327
+ // BOTH are empty. ADRs that adopt neither header opt out entirely.
328
+ const evidence = listHeader(text, "Evidence");
329
+ const landed = listHeader(text, "Landed");
330
+ const isBare = (v) => {
331
+ const t = (v ?? "").trim();
332
+ return t === "" || t === "—" || t === "-";
333
+ };
334
+ for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
335
+ if (value === null || isBare(value)) continue;
336
+ for (const token of extractBacktickPaths(value)) {
337
+ const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
338
+ if (!candidates.some(exists)) {
339
+ warnings.push(
340
+ `${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
341
+ `the decision may have drifted from the code (update it or supersede the ADR)`,
342
+ );
343
+ }
344
+ }
345
+ }
346
+ const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
347
+ if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
348
+ warnings.push(
349
+ `${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
350
+ `(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
351
+ `it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
352
+ `waiting to be superseded`,
353
+ );
354
+ }
355
+ }
356
+
357
+ // 5c. Duplicate ADR numbers (C2).
358
+ for (const [num, files] of adrByNumber) {
359
+ if (files.length > 1) {
360
+ errors.push(
361
+ `ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
362
+ `two branches likely allocated it concurrently; renumber all but one ` +
363
+ `(the index keys decisions by number, so duplicates silently shadow each other)`,
364
+ );
365
+ }
366
+ }
367
+ }
368
+
369
+ // 5d. Scope drift: a spec cites an ADR that does not name it (change 0075).
370
+ //
371
+ // `Scope:` decides which context packs an ADR loads into. A spec that
372
+ // CITES an ADR has declared it load-bearing for that capability — so if
373
+ // the ADR's scope omits the capability, the pack gets it only by
374
+ // dependency inheritance, and inheritance is the first tier the budget
375
+ // fitter drops. The ADRs a capability most needs are then the ones it
376
+ // loses first, silently.
377
+ //
378
+ // Splitting a spec is what produces this: change 0054 created
379
+ // `authoring` out of `cli` and left seven cited ADRs pointing at `cli`;
380
+ // the same drift reached `insight` and `scaffolding` from earlier
381
+ // splits. Nothing reported it, because nothing compared the two.
382
+ //
383
+ // A global (unscoped) ADR loads everywhere and is never a violation.
384
+ const specsDirForScope = path.join(projectRoot, ".doctrina", "specs");
385
+ if (isDir(specsDirForScope)) {
386
+ /** @type {Map<string, string[]|null>} */
387
+ const adrScopes = new Map();
388
+ const decisionsDirForScope = path.join(projectRoot, ".doctrina", "decisions");
389
+ if (isDir(decisionsDirForScope)) {
390
+ for (const f of walk(decisionsDirForScope)) {
391
+ const num = /(\d{4})-/.exec(path.basename(f))?.[1];
392
+ if (!num || !f.endsWith(".md")) continue;
393
+ const text = read(f);
394
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
395
+ const scope = parseAdrScope(text);
396
+ adrScopes.set(num, scope.length > 0 ? scope : null);
397
+ }
398
+ }
399
+ for (const capDir of readdirSync(specsDirForScope, { withFileTypes: true })) {
400
+ if (!capDir.isDirectory()) continue;
401
+ const cap = capDir.name;
402
+ const specFile = path.join(specsDirForScope, cap, "spec.md");
403
+ if (!isFile(specFile)) continue;
404
+ const cited = new Set((read(specFile).match(/ADR (\d{4})/g) ?? []).map((m) => m.slice(4)));
405
+ for (const num of [...cited].sort()) {
406
+ const scope = adrScopes.get(num);
407
+ if (scope === undefined || scope === null) continue; // unknown or global
408
+ if (scope.includes(cap)) continue;
409
+ warnings.push(
410
+ `.doctrina/specs/${cap}/spec.md cites ADR ${num}, but that ADR's Scope: ` +
411
+ `(${scope.join(", ")}) does not name "${cap}" — the pack for ${cap} receives it ` +
412
+ `only by dependency, which is the first thing the context budget drops. ` +
413
+ `Add ${cap} to the ADR's Scope: header, or stop citing it here`,
414
+ );
415
+ }
416
+ }
417
+ }
418
+
419
+ // 6. Adapter templates under 30 lines
420
+ const adaptersDir = path.join(projectRoot, ".doctrina", "templates", "adapters");
421
+ if (isDir(adaptersDir)) {
422
+ for (const f of walk(adaptersDir)) {
423
+ if (!f.endsWith(".template")) continue;
424
+ const lines = lineCount(f);
425
+ if (lines > 30) errors.push(`${relPath(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
426
+ }
427
+ }
428
+
429
+ // 7. Every open change has a proposal, and its delta files carry a
430
+ // well-formed **Operation:** header. The delta used to be the one
431
+ // 100% hand-authored file with no early check: a missing header passed
432
+ // silently at creation and exploded days later in the closing analyze
433
+ // (operator review 2026-07-19 §3.2). Warning-level — the change is
434
+ // still a draft — but now every `validate` run names it.
435
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
436
+ if (isDir(changesDir)) {
437
+ for (const entry of readdirSync(changesDir)) {
438
+ if (entry === "archive" || entry.startsWith(".")) continue;
439
+ const proposal = path.join(changesDir, entry, "proposal.md");
440
+ if (!isFile(proposal)) {
441
+ errors.push(
442
+ `open change "${entry}" missing proposal.md ` +
443
+ `(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
444
+ );
445
+ }
446
+ // A change whose tasks are still the scaffold placeholders was opened
447
+ // but never planned — the agent is (or will be) implementing with no
448
+ // recorded plan. Early, every-run signal; analyze/close hard-fail it.
449
+ const tasksPath = path.join(changesDir, entry, "tasks.md");
450
+ if (isFile(tasksPath)) {
451
+ const ph = checklistProgress(read(tasksPath)).placeholders;
452
+ if (ph > 0) {
453
+ warnings.push(
454
+ `open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
455
+ `the change was opened but never planned (replace them with real tasks; \`change check\` and the close refuse them)`,
456
+ );
457
+ }
458
+ }
459
+ // A ghost in `Affects specs:` (change 0108). The header names the
460
+ // capabilities a change touches, and a name with no spec behind it
461
+ // — a typo, or a capability that was never created — passed
462
+ // analyze, check and close in silence. A capability this change is
463
+ // about to CREATE (an ADDED delta) is not a ghost. Neither is the
464
+ // header's own "none": a chore is scaffolded with "(none — chore)",
465
+ // and reading its words as names warned twice on every chore (change
466
+ // 0190). A parenthesised aside is a note, not a capability.
467
+ if (isFile(proposal)) {
468
+ const header = (listHeader(read(proposal), "Affects specs") ?? "").trim();
469
+ const affects = /^\(?\s*(none|n\/a)\b/i.test(header)
470
+ ? []
471
+ : header.replace(/\([^)]*\)/g, "").match(/[a-z][a-z0-9-]*/g) ?? [];
472
+ const specsRoot = path.join(projectRoot, ".doctrina", "specs");
473
+ for (const cap of affects) {
474
+ if (isFile(path.join(specsRoot, cap, "spec.md"))) continue;
475
+ if (isFile(path.join(changesDir, entry, "specs", cap, "delta.md"))) continue;
476
+ warnings.push(
477
+ `open change "${entry}" Affects specs: names "${cap}", which has no spec and no delta ` +
478
+ `in this change — correct the name, or \`doctrina spec new ${cap}\``,
479
+ );
480
+ }
481
+ }
482
+ for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
483
+ if (!deltaPath.endsWith("delta.md")) continue;
484
+ const text = read(deltaPath);
485
+ const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
486
+ if (!m) {
487
+ warnings.push(
488
+ `${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
489
+ `\`change apply\` and the close will refuse it (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
490
+ );
491
+ } else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
492
+ warnings.push(
493
+ `${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
494
+ `\`change apply\` and the close will refuse it`,
495
+ );
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ // 8. Specs over 400 lines warn (lost-in-the-middle).
502
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
503
+ if (isDir(specsDir)) {
504
+ const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
505
+ // The coverage arithmetic, read once for the whole tree: every spec's
506
+ // Implementation header is checked against it below (8d).
507
+ const derived = derivedImplementations(projectRoot);
508
+ for (const cap of readdirSync(specsDir)) {
509
+ const specPath = path.join(specsDir, cap, "spec.md");
510
+ if (isFile(specPath)) {
511
+ const lines = lineCount(specPath);
512
+ if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
513
+
514
+ // 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
515
+ const text = read(specPath);
516
+ if (isEarsSpec(text)) {
517
+ for (const f of checkEars(text)) {
518
+ warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
519
+ }
520
+ }
521
+
522
+ // 8b-ii. ORDERED requirements (change 0029). EARS states each
523
+ // event-driven requirement independently and says nothing about
524
+ // order, so a consumer step and its producer both pass while the
525
+ // consumer reads last run's file. A `### Pipeline` block declares
526
+ // the order and the artifact each step hands on; the invariant is
527
+ // that a step may only require what an EARLIER step produced.
528
+ // Opt-in: a spec without the block is never checked.
529
+ for (const f of checkPipeline(parsePipeline(text))) {
530
+ errors.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message} [${f.code}] — ${f.remedy}`);
531
+ }
532
+
533
+ // 8i. An ACTIVE spec that declares no acceptance criterion. It says
534
+ // what the system must do and never says how anyone would know
535
+ // it does — and the section being present and empty is the
536
+ // common shape, since `spec new` scaffolds the heading.
537
+ //
538
+ // Measured before this check: on such a spec, `clarify` failed,
539
+ // `doctor` warned and `status` showed 0/0, while `validate` said
540
+ // "ok all validation checks passed" and `coverage --strict`
541
+ // exited 0. Three surfaces knew; the two a pipeline runs
542
+ // approved. The information was in the tree — the gate that
543
+ // decides was the one not using it (change 0091).
544
+ //
545
+ // Scoped to ACTIVE, symmetrically with 8c: a spec still being
546
+ // drawn may not know how to prove anything yet, and `Status:
547
+ // draft` is the escape hatch the scaffold already starts in.
548
+ {
549
+ const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
550
+ if (docStatus === "active" && parseAcceptanceCriteria(text).length === 0) {
551
+ errors.push(
552
+ `${relPath(projectRoot, specPath)}: Status is "active" but the spec declares no ` +
553
+ `acceptance criterion — an active capability states what the system must do ` +
554
+ `and nothing about how anyone would know it does (write one, or set ` +
555
+ `Status: draft while it is still being drawn)`,
556
+ );
557
+ }
558
+ }
559
+
560
+ // 8c. Capability-state honesty (two-axis status). An "active"
561
+ // document that records no built capability is an inventory
562
+ // claim — the gap the framework is meant to make visible.
563
+ // Warn when Status is active and Implementation is "planned"
564
+ // with no explanatory note after the state word. A note
565
+ // ("planned — deferred, see ADR 0007") is the deliberate-gap
566
+ // escape hatch. Specs with no Implementation header are off
567
+ // the axis and never warn.
568
+ // 8j. The two machine-read headers and the criterion mark have a
569
+ // DOMAIN (change 0102). Before this check `Status: bogus`,
570
+ // `Implementation: banana` and `[banana]` were written by `spec
571
+ // set`, synced into the index, and passed here with 0 errors —
572
+ // `prime` then reported "1 banana". Every gate branches on these
573
+ // words; a word outside the domain is a value no branch reads.
574
+ // A note after the value stays legal: only the word is checked.
575
+ for (const name of ["Status", "Implementation"]) {
576
+ const raw = specHeader(text, name);
577
+ if (raw === null || raw.trim() === "") continue;
578
+ const domain = headerValueError(name, raw);
579
+ if (domain) errors.push(`${relPath(projectRoot, specPath)}: ${domain}`);
580
+ }
581
+ for (const crit of parseAcceptanceCriteria(text)) {
582
+ if (crit.marker === null) continue;
583
+ const domain = criterionMarkError(crit.marker);
584
+ if (domain) errors.push(`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n}: ${domain}`);
585
+ }
586
+
587
+ const implRaw = specHeader(text, "Implementation");
588
+ if (implRaw) {
589
+ const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
590
+ const implTokens = implRaw.trim().split(/\s+/);
591
+ const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
592
+ const hasNote = implTokens.length > 1;
593
+ if (docStatus === "active" && implWord === "planned" && !hasNote) {
594
+ warnings.push(
595
+ `${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
596
+ `"planned" with no note — an active spec with no built capability is an ` +
597
+ `inventory claim (advance Implementation, set Status: draft, or add a note ` +
598
+ `after the value: "planned — <why deferred>")`,
599
+ );
600
+ }
601
+
602
+ // 8d. The same header, checked against the ARITHMETIC rather than
603
+ // against Status (audit finding F10). Coverage already knows how
604
+ // many of a spec's criteria cite proof that resolves, which is
605
+ // what "verified" means — so a header that disagrees with its own
606
+ // evidence is a warning with the exact op that settles it, not a
607
+ // field the agent is asked to remember. Never rewritten here: the
608
+ // gate proposes, a human or `spec set --implementation auto`
609
+ // applies. A note after the state word silences it, as it does
610
+ // the coverage gate itself.
611
+ const mismatch = implementationMismatch(implRaw, derived.get(cap)?.derived ?? null);
612
+ if (mismatch) {
613
+ const row = derived.get(cap);
614
+ const stillMarked = (row.unverified ?? 0) > 0
615
+ ? ` and ${row.unverified} of them ${row.unverified === 1 ? "is" : "are"} still marked [unverified]`
616
+ : "";
617
+ warnings.push(
618
+ `${relPath(projectRoot, specPath)}: Implementation is "${mismatch.written}" but ` +
619
+ `${row.covered}/${row.total} criteria have resolving proof${stillMarked}, which supports ` +
620
+ `"${mismatch.derived}" (apply it with \`doctrina spec set ${cap} --implementation auto\`, ` +
621
+ `or add a note after the value saying why the count is not the whole story)`,
622
+ );
623
+ }
624
+ }
625
+
626
+ // 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
627
+ // intent→capability link, but it stays inert when nothing forces
628
+ // the tag, so an active capability with no Realizes: header is an
629
+ // untraced promise — warn here (validate is always run) rather than
630
+ // only in the opt-in `trace`. Advisory, with the same escape hatch
631
+ // pattern as Implementation: any value silences it, including a
632
+ // deliberate "n/a — <why>" for an internal capability that maps to
633
+ // no product anchor. Bug specs (off the implementation axis) and
634
+ // draft/deprecated documents are exempt — only active capability
635
+ // specs make the promise.
636
+ // The escape hatch used to arrive PRE-ARMED: the scaffold writes a
637
+ // placeholder value for Realizes, any value silenced the check, so
638
+ // in the normal flow this warning was dead code and `trace` was
639
+ // left to report the intent as dropped, far from the cause. A value
640
+ // that is still the template's placeholder counts as absent
641
+ // (change 0057).
642
+ const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
643
+ const onImplAxis = specHeader(text, "Implementation") !== null;
644
+ const realizesValue = specHeader(text, "Realizes");
645
+ const realizesMissing = isPlaceholderHeaderValue(realizesValue, {
646
+ template: "spec.md.template", name: "Realizes",
647
+ });
648
+ if (docStatusForRealizes === "active" && onImplAxis && realizesMissing) {
649
+ warnings.push(
650
+ `${relPath(projectRoot, specPath)}: active spec ${realizesValue === null ? "has no" : "still carries the scaffold's"} Realizes: header — ` +
651
+ `it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
652
+ `success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
653
+ `record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
654
+ );
655
+ }
656
+
657
+ // 8g. An acceptance criterion still in the template's placeholder form
658
+ // (change 0065). `spec new` ships one — "[unverified] <observable
659
+ // signal> — verified by `path/to/test`" — and nothing reported it.
660
+ // It survived into an ACTIVE spec and only surfaced days later as
661
+ // a coverage failure in the close of an unrelated change, because
662
+ // `path/to/test` resolves nowhere. The same ruler change 0057
663
+ // applied to headers: a value still wrapped in <...> is a value
664
+ // nobody wrote.
665
+ for (const crit of parseAcceptanceCriteria(text)) {
666
+ const placeholder = /^\s*\[[^\]]*\]\s*<[^>]*>/.test(crit.body)
667
+ || crit.proofPaths.includes("path/to/test");
668
+ if (!placeholder) continue;
669
+ warnings.push(
670
+ `${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is still the ` +
671
+ `scaffold's placeholder — write the observable signal and cite proof that exists, ` +
672
+ `or delete the criterion (it resolves nowhere, so \`coverage\` reports it dangling ` +
673
+ `in the close of whatever change touches this capability next)`,
674
+ );
675
+ }
676
+
677
+ // 8h. A `**Source:**` glob that claims code which is not there
678
+ // (change 0077). The header is the tree's only machine-readable
679
+ // statement that a capability OWNS a file, and `review` decides
680
+ // from it whether the spec kept up with the code. A pattern
681
+ // matching nothing is the same failure as a selector matching no
682
+ // target (RT05): the declaration reads as coverage and provides
683
+ // none, so a file moved out from under it goes unnoticed.
684
+ for (const glob of parseSourceGlobs(text)) {
685
+ if (filesMatching(projectRoot, glob).length > 0) continue;
686
+ warnings.push(
687
+ `${relPath(projectRoot, specPath)}: **Source:** pattern \`${glob}\` matches no file ` +
688
+ `— the capability claims code that is not there; correct the pattern or drop it`,
689
+ );
690
+ }
691
+
692
+ // 8d. Metadata-header shape (review G11). In the header block (before
693
+ // the first `## ` section or `<!--` comment), a known key written
694
+ // without the canonical `**Key:** value` form silently fails to
695
+ // parse — the index then falls back to a default and drifts. Warn
696
+ // with the fix rather than letting the mis-typed header vanish.
697
+ const headerLines = text.split(/\r?\n/);
698
+ for (let i = 0; i < headerLines.length; i++) {
699
+ if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
700
+ for (const key of KNOWN_SPEC_HEADERS) {
701
+ const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
702
+ const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
703
+ if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
704
+ warnings.push(
705
+ `${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
706
+ `canonical "**${key}:** value" form — it will not parse (G11)`,
707
+ );
708
+ }
709
+ }
710
+ }
711
+
712
+ // 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
713
+ // criterion that asserts [verified] but cites no proof path is a
714
+ // claim with nothing behind it — the dishonest-green case. `coverage`
715
+ // reports every bare criterion; this names the specific combo
716
+ // (claims verified, proves nothing) in the always-run gate. It reads
717
+ // through the shared multi-line parser, so a proof cited on a
718
+ // continuation line still counts (no false positive).
719
+ for (const crit of parseAcceptanceCriteria(text)) {
720
+ if (isVerified(crit) && crit.proofPaths.length === 0) {
721
+ warnings.push(
722
+ `${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
723
+ `[verified] but cites no proof path — self-certified (cite the file/test in ` +
724
+ `backticks, or drop the marker; \`doctrina coverage\`)`,
725
+ );
726
+ }
727
+ }
728
+ }
729
+ }
730
+ }
731
+
732
+ // 8k. A dependency on a capability that does not exist, and an intent
733
+ // anchor declared twice (change 0108). `**Depends on:** fantasma`
734
+ // feeds `why`, `context` and `review` and none of them said the
735
+ // capability was not there — `why` printed it, `context` dropped it
736
+ // in silence. Contrast `Realizes: SC9`, which `trace` names as
737
+ // dangling. And a second `- [SC1] ...` bullet in product.md was
738
+ // deduplicated on read, so the second intent vanished from every
739
+ // view while `trace --strict` stayed green.
740
+ if (isDir(specsDir)) {
741
+ const known = new Set(readdirSync(specsDir).filter((cap) => isFile(path.join(specsDir, cap, "spec.md"))));
742
+ for (const cap of [...known].sort()) {
743
+ const text = read(path.join(specsDir, cap, "spec.md"));
744
+ for (const dep of parseDependsOn(text)) {
745
+ if (known.has(dep)) continue;
746
+ errors.push(
747
+ `.doctrina/specs/${cap}/spec.md: Depends on "${dep}", which has no spec — ` +
748
+ `the pack, the graph and the review all read this header; correct the name, or \`doctrina spec new ${dep}\``,
749
+ );
750
+ }
751
+ }
752
+ }
753
+ {
754
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
755
+ if (isFile(productPath)) {
756
+ const seen = new Map();
757
+ // The mirror case: ONE intent under TWO ids. Realize either and the
758
+ // other stays "dropped" forever, so `trace --strict` — a CI gate —
759
+ // fails on a gap nobody can close. Compared folded, so case, accents
760
+ // and spacing do not hide a twin.
761
+ const seenText = new Map();
762
+ const lines = read(productPath).split(/\r?\n/);
763
+ for (let i = 0; i < lines.length; i++) {
764
+ const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+(.*)$/);
765
+ if (!m) continue;
766
+ const key = intentKey(m[2]);
767
+ if (key && seenText.has(key) && seenText.get(key).id !== m[1]) {
768
+ const first = seenText.get(key);
769
+ warnings.push(
770
+ `.doctrina/product.md:${i + 1} intent [${m[1]}] states the same intent as [${first.id}] (line ${first.line}) — ` +
771
+ `realizing one leaves the other dropped, so \`trace --strict\` fails on a gap nobody can close; delete the twin`,
772
+ );
773
+ } else if (key && !seenText.has(key)) {
774
+ seenText.set(key, { id: m[1], line: i + 1 });
775
+ }
776
+ if (seen.has(m[1])) {
777
+ errors.push(
778
+ `.doctrina/product.md:${i + 1} intent anchor [${m[1]}] is declared twice (first at line ${seen.get(m[1])}) — ` +
779
+ `two intents under one id leave the second invisible to trace, why and every pack; give it its own id`,
780
+ );
781
+ } else {
782
+ seen.set(m[1], i + 1);
783
+ }
784
+ }
785
+ }
786
+ }
787
+
788
+ // 8l. A contract still carrying the scaffold's rows (change 0111).
789
+ // Tasks and specs had a placeholder check; a contract whose Wiring,
790
+ // Selectors and References were all `<NAME>` / `specs/<capability>`
791
+ // passed here with nothing said — `contract check` ignores such rows
792
+ // by design (change 0029), which is right for the runtime verdict and
793
+ // silent about the artifact.
794
+ {
795
+ const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
796
+ if (isDir(contractsDir)) {
797
+ for (const f of walk(contractsDir)) {
798
+ if (!f.endsWith(".md")) continue;
799
+ // The template's own comments mention the placeholders; only the
800
+ // content counts (comment-is-not-content, change 0055).
801
+ const text = maskComments(read(f));
802
+ const left = [];
803
+ if (/^\|\s*<NAME>\s*\|/m.test(text)) left.push("a `<NAME>` row in Wiring/Selectors/Budgets");
804
+ if (/specs\/<capability>/.test(text)) left.push("`specs/<capability>` under References");
805
+ if (left.length > 0) {
806
+ warnings.push(`${relPath(projectRoot, f)} still carries the scaffold's placeholder rows (${left.join("; ")}) — write them, or delete the rows that do not apply`);
807
+ }
808
+ }
809
+ }
810
+ }
811
+
812
+ // 8m. A spec that is not where the tree reads specs from (change 0113).
813
+ // A change directory without `proposal.md` is an error above; a spec
814
+ // at `.doctrina/specs/legacy.md`, a capability directory with no
815
+ // `spec.md`, and a second `spec-old.md` beside the real one were
816
+ // silence — not indexed, in no pack, not counted, not traced.
817
+ if (isDir(specsDir)) {
818
+ for (const entry of readdirSync(specsDir, { withFileTypes: true })) {
819
+ if (entry.name.startsWith(".")) continue;
820
+ if (entry.isFile()) {
821
+ if (!entry.name.endsWith(".md")) continue;
822
+ warnings.push(`.doctrina/specs/${entry.name} is not where a spec is read from — a spec lives at .doctrina/specs/<capability>/spec.md; move it, or it stays outside every pack, count and trace`);
823
+ continue;
824
+ }
825
+ if (!entry.isDirectory()) continue;
826
+ const capDir = path.join(specsDir, entry.name);
827
+ if (!isFile(path.join(capDir, "spec.md"))) {
828
+ warnings.push(`.doctrina/specs/${entry.name}/ has no spec.md — a capability directory is read only through .doctrina/specs/${entry.name}/spec.md (\`doctrina spec new ${entry.name}\`, or remove the directory)`);
829
+ continue;
830
+ }
831
+ for (const sibling of readdirSync(capDir)) {
832
+ if (sibling === "spec.md" || !sibling.endsWith(".md")) continue;
833
+ const head = read(path.join(capDir, sibling)).split(/\r?\n/).find((l) => l.trim() !== "") ?? "";
834
+ if (/^#\s+Spec\b/i.test(head)) {
835
+ warnings.push(`.doctrina/specs/${entry.name}/${sibling} opens like a spec but only spec.md is read — merge it into spec.md, or rename it so it does not claim to be one`);
836
+ }
837
+ }
838
+ }
839
+ }
840
+
841
+ // 9. ADRs over 300 lines warn.
842
+ if (isDir(adrDir)) {
843
+ for (const f of walk(adrDir)) {
844
+ if (!f.endsWith(".md")) continue;
845
+ const lines = lineCount(f);
846
+ if (lines > 300) warnings.push(`${relPath(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
847
+ }
848
+ }
849
+
850
+ // 10b. Stale-reference detection — Markdown link targets inside specs
851
+ // and ADRs that no longer exist on disk. Backtick paths are NOT
852
+ // checked (descriptive prose, too noisy). Links are pointers.
853
+ for (const dir of [specsDir, adrDir]) {
854
+ if (!isDir(dir)) continue;
855
+ for (const f of walk(dir)) {
856
+ if (!f.endsWith(".md")) continue;
857
+ const text = read(f);
858
+ const fileDir = path.dirname(f);
859
+ for (const ref of extractPathReferences(text)) {
860
+ // Try relative to the file first, then to project root
861
+ const candidates = [
862
+ path.resolve(fileDir, ref),
863
+ path.resolve(projectRoot, ref),
864
+ ];
865
+ if (!candidates.some(exists)) {
866
+ warnings.push(`${relPath(projectRoot, f)} references missing path \`${ref}\``);
867
+ }
868
+ }
869
+ }
870
+ }
871
+
872
+ // 10. Orphan detection — files on disk not referenced in index.json.
873
+ if (index?.artifacts) {
874
+ const indexedSpecIds = new Set((index.artifacts.specs ?? []).map((s) => s.id ?? s.capability));
875
+ if (isDir(specsDir)) {
876
+ for (const cap of readdirSync(specsDir)) {
877
+ const specPath = path.join(specsDir, cap, "spec.md");
878
+ if (isFile(specPath) && !indexedSpecIds.has(cap)) {
879
+ warnings.push(`orphan spec ${relPath(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
880
+ }
881
+ }
882
+ }
883
+ const indexedAdrIds = new Set((index.artifacts.decisions ?? []).map((d) => d.id));
884
+ if (isDir(adrDir)) {
885
+ for (const f of walk(adrDir)) {
886
+ if (!f.endsWith(".md")) continue;
887
+ const m = path.basename(f).match(/^(\d{4})-/);
888
+ if (m && !indexedAdrIds.has(m[1])) {
889
+ warnings.push(`orphan ADR ${relPath(projectRoot, f)} (id "${m[1]}" not in index.json)`);
890
+ }
891
+ }
892
+ }
893
+ const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
894
+ const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
895
+ if (isDir(contractsDir)) {
896
+ for (const f of walk(contractsDir)) {
897
+ if (!f.endsWith(".md")) continue;
898
+ const id = path.basename(f, ".md");
899
+ if (!indexedContractIds.has(id)) {
900
+ warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
901
+ }
902
+ }
903
+ }
904
+ }
905
+
906
+ // 10b. An artifact that carries nothing. Check 4 asks whether the file
907
+ // EXISTS; a zero-byte file exists. Measured before this check: a
908
+ // spec, a proposal, a contract, a skill and `product.md` emptied to
909
+ // zero bytes each reported `ok, 0 errors`, and only an ADR was
910
+ // caught. The header-vs-index comparison runs on the headers it
911
+ // FINDS, so a file with none is compared against nothing and agrees.
912
+ // Third appearance of one pattern — absence is not approval, fixed in
913
+ // `coverage` by change 0057 and in `trace` by 0083, and it was in the
914
+ // structural gate the whole time (change 0087).
915
+ //
916
+ // Two levels, both cheap and both indisputable: nothing at all, and
917
+ // something that is not an artifact — every template the CLI ships
918
+ // writes an H1, so a Markdown artifact without one was not scaffolded
919
+ // and was not written by hand either.
920
+ for (const rel of artifactFiles(projectRoot)) {
921
+ const full = path.join(projectRoot, rel);
922
+ if (!isFile(full)) continue; // check 4 owns the missing-file case
923
+ const text = read(full);
924
+ if (text.trim() === "") {
925
+ errors.push(`${rel} is empty — an artifact with no content is registered as if it said something`);
926
+ } else if (!/^#\s+\S/m.test(maskComments(text))) {
927
+ errors.push(`${rel} carries no title — every scaffolded artifact opens with an \`# \` heading, so a file without one is neither scaffolded nor authored`);
928
+ }
929
+ }
930
+
931
+ // 11. Archive ledger ↔ index cross-check. The history of archived
932
+ // changes is recorded twice — once human-facing in
933
+ // changes/archive/LEDGER.md, once machine-facing in
934
+ // index.json.changes_archive. Two sources of truth that silently
935
+ // disagree are worse than one: an incomplete ledger makes the
936
+ // history look shorter than it is. When the ledger exists, every
937
+ // archived change must appear in both, or validation fails.
938
+ if (index?.artifacts && isFile(ledgerFile(projectRoot))) {
939
+ // One parse of the ledger grammar, in lib/ledger.js (change 0046). This
940
+ // check used to carry its own regex, so the file's readers and its
941
+ // writers could drift apart with nothing to notice.
942
+ //
943
+ // An abandoned change is ledger-only BY DESIGN: `change abandon` records
944
+ // the discard in history but deletes the folder, so there is deliberately
945
+ // no archive entry to cross-check. Requiring one made every abandonment
946
+ // turn validate permanently red (found dogfooding change
947
+ // 0001-review-followups). A gap line records a waived gate, not a change
948
+ // that landed, so it is not cross-checked either.
949
+ const ledgerIds = new Set(
950
+ readLedger(projectRoot).entries.filter((e) => e.kind === "archived").map((e) => e.id),
951
+ );
952
+ const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
953
+ for (const id of archiveIds) {
954
+ if (!ledgerIds.has(id)) {
955
+ errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
956
+ }
957
+ }
958
+ for (const id of ledgerIds) {
959
+ if (!archiveIds.has(id)) {
960
+ errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
961
+ }
962
+ }
963
+ }
964
+
965
+ // 12-14. Skills validation
966
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
967
+ if (isDir(skillsDir)) {
968
+ for (const f of walk(skillsDir)) {
969
+ if (!f.endsWith(".md")) continue;
970
+ const text = read(f);
971
+ const rel = relPath(projectRoot, f);
972
+ const baseName = path.basename(f, ".md");
973
+ const nameField = parseFrontmatter(text, "name");
974
+ const descField = parseFrontmatter(text, "description");
975
+ const whenField = parseFrontmatter(text, "when");
976
+ if (!nameField) warnings.push(`${rel} missing required frontmatter field "name"`);
977
+ if (!descField) warnings.push(`${rel} missing required frontmatter field "description"`);
978
+ if (!whenField) warnings.push(`${rel} missing required frontmatter field "when"`);
979
+ if (nameField && nameField !== baseName) {
980
+ warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
981
+ }
982
+ // 14c. A frontmatter value still in the scaffold's `<...>` form is a
983
+ // value nobody wrote (change 0111) — and it was served into every
984
+ // context pack as the skill's description, while `skill sync`
985
+ // called the file up to date.
986
+ for (const [field, value] of [["description", descField], ["when", whenField]]) {
987
+ if (value && isScaffoldValue(value)) {
988
+ warnings.push(`${rel} frontmatter "${field}" is still the scaffold's placeholder — write it, or the pack lists a skill nobody described (${value.length > 50 ? `${value.slice(0, 49)}…` : value})`);
989
+ }
990
+ }
991
+
992
+ // 14b. A skill nothing can TRIGGER is a file, not a memory (change
993
+ // 0029). `context` lists a skill's `when:` so an agent can fire
994
+ // the right one without loading any of them — but a trigger
995
+ // written as pure prose ("when it makes sense", "as needed")
996
+ // gives it nothing to match on, and the skill is never loaded by
997
+ // anyone who did not already know it existed. A usable trigger
998
+ // names something concrete: a keyword, a path, a command, an
999
+ // error string.
1000
+ if (whenField && !hasDetectableTrigger(whenField)) {
1001
+ warnings.push(
1002
+ `${rel} frontmatter "when" has no detectable trigger — name a concrete ` +
1003
+ `keyword, path, command or error string, or nothing can match it ` +
1004
+ `(got: "${whenField.length > 60 ? `${whenField.slice(0, 59)}…` : whenField}")`,
1005
+ );
1006
+ }
1007
+ const lines = lineCount(f);
1008
+ if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
1009
+ else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
1010
+
1011
+ // 15. Skill description drift — index.json must mirror the
1012
+ // frontmatter description (`doctrina index rebuild` does).
1013
+ if (descField && index?.artifacts?.skills) {
1014
+ const entry = index.artifacts.skills.find((s) => s.id === baseName);
1015
+ if (entry && entry.description !== descField) {
1016
+ warnings.push(`${rel} description differs from index.json (run \`doctrina index rebuild\`)`);
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ // 16. Bilingual docs parity. Projects that keep an EN-source docs tree
1023
+ // with a PT mirror (docs/en + docs/pt) get filename-parity checking:
1024
+ // a file added in one language and missing in the other is invisible
1025
+ // to every other gate, and the mirror silently falls behind. Only the
1026
+ // deterministic half (file sets) is checked — content staleness stays
1027
+ // a human judgement. Projects without both trees never see this.
1028
+ const docsEn = path.join(projectRoot, "docs", "en");
1029
+ const docsPt = path.join(projectRoot, "docs", "pt");
1030
+ if (isDir(docsEn) && isDir(docsPt)) {
1031
+ const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
1032
+ const enSet = mdSet(docsEn);
1033
+ const ptSet = mdSet(docsPt);
1034
+ for (const f of [...enSet].sort()) {
1035
+ if (!ptSet.has(f)) {
1036
+ warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
1037
+ }
1038
+ }
1039
+ for (const f of [...ptSet].sort()) {
1040
+ if (!enSet.has(f)) {
1041
+ warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ // Project rules — permanent, lintable constraints (.doctrina/rules.json;
1047
+ // 0.11.0 field review item 12: "never reference company X" lived only in
1048
+ // agent memory, and the remaining mentions sailed through every gate). Each
1049
+ // rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
1050
+ // the rule's own message. Deterministic, zero-deps, and the instruction
1051
+ // survives sessions because it is an artifact, not a memory.
1052
+ const ruleFindings = checkProjectRules(projectRoot);
1053
+ for (const line of ruleFindings.errors) errors.push(line);
1054
+ for (const line of ruleFindings.warnings) warnings.push(line);
1055
+ for (const key of loadConfig(projectRoot).unknown) {
1056
+ warnings.push(`${CONFIG_REL}: unknown key "${key}" is ignored — the keys are language, context_budget, rules (a typo here is a setting that never applied)`);
1057
+ }
1058
+
1059
+ // 18. --runtime folds the RUNTIME gate into the structural one, so a
1060
+ // single call covers both halves of the truth: the shape of the
1061
+ // artifacts, and whether what they declare about the running system
1062
+ // still holds. Opt-in because it reads files outside .doctrina/
1063
+ // (workflows, consumers, test sources) and a structural validate
1064
+ // should stay cheap. Delegates to lib/runtime.js — the same checks
1065
+ // `contract check`, `triage` and `doctor` render, so the four can
1066
+ // never disagree.
1067
+ if (runtime) {
1068
+ for (const f of collectRuntimeFindings(projectRoot).findings) {
1069
+ const line = `${f.contract}: ${f.message} [${f.code}] — ${f.remedy}`;
1070
+ if (f.level === "error") errors.push(line);
1071
+ else warnings.push(line);
1072
+ }
1073
+ }
1074
+
1075
+ return { errors, warnings, fixes };
1076
+ }
1077
+
1078
+ // Does a skill's `when:` give anything to MATCH on? A trigger written as
1079
+ // pure prose ("when it seems relevant", "as needed") reads fine and can
1080
+ // never fire: `context` ranks skills by comparing the trigger against the
1081
+ // task, and there is nothing there to compare. A usable trigger names
1082
+ // something concrete — a path or glob, a command, a quoted error string, a
1083
+ // file extension, an ALL_CAPS identifier, or simply enough distinctive
1084
+ // keywords to match on.
1085
+ // One list per language, because this framework runs on Portuguese projects
1086
+ // too — and the vagueness it exists to catch is written in the project's own
1087
+ // language. Change 0158 found the same split in `clarify`'s lexicons; this is
1088
+ // the same asymmetry one module over. Matched against the FOLDED text, so
1089
+ // "necessário" and "necessario" are one phrase.
1090
+ const VAGUE_TRIGGER_EN = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?|makes?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|sense|right|as needed|if needed)\.?$/i;
1091
+ const VAGUE_TRIGGER_PT =
1092
+ /^(?:(?:sempre\s+)?que|quando|se)?\s*(?:isso|isto|voce|o agente)?\s*(?:for|foi|parecer|fizer|achar|julgar)?\s*(?:relevante|apropriad[oa]|necessari[oa]|util|aplicavel|preciso|sentido|cert[oa])\.?$/i;
1093
+
1094
+ function looksVague(text) {
1095
+ const folded = fold(text).trim();
1096
+ if (folded === "") return true;
1097
+ return VAGUE_TRIGGER_EN.test(folded) || VAGUE_TRIGGER_PT.test(folded);
1098
+ }
1099
+
1100
+ // Owned by the document model since change 0177 (the rebuild needs it and
1101
+ // cannot import this module); re-exported so existing callers stay put.
1102
+ export { isScaffoldValue };
1103
+
1104
+ /** An intent's text, folded for comparison: case, accents and spacing ignored. */
1105
+ export function intentKey(text) {
1106
+ return fold(String(text ?? "")).replace(/\s+/g, " ").replace(/[.;:!\s]+$/, "").trim();
1107
+ }
1108
+
1109
+ export function hasDetectableTrigger(when) {
1110
+ const text = String(when ?? "").trim();
1111
+ if (looksVague(text)) return false;
1112
+ // The scaffold's own placeholder has enough words to rank on and names
1113
+ // nothing (change 0111): a trigger nobody wrote fires for nobody.
1114
+ if (isScaffoldValue(text)) return false;
1115
+ // Anything structural is inherently matchable.
1116
+ if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
1117
+ // Otherwise: enough distinctive words to rank on — counted by the SHARED
1118
+ // lexicon, which folds and carries the connective tissue of both languages.
1119
+ // The private stop list here was English-only, so every Portuguese function
1120
+ // word ("quando", "que", "para") counted as distinctive and a two-word
1121
+ // Portuguese phrase cleared the bar on grammar alone. One lexicon, the way
1122
+ // `work` and `context` already read text (audit findings F5/F6).
1123
+ return terms(text).length >= 2;
1124
+ }
1125
+
1126
+ // Enforce the project's rules — permanent constraints as forbid-regexes over
1127
+ // glob-scoped paths:
1128
+ // { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
1129
+ // "paths": ["src/**", ".doctrina/specs/**"],
1130
+ // "message": "white-label product; use a generic placeholder" } ] }
1131
+ // Declared in .doctrina/config.json; still read from the legacy
1132
+ // .doctrina/rules.json when that is where a project put them (change 0047).
1133
+ // Returns { errors, warnings }: an error per offending file+rule (capped per
1134
+ // rule so a mass violation stays readable), and a warning for a rule that
1135
+ // reached no file at all. No rules → nothing to enforce; a malformed file is
1136
+ // reported once, by the reader.
1137
+ //
1138
+ // The dead-scope warning is the same finding 8h makes about a `**Source:**`
1139
+ // glob and RT05 makes about a selector: a declaration that matches nothing
1140
+ // reads as coverage and provides none. A rule is the strongest of the three
1141
+ // — a permanent constraint the project believes is enforced — and it was the
1142
+ // one that said nothing, while `doctor` went on counting it as configured. Binary-ish and vendored dirs are
1143
+ // skipped by the same bounded walk validate already uses elsewhere.
1144
+ const RULES_SKIP_DIRS = new Set([
1145
+ ".git", "node_modules", "vendor", "dist", "build", "out", "target",
1146
+ ".venv", "venv", "__pycache__", ".next", "coverage",
1147
+ ]);
1148
+ const RULES_MAX_HITS_PER_RULE = 10;
1149
+
1150
+ function checkProjectRules(projectRoot) {
1151
+ const cfg = loadConfig(projectRoot);
1152
+ const out = [...cfg.errors];
1153
+ const warn = [];
1154
+ const where = cfg.sources.rules === SOURCES.config ? CONFIG_REL : RULES_REL;
1155
+ const compiled = [];
1156
+ for (const r of cfg.rules) {
1157
+ if (!r || typeof r.forbid !== "string" || !r.forbid) {
1158
+ out.push(`${where}: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
1159
+ continue;
1160
+ }
1161
+ let re;
1162
+ try {
1163
+ re = new RegExp(r.forbid, "m");
1164
+ } catch (err) {
1165
+ out.push(`${where}: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
1166
+ continue;
1167
+ }
1168
+ const declared = Array.isArray(r.paths) && r.paths.length ? r.paths : null;
1169
+ const paths = declared ?? ["**"];
1170
+ compiled.push({
1171
+ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp),
1172
+ message: r.message ?? "", hits: 0,
1173
+ // Only a DECLARED scope can be dead; the implicit `**` is not a claim.
1174
+ declared, scanned: 0,
1175
+ });
1176
+ }
1177
+ if (compiled.length === 0) return { errors: out, warnings: warn };
1178
+
1179
+ // One bounded walk; each text file is read at most once.
1180
+ const stack = [projectRoot];
1181
+ while (stack.length) {
1182
+ const dir = stack.pop();
1183
+ let entries;
1184
+ try {
1185
+ entries = readdirSync(dir, { withFileTypes: true });
1186
+ } catch {
1187
+ continue;
1188
+ }
1189
+ for (const entry of entries) {
1190
+ const full = path.join(dir, entry.name);
1191
+ if (entry.isDirectory()) {
1192
+ if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
1193
+ continue;
1194
+ }
1195
+ const rel = relPath(projectRoot, full).replace(/\\/g, "/");
1196
+ if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
1197
+ // Count the reach BEFORE the hit cap: a rule that stopped reporting
1198
+ // because it hit the cap has plainly reached files.
1199
+ const inScope = compiled.filter((cr) => cr.matchers.some((m) => m.test(rel)));
1200
+ for (const cr of inScope) cr.scanned += 1;
1201
+ const applicable = inScope.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE);
1202
+ if (applicable.length === 0) continue;
1203
+ let text;
1204
+ try {
1205
+ text = read(full);
1206
+ } catch {
1207
+ continue;
1208
+ }
1209
+ for (const cr of applicable) {
1210
+ const m = cr.re.exec(text);
1211
+ if (m) {
1212
+ cr.hits += 1;
1213
+ const lineNo = text.slice(0, m.index).split("\n").length;
1214
+ out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ for (const cr of compiled) {
1220
+ if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
1221
+ out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
1222
+ }
1223
+ if (cr.declared && cr.scanned === 0) {
1224
+ warn.push(
1225
+ `${where}: rule "${cr.id}" scopes to ${cr.declared.map((g) => `\`${g}\``).join(", ")}, ` +
1226
+ "which matches no file — the constraint is declared and never enforced; " +
1227
+ "correct the paths or drop the rule",
1228
+ );
1229
+ }
1230
+ }
1231
+ return { errors: out, warnings: warn };
1232
+ }
1233
+
1234
+ // Minimal glob → RegExp: ** crosses directories, * stays within a segment.
1235
+ // Anchored to the whole repo-relative POSIX path.
1236
+ function globToRegExp(glob) {
1237
+ const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
1238
+ const body = esc
1239
+ .replace(/\*\*\//g, "(?:.*/)?")
1240
+ .replace(/\*\*/g, ".*")
1241
+ .replace(/\*/g, "[^/]*")
1242
+ .replace(/\?/g, "[^/]");
1243
+ return new RegExp(`^${body}$`);
1244
+ }
1245
+
1246
+ // Per-entry metadata drift between the on-disk index and the tree-derived
1247
+ // one, for entries present in BOTH. Presence drift (an artifact on disk but
1248
+ // not indexed, or indexed but missing) is left to the orphan warning and the
1249
+ // missing-artifact error; this isolates the case the review hit — an indexed
1250
+ // artifact whose recorded metadata no longer matches its file.
1251
+ function metadataDrift(current, derived) {
1252
+ const lines = [];
1253
+ // Skills are deliberately excluded: their description is reconciled by
1254
+ // `doctrina index rebuild` and stays an advisory warning (see section
1255
+ // 15), not a hard error.
1256
+ const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
1257
+ for (const cat of cats) {
1258
+ const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
1259
+ const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
1260
+ for (const [id, entry] of cur) {
1261
+ if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
1262
+ lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
1263
+ }
1264
+ }
1265
+ }
1266
+ return lines;
1267
+ }
1268
+
1269
+ // Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
1270
+ // hierarchy). Bounded walk: dependency, build, and VCS directories are
1271
+ // skipped so validate stays fast on large repositories.
1272
+ const NESTED_SKIP_DIRS = new Set([
1273
+ ".git", "node_modules", ".doctrina", "vendor", "dist", "build", "out",
1274
+ "target", ".venv", "venv", "__pycache__", ".next", "coverage",
1275
+ ]);
1276
+
1277
+ function findNestedAgentsMd(projectRoot) {
1278
+ const found = [];
1279
+ const stack = readdirSync(projectRoot)
1280
+ .filter((e) => !NESTED_SKIP_DIRS.has(e) && !e.startsWith("."))
1281
+ .map((e) => path.join(projectRoot, e))
1282
+ .filter(isDir);
1283
+ while (stack.length) {
1284
+ const dir = stack.pop();
1285
+ for (const entry of readdirSync(dir)) {
1286
+ const full = path.join(dir, entry);
1287
+ if (isDir(full)) {
1288
+ if (!NESTED_SKIP_DIRS.has(entry) && !entry.startsWith(".")) stack.push(full);
1289
+ } else if (entry === "AGENTS.md") {
1290
+ found.push(full);
1291
+ }
1292
+ }
1293
+ }
1294
+ return found.sort();
1295
+ }
1296
+
1297
+ // Extract candidate file path references from Markdown text. Only
1298
+ // Markdown link targets `[text](path)` are checked — backtick spans
1299
+ // are descriptive prose and produce too many false positives.
1300
+ function extractPathReferences(text) {
1301
+ const out = new Set();
1302
+ for (const m of text.matchAll(/\]\(([^)\s]+)\)/g)) {
1303
+ const token = m[1].trim();
1304
+ if (isLikelyPath(token)) out.add(stripFragment(token));
1305
+ }
1306
+ return [...out];
1307
+ }
1308
+
1309
+ // Path-like tokens inside backtick spans — the convention for citing
1310
+ // evidence (ADR **Evidence:**, acceptance-criteria links).
1311
+ function extractBacktickPaths(text) {
1312
+ const out = new Set();
1313
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
1314
+ const token = m[1].trim();
1315
+ if (isLikelyPath(token)) out.add(stripFragment(token));
1316
+ }
1317
+ return [...out];
1318
+ }
1319
+
1320
+ function stripFragment(s) {
1321
+ // Remove an in-page anchor (#section) or query string from a path token.
1322
+ return s.split(/[#?]/)[0];
1323
+ }
1324
+
1325
+ function isLikelyPath(s) {
1326
+ if (!s) return false;
1327
+ if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
1328
+ if (s.includes("{{") || s.includes("}}")) return false;
1329
+ if (s.includes("*") || s.includes("?")) return false;
1330
+ if (s.includes("<") || s.includes(">")) return false;
1331
+ if (s.includes(" ") || s.includes("`")) return false;
1332
+ // Strip in-page anchor or query string before further checks
1333
+ const head = stripFragment(s);
1334
+ if (!head) return false;
1335
+ // Folder references (path ending with /) are skipped: not file targets
1336
+ if (head.endsWith("/")) return false;
1337
+ // Must look like a file path: contains a slash or has a file extension
1338
+ const hasSlash = head.includes("/");
1339
+ const hasExt = /\.[a-z0-9]{1,8}$/i.test(head);
1340
+ if (!hasSlash && !hasExt) return false;
1341
+ if (head.startsWith("-")) return false;
1342
+ return true;
1343
+ }
1344
+
1345
+ /**
1346
+ * Every Markdown artifact the framework owns, as project-relative paths.
1347
+ *
1348
+ * The index records a change by its DIRECTORY, so a proposal cannot be
1349
+ * reached by walking `index.json` alone; and skills are indexed but were
1350
+ * outside the existence check. This is the one list of "files that are
1351
+ * supposed to say something", used by the emptiness check (change 0087).
1352
+ *
1353
+ * The archive is excluded, like everywhere else: it is history, out of the
1354
+ * default read path.
1355
+ */
1356
+ export function artifactFiles(projectRoot) {
1357
+ const dot = path.join(projectRoot, ".doctrina");
1358
+ const out = [];
1359
+ const add = (full) => {
1360
+ if (isFile(full)) out.push(relPath(projectRoot, full).replaceAll("\\\\", "/"));
1361
+ };
1362
+ add(path.join(dot, "product.md"));
1363
+ add(path.join(dot, "intake.md"));
1364
+ const specsDir = path.join(dot, "specs");
1365
+ if (isDir(specsDir)) {
1366
+ for (const cap of readdirSync(specsDir)) add(path.join(specsDir, cap, "spec.md"));
1367
+ }
1368
+ const changesDir = path.join(dot, "changes");
1369
+ if (isDir(changesDir)) {
1370
+ for (const id of readdirSync(changesDir)) {
1371
+ if (id === "archive" || id.startsWith(".")) continue;
1372
+ add(path.join(changesDir, id, "proposal.md"));
1373
+ }
1374
+ }
1375
+ for (const dir of ["decisions", "contracts", "skills"]) {
1376
+ const full = path.join(dot, dir);
1377
+ if (!isDir(full)) continue;
1378
+ for (const f of walk(full)) if (f.endsWith(".md")) add(f);
1379
+ }
1380
+ return out;
1381
+ }