doctrina-cli 0.16.0 → 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 (60) hide show
  1. package/README.md +27 -72
  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 +28 -77
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +65 -10
  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 +1 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/skill.js +8 -15
  22. package/src/commands/spec.js +13 -7
  23. package/src/commands/status.js +39 -20
  24. package/src/commands/templates.js +4 -10
  25. package/src/commands/upgrade.js +31 -6
  26. package/src/commands/validate.js +32 -8
  27. package/src/commands/work.js +10 -5
  28. package/src/index.js +34 -6
  29. package/src/lib/actions.js +30 -24
  30. package/src/lib/args.js +14 -0
  31. package/src/lib/change-ops.js +3 -1
  32. package/src/lib/commands.js +88 -15
  33. package/src/lib/coverage-model.js +40 -1
  34. package/src/lib/doc-model.js +47 -6
  35. package/src/lib/docs-impact.js +74 -0
  36. package/src/lib/ears.js +24 -1
  37. package/src/lib/gates.js +53 -9
  38. package/src/lib/index-json.js +40 -5
  39. package/src/lib/intake-model.js +71 -2
  40. package/src/lib/lexicon.js +38 -1
  41. package/src/lib/metrics-model.js +30 -0
  42. package/src/lib/project.js +49 -1
  43. package/src/lib/scan.js +126 -14
  44. package/src/lib/snapshot.js +23 -1
  45. package/src/lib/spec-ops.js +24 -5
  46. package/src/lib/templates-model.js +35 -8
  47. package/src/lib/validation-model.js +116 -34
  48. package/src/lib/views.js +4 -1
  49. package/src/lib/work-model.js +25 -0
  50. package/templates/AGENTS.md.template +3 -4
  51. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  52. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  53. package/templates/change/proposal.md.template +15 -0
  54. package/templates/change/tasks.md.template +3 -8
  55. package/templates/contract.md.template +2 -2
  56. package/templates/hooks/pre-commit.sample +10 -0
  57. package/templates/playbooks/bootstrap.md.template +6 -2
  58. package/templates/playbooks/chore.md.template +9 -4
  59. package/templates/playbooks/work.md.template +10 -3
  60. package/src/commands/constitution.js +0 -68
@@ -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
  }
