doctrina-cli 0.16.0 → 0.18.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 (71) hide show
  1. package/README.md +31 -73
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +3 -2
  4. package/src/commands/analyze.js +8 -0
  5. package/src/commands/change.js +86 -80
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +136 -34
  8. package/src/commands/completion.js +8 -2
  9. package/src/commands/context.js +6 -4
  10. package/src/commands/decision.js +12 -0
  11. package/src/commands/doctor.js +1 -1
  12. package/src/commands/hooks.js +6 -1
  13. package/src/commands/index-rebuild.js +52 -2
  14. package/src/commands/init.js +19 -4
  15. package/src/commands/intake.js +55 -7
  16. package/src/commands/intent.js +14 -1
  17. package/src/commands/metrics.js +13 -5
  18. package/src/commands/next.js +2 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/review.js +47 -8
  22. package/src/commands/skill.js +8 -139
  23. package/src/commands/spec.js +13 -7
  24. package/src/commands/status.js +39 -20
  25. package/src/commands/templates.js +4 -10
  26. package/src/commands/upgrade.js +31 -6
  27. package/src/commands/validate.js +32 -8
  28. package/src/commands/verify.js +59 -6
  29. package/src/commands/work.js +63 -8
  30. package/src/index.js +34 -6
  31. package/src/lib/actions.js +38 -28
  32. package/src/lib/args.js +14 -0
  33. package/src/lib/change-ops.js +95 -1
  34. package/src/lib/commands.js +96 -15
  35. package/src/lib/coverage-model.js +40 -1
  36. package/src/lib/doc-model.js +47 -6
  37. package/src/lib/docs-impact.js +74 -0
  38. package/src/lib/ears.js +24 -1
  39. package/src/lib/gates.js +53 -9
  40. package/src/lib/git.js +59 -2
  41. package/src/lib/index-json.js +40 -5
  42. package/src/lib/intake-model.js +71 -2
  43. package/src/lib/lexicon.js +38 -1
  44. package/src/lib/metrics-model.js +30 -0
  45. package/src/lib/playbook.js +1 -1
  46. package/src/lib/project.js +49 -1
  47. package/src/lib/runtime.js +6 -0
  48. package/src/lib/scan.js +139 -14
  49. package/src/lib/signoff.js +24 -0
  50. package/src/lib/skill-candidates.js +162 -0
  51. package/src/lib/snapshot.js +61 -2
  52. package/src/lib/spec-ops.js +141 -6
  53. package/src/lib/templates-model.js +35 -8
  54. package/src/lib/validation-model.js +117 -35
  55. package/src/lib/verify-cache.js +76 -0
  56. package/src/lib/views.js +10 -2
  57. package/src/lib/work-model.js +57 -12
  58. package/templates/AGENTS.md.template +3 -4
  59. package/templates/README.md +2 -0
  60. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  61. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  62. package/templates/change/proposal.md.template +15 -0
  63. package/templates/change/spec-delta.md.template +2 -0
  64. package/templates/change/tasks.md.template +3 -8
  65. package/templates/contract.md.template +2 -2
  66. package/templates/hooks/pre-commit.sample +10 -0
  67. package/templates/playbooks/bootstrap.md.template +6 -2
  68. package/templates/playbooks/chore.md.template +9 -4
  69. package/templates/playbooks/consolidate.md.template +28 -0
  70. package/templates/playbooks/work.md.template +13 -3
  71. package/src/commands/constitution.js +0 -68
@@ -117,6 +117,40 @@ function matchOpsBlock(text) {
117
117
  return null;
118
118
  }
119
119
 
