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,231 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
5
+ import { projectName } from "./project.js";
6
+ import * as idx from "./index-json.js";
7
+ import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
8
+ import { cliVersion } from "./version.js";
9
+ import { summarize as coverageSummary } from "./coverage-model.js";
10
+ import { summarize as traceSummary } from "./trace-model.js";
11
+ import { acceptedDecisions, productSection, hasProductSection } from "./constitution-model.js";
12
+ import { computeActions } from "./actions.js";
13
+ import { readLedger } from "./ledger.js";
14
+ import { parseChangeTitle, checklistProgress } from "./doc-model.js";
15
+ import { summarizeSignoffs } from "./signoff.js";
16
+ import { gitWindow, historyState, windowCutoff } from "./git.js";
17
+ import { collectMetrics } from "./metrics-model.js";
18
+
19
+ // ONE collector, several views (audit finding F7).
20
+ //
21
+ // `status`, `prime`, `handoff` and `report` were four commands rendering the
22
+ // same three collections, and they got at them by importing functions out of
23
+ // each other's modules: `prime` reached into `status` for `collectStatus`,
24
+ // `handoff` and `report` reached into `prime` for `openChanges`, and all
25
+ // three reached into `coverage` and `trace` for their summaries. Every one of
26
+ // those is a command module — a RENDERER — being used as a data source, which
27
+ // is how four surfaces end up able to disagree about the same number.
28
+ //
29
+ // So the project's state is collected ONCE, here, into a plain object. The
30
+ // four commands become formatters over it: same numbers, four shapes, and a
31
+ // new view costs a function rather than a fifth traversal of the tree.
32
+ //
33
+ // Nothing here prints and nothing here writes.
34
+
35
+ const VERIFY_CONFIG_REL = ".doctrina/verify.json";
36
+
37
+ /**
38
+ * Everything the read-only views report, collected in one pass.
39
+ *
40
+ * `actions` is included because three of the four views end with "what next",
41
+ * and computing it separately in each was the same duplication one level up.
42
+ */
43
+ export function collectSnapshot(projectRoot, { actions = true } = {}) {
44
+ let index = null;
45
+ try {
46
+ index = idx.load(projectRoot);
47
+ } catch {
48
+ index = null;
49
+ }
50
+ let indexState = "missing";
51
+ if (index) {
52
+ indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
53
+ }
54
+
55
+ // Collected once, then shared: the views render it and `computeActions`
56
+ // recommends from it, so a recommendation can never disagree with the row
57
+ // it comes from (change 0037, extended to `next` by change 0064).
58
+ const coverage = coverageSummary(projectRoot);
59
+ const trace = traceSummary(projectRoot);
60
+ const verify = readVerifyConfig(projectRoot);
61
+
62
+ return {
63
+ project: projectName(projectRoot, index),
64
+ stamp: index?.framework_version ?? null,
65
+ cli: cliVersion(),
66
+ indexState,
67
+ coverage,
68
+ trace,
69
+ verify,
70
+ specs: countSpecs(projectRoot),
71
+ decisions: countDecisions(projectRoot),
72
+ skills: countSkills(projectRoot),
73
+ openChanges: openChanges(projectRoot),
74
+ adrs: acceptedDecisions(projectRoot),
75
+ nonGoals: productSection(projectRoot, "Non-goals"),
76
+ hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
77
+ // The gate state is already collected above; hand it over so the whole
78
+ // snapshot stays one read of the tree (change 0037).
79
+ actions: actions ? computeActions(projectRoot, { coverage, trace, verify }) : [],
80
+ archive: index?.artifacts?.changes_archive ?? [],
81
+ // The lane each open change was born in, for the report's mix. Read from
82
+ // the index rather than re-parsed, so one derivation owns the field.
83
+ openLanes: (index?.artifacts?.changes ?? []).map((c) => c.lane ?? null),
84
+ // The archive ledger, parsed (change 0046). The index records WHICH
85
+ // changes landed; the ledger records what each one touched and when, in
86
+ // one append-only file — which is what a question like "how often has
87
+ // this capability moved?" is actually asking about.
88
+ ledger: readLedger(projectRoot).entries,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The subset `status --json`, `doctor` and the dashboard have always
94
+ * published. Kept as its own shape so the JSON envelope does not silently
95
+ * grow the day a view needs one more field.
96
+ */
97
+ export function collectStatus(projectRoot) {
98
+ const s = collectSnapshot(projectRoot, { actions: false });
99
+ return {
100
+ project: s.project,
101
+ stamp: s.stamp,
102
+ cli: s.cli,
103
+ indexState: s.indexState,
104
+ coverage: s.coverage,
105
+ trace: s.trace,
106
+ verify: s.verify,
107
+ specs: s.specs,
108
+ decisions: s.decisions,
109
+ skills: s.skills,
110
+ };
111
+ }
112
+
113
+ function readVerifyConfig(projectRoot) {
114
+ const p = path.join(projectRoot, VERIFY_CONFIG_REL);
115
+ if (!isFile(p)) return { configured: false, checks: 0, invalid: false, signoffs: null };
116
+ try {
117
+ const cfg = JSON.parse(read(p));
118
+ const checks = Array.isArray(cfg?.checks) ? cfg.checks : [];
119
+ // Executed proof and SIGNED proof are different claims (change 0039), and
120
+ // a report that adds them together hides which is which. The counts ride
121
+ // along so every view can say so without loading the sign-off store
122
+ // itself.
123
+ const signoffs = summarizeSignoffs(projectRoot, checks);
124
+ return { configured: true, checks: checks.length, invalid: false, signoffs };
125
+ } catch {
126
+ return { configured: true, checks: 0, invalid: true, signoffs: null };
127
+ }
128
+ }
129
+
130
+ /** Open changes with their proposal status/title and task progress. */
131
+ export function openChanges(projectRoot) {
132
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
133
+ const out = [];
134
+ if (!isDir(changesDir)) return out;
135
+ for (const id of readdirSync(changesDir).sort()) {
136
+ if (id === "archive" || id.startsWith(".")) continue;
137
+ if (!isDir(path.join(changesDir, id))) continue;
138
+ const proposalPath = path.join(changesDir, id, "proposal.md");
139
+ const proposal = isFile(proposalPath) ? read(proposalPath) : null;
140
+ const title = proposal ? parseChangeTitle(proposal) : null;
141
+ const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
142
+ const tasksPath = path.join(changesDir, id, "tasks.md");
143
+ // One counter for every surface (change 0067). This one used to require
144
+ // TEXT after the box, so the scaffold's empty placeholders were invisible
145
+ // and `prime` reported "tasks 0/3" for a change with six open boxes.
146
+ const progress = isFile(tasksPath)
147
+ ? checklistProgress(read(tasksPath))
148
+ : { total: 0, done: 0, open: [], placeholders: 0 };
149
+ const tasksTotal = progress.total;
150
+ const tasksDone = progress.done;
151
+ const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
152
+ out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function countSpecs(projectRoot) {
158
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
159
+ const impl = {};
160
+ let total = 0;
161
+ if (isDir(specsDir)) {
162
+ for (const cap of readdirSync(specsDir).sort()) {
163
+ const p = path.join(specsDir, cap, "spec.md");
164
+ if (!isFile(p)) continue;
165
+ total += 1;
166
+ const implRaw = specHeader(read(p), "Implementation");
167
+ if (implRaw) {
168
+ const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
169
+ impl[word] = (impl[word] ?? 0) + 1;
170
+ }
171
+ }
172
+ }
173
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
174
+ let open = 0;
175
+ if (isDir(changesDir)) {
176
+ for (const e of readdirSync(changesDir)) {
177
+ if (e === "archive" || e.startsWith(".")) continue;
178
+ if (isDir(path.join(changesDir, e))) open += 1;
179
+ }
180
+ }
181
+ return { total, impl, openChanges: open };
182
+ }
183
+
184
+ function countDecisions(projectRoot) {
185
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
186
+ let total = 0, proposed = 0, bare = 0;
187
+ const isBare = (v) => {
188
+ const t = (v ?? "").trim();
189
+ return t === "" || t === "—" || t === "-";
190
+ };
191
+ if (isDir(adrDir)) {
192
+ for (const f of walk(adrDir)) {
193
+ if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
194
+ total += 1;
195
+ const text = read(f);
196
+ const status = (listHeader(text, "Status") ?? "").toLowerCase();
197
+ if (status === "proposed") proposed += 1;
198
+ if (status === "accepted") {
199
+ const evidence = listHeader(text, "Evidence");
200
+ const landed = listHeader(text, "Landed");
201
+ if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
202
+ }
203
+ }
204
+ }
205
+ return { total, proposed, bare };
206
+ }
207
+
208
+ function countSkills(projectRoot) {
209
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
210
+ if (!isDir(skillsDir)) return 0;
211
+ return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
212
+ }
213
+
214
+ /**
215
+ * Everything the period digest reads beyond the snapshot, for a window of
216
+ * `days`. ONE collector for every door that renders the digest: `report`
217
+ * passed the metrics and `status --view report` did not, so the view that
218
+ * promised "never different numbers" printed two lines fewer (change 0176).
219
+ *
220
+ * @param {string} projectRoot
221
+ * @param {number} days
222
+ */
223
+ export function collectReportWindow(projectRoot, days) {
224
+ return {
225
+ days,
226
+ cutoffIso: windowCutoff(days),
227
+ git: gitWindow(projectRoot, days),
228
+ gitState: historyState(projectRoot),
229
+ metrics: collectMetrics(projectRoot, `${days} days ago`),
230
+ };
231
+ }
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { commentRanges, isInsideComment } from "./doc-model.js";
2
3
  // Structured spec operations (ADR 0007 / review F3). A MODIFIED spec delta
3
4
  // historically carried prose ("flip criterion 2 to verified", "bump to
4
5
  // 0.3.0") that a human merged by hand — the single most error-prone step in
@@ -39,6 +40,40 @@
39
40
  const HEADER_RE = (name) =>
40
41
  new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
41
42
 
43
+ // The ENUMS behind the two machine-read headers and the criterion mark
44
+ // (change 0102). `--version` and `--bump` always validated their input;
45
+ // `--status`, `--implementation` and `--criterion` wrote whatever string they
46
+ // were given, synced it into the index, and `validate` passed it — so a spec
47
+ // could say `Status: bogus` and `prime` would report "1 banana" without
48
+ // finding anything odd. A header every gate branches on has a domain, and
49
+ // this is it. A note after the state word stays legal ("planned — deferred,
50
+ // see ADR 0007"): only the WORD is checked.
51
+ export const STATUS_VALUES = ["draft", "active", "deprecated"];
52
+ export const IMPLEMENTATION_VALUES = ["planned", "partial", "implemented", "verified"];
53
+ export const CRITERION_MARKS = ["verified", "unverified", "orchestration"];
54
+
55
+ /** The state word of a header/mark value: the first token, note stripped. */
56
+ export function stateWord(value) {
57
+ const first = String(value ?? "").trim().split(/\s+/)[0] ?? "";
58
+ return first.replace(/[—:-]+$/, "").toLowerCase();
59
+ }
60
+
61
+ /** null when `value` is a legal value for `name`; otherwise the error text. */
62
+ export function headerValueError(name, value) {
63
+ const allowed = name === "Status" ? STATUS_VALUES : name === "Implementation" ? IMPLEMENTATION_VALUES : null;
64
+ if (!allowed) return null;
65
+ const word = stateWord(value);
66
+ if (allowed.includes(word)) return null;
67
+ return `${name} must be one of ${allowed.join("|")} (got "${String(value).trim()}") — a note may follow the value, the value itself does not change`;
68
+ }
69
+
70
+ /** null when `mark` is a legal criterion mark; otherwise the error text. */
71
+ export function criterionMarkError(mark) {
72
+ const word = stateWord(mark);
73
+ if (CRITERION_MARKS.includes(word)) return null;
74
+ return `a criterion mark must be one of ${CRITERION_MARKS.join("|")} (got "${String(mark).trim()}")`;
75
+ }
76
+
42
77
  function escapeRe(s) {
43
78
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
79
  }
@@ -71,11 +106,8 @@ export function extractOps(deltaText) {
71
106
  // contain a comment (an `<!-- illustrative -->` marker inside an
72
107
  // append-criterion landed in the spec with the marker silently gutted).
73
108
  function matchOpsBlock(text) {
74
- const commentRanges = [];
75
- for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) {
76
- commentRanges.push([m.index, m.index + m[0].length]);
77
- }
78
- const insideComment = (offset) => commentRanges.some(([a, b]) => offset >= a && offset < b);
109
+ const ranges = commentRanges(text);
110
+ const insideComment = (offset) => isInsideComment(ranges, offset);
79
111
 
80
112
  const fenceRe = /^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
81
113
  let m;
@@ -219,6 +251,15 @@ export function setHeader(text, name, value) {
219
251
  if (!re.test(text)) {
220
252
  return { error: `set-header: no "**${name}:**" header in the spec to set` };
221
253
  }
254
+ // The domain is the SPEC's (bare bold header). The list form
255
+ // (`- **Status:** proposed`) is a proposal's or an ADR's, whose Status
256
+ // has a lifecycle of its own (proposed -> applied, accepted, superseded).
257
+ const listForm = /^\s*-/.test(text.match(re)?.[1] ?? "");
258
+ const domain = listForm ? null : headerValueError(name, value);
259
+ // `cause: "argument"` marks an error about the value supplied, not about
260
+ // the document: `spec set` answers it as USAGE, a delta as a defect of
261
+ // the work (ADR 0018).
262
+ if (domain) return { error: `set-header: ${domain}`, cause: "argument" };
222
263
  const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
223
264
  return { text: next, summary: `set ${name}: ${value}` };
224
265
  }
@@ -271,8 +312,10 @@ export function setCriterionMark(text, n, markRaw) {
271
312
  const loc = locateCriteria(text);
272
313
  if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
273
314
  const item = loc.items.find((it) => it.n === n);
274
- if (!item) return { error: `set-criterion: no criterion #${n} found` };
315
+ if (!item) return { error: `set-criterion: no criterion #${n} found`, cause: "argument" };
275
316
  const mark = markRaw.replace(/^\[|\]$/g, "").trim();
317
+ const domain = criterionMarkError(mark);
318
+ if (domain) return { error: `set-criterion: ${domain}`, cause: "argument" };
276
319
  const line = loc.lines[item.lineIndex];
277
320
  let next;
278
321
  if (/\[[^\]]*\]/.test(line)) {
@@ -441,8 +484,24 @@ export function replaceRequirement(text, section, n, value) {
441
484
  if (n > real.length) {
442
485
  return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
443
486
  }
444
- const indent = loc.lines[real[n - 1]].match(/^(\s*)/)[1];
445
- loc.lines[real[n - 1]] = `${indent}- ${value}`;
487
+ // REPLACE THE WHOLE ITEM, continuation lines included.
488
+ //
489
+ // Writing only the first line leaves the old bullet's wrapped prose sitting
490
+ // under the new one, indented, reading as part of it. That is not cosmetic:
491
+ // the orphan is the PREVIOUS version of the requirement, so the spec ends up
492
+ // stating a contract and then contradicting it two lines later, and a spec
493
+ // is the one artifact this framework asks everyone to trust.
494
+ //
495
+ // It had already happened four times in this repository's own tree before
496
+ // anyone noticed — twice in `gates` (a closing sequence missing its review
497
+ // and runtime steps, a doctor set missing its runtime row) and once in
498
+ // `insight`, all three describing older behaviour beside the current one.
499
+ //
500
+ // `append-requirement` learned this in 0.13.0 and got `endOfItem`; this verb
501
+ // was written afterwards and never picked it up.
502
+ const start = real[n - 1];
503
+ const indent = loc.lines[start].match(/^(\s*)/)[1];
504
+ loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1, `${indent}- ${value}`);
446
505
  return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
447
506
  }
448
507
 
@@ -454,6 +513,11 @@ export function appendCriterion(text, value) {
454
513
  if (loc.items.length === 0) {
455
514
  return { error: "append-criterion: no existing numbered criterion to append after" };
456
515
  }
516
+ const leadingMark = String(value).match(/^\s*\[([^\]]*)\]/);
517
+ if (leadingMark) {
518
+ const domain = criterionMarkError(leadingMark[1]);
519
+ if (domain) return { error: `append-criterion: ${domain}` };
520
+ }
457
521
  const last = loc.items[loc.items.length - 1];
458
522
  const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
459
523
  loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
@@ -0,0 +1,289 @@
1
+ // @ts-check
2
+ // The template-shape FINDINGS.
3
+ //
4
+ // `doctor` reports these as one of its rows and used to import them out of
5
+ // `commands/templates.js` (audit finding F7). Collecting is not rendering, so
6
+ // the collection lives here and both surfaces read it.
7
+ import path from "node:path";
8
+ import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
9
+ import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
10
+ import { readTemplate } from "./templates.js";
11
+ import { PLAYBOOKS } from "./playbook.js";
12
+ import { COMMAND_META, COMMAND_NAMES, DEPRECATED, REMOVED, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
13
+ import { declaredBudget } from "./runtime.js";
14
+ import { agentsMdBudget } from "./validation-model.js";
15
+ /**
16
+ * The surface-block budget: how many lines the generated block spends, the
17
+ * ceiling it spends them against, and what is left.
18
+ *
19
+ * This number is COUPLED to `agents-md-lines`: the block is written into
20
+ * AGENTS.md, so one command added to the catalog spends a line of both.
21
+ * Every caller that reports on it reads it HERE, so `templates check` and
22
+ * `doctor` can never quote two different sizes for one block — the second
23
+ * count is how the two AGENTS.md numbers drifted before change 0059, and
24
+ * this budget had no owner at all until 0072.
25
+ *
26
+ * @param {string|null} [projectRoot] The project whose contract may declare
27
+ * the ceiling; omit to measure against the shipped default alone.
28
+ * @returns {{ used: number, budget: number, slack: number, declared: boolean }}
29
+ */
30
+ export function surfaceBudget(projectRoot = null) {
31
+ const used = surfaceMarkdown().split("\n").length;
32
+ const { value: budget, declared } = projectRoot
33
+ ? declaredBudget(projectRoot, "surface-block-lines", SURFACE_LINE_BUDGET)
34
+ : { value: SURFACE_LINE_BUDGET, declared: false };
35
+ return { used, budget, slack: budget - used, declared };
36
+ }
37
+
38
+ // What one appended stub section costs AGENTS.md: a blank line, the heading,
39
+ // a blank line and the fill-me comment. Kept beside the writer that emits
40
+ // that shape (`templates update --write`) so the estimate and the write
41
+ // cannot drift.
42
+ export const STUB_SECTION_LINES = 4;
43
+
44
+ /**
45
+ * What appending stub sections to AGENTS.md would cost, against what the
46
+ * file has left of its declared ceiling.
47
+ *
48
+ * `templates check` recommends the sections and `agents-md-lines` caps the
49
+ * file, and the two never spoke: following the recommendation on this very
50
+ * repository took AGENTS.md from 147 to 155 lines, past a ceiling `analyze`
51
+ * refuses to raise because the budget is declared OUTPUT. A recommendation
52
+ * that cannot say what it costs is a recommendation that sends you into
53
+ * another gate's refusal (change 0078).
54
+ *
55
+ * @param {string} projectRoot
56
+ * @param {string[]} missing The recommended headings AGENTS.md does not have.
57
+ * @returns {{ cost: number, slack: number, fits: boolean, over: number }}
58
+ */
59
+ export function agentsSectionCost(projectRoot, missing) {
60
+ const cost = missing.length * STUB_SECTION_LINES;
61
+ const { slack } = agentsMdBudget(projectRoot);
62
+ return { cost, slack, fits: cost <= slack, over: Math.max(0, cost - slack) };
63
+ }
64
+ // Recommended sections per file kind. Adopters whose files lack these
65
+ // headings get a warning from `templates check`; they are recommendations,
66
+ // not hard requirements (validate handles the hard requirements).
67
+ export const AGENTS_SECTIONS = [
68
+ "## Stack",
69
+ "## Commands",
70
+ "## Repository structure",
71
+ "## Conventions and boundaries",
72
+ "## How to read context",
73
+ ];
74
+ export const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
75
+ export const PRODUCT_SECTIONS = [
76
+ "## Vision",
77
+ "## Problem",
78
+ "## Target users",
79
+ "## Scope",
80
+ "## Success criteria",
81
+ ];
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
+
93
+ // The findings `templates check` reports, as structured records with an
94
+ // executable remedy. Exported so a test can seed each finding, run the
95
+ // remedy it names, and assert the finding clears — a remedy that cannot
96
+ // resolve its own finding is not a remedy (C2).
97
+ export function collectFindings(projectRoot) {
98
+ const findings = [];
99
+ const ok = [];
100
+
101
+
102
+ // AGENTS.md sections
103
+ const agentsPath = path.join(projectRoot, "AGENTS.md");
104
+ if (isFile(agentsPath)) {
105
+ const text = read(agentsPath);
106
+ const missingSections = AGENTS_SECTIONS.filter((h) => !hasHeading(text, h));
107
+ const budget = agentsSectionCost(projectRoot, missingSections);
108
+ for (const heading of AGENTS_SECTIONS) {
109
+ if (hasHeading(text, heading)) {
110
+ ok.push(`AGENTS.md: ${heading}`);
111
+ continue;
112
+ }
113
+ // The recommendation names its price. When the stubs do not fit, the
114
+ // remedy names the CUT first — appending them anyway would clear this
115
+ // finding by breaching a ceiling `analyze` refuses to raise, which is
116
+ // one gate sending you into another's refusal.
117
+ findings.push({
118
+ message: `AGENTS.md missing recommended section "${heading}"` +
119
+ (budget.fits
120
+ ? ``
121
+ : ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
122
+ `AGENTS.md has ${budget.slack} left of its declared ceiling`),
123
+ remedy: budget.fits
124
+ ? "doctrina upgrade --write"
125
+ : `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina upgrade --write\``,
126
+ });
127
+ }
128
+ // Command-surface block: present and current vs the installed catalog.
129
+ const block = findSurfaceBlock(text);
130
+ if (!block) {
131
+ findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
132
+ } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
133
+ findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
134
+ } else {
135
+ ok.push("AGENTS.md: doctrina:surface block current");
136
+ }
137
+ } else {
138
+ findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
139
+ }
140
+
141
+ // product.md sections
142
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
143
+ if (isFile(productPath)) {
144
+ const text = read(productPath);
145
+ for (const heading of PRODUCT_SECTIONS) {
146
+ if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
147
+ else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina upgrade --write" });
148
+ }
149
+ } else {
150
+ findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
151
+ }
152
+
153
+ // Installed agent adapters: a HUB POINTER file must still reference
154
+ // AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
155
+ // every installed agent). Only files whose template declares the
156
+ // {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
157
+ // hub through its parent pointer file and is not checked (C2).
158
+ for (const name of listAdapterNames(projectRoot)) {
159
+ const spec = adapterFiles(projectRoot, name);
160
+ if (!spec) continue;
161
+ for (const file of spec.files) {
162
+ const installed = path.join(projectRoot, file.relativePath);
163
+ if (!isFile(installed)) continue; // not installed — nothing to check
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
+ }
182
+ if (read(installed).includes("AGENTS.md")) {
183
+ ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
184
+ } else {
185
+ findings.push({
186
+ message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
187
+ // Executable and verified: `adapter add --force` rewrites exactly
188
+ // this file from its template. A remedy that cannot resolve the
189
+ // finding it is attached to is not a remedy (C2).
190
+ remedy: `doctrina adapter add ${name} --force`,
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ // M2: a command that cannot state its trigger has not earned a place on
197
+ // the surface. The block is an agent's only discovery surface, so both
198
+ // fields are required and the block has a declared size budget.
199
+ for (const name of COMMAND_NAMES) {
200
+ const meta = COMMAND_META[name];
201
+ if (!meta || !meta.purpose || !meta.when) {
202
+ findings.push({
203
+ message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
204
+ remedy: null,
205
+ });
206
+ }
207
+ }
208
+ const surface = surfaceBudget(projectRoot);
209
+ if (surface.used > surface.budget) {
210
+ findings.push({
211
+ message: `the generated surface block is ${surface.used} lines (budget ${surface.budget}) — cut commands rather than raising the budget`,
212
+ remedy: null,
213
+ });
214
+ } else {
215
+ ok.push(`surface block within budget (${surface.used}/${surface.budget} lines)`);
216
+ }
217
+
218
+ // index.json schema fields
219
+ const indexPath = path.join(projectRoot, ".doctrina", "index.json");
220
+ if (isFile(indexPath)) {
221
+ try {
222
+ const idx = JSON.parse(read(indexPath));
223
+ for (const field of INDEX_FIELDS) {
224
+ if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
225
+ else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina upgrade --write" });
226
+ }
227
+ if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
228
+ findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
229
+ }
230
+ } catch (err) {
231
+ findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
232
+ }
233
+ } else {
234
+ findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
235
+ }
236
+
237
+ // Output
238
+ // The playbooks (change 0038). A playbook is the procedure the agent
239
+ // actually executes, and since it became a template an adopter can replace
240
+ // it — which means it can also be MISSING or malformed, and a playbook that
241
+ // does not render is a broken session, not a cosmetic gap. Each is checked
242
+ // for resolution and for the shape every playbook must have: a title line
243
+ // and the ordered steps.
244
+ for (const name of PLAYBOOKS) {
245
+ const rel = `playbooks/${name}.md.template`;
246
+ let body = null;
247
+ try {
248
+ body = readTemplate(projectRoot, rel).body;
249
+ } catch {
250
+ body = null;
251
+ }
252
+ if (body === null) {
253
+ findings.push({
254
+ message: `playbook "${name}" does not resolve (${rel} is missing from the project AND the installed CLI)`,
255
+ remedy: "reinstall doctrina-cli, or restore .doctrina/templates/" + rel,
256
+ });
257
+ continue;
258
+ }
259
+ const problems = [];
260
+ if (body.trim() === "") problems.push("it is empty");
261
+ if (!/^\s*1\./m.test(body)) problems.push("it has no numbered first step");
262
+ for (const [open, close] of [["[[c]]", "[[/c]]"], ["[[g]]", "[[/g]]"], ["[[b]]", "[[/b]]"], ["[[y]]", "[[/y]]"]]) {
263
+ const opens = body.split(open).length - 1;
264
+ const closes = body.split(close).length - 1;
265
+ if (opens !== closes) problems.push(`${opens} ${open} spans but ${closes} ${close}`);
266
+ }
267
+ if (problems.length > 0) {
268
+ findings.push({
269
+ message: `playbook "${name}" is malformed: ${problems.join("; ")}`,
270
+ remedy: `fix .doctrina/templates/${rel}, or delete it to fall back to the bundled playbook`,
271
+ });
272
+ } else {
273
+ ok.push(`playbook: ${name}`);
274
+ }
275
+ }
276
+
277
+ return { findings, ok };
278
+ }
279
+
280
+ export function normalizeBlock(s) {
281
+ return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
282
+ }
283
+
284
+ export function hasHeading(text, heading) {
285
+ // Match an exact h2 heading at line start (case-insensitive for the body
286
+ // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
287
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288
+ return new RegExp(`^${escaped}\\b`, "im").test(text);
289
+ }