doctrina-cli 0.15.0 → 0.16.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 (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -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,12 @@ 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
+ if (domain) return { error: `set-header: ${domain}` };
222
260
  const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
223
261
  return { text: next, summary: `set ${name}: ${value}` };
224
262
  }
@@ -273,6 +311,8 @@ export function setCriterionMark(text, n, markRaw) {
273
311
  const item = loc.items.find((it) => it.n === n);
274
312
  if (!item) return { error: `set-criterion: no criterion #${n} found` };
275
313
  const mark = markRaw.replace(/^\[|\]$/g, "").trim();
314
+ const domain = criterionMarkError(mark);
315
+ if (domain) return { error: `set-criterion: ${domain}` };
276
316
  const line = loc.lines[item.lineIndex];
277
317
  let next;
278
318
  if (/\[[^\]]*\]/.test(line)) {
@@ -454,6 +494,11 @@ export function appendCriterion(text, value) {
454
494
  if (loc.items.length === 0) {
455
495
  return { error: "append-criterion: no existing numbered criterion to append after" };
456
496
  }
497
+ const leadingMark = String(value).match(/^\s*\[([^\]]*)\]/);
498
+ if (leadingMark) {
499
+ const domain = criterionMarkError(leadingMark[1]);
500
+ if (domain) return { error: `append-criterion: ${domain}` };
501
+ }
457
502
  const last = loc.items[loc.items.length - 1];
458
503
  const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
459
504
  loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
@@ -0,0 +1,262 @@
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, 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 findings `templates check` reports, as structured records with an
84
+ // executable remedy. Exported so a test can seed each finding, run the
85
+ // remedy it names, and assert the finding clears — a remedy that cannot
86
+ // resolve its own finding is not a remedy (C2).
87
+ export function collectFindings(projectRoot) {
88
+ const findings = [];
89
+ const ok = [];
90
+
91
+
92
+ // AGENTS.md sections
93
+ const agentsPath = path.join(projectRoot, "AGENTS.md");
94
+ if (isFile(agentsPath)) {
95
+ const text = read(agentsPath);
96
+ const missingSections = AGENTS_SECTIONS.filter((h) => !hasHeading(text, h));
97
+ const budget = agentsSectionCost(projectRoot, missingSections);
98
+ for (const heading of AGENTS_SECTIONS) {
99
+ if (hasHeading(text, heading)) {
100
+ ok.push(`AGENTS.md: ${heading}`);
101
+ continue;
102
+ }
103
+ // The recommendation names its price. When the stubs do not fit, the
104
+ // remedy names the CUT first — appending them anyway would clear this
105
+ // finding by breaching a ceiling `analyze` refuses to raise, which is
106
+ // one gate sending you into another's refusal.
107
+ findings.push({
108
+ message: `AGENTS.md missing recommended section "${heading}"` +
109
+ (budget.fits
110
+ ? ``
111
+ : ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
112
+ `AGENTS.md has ${budget.slack} left of its declared ceiling`),
113
+ remedy: budget.fits
114
+ ? "doctrina templates update --write"
115
+ : `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina templates update --write\``,
116
+ });
117
+ }
118
+ // Command-surface block: present and current vs the installed catalog.
119
+ const block = findSurfaceBlock(text);
120
+ if (!block) {
121
+ findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
122
+ } 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" });
124
+ } else {
125
+ ok.push("AGENTS.md: doctrina:surface block current");
126
+ }
127
+ } else {
128
+ findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
129
+ }
130
+
131
+ // product.md sections
132
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
133
+ if (isFile(productPath)) {
134
+ const text = read(productPath);
135
+ for (const heading of PRODUCT_SECTIONS) {
136
+ 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" });
138
+ }
139
+ } else {
140
+ findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
141
+ }
142
+
143
+ // Installed agent adapters: a HUB POINTER file must still reference
144
+ // AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
145
+ // every installed agent). Only files whose template declares the
146
+ // {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
147
+ // hub through its parent pointer file and is not checked (C2).
148
+ for (const name of listAdapterNames(projectRoot)) {
149
+ const spec = adapterFiles(projectRoot, name);
150
+ if (!spec) continue;
151
+ for (const file of spec.files) {
152
+ const installed = path.join(projectRoot, file.relativePath);
153
+ if (!isFile(installed)) continue; // not installed — nothing to check
154
+ if (!isHubPointer(file)) continue; // command shim, not a pointer
155
+ if (read(installed).includes("AGENTS.md")) {
156
+ ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
157
+ } else {
158
+ findings.push({
159
+ message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
160
+ // Executable and verified: `adapter add --force` rewrites exactly
161
+ // this file from its template. A remedy that cannot resolve the
162
+ // finding it is attached to is not a remedy (C2).
163
+ remedy: `doctrina adapter add ${name} --force`,
164
+ });
165
+ }
166
+ }
167
+ }
168
+
169
+ // M2: a command that cannot state its trigger has not earned a place on
170
+ // the surface. The block is an agent's only discovery surface, so both
171
+ // fields are required and the block has a declared size budget.
172
+ for (const name of COMMAND_NAMES) {
173
+ const meta = COMMAND_META[name];
174
+ if (!meta || !meta.purpose || !meta.when) {
175
+ findings.push({
176
+ message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
177
+ remedy: null,
178
+ });
179
+ }
180
+ }
181
+ const surface = surfaceBudget(projectRoot);
182
+ if (surface.used > surface.budget) {
183
+ findings.push({
184
+ message: `the generated surface block is ${surface.used} lines (budget ${surface.budget}) — cut commands rather than raising the budget`,
185
+ remedy: null,
186
+ });
187
+ } else {
188
+ ok.push(`surface block within budget (${surface.used}/${surface.budget} lines)`);
189
+ }
190
+
191
+ // index.json schema fields
192
+ const indexPath = path.join(projectRoot, ".doctrina", "index.json");
193
+ if (isFile(indexPath)) {
194
+ try {
195
+ const idx = JSON.parse(read(indexPath));
196
+ for (const field of INDEX_FIELDS) {
197
+ 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" });
199
+ }
200
+ if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
201
+ findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
202
+ }
203
+ } catch (err) {
204
+ findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
205
+ }
206
+ } else {
207
+ findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
208
+ }
209
+
210
+ // Output
211
+ // The playbooks (change 0038). A playbook is the procedure the agent
212
+ // actually executes, and since it became a template an adopter can replace
213
+ // it — which means it can also be MISSING or malformed, and a playbook that
214
+ // does not render is a broken session, not a cosmetic gap. Each is checked
215
+ // for resolution and for the shape every playbook must have: a title line
216
+ // and the ordered steps.
217
+ for (const name of PLAYBOOKS) {
218
+ const rel = `playbooks/${name}.md.template`;
219
+ let body = null;
220
+ try {
221
+ body = readTemplate(projectRoot, rel).body;
222
+ } catch {
223
+ body = null;
224
+ }
225
+ if (body === null) {
226
+ findings.push({
227
+ message: `playbook "${name}" does not resolve (${rel} is missing from the project AND the installed CLI)`,
228
+ remedy: "reinstall doctrina-cli, or restore .doctrina/templates/" + rel,
229
+ });
230
+ continue;
231
+ }
232
+ const problems = [];
233
+ if (body.trim() === "") problems.push("it is empty");
234
+ if (!/^\s*1\./m.test(body)) problems.push("it has no numbered first step");
235
+ for (const [open, close] of [["[[c]]", "[[/c]]"], ["[[g]]", "[[/g]]"], ["[[b]]", "[[/b]]"], ["[[y]]", "[[/y]]"]]) {
236
+ const opens = body.split(open).length - 1;
237
+ const closes = body.split(close).length - 1;
238
+ if (opens !== closes) problems.push(`${opens} ${open} spans but ${closes} ${close}`);
239
+ }
240
+ if (problems.length > 0) {
241
+ findings.push({
242
+ message: `playbook "${name}" is malformed: ${problems.join("; ")}`,
243
+ remedy: `fix .doctrina/templates/${rel}, or delete it to fall back to the bundled playbook`,
244
+ });
245
+ } else {
246
+ ok.push(`playbook: ${name}`);
247
+ }
248
+ }
249
+
250
+ return { findings, ok };
251
+ }
252
+
253
+ export function normalizeBlock(s) {
254
+ return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
255
+ }
256
+
257
+ export function hasHeading(text, heading) {
258
+ // Match an exact h2 heading at line start (case-insensitive for the body
259
+ // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
260
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
261
+ return new RegExp(`^${escaped}\\b`, "im").test(text);
262
+ }
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read } from "./fs-ops.js";
5
+ import { specHeader } from "./scan.js";
6
+
7
+ // The trace MODEL: product intent anchors, the specs that realize them, and
8
+ // the provenance arithmetic over the two. Lifted out of `commands/trace.js`
9
+ // so `status`, `review` and the snapshot read it directly instead of
10
+ // importing out of a command module (audit finding F7). Nothing here prints.
11
+
12
+ const ANCHOR_RE = /[A-Z]+\d+/g;
13
+
14
+ // Pure summary of intent provenance, for other commands (`status`, `review`)
15
+ // that need the numbers without the report output.
16
+ export function summarize(projectRoot) {
17
+ const anchors = collectAnchors(projectRoot);
18
+ const specs = collectSpecs(projectRoot);
19
+ const anchorIds = new Set(anchors.map((a) => a.id));
20
+ const realizedBy = new Map();
21
+ let dangling = 0;
22
+ for (const s of specs) {
23
+ if (s.realizes === null) continue;
24
+ for (const id of s.realizes) {
25
+ if (anchorIds.has(id)) {
26
+ if (!realizedBy.has(id)) realizedBy.set(id, []);
27
+ realizedBy.get(id).push(s.cap);
28
+ } else {
29
+ dangling += 1;
30
+ }
31
+ }
32
+ }
33
+ const untraceable = specs.filter((s) => s.realizes === null && s.status === "active").length;
34
+ let realized = 0;
35
+ for (const a of anchors) if ((realizedBy.get(a.id) ?? []).length > 0) realized += 1;
36
+ return {
37
+ anchors: anchors.length,
38
+ realized,
39
+ dropped: anchors.length - realized,
40
+ dangling,
41
+ untraceable,
42
+ };
43
+ }
44
+
45
+ // Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
46
+ // anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
47
+ export function collectAnchors(projectRoot) {
48
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
49
+ if (!isFile(productPath)) return [];
50
+ const out = [];
51
+ const seen = new Set();
52
+ for (const line of read(productPath).split(/\r?\n/)) {
53
+ const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
54
+ if (m && !seen.has(m[1])) {
55
+ seen.add(m[1]);
56
+ out.push({ id: m[1] });
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // Anchor ids declared MORE THAN ONCE in product.md (change 0108). The
63
+ // collector above keeps the first bullet per id, which is right for the
64
+ // graph and wrong as the only reading: the second intent under the same id
65
+ // vanished from trace, why, intent list and every pack, with `trace
66
+ // --strict` green. Returned as {id, lines} so trace can name both bullets.
67
+ export function collectAnchorDuplicates(projectRoot) {
68
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
69
+ if (!isFile(productPath)) return [];
70
+ const lines = new Map();
71
+ const rows = read(productPath).split(/\r?\n/);
72
+ for (let i = 0; i < rows.length; i++) {
73
+ const m = rows[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
74
+ if (!m) continue;
75
+ if (!lines.has(m[1])) lines.set(m[1], []);
76
+ lines.get(m[1]).push(i + 1);
77
+ }
78
+ return [...lines].filter(([, at]) => at.length > 1).map(([id, at]) => ({ id, lines: at }));
79
+ }
80
+
81
+ export function collectSpecs(projectRoot) {
82
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
83
+ if (!isDir(specsDir)) return [];
84
+ const out = [];
85
+ for (const cap of readdirSync(specsDir).sort()) {
86
+ const specPath = path.join(specsDir, cap, "spec.md");
87
+ if (!isFile(specPath)) continue;
88
+ const text = read(specPath);
89
+ const realizesRaw = specHeader(text, "Realizes");
90
+ const realizes = realizesRaw === null ? null : (realizesRaw.match(ANCHOR_RE) ?? []);
91
+ const status = (specHeader(text, "Status") ?? "active").trim().toLowerCase();
92
+ out.push({ cap, status, realizes });
93
+ }
94
+ return out;
95
+ }
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+ import { fold } from "./lexicon.js";
3
+ // The lane CLASSIFIER (ADR 0024).
4
+ //
5
+ // `work` asks the same question `triage` answers — is this request PRODUCT,
6
+ // RUNTIME or CHORE — and imported the answer out of the command module
7
+ // (audit finding F7). One classifier, read by both.
8
+
9
+ // The prompt is FOLDED before matching (change 0107): accents stripped,
10
+ // lower-cased — the same `fold` `work` and `context --for` retrieve with
11
+ // (ADR 0040). Every pattern below is written in folded (ASCII) form, and
12
+ // the Portuguese half of each list sits next to the English half: the
13
+ // lexicon was English-only, so "the build is broken in CI, DATABASE_URL
14
+ // never reaches the process" was held as RUNTIME with exit 3 while the
15
+ // same sentence in Portuguese opened a change. A project whose prompts
16
+ // arrive in Portuguese (this one) had no guard at all.
17
+ export const CHORE_SIGNALS = [
18
+ [3, /\b(bump|upgrade|pin|lockfile|dependency|dependencies|devdependenc)\b/i],
19
+ [2, /\b(rename|move|reorganis|reorganiz|refactor|tidy|cleanup|clean up|format(ting)?|lint)\b/i],
20
+ [2, /\b(typo|comment|readme|changelog|docs? only)\b/i],
21
+ [2, /\b(already (specified|in the spec)|wiring only|no behaviour change|no behavior change)\b/i],
22
+ // pt
23
+ [3, /\b(atualizar (a |as )?dependencia|dependencias|fixar (a )?versao)\b/i],
24
+ [2, /\b(renomear|mover|reorganizar|refatorar|arrumar|limpeza|limpar|formatar|formatacao)\b/i],
25
+ [2, /\b(erro de digitacao|comentario|so (a )?doc(umentacao)?)\b/i],
26
+ [2, /\b(ja (esta )?(especificad[oa]|na spec)|so (a )?fiacao|sem mudanca de comportamento)\b/i],
27
+ ];
28
+ export const PRODUCT_SIGNALS = [
29
+ [3, /\b(add|introduce|support|allow|enable|implement|build)\b/i],
30
+ [3, /\b(should|shall|must|requirement|acceptance|criteri)\b/i],
31
+ // AUTHORING VERBS. Both lanes above match domain NOUNS, which turned out
32
+ // to misread work ON the machinery as an incident IN it: "declare the
33
+ // release workflow wiring in a contract" scored RUNTIME on "workflow" and
34
+ // "wiring", and "so an intentional rename stops warning" scored CHORE on
35
+ // "rename" — a noun, not the act. Neither prompt was a diagnosis; both
36
+ // were someone sitting down to write an artifact.
37
+ //
38
+ // These are the verbs of authoring. A prompt whose verb is "declare" or
39
+ // "document" is describing work on the tree, whatever nouns surround it.
40
+ [3, /\b(declare|declaring|document|documenting|record|recording|specify|specifying|define|defining)\b/i],
41
+ [2, /\b(new (feature|capability|command|endpoint|screen|flow))\b/i],
42
+ [2, /\b(users? (can|should|want)|so that|in order to)\b/i],
43
+ [1, /\b(change|behaviour|behavior|spec)\b/i],
44
+ // pt
45
+ [3, /\b(adicionar|introduzir|suportar|permitir|habilitar|implementar|construir|criar)\b/i],
46
+ [3, /\b(deve|devera|requisito|aceite|aceitacao|criterio)\b/i],
47
+ [3, /\b(declarar|declarando|documentar|documentando|registrar|registrando|especificar|especificando|definir|definindo)\b/i],
48
+ [2, /\b(nov[oa] (feature|funcionalidade|capability|capacidade|comando|endpoint|tela|fluxo))\b/i],
49
+ [2, /\b(usuari[oa]s? (pode|podem|deve|devem|quer|querem)|para que|de modo que|a fim de)\b/i],
50
+ [1, /\b(mudanca|comportamento)\b/i],
51
+ ];
52
+ // Signals that a request is about the running system rather than about what
53
+ // the system should do. Weighted: a phrase that can only mean runtime counts
54
+ // for more than a word that merely leans that way.
55
+ export const RUNTIME_SIGNALS = [
56
+ [3, /\b(workflow|pipeline|ci|github actions|actions run|job|runner)\b/i],
57
+ [3, /\b(env var|environment variable|dotenv|\.env|secret|secrets|vars)\b/i],
58
+ [3, /\b(0 (tests?|scenarios?|cases?)|no (tests?|scenarios?) ran|ran nothing|empty (run|suite|summary))\b/i],
59
+ [2, /\b(exit code|stack ?trace|traceback|logs?|step summary|artifact missing)\b/i],
60
+ [2, /\b(green but|passes but|silently|never (fires|applies|reaches|ran))\b/i],
61
+ [2, /\b(flaky|timeout|timed out|hangs?|crash(es|ed)?|broken build|failing build)\b/i],
62
+ [2, /\b(tag|selector|filter|dispatch)\b/i],
63
+ [1, /\b(config|configuration|wiring|wired|deploy(ment)?|docker|compose)\b/i],
64
+ [1, /\b(debug|diagnos(e|is)|investigate|why (is|does|did)|not working|doesn't work)\b/i],
65
+ // pt
66
+ [3, /\b(execucao d[oa]s? actions|esteira)\b/i],
67
+ [3, /\b(variavel de ambiente|variaveis de ambiente|segredo|segredos)\b/i],
68
+ [3, /\b(0 (testes?|cenarios?|casos?)|nenhum (teste|cenario|caso) (rodou|correu|executou)|(rodou|correu|executou) nada|(execucao|suite|resumo) vazi[oa])\b/i],
69
+ [2, /\b(codigo de saida|resumo d[oa] (step|etapa)|artefato (ausente|faltando|sumiu))\b/i],
70
+ [2, /\b(verde mas|passa mas|silenciosamente|em silencio|nunca (dispara|aplica|chega|rodou|correu|executou))\b/i],
71
+ [2, /\b(intermitente|estourou o tempo|trava|travou|crashou|build (quebrad[oa]|falhando|falhou))\b/i],
72
+ [2, /\b(seletor|filtro|despacho)\b/i],
73
+ [1, /\b(configuracao|fiacao|implantacao)\b/i],
74
+ [1, /\b(depurar|diagnostic(o|ar)|investigar|por que (esta|e|nao)|nao funciona|nao esta funcionando)\b/i],
75
+ ];
76
+
77
+ export function classify(prompt) {
78
+ const text = fold(prompt);
79
+ const score = (signals) => {
80
+ let total = 0;
81
+ const hits = [];
82
+ for (const [weight, re] of signals) {
83
+ const m = text.match(re);
84
+ if (m) {
85
+ total += weight;
86
+ hits.push(m[0].toLowerCase());
87
+ }
88
+ }
89
+ return { total, hits };
90
+ };
91
+
92
+ const lanes = {
93
+ runtime: score(RUNTIME_SIGNALS),
94
+ chore: score(CHORE_SIGNALS),
95
+ product: score(PRODUCT_SIGNALS),
96
+ };
97
+
98
+ // Product is the default: when nothing distinguishes the request, the
99
+ // ceremony that writes a spec delta is the safe one to fall into. Runtime
100
+ // must BEAT product to win, so an ordinary feature request mentioning a
101
+ // "job" does not get diverted into a diagnosis.
102
+ let lane = "product";
103
+ let best = lanes.product.total;
104
+ for (const name of ["runtime", "chore"]) {
105
+ if (lanes[name].total > best) {
106
+ lane = name;
107
+ best = lanes[name].total;
108
+ }
109
+ }
110
+ if (best === 0) lane = "product";
111
+
112
+ // Confidence is the MARGIN over the runner-up, not the winner's own score:
113
+ // a prompt that scores 5 for runtime and 4 for product is a coin toss the
114
+ // agent should see as one, and a clear product request must be able to read
115
+ // as confident too (comparing a lane against itself never could).
116
+ const runnerUp = Object.entries(lanes)
117
+ .filter(([name]) => name !== lane)
118
+ .reduce((max, [, v]) => Math.max(max, v.total), 0);
119
+
120
+ return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
121
+ }
package/src/lib/usage.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // @ts-check
2
2
  // Audit item M8: instrument the command surface before shrinking it.
3
3
  //
4
- // The surface is 36 commands and 59 operations. The audit's charge is that
5
- // some of them exist because they were easy to add, not because anybody
6
- // reaches for them — and the honest way to find out which is to MEASURE,
7
- // not to guess from the outside. Guessing is how `prime`, `handoff` and
4
+ // How big the surface is, this module does not say: `COMMAND_NAMES` and
5
+ // `OPERATIONS` in lib/commands.js own that number, and a copy of it here
6
+ // would be one more count to rot (change 0059 found four, all disagreeing).
7
+ // The audit's charge is that some of those commands exist because they were
8
+ // easy to add, not because anybody reaches for them — and the honest way to
9
+ // find out which is to MEASURE, not to guess from the outside. Guessing is how `prime`, `handoff` and
8
10
  // `doctor` were nearly cut in 0.13.0, right before the operator review
9
11
  // found they had gone unused only because AGENTS.md never named them.
10
12
  //