120
+ /** One op line, parsed by the grammar `apply` reads: `{ verb, ... }` or `{ verb, error }`. */
121
+ export function parseOp(line) {
122
+ return parseOpLine(String(line).trim());
123
+ }
124
+
125
+ /**
126
+ * Append op lines to a delta's live `ops` block — creating the block at the
127
+ * end when there is none (change 0205). A `bump-version` line replaces the
128
+ * block's existing one instead of stacking a second bump on it. Pure.
129
+ *
130
+ * @param {string} deltaText
131
+ * @param {string[]} lines already validated with `parseOp`
132
+ * @returns {string}
133
+ */
134
+ export function insertOps(deltaText, lines) {
135
+ const ranges = commentRanges(deltaText);
136
+ const fenceRe = /^([ \t]*```[ \t]*ops[ \t]*\r?\n)([\s\S]*?)(^[ \t]*```[ \t]*$)/gm;
137
+ const bumps = lines.some((l) => parseOp(l).verb === "bump-version");
138
+ let m;
139
+ while ((m = fenceRe.exec(deltaText))) {
140
+ if (isInsideComment(ranges, m.index)) continue;
141
+ const kept = m[2].split(/\r?\n/).filter((l) => l.trim() !== "")
142
+ .filter((l) => !(bumps && parseOp(l).verb === "bump-version"));
143
+ // The bump goes last, where a reader looks for it.
144
+ const ordered = [...kept, ...lines].sort((a, b) =>
145
+ Number(parseOp(a).verb === "bump-version") - Number(parseOp(b).verb === "bump-version"));
146
+ const block = `${m[1]}${ordered.join("\n")}\n${m[3]}`;
147
+ return deltaText.slice(0, m.index) + block + deltaText.slice(m.index + m[0].length);
148
+ }
149
+ const ordered = [...lines].sort((a, b) =>
150
+ Number(parseOp(a).verb === "bump-version") - Number(parseOp(b).verb === "bump-version"));
151
+ return `${deltaText.replace(/\s*$/, "")}\n\n\`\`\`ops\n${ordered.join("\n")}\n\`\`\`\n`;
152
+ }
153
+
120
154
  function parseOpLine(line) {
121
155
  // `<verb> <rest>` — the verb is the first whitespace-delimited token.
122
156
  const sp = line.indexOf(" ");
@@ -178,6 +212,24 @@ function parseOpLine(line) {
178
212
  if (!value) return { verb, error: `replace-requirement ${n} is missing the requirement text` };
179
213
  return { verb, section, n, value };
180
214
  }
215
+ case "remove-requirement": {
216
+ // remove-requirement <section> <n>[: <why>] — the reason is for the
217
+ // reader of the delta; the spec loses the bullet either way.
218
+ const colon = rest.indexOf(":");
219
+ const head = (colon < 0 ? rest : rest.slice(0, colon)).trim().split(/\s+/);
220
+ const n = Number(head[head.length - 1]);
221
+ const section = normalizeSection(head.slice(0, -1).join(" "));
222
+ if (!section) return { verb, error: `remove-requirement: unknown section "${head.slice(0, -1).join(" ")}" (use ${SECTION_TOKENS})` };
223
+ if (!Number.isInteger(n) || n < 1) return { verb, error: `remove-requirement needs "<section> <n>" with a positive bullet number (got "${rest}")` };
224
+ return { verb, section, n };
225
+ }
226
+ case "remove-criterion": {
227
+ // remove-criterion <n>[: <why>]
228
+ const colon = rest.indexOf(":");
229
+ const n = Number((colon < 0 ? rest : rest.slice(0, colon)).trim());
230
+ if (!Number.isInteger(n) || n < 1) return { verb, error: `remove-criterion needs a positive criterion number (got "${rest}")` };
231
+ return { verb, n };
232
+ }
181
233
  default:
182
234
  return { verb: null, error: `unknown operation "${verb}"` };
183
235
  }
@@ -238,6 +290,10 @@ function applyOne(text, op) {
238
290
  return appendRequirement(text, op.section, op.value);
239
291
  case "replace-requirement":
240
292
  return replaceRequirement(text, op.section, op.n, op.value);
293
+ case "remove-requirement":
294
+ return removeRequirement(text, op.section, op.n);
295
+ case "remove-criterion":
296
+ return removeCriterion(text, op.n);
241
297
  default:
242
298
  return { error: `unknown operation "${op.verb}"` };
243
299
  }
@@ -256,7 +312,10 @@ export function setHeader(text, name, value) {
256
312
  // has a lifecycle of its own (proposed -> applied, accepted, superseded).
257
313
  const listForm = /^\s*-/.test(text.match(re)?.[1] ?? "");
258
314
  const domain = listForm ? null : headerValueError(name, value);
259
- if (domain) return { error: `set-header: ${domain}` };
315
+ // `cause: "argument"` marks an error about the value supplied, not about
316
+ // the document: `spec set` answers it as USAGE, a delta as a defect of
317
+ // the work (ADR 0018).
318
+ if (domain) return { error: `set-header: ${domain}`, cause: "argument" };
260
319
  const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
261
320
  return { text: next, summary: `set ${name}: ${value}` };
262
321
  }