@@ -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]));
package/src/lib/views.js CHANGED
@@ -222,6 +222,9 @@ export function handoff(s) {
222
222
  out.push("## Open work");
223
223
  out.push("");
224
224
  out.push("- none — the tree is at rest; start with `doctrina work \"<prompt>\"`");
225
+ // The note is pasted into another session, so the section has to END like
226
+ // the loop below ends: a heading glued to a list item is not a heading.
227
+ out.push("");
225
228
  } else {
226
229
  for (const ch of s.openChanges) {
227
230
  out.push(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
@@ -405,6 +408,6 @@ export function report(s, { days = 7, cutoffIso = "", git = null, gitState = nul
405
408
  }
406
409
 
407
410
  out.push("");
408
- out.push(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
411
+ out.push(`*Generated read-only by \`doctrina status --view report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
409
412
  return out;
410
413
  }
@@ -44,6 +44,31 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
44
44
  return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
45
45
  }
46
46
 
47
+ /**
48
+ * The capabilities whose `Source:` header CLAIMS a file — ADR 0027's question
49
+ * asked directly, with no inference in the answer.
50
+ *
51
+ * `rankCapabilitiesByDiff` deliberately falls back to inferences so a project
52
+ * that has not declared anything still gets a hint. That makes it the wrong
53
+ * function to enforce the declaration with: a file no spec names still comes
54
+ * back with an owner, scored 5 instead of 10, and a test asking "does this
55
+ * have an owner" cannot tell the two apart. `lib/names.js` sat undeclared
56
+ * behind exactly that.
57
+ */
58
+ export function declaredOwners(projectRoot, file) {
59
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
60
+ if (!isDir(specsDir)) return [];
61
+ const norm = String(file).replace(/\\/g, "/");
62
+ const owners = [];
63
+ for (const cap of readdirSync(specsDir).sort()) {
64
+ const specPath = path.join(specsDir, cap, "spec.md");
65
+ if (!isFile(specPath)) continue;
66
+ const globs = parseSourceGlobs(read(specPath)).map((g) => globToRegExp(g));
67
+ if (globs.some((re) => re.test(norm))) owners.push(cap);
68
+ }
69
+ return owners;
70
+ }
71
+
47
72
  // Deterministic term overlap: fold prompt and spec text with the SHARED
48
73
  // lexicon, then score. This is a hint for the agent, never a decision
49
74
  // (ADR 0005).
@@ -46,13 +46,12 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
46
46
  - `doctrina triage` — classify the lane (product/runtime/chore) and check the declared runtime surface. *When:* a request arrives — BEFORE scaffolding, especially if it smells like an incident.
47
47
  - `doctrina work "<prompt>" (--capability · --chore · --from-diff · --quiet)` — scaffold a change and print the playbook to execute. *When:* a request arrives that changes behaviour (triage says PRODUCT).
48
48
  - `doctrina spec new|list|set` — create, list, and edit capability specs. *When:* a capability needs creating or its headers advancing.
49
- - `doctrina change new|apply|archive|check|tick|abandon` — new / apply / archive / check (--verbose) / tick / abandon. *When:* driving a change through its lifecycle by hand.
49
+ - `doctrina change new|apply|archive|check|tick|abandon` — drive a change by hand — check --verbose previews every delta. *When:* driving a change through its lifecycle by hand.
50
50
  - `doctrina contract new|list|check` — own and verify the integration surface. *When:* the change touches ports, env vars, or public endpoints.
51
51
  - `doctrina decision new|accept|land|supersede|list|scope` — record, accept, land, scope, and supersede ADRs. *When:* the change decides something a later session must not relitigate.
52
- - `doctrina skill new|list|sync|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
52
+ - `doctrina skill new|list|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
53
53
  - `doctrina intent add|list` — append and list product intent anchors. *When:* new product intent appears after the intake.
54
54
  **Gate**
55
- - `doctrina analyze` — structural pre-flight on a change folder. *When:* before applying a change.
56
55
  - `doctrina clarify --all (--lang pt|en)` — smell-test Markdown for ambiguity. *When:* before applying, or before opening a PR.
57
56
  - `doctrina validate (--fix)` — schema, structure, EARS, and index drift. *When:* after any artifact edit, and before considering work done.
58
57
  - `doctrina coverage --strict` — acceptance criteria against cited evidence. *When:* before claiming a capability is proven.
@@ -61,7 +60,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
61
60
  - `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
62
61
  - `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
63
62
  - `doctrina doctor` — aggregate diagnostic with per-finding remedies. *When:* something looks wrong and you do not know which gate to ask.
64
- **Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list|check|update` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina report` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
63
+ **Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
65
64
  <!-- doctrina:surface:end -->
66
65
 
67
66
  ## Stack and tooling
@@ -9,9 +9,12 @@ Drive the Doctrina spec-driven workflow for **{{PROJECT_NAME}}**.
9
9
  (No prompt, but you already edited code? Use `doctrina work --from-diff`.)
10
10
  2. Execute the printed playbook in order: read the pack with
11
11
  `doctrina context <capability> --concat`, write the spec delta, fill
12
- `tasks.md`, implement, then `doctrina analyze <id>` → `doctrina change apply <id>`.
13
- 3. Close it: `doctrina close <id>` (verify → coverage → archive → validate) and
14
- resolve every `error:` before reporting the work done.
12
+ `tasks.md`, implement. `doctrina change check <id>` previews everything
13
+ the close would refuse.
14
+ 3. Close it: `doctrina close <id>` — one attested pass over every gate. The
15
+ sequence is declared in one place and printed by `doctrina close --help`;
16
+ read it there rather than from a copy. Resolve every `error:` before
17
+ reporting the work done.
15
18
 
16
19
  Reach for the CLI instead of hand-authoring artifacts. Ask the human only on
17
20
  genuine ambiguity.
@@ -5,8 +5,9 @@ Turn the request into a Doctrina change and drive it through the gates.
5
5
  1. Run `doctrina work "<the request>"` (or `doctrina work --from-diff` if you
6
6
  already edited code) and follow the printed playbook.
7
7
  2. `doctrina context <capability> --concat` → write the spec delta → fill
8
- `tasks.md` → implement → `doctrina analyze <id>` → `doctrina change apply <id>`.
9
- 3. `doctrina close <id>` (verify → coverage → archive → validate); resolve
10
- every `error:` before reporting the work done.
8
+ `tasks.md` → implement → `doctrina change check <id>` (the close's preview).
9
+ 3. `doctrina close <id>` — one attested pass over every gate; the sequence is
10
+ declared in one place and printed by `doctrina close --help`, so read it
11
+ there and not from a copy. Resolve every `error:` before reporting done.
11
12
 
12
13
  Prefer the CLI over hand-authoring artifacts. Ask only on genuine ambiguity.
@@ -6,6 +6,21 @@
6
6
  - **Lane:**
7
7
  - **Affects specs:**
8
8
 
9
+ <!--
10
+ Optional, and usually absent. The closing docs gate reads COMMAND and FLAG
11
+ names out of the prose below and asks for documentation when it finds any.
12
+ It cannot tell a change from a mention: explaining an effect, or writing a
13
+ Scope boundaries line about what this deliberately does NOT touch, names
14
+ things just as loudly as changing them would.
15
+
16
+ When that happens, say so on the record instead of forcing the close:
17
+
18
+ - **Documented surface:** n/a — names two commands to explain an effect; alters neither
19
+
20
+ `none` reads the same as `n/a`, and a BARE one silences nothing — the
21
+ reason is the declaration.
22
+ -->
23
+
9
24
  ## Why
10
25
 
11
26
  <!-- The user-visible or technical reason this change exists. -->
@@ -2,16 +2,11 @@
2
2
 
3
3
  <!--
4
4
  Each task is a single checkable item. Keep tasks small (under a few hours
5
- of work). The change is done when every box is checked, including the
6
- three closing steps at the bottom.
5
+ of work). The change is done when every box is checked and
6
+ `doctrina close {{CHANGE_ID}}` succeeds — the close applies the deltas,
7
+ archives the change and updates the index, so those are not boxes here.
7
8
  -->
8
9
 
9
10
  - [ ]
10
11
  - [ ]
11
12
  - [ ]
12
-
13
- ## Closing steps
14
-
15
- - [ ] Apply the change: merge each delta into the corresponding spec.
16
- - [ ] Archive the change folder to `.doctrina/changes/archive/{{DATE}}-{{CHANGE_ID}}/`.
17
- - [ ] Update `.doctrina/index.json` with new or modified artifacts.
@@ -107,8 +107,8 @@ Example (Behave): features/**/*.feature with pattern @([a-z0-9_-]+)
107
107
  <!--
108
108
  Ceilings that are a CONTRACT, not a preference: token limits, payload
109
109
  caps, timeouts. Direction distinguishes what goes IN from what comes
110
- OUT, because they fail differently — and `doctrina analyze` refuses a
111
- change that "fixes" an overflow by raising an OUTPUT ceiling, which
110
+ OUT, because they fail differently — and `doctrina change check` and the
111
+ close refuse a change that "fixes" an overflow by raising an OUTPUT ceiling, which
112
112
  buys headroom by truncating what mattered rather than sending less.
113
113
  -->
114
114
 
@@ -26,3 +26,13 @@ $DOCTRINA validate --fix || exit 1
26
26
  if [ -f .doctrina/index.json ]; then
27
27
  git add .doctrina/index.json
28
28
  fi
29
+
30
+ # ...and then check that the index this commit carries describes THIS COMMIT.
31
+ #
32
+ # Everything above reads the working tree. index.json is derived from the
33
+ # whole tree, so it cannot describe a subset of one: stage part of
34
+ # .doctrina/ and the index that just got staged names artifacts the commit
35
+ # does not contain. On your disk they are all still there, so `validate`,
36
+ # `index rebuild --check` and the close all pass — and the checkout in CI
37
+ # fails with "index.json references missing artifact".
38
+ $DOCTRINA index rebuild --check --staged || exit 1
@@ -35,8 +35,12 @@ Execute in order, in a single linear pass:
35
35
  If something in the intake is genuinely ambiguous, ask the user
36
36
  before assuming.
37
37
 
38
- 7. Mark the intake consumed: in .doctrina/intake.md flip
39
- "- **Status:** pending" to "- **Status:** converted".
38
+ 7. Mark the intake consumed — through the CLI, not by hand:
39
+ [[c]]doctrina intake --converted[[/c]]
40
+ [[g]] `next` branches on that header and `validate` refuses any other[[/g]]
41
+ [[g]] value, because every word but the two declared ones used to read[[/g]]
42
+ [[g]] as pending in silence. It refuses while `validate` reports an[[/g]]
43
+ [[g]] error — the step-6 gates are what "done" means.[[/g]]
40
44
 
41
45
  8. [[c]]doctrina next[[/c]] — follow the recommendation. Implementation then
42
46
  flows through [[c]]doctrina work "<brief prompt>"[[/c]], one change at a time.
@@ -6,14 +6,19 @@ Execute in order, in a single linear pass:
6
6
 
7
7
  1. Replace the placeholder tasks in .doctrina/changes/{{CHANGE_ID}}/tasks.md
8
8
  with small, checkable steps; implement them, checking each box.
9
- [[g]] (Do it before implementing — analyze/close refuse leftover scaffold[[/g]]
9
+ [[g]] (Do it before implementing — the close refuses leftover scaffold[[/g]]
10
10
  [[g]] placeholders, and `change tick` will not tick an empty box.)[[/g]]
11
- 2. Prove it (archive refuses unchecked boxes):
11
+ 2. Prove it (the close refuses unchecked boxes):
12
12
  [[c]]doctrina verify[[/c]] — the project's typecheck/test/build gate
13
13
  [[c]]doctrina verify --clean[[/c]] — clean-checkout reproducibility lint
14
14
  Then check the proposal's ## Verification boxes and every task.
15
15
  3. This is a chore: no spec changes. If you find yourself needing a
16
16
  spec delta, it is not a chore — reopen with [[c]]doctrina work "<prompt>"[[/c]].
17
- 4. [[c]]doctrina change apply {{CHANGE_ID}}[[/c]] (zero deltas → flips to applied).
18
- 5. [[c]]doctrina change archive {{CHANGE_ID}}[[/c]], then [[c]]doctrina validate[[/c]].
17
+ 4. Close in one attested pass — the same close as any change; a chore
18
+ simply has no delta to apply:
19
+ [[c]]doctrina close {{CHANGE_ID}}[[/c]]
20
+ [[g]] It stops at the first failure with the exact rerun command, and the[[/g]]
21
+ [[g]] sequence is printed by the close itself. A chore that alters a[[/g]]
22
+ [[g]] documented surface still owes the docs and a changelog entry — or[[/g]]
23
+ [[g]] `- **Documented surface:** n/a — <why>` in the proposal.[[/g]]
19
24
  [[g]] If this fix taught a lesson worth not relearning, capture it: [[/g]][[c]]doctrina skill new <slug>[[/c]][[g]].[[/g]]
@@ -45,7 +45,7 @@ Execute in order, in a single linear pass:
45
45
  4. Replace the placeholder tasks in .doctrina/changes/{{CHANGE_ID}}/tasks.md
46
46
  with small, checkable implementation tasks (a few hours each, max),
47
47
  and record the change's What/Scope in its proposal.md. Do this BEFORE
48
- implementing — analyze and close refuse a change whose scaffold
48
+ implementing — `change check` and close refuse a change whose scaffold
49
49
  placeholders were never replaced, and `change tick` will not tick them.
50
50
 
51
51
  5. Implement task by task, checking each box as it lands. Advance the
@@ -67,9 +67,16 @@ Execute in order, in a single linear pass:
67
67
  [[g]] It self-reviews on the way through: the close runs `doctrina review`[[/g]]
68
68
  [[g]] before the apply, where its findings can still change what gets[[/g]]
69
69
  [[g]] written. Advisory — it reports and the close continues.[[/g]]
70
- [[g]] (equivalent, step by step: [[/g]][[g]]analyze → change apply → verify → coverage → trace → change archive → validate)[[/g]]
70
+ [[g]] The sequence is declared in one place and printed by the close itself,[[/g]]
71
+ [[g]] so read it there rather than from a copy that goes stale.[[/g]]
71
72
  Before it: check the proposal's ## Verification boxes and every task,
72
- closing steps included, and bump Implementation to verified.
73
+ and bump Implementation to verified.
74
+ Two gates ask for WRITING, and both refuse late if you leave them:
75
+ a change that alters a documented surface needs the documentation to
76
+ move with it, AND an entry in the project's changelog — one says how it
77
+ works, the other says that it changed. If the command and flag names in
78
+ your prose are only MENTIONED, declare it instead of forcing:
79
+ [[c]]- **Documented surface:** n/a — <why>[[/c]] in the proposal.
73
80
 
74
81
  8. [[c]]doctrina next[[/c]] for the follow-up.
75
82
  [[g]] If this change taught a reusable lesson (a fix you'd hate to relearn,[[/g]]