@@ -309,10 +368,10 @@ export function setCriterionMark(text, n, markRaw) {
309
368
  const loc = locateCriteria(text);
310
369
  if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
311
370
  const item = loc.items.find((it) => it.n === n);
312
- if (!item) return { error: `set-criterion: no criterion #${n} found` };
371
+ if (!item) return { error: `set-criterion: no criterion #${n} found`, cause: "argument" };
313
372
  const mark = markRaw.replace(/^\[|\]$/g, "").trim();
314
373
  const domain = criterionMarkError(mark);
315
- if (domain) return { error: `set-criterion: ${domain}` };
374
+ if (domain) return { error: `set-criterion: ${domain}`, cause: "argument" };
316
375
  const line = loc.lines[item.lineIndex];
317
376
  let next;
318
377
  if (/\[[^\]]*\]/.test(line)) {
@@ -331,10 +390,33 @@ export function replaceCriterion(text, n, value) {
331
390
  if (!loc) return { error: "replace-criterion: spec has no '## Acceptance criteria' section" };
332
391
  const item = loc.items.find((it) => it.n === n);
333
392
  if (!item) return { error: `replace-criterion: no criterion #${n} found` };
334
- loc.lines[item.lineIndex] = `${item.indent}${n}. ${value}`;
393
+ // The whole item, continuation lines included (change 0206) — the lesson
394
+ // `replace-requirement` learned, which this verb never picked up: a
395
+ // criterion wrapped over three lines kept its old second and third lines
396
+ // under the new first one, so the spec stated a proof and then the
397
+ // previous proof's tail.
398
+ loc.lines.splice(item.lineIndex, endOfItem(loc.lines, item.lineIndex) - item.lineIndex + 1, `${item.indent}${n}. ${value}`);
335
399
  return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
336
400
  }
337
401
 
402
+ // remove-criterion <n> — delete criterion n, continuation lines included,
403
+ // and renumber the ones after it so the list stays 1..N (change 0206). A
404
+ // spec only ever grew: there was no verb to take a criterion out, so a
405
+ // superseded one stayed beside its replacement for good.
406
+ export function removeCriterion(text, n) {
407
+ const loc = locateCriteria(text);
408
+ if (!loc) return { error: "remove-criterion: spec has no '## Acceptance criteria' section" };
409
+ const item = loc.items.find((it) => it.n === n);
410
+ if (!item) return { error: `remove-criterion: no criterion #${n} found` };
411
+ const end = endOfItem(loc.lines, item.lineIndex);
412
+ const lines = loc.lines;
413
+ for (const it of loc.items) {
414
+ if (it.n > n) lines[it.lineIndex] = lines[it.lineIndex].replace(/^(\s*)\d+\./, `$1${it.n - 1}.`);
415
+ }
416
+ lines.splice(item.lineIndex, end - item.lineIndex + 1);
417
+ return { text: lines.join("\n"), summary: `remove-criterion ${n} (${loc.items.length - n} renumbered)` };
418
+ }
419
+
338
420
  // The `### <Section>` blocks under `## Requirements (EARS)`. Returns
339
421
  // { lines, sections: { <token>: { headingIndex, endIndex, bullets: [lineIndex] } } }
340
422
  // or null when the Requirements section is absent. A section's token is the
@@ -481,11 +563,44 @@ export function replaceRequirement(text, section, n, value) {
481
563
  if (n > real.length) {
482
564
  return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
483
565
  }
484
- const indent = loc.lines[real[n - 1]].match(/^(\s*)/)[1];
485
- loc.lines[real[n - 1]] = `${indent}- ${value}`;
566
+ // REPLACE THE WHOLE ITEM, continuation lines included.
567
+ //
568
+ // Writing only the first line leaves the old bullet's wrapped prose sitting
569
+ // under the new one, indented, reading as part of it. That is not cosmetic:
570
+ // the orphan is the PREVIOUS version of the requirement, so the spec ends up
571
+ // stating a contract and then contradicting it two lines later, and a spec
572
+ // is the one artifact this framework asks everyone to trust.
573
+ //
574
+ // It had already happened four times in this repository's own tree before
575
+ // anyone noticed — twice in `gates` (a closing sequence missing its review
576
+ // and runtime steps, a doctor set missing its runtime row) and once in
577
+ // `insight`, all three describing older behaviour beside the current one.
578
+ //
579
+ // `append-requirement` learned this in 0.13.0 and got `endOfItem`; this verb
580
+ // was written afterwards and never picked it up.
581
+ const start = real[n - 1];
582
+ const indent = loc.lines[start].match(/^(\s*)/)[1];
583
+ loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1, `${indent}- ${value}`);
486
584
  return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
487
585
  }
488
586
 
587
+ // remove-requirement <section> <n> — delete the nth bullet (1-based) of the
588
+ // EARS subsection, continuation lines included (change 0206). The heading
589
+ // stays even when it empties: `append-requirement` can fill it again.
590
+ export function removeRequirement(text, section, n) {
591
+ const loc = locateRequirements(text);
592
+ if (!loc) return { error: "remove-requirement: spec has no '## Requirements (EARS)' section" };
593
+ const sec = loc.sections[section];
594
+ if (!sec) return { error: `remove-requirement: no '### ${section}...' subsection found under Requirements` };
595
+ const real = sec.bullets.filter((i) => !/^\s*-\s*$/.test(loc.lines[i]));
596
+ if (n > real.length) {
597
+ return { error: `remove-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
598
+ }
599
+ const start = real[n - 1];
600
+ loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1);
601
+ return { text: loc.lines.join("\n"), summary: `remove-requirement ${section} ${n}` };
602
+ }
603
+
489
604
  // append-criterion <text> — add a new numbered criterion after the last one
490
605
  // in the section.
491
606
  export function appendCriterion(text, value) {
@@ -504,3 +619,23 @@ export function appendCriterion(text, value) {
504
619
  loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
505
620
  return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
506
621
  }
622
+
623
+ /**
624
+ * The size of a spec, as `work --consolidate` reports it (change 0206):
625
+ * lines, real requirement bullets per EARS subsection, and criteria.
626
+ * @param {string} text
627
+ */
628
+ export function specShape(text) {
629
+ const req = locateRequirements(text);
630
+ const sections = {};
631
+ let requirements = 0;
632
+ for (const token of EARS_ORDER) {
633
+ const sec = req?.sections[token];
634
+ if (!sec) continue;
635
+ const n = sec.bullets.filter((i) => !/^\s*-\s*$/.test(req.lines[i])).length;
636
+ sections[token] = n;
637
+ requirements += n;
638
+ }
639
+ const crit = locateCriteria(text);
640
+ return { lines: text.split(/\r?\n/).length, requirements, sections, criteria: crit ? crit.items.length : 0 };
641
+ }
@@ -9,7 +9,7 @@ import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./
9
9
  import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
10
10
  import { readTemplate } from "./templates.js";
11
11
  import { PLAYBOOKS } from "./playbook.js";
12
- import { COMMAND_META, COMMAND_NAMES, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
12
+ import { COMMAND_META, COMMAND_NAMES, DEPRECATED, REMOVED, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
13
13
  import { declaredBudget } from "./runtime.js";
14
14
  import { agentsMdBudget } from "./validation-model.js";
15
15
  /**
@@ -80,6 +80,16 @@ export const PRODUCT_SECTIONS = [
80
80
  "## Success criteria",
81
81
  ];
82
82
 
83
+ // The first deprecated or removed operation a text tells you to run, as
84
+ // `doctrina <op>`, or null.
85
+ function retiredCommandIn(text) {
86
+ for (const op of [...Object.keys(DEPRECATED), ...Object.keys(REMOVED)]) {
87
+ const name = op.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88
+ if (new RegExp(`doctrina ${name}(?![a-z-])`).test(text)) return op;
89
+ }
90
+ return null;
91
+ }
92
+
83
93
  // The findings `templates check` reports, as structured records with an
84
94
  // executable remedy. Exported so a test can seed each finding, run the
85
95
  // remedy it names, and assert the finding clears — a remedy that cannot
@@ -111,16 +121,16 @@ export function collectFindings(projectRoot) {
111
121
  : ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
112
122
  `AGENTS.md has ${budget.slack} left of its declared ceiling`),
113
123
  remedy: budget.fits
114
- ? "doctrina templates update --write"
115
- : `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina templates update --write\``,
124
+ ? "doctrina upgrade --write"
125
+ : `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina upgrade --write\``,
116
126
  });
117
127
  }
118
128
  // Command-surface block: present and current vs the installed catalog.
119
129
  const block = findSurfaceBlock(text);
120
130
  if (!block) {
121
- findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
131
+ findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
122
132
  } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
123
- findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina templates update --write" });
133
+ findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
124
134
  } else {
125
135
  ok.push("AGENTS.md: doctrina:surface block current");
126
136
  }
@@ -134,7 +144,7 @@ export function collectFindings(projectRoot) {
134
144
  const text = read(productPath);
135
145
  for (const heading of PRODUCT_SECTIONS) {
136
146
  if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
137
- else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
147
+ else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina upgrade --write" });
138
148
  }
139
149
  } else {
140
150
  findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
@@ -151,7 +161,24 @@ export function collectFindings(projectRoot) {
151
161
  for (const file of spec.files) {
152
162
  const installed = path.join(projectRoot, file.relativePath);
153
163
  if (!isFile(installed)) continue; // not installed — nothing to check
154
- if (!isHubPointer(file)) continue; // command shim, not a pointer
164
+ if (!isHubPointer(file)) {
165
+ // A command shim is copied into the project once, so an upgrade
166
+ // of the CLI leaves it teaching whatever the CLI taught then: the
167
+ // 0.16 `/doctrina-work` still ran `analyze` → `change apply` after
168
+ // 0.17 deprecated one and handed the other to the close (change
169
+ // 0187). A shim naming a retired command is stale; the same rewrite
170
+ // from the template that repairs a pointer repairs it.
171
+ const retired = retiredCommandIn(read(installed));
172
+ if (retired) {
173
+ findings.push({
174
+ message: `adapter ${file.relativePath} still teaches \`doctrina ${retired}\`, which this CLI ${DEPRECATED[retired] ? "deprecated" : "removed"} — the agent running it follows the old flow`,
175
+ remedy: `doctrina adapter add ${name} --force`,
176
+ });
177
+ } else {
178
+ ok.push(`adapter ${file.relativePath}: teaches live commands only`);
179
+ }
180
+ continue;
181
+ }
155
182
  if (read(installed).includes("AGENTS.md")) {
156
183
  ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
157
184
  } else {
@@ -195,7 +222,7 @@ export function collectFindings(projectRoot) {
195
222
  const idx = JSON.parse(read(indexPath));
196
223
  for (const field of INDEX_FIELDS) {
197
224
  if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
198
- else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
225
+ else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina upgrade --write" });
199
226
  }
200
227
  if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
201
228
  findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
@@ -16,11 +16,13 @@
16
16
  import path from "node:path";
17
17
  import { readdirSync } from "node:fs";
18
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";
19
21
  import * as idx from "./index-json.js";
20
22
  import { SCHEMA_VERSION } from "./index-json.js";
21
23
  import { cliVersion, newestVersion } from "./version.js";
22
24
  import { today } from "./dates.js";
23
- import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue } from "./doc-model.js";
25
+ import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue, isScaffoldValue } from "./doc-model.js";
24
26
  import { checkEars, isEarsSpec } from "./ears.js";
25
27
  import { parseAdrScope, parseSourceGlobs, parseDependsOn, specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "./scan.js";
26
28
  import { COMMAND_NAMES, referencedCommands, DEPRECATED } from "./commands.js";
@@ -144,6 +146,21 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
144
146
  const productMd = path.join(projectRoot, ".doctrina", "product.md");
145
147
  if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
146
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
+
147
164
  // Header repair (M3). One grammar means a non-canonical header can be
148
165
  // REWRITTEN, not just reported: `- **Status**: x` becomes
149
166
  // `- **Status:** x`, and a spec written with list-style headers is
@@ -435,7 +452,7 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
435
452
  if (ph > 0) {
436
453
  warnings.push(
437
454
  `open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
438
- `the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
455
+ `the change was opened but never planned (replace them with real tasks; \`change check\` and the close refuse them)`,
439
456
  );
440
457
  }
441
458
  }
@@ -443,10 +460,15 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
443
460
  // capabilities a change touches, and a name with no spec behind it
444
461
  // — a typo, or a capability that was never created — passed
445
462
  // analyze, check and close in silence. A capability this change is
446
- // about to CREATE (an ADDED delta) is not a ghost.
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.
447
467
  if (isFile(proposal)) {
448
- const affects = (listHeader(read(proposal), "Affects specs") ?? "")
449
- .match(/[a-z][a-z0-9-]*/g) ?? [];
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) ?? [];
450
472
  const specsRoot = path.join(projectRoot, ".doctrina", "specs");
451
473
  for (const cap of affects) {
452
474
  if (isFile(path.join(specsRoot, cap, "spec.md"))) continue;
@@ -464,12 +486,12 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
464
486
  if (!m) {
465
487
  warnings.push(
466
488
  `${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
467
- `analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
489
+ `\`change apply\` and the close will refuse it (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
468
490
  );
469
491
  } else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
470
492
  warnings.push(
471
493
  `${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
472
- `analyze/apply will refuse it at close time`,
494
+ `\`change apply\` and the close will refuse it`,
473
495
  );
474
496
  }
475
497
  }
@@ -487,7 +509,7 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
487
509
  const specPath = path.join(specsDir, cap, "spec.md");
488
510
  if (isFile(specPath)) {
489
511
  const lines = lineCount(specPath);
490
- if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
512
+ if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap) — consolidate it: \`doctrina work --consolidate ${path.basename(path.dirname(specPath))}\``);
491
513
 
492
514
  // 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
493
515
  const text = read(specPath);
@@ -732,10 +754,25 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
732
754
  const productPath = path.join(projectRoot, ".doctrina", "product.md");
733
755
  if (isFile(productPath)) {
734
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();
735
762
  const lines = read(productPath).split(/\r?\n/);
736
763
  for (let i = 0; i < lines.length; i++) {
737
- const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
764
+ const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+(.*)$/);
738
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
+ }
739
776
  if (seen.has(m[1])) {
740
777
  errors.push(
741
778
  `.doctrina/product.md:${i + 1} intent anchor [${m[1]}] is declared twice (first at line ${seen.get(m[1])}) — ` +
@@ -972,11 +1009,11 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
972
1009
  else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
973
1010
 
974
1011
  // 15. Skill description drift — index.json must mirror the
975
- // frontmatter description (run `doctrina skill sync`).
1012
+ // frontmatter description (`doctrina index rebuild` does).
976
1013
  if (descField && index?.artifacts?.skills) {
977
1014
  const entry = index.artifacts.skills.find((s) => s.id === baseName);
978
1015
  if (entry && entry.description !== descField) {
979
- warnings.push(`${rel} description differs from index.json (run \`doctrina skill sync\`)`);
1016
+ warnings.push(`${rel} description differs from index.json (run \`doctrina index rebuild\`)`);
980
1017
  }
981
1018
  }
982
1019
  }
@@ -1012,7 +1049,9 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
1012
1049
  // rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
1013
1050
  // the rule's own message. Deterministic, zero-deps, and the instruction
1014
1051
  // survives sessions because it is an artifact, not a memory.
1015
- for (const line of checkProjectRules(projectRoot)) errors.push(line);
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);
1016
1055
  for (const key of loadConfig(projectRoot).unknown) {
1017
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)`);
1018
1057
  }
@@ -1043,27 +1082,45 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
1043
1082
  // something concrete — a path or glob, a command, a quoted error string, a
1044
1083
  // file extension, an ALL_CAPS identifier, or simply enough distinctive
1045
1084
  // keywords to match on.
1046
- const VAGUE_TRIGGER = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|as needed|if needed)\.?$/i;
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;
1047
1093
 
1048
- /** A value still wrapped in the template's angle brackets: `<one-sentence …>`. */
1049
- export function isScaffoldValue(value) {
1050
- return /^<[^<>]*>$/.test(String(value ?? "").trim());
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();
1051
1107
  }
1052
1108
 
1053
1109
  export function hasDetectableTrigger(when) {
1054
1110
  const text = String(when ?? "").trim();
1055
- if (text === "" || VAGUE_TRIGGER.test(text)) return false;
1111
+ if (looksVague(text)) return false;
1056
1112
  // The scaffold's own placeholder has enough words to rank on and names
1057
1113
  // nothing (change 0111): a trigger nobody wrote fires for nobody.
1058
1114
  if (isScaffoldValue(text)) return false;
1059
1115
  // Anything structural is inherently matchable.
1060
- if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
1061
- // Otherwise: enough distinctive words to rank on. Stopwords do not count.
1062
- const STOP = new Set(["when", "whenever", "the", "a", "an", "is", "are", "you", "your",
1063
- "it", "its", "this", "that", "and", "or", "to", "of", "in", "on", "for", "with",
1064
- "any", "some", "need", "needs", "needed", "should", "must", "at", "as", "by", "be"]);
1065
- const words = text.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? [];
1066
- return words.filter((w) => !STOP.has(w)).length >= 2;
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;
1067
1124
  }
1068
1125
 
1069
1126
  // Enforce the project's rules — permanent constraints as forbid-regexes over
@@ -1073,9 +1130,16 @@ export function hasDetectableTrigger(when) {
1073
1130
  // "message": "white-label product; use a generic placeholder" } ] }
1074
1131
  // Declared in .doctrina/config.json; still read from the legacy
1075
1132
  // .doctrina/rules.json when that is where a project put them (change 0047).
1076
- // Returns error strings (one per offending file+rule, capped per rule so a
1077
- // mass violation stays readable). No rules → nothing to enforce; a malformed
1078
- // file is reported once, by the reader. Binary-ish and vendored dirs are
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
1079
1143
  // skipped by the same bounded walk validate already uses elsewhere.
1080
1144
  const RULES_SKIP_DIRS = new Set([
1081
1145
  ".git", "node_modules", "vendor", "dist", "build", "out", "target",
@@ -1086,6 +1150,7 @@ const RULES_MAX_HITS_PER_RULE = 10;
1086
1150
  function checkProjectRules(projectRoot) {
1087
1151
  const cfg = loadConfig(projectRoot);
1088
1152
  const out = [...cfg.errors];
1153
+ const warn = [];
1089
1154
  const where = cfg.sources.rules === SOURCES.config ? CONFIG_REL : RULES_REL;
1090
1155
  const compiled = [];
1091
1156
  for (const r of cfg.rules) {
@@ -1100,10 +1165,16 @@ function checkProjectRules(projectRoot) {
1100
1165
  out.push(`${where}: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
1101
1166
  continue;
1102
1167
  }
1103
- const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
1104
- compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
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
+ });
1105
1176
  }
1106
- if (compiled.length === 0) return out;
1177
+ if (compiled.length === 0) return { errors: out, warnings: warn };
1107
1178
 
1108
1179
  // One bounded walk; each text file is read at most once.
1109
1180
  const stack = [projectRoot];
@@ -1123,7 +1194,11 @@ function checkProjectRules(projectRoot) {
1123
1194
  }
1124
1195
  const rel = relPath(projectRoot, full).replace(/\\/g, "/");
1125
1196
  if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
1126
- const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
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);
1127
1202
  if (applicable.length === 0) continue;
1128
1203
  let text;
1129
1204
  try {
@@ -1145,8 +1220,15 @@ function checkProjectRules(projectRoot) {
1145
1220
  if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
1146
1221
  out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
1147
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
+ }
1148
1230
  }
1149
- return out;
1231
+ return { errors: out, warnings: warn };
1150
1232
  }
1151
1233
 
1152
1234
  // Minimal glob → RegExp: ** crosses directories, * stays within a segment.
@@ -1168,9 +1250,9 @@ function globToRegExp(glob) {
1168
1250
  // artifact whose recorded metadata no longer matches its file.
1169
1251
  function metadataDrift(current, derived) {
1170
1252
  const lines = [];
1171
- // Skills are deliberately excluded: their description has a dedicated
1172
- // reconciliation command (`doctrina skill sync`) and stays an advisory
1173
- // warning (see section 15), not a hard error.
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.
1174
1256
  const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
1175
1257
  for (const cat of cats) {
1176
1258
  const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));