doctrina-cli 0.11.0 → 0.12.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.
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
6
+ import { specHeader } from "../lib/scan.js";
7
+ import { flagBool, flagString } from "../lib/args.js";
6
8
  import { c } from "../lib/colors.js";
7
9
 
8
10
  // Traceability report: how many acceptance criteria cite an artifact or
@@ -28,7 +30,21 @@ export async function run(_positional, flags) {
28
30
  const strict = flagBool(flags, "strict", false);
29
31
  const json = flagBool(flags, "json", false);
30
32
 
31
- const reports = collect(projectRoot);
33
+ // --only <cap,cap>: scope the report/gate to specific capabilities — the
34
+ // hook `doctrina close` uses so one deliberately deferred spec elsewhere in
35
+ // the tree cannot block closing a change that never touched it.
36
+ const onlyRaw = flagString(flags, "only");
37
+ const only = onlyRaw ? new Set(onlyRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null;
38
+
39
+ const reports = collect(projectRoot, { only });
40
+
41
+ // --run: execute the cited evidence instead of only checking it exists —
42
+ // promotes "the file is on disk" to "the proof passes". The runner is
43
+ // project-declared in .doctrina/verify.json ("evidence_runner": a command
44
+ // template with a {file} placeholder); the CLI never guesses a test runner.
45
+ if (flagBool(flags, "run", false)) {
46
+ return runEvidence(projectRoot, reports, strict);
47
+ }
32
48
 
33
49
  if (reports.length === 0) {
34
50
  if (json) {
@@ -43,19 +59,22 @@ export async function run(_positional, flags) {
43
59
  let totalCovered = 0;
44
60
  let totalDangling = 0;
45
61
  let totalConditional = 0;
62
+ let totalDeferred = 0;
46
63
  for (const rep of reports) {
47
64
  totalCriteria += rep.rows.length;
48
65
  totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
49
66
  totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
50
67
  totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
68
+ totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
51
69
  }
52
70
  const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
53
- const jsonClean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
71
+ // Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
72
+ const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
54
73
 
55
74
  if (json) {
56
75
  console.log(JSON.stringify({
57
- specs: reports.map((rep) => ({ capability: rep.cap, criteria: rep.rows })),
58
- summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, pct: jsonPct },
76
+ specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
77
+ summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
59
78
  }, null, 2));
60
79
  return jsonClean ? 0 : strict ? 1 : 0;
61
80
  }
@@ -67,15 +86,19 @@ export async function run(_positional, flags) {
67
86
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
68
87
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
69
88
  const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
89
+ const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
70
90
 
71
91
  const notes = [];
72
92
  if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
73
93
  if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
94
+ if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
74
95
  const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
75
96
  console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
76
97
  for (const r of rep.rows) {
77
98
  if (r.kind === "covered") continue;
78
- if (r.kind === "bare") {
99
+ if (r.kind === "deferred") {
100
+ console.log(` ${c.gray("○")} #${r.n} deferred — spec declares "Implementation: planned — <why>" (visible, not gated)`);
101
+ } else if (r.kind === "bare") {
79
102
  console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
80
103
  } else if (r.kind === "conditional") {
81
104
  console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
@@ -88,64 +111,141 @@ export async function run(_positional, flags) {
88
111
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
89
112
  console.log("");
90
113
  const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
91
- const clean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
92
- if (clean) {
93
- console.log(c.green("ok") + " " + summary);
94
- return 0;
95
- }
114
+ const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
96
115
  const extras = [];
97
116
  if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
98
117
  if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
118
+ if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
99
119
  const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
120
+ if (clean) {
121
+ console.log(c.green("ok") + " " + summary + extraSummary);
122
+ return 0;
123
+ }
100
124
  console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + extraSummary);
101
125
  // A report by default (exit 0); a gate under --strict (exit 1 for CI). A
102
126
  // conditional criterion fails the gate too: a skipped test is not proof.
127
+ // Deferred criteria never fail: the deferral is declared in the spec.
103
128
  return strict ? 1 : 0;
104
129
  }
105
130
 
131
+ // --run: execute every unique cited-and-resolving evidence file through the
132
+ // project-declared runner. Declared in .doctrina/verify.json as
133
+ // "evidence_runner": "python -m pytest {file}"
134
+ // ({file} is replaced per file). Exit 1 when any run fails (or under --strict
135
+ // when nothing is runnable). Deferred specs are skipped like everywhere else.
136
+ function runEvidence(projectRoot, reports, strict) {
137
+ const cfgPath = path.join(projectRoot, ".doctrina", "verify.json");
138
+ let runner = null;
139
+ if (isFile(cfgPath)) {
140
+ try { runner = JSON.parse(read(cfgPath))?.evidence_runner ?? null; } catch { runner = null; }
141
+ }
142
+ if (!runner || typeof runner !== "string" || !runner.includes("{file}")) {
143
+ console.error(c.red("error:") + " coverage --run needs an \"evidence_runner\" in .doctrina/verify.json");
144
+ console.error(c.gray("hint: ") + `add e.g. "evidence_runner": "python -m pytest {file}" (the {file} placeholder is required — the CLI never guesses a test runner)`);
145
+ return 1;
146
+ }
147
+
148
+ // Unique evidence files: cited by a non-deferred criterion, resolving on
149
+ // disk, and test-shaped (running a cited source artifact proves nothing).
150
+ const files = new Set();
151
+ for (const rep of reports) {
152
+ for (const r of rep.rows) {
153
+ if (rep.deferred || r.kind === "deferred") continue;
154
+ for (const token of r.evidence ?? []) files.add(token);
155
+ }
156
+ }
157
+
158
+ if (files.size === 0) {
159
+ console.log(c.gray("no runnable evidence found (no non-deferred criterion cites a resolving test file)"));
160
+ return strict ? 1 : 0;
161
+ }
162
+
163
+ console.log(c.bold("coverage --run") + c.gray(` — executing ${files.size} evidence file${files.size === 1 ? "" : "s"} via: ${runner}`));
164
+ let failed = 0;
165
+ for (const file of [...files].sort()) {
166
+ const cmd = runner.replaceAll("{file}", file);
167
+ console.log("");
168
+ console.log(c.gray(`──── ${cmd}`));
169
+ const res = spawnSync(cmd, { cwd: projectRoot, shell: true, stdio: "inherit" });
170
+ const ok = !res.error && res.status === 0;
171
+ console.log(ok ? c.green(`✓ ${file}`) : c.red(`✗ ${file}${res.error ? ` — ${res.error.message}` : ` (exit ${res.status})`}`));
172
+ if (!ok) failed += 1;
173
+ }
174
+ console.log("");
175
+ if (failed === 0) {
176
+ console.log(c.green("ok") + ` ${files.size}/${files.size} evidence runs passed — cited proof actually proves`);
177
+ return 0;
178
+ }
179
+ console.log(c.red("fail") + ` ${files.size - failed}/${files.size} evidence runs passed — a cited proof does not pass`);
180
+ return 1;
181
+ }
182
+
106
183
  // Per-spec criterion rows — the full classification behind both the report
107
184
  // and the --json output. Each row: { n, kind, missing?, skipped? }.
108
- export function collect(projectRoot) {
185
+ //
186
+ // A spec that declares a deliberate deferral — `Implementation: planned —
187
+ // <why>`, the exact escape hatch `validate` already honours — has its
188
+ // non-covered criteria remapped to kind "deferred": visible in every report,
189
+ // never a --strict failure. Declared debt and hidden debt stop being punished
190
+ // identically (0.11.0 field review item 4: one deferred capability poisoned
191
+ // the close of every unrelated change).
192
+ export function collect(projectRoot, { only = null } = {}) {
109
193
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
110
194
  const reports = [];
111
195
  if (isDir(specsDir)) {
112
196
  for (const cap of readdirSync(specsDir).sort()) {
197
+ if (only && !only.has(cap)) continue;
113
198
  const specPath = path.join(specsDir, cap, "spec.md");
114
199
  if (!isFile(specPath)) continue;
115
- const criteria = extractAcceptanceCriteria(read(specPath));
200
+ const text = read(specPath);
201
+ const criteria = extractAcceptanceCriteria(text);
116
202
  if (criteria.length === 0) continue;
117
- const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
118
- reports.push({ cap, specPath, rows });
203
+ const deferred = isDeclaredDeferral(text);
204
+ const rows = criteria.map((crit, i) => {
205
+ const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
206
+ if (deferred && row.kind !== "covered") {
207
+ return { ...row, kind: "deferred", was: row.kind };
208
+ }
209
+ return row;
210
+ });
211
+ reports.push({ cap, specPath, rows, deferred });
119
212
  }
120
213
  }
121
214
  return reports;
122
215
  }
123
216
 
217
+ // The declared-deferral escape hatch, matching validate's two-axis check:
218
+ // Implementation is "planned" WITH an explanatory note after the state word.
219
+ // A bare "planned" is an inventory claim, not a deferral, and gets no pass.
220
+ function isDeclaredDeferral(specText) {
221
+ const implRaw = specHeader(specText, "Implementation");
222
+ if (!implRaw) return false;
223
+ const tokens = implRaw.trim().split(/\s+/);
224
+ const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
225
+ return word === "planned" && tokens.length > 1;
226
+ }
227
+
124
228
  // Pure summary of coverage across the spec tree, for other commands
125
229
  // (`status`, `review`) that need the numbers without the report output.
230
+ // Deferred criteria (declared deferral, see collect) are counted separately
231
+ // and excluded from the problem counts, matching the gate semantics.
126
232
  export function summarize(projectRoot) {
127
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
128
- let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0;
233
+ let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
129
234
  const perCap = [];
130
- if (isDir(specsDir)) {
131
- for (const cap of readdirSync(specsDir).sort()) {
132
- const specPath = path.join(specsDir, cap, "spec.md");
133
- if (!isFile(specPath)) continue;
134
- const criteria = extractAcceptanceCriteria(read(specPath));
135
- if (criteria.length === 0) continue;
136
- const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
137
- const covered = rows.filter((r) => r.kind === "covered").length;
138
- const dangling = rows.filter((r) => r.kind === "dangling").length;
139
- const conditional = rows.filter((r) => r.kind === "conditional").length;
140
- totalCriteria += rows.length;
141
- totalCovered += covered;
142
- totalDangling += dangling;
143
- totalConditional += conditional;
144
- perCap.push({ cap, total: rows.length, covered, dangling, conditional });
145
- }
235
+ for (const rep of collect(projectRoot)) {
236
+ const covered = rep.rows.filter((r) => r.kind === "covered").length;
237
+ const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
238
+ const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
239
+ const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
240
+ totalCriteria += rep.rows.length;
241
+ totalCovered += covered;
242
+ totalDangling += dangling;
243
+ totalConditional += conditional;
244
+ totalDeferred += deferred;
245
+ perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, deferred });
146
246
  }
147
247
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
148
- return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, pct };
248
+ return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
149
249
  }
150
250
 
151
251
  // Pull the numbered items out of the "## Acceptance criteria" section.
@@ -206,11 +306,14 @@ function classify(criterion, n, projectRoot, specDir) {
206
306
  resolved.push({ token, isTest, skipped });
207
307
  }
208
308
  if (resolved.length === 0) return { kind: "dangling", n, missing };
309
+ // Runnable evidence: the resolving test-shaped citations, recorded so
310
+ // `coverage --run` can execute them (existence → passing proof).
311
+ const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
209
312
  // Real proof = a non-test artifact, or a test file whose suite runs. If the
210
313
  // only thing that resolves is a skipped test, the criterion is conditional.
211
314
  const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
212
- if (hasRealProof) return { kind: "covered", n };
213
- return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
315
+ if (hasRealProof) return { kind: "covered", n, evidence };
316
+ return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [] };
214
317
  }
215
318
 
216
319
  // A cited path is a test file when it sits under a tests directory or carries
@@ -265,7 +368,7 @@ function looksLikePath(s) {
265
368
  }
266
369
 
267
370
  export const help = `
268
- Usage: doctrina coverage [--strict]
371
+ Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
269
372
 
270
373
  Report how many acceptance criteria across .doctrina/specs/ cite an
271
374
  artifact or test that exists on disk. Each numbered criterion may cite
@@ -275,10 +378,23 @@ A criterion is covered when a cited path resolves to real proof,
275
378
  conditional when its only resolving proof is a test file whose suite is
276
379
  skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
277
380
  nothing), dangling when a cited path is missing on disk, and bare when
278
- nothing is cited. Read-only.
381
+ nothing is cited. A spec that declares a deliberate deferral —
382
+ \`Implementation: planned — <why>\`, the same escape hatch validate
383
+ honours — has its unproven criteria reported as DEFERRED: visible, but
384
+ never a --strict failure (declared debt is not hidden debt).
279
385
 
280
386
  Flags:
281
- --strict Exit 1 when any criterion is bare, dangling, or conditional
282
- (CI gate). Without it the command always exits 0 (a report).
283
- --json Emit per-spec criterion rows + summary as JSON.
387
+ --strict Exit 1 when any criterion is bare, dangling, or
388
+ conditional (CI gate). Deferred never fails. Without
389
+ it the command always exits 0 (a report).
390
+ --only <cap,cap> Scope the report/gate to specific capabilities
391
+ (\`doctrina close\` uses this so an unrelated deferred
392
+ spec cannot block a change's close).
393
+ --run Execute the cited evidence via the project-declared
394
+ "evidence_runner" in .doctrina/verify.json (a command
395
+ template with a {file} placeholder, e.g.
396
+ "python -m pytest {file}"). Exits 1 when any run
397
+ fails — promotes "the file exists" to "the proof
398
+ passes". The CLI never guesses a test runner.
399
+ --json Emit per-spec criterion rows + summary as JSON.
284
400
  `;
@@ -0,0 +1,173 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
+ import { c } from "../lib/colors.js";
5
+ import { suggest } from "../lib/suggest.js";
6
+
7
+ // Post-intake intent evolution (0.11.0 field review item 8). The intake is
8
+ // converted once; capabilities born later — brainstorms, pivots, new asks —
9
+ // had no way to gain a product anchor, so they inevitably ended at
10
+ // `Realizes: n/a` and `trace` reported "14/14 ok" while blind to 40% of the
11
+ // system. `intent add` appends a new [SC*] anchor bullet to product.md's
12
+ // Success criteria (allocating the next number), so growth keeps provenance:
13
+ // new intent gets an anchor, the new spec Realizes it, `trace` sees it.
14
+ //
15
+ // The CLI allocates and appends; WHAT the intent says is the human/agent's
16
+ // words verbatim (ADR 0005).
17
+
18
+ const SUBCOMMANDS = ["add", "list"];
19
+
20
+ export async function run(positional, _flags) {
21
+ const sub = positional[0];
22
+ if (!SUBCOMMANDS.includes(sub)) {
23
+ console.error(c.red("error:") + ` unknown intent subcommand "${sub ?? ""}"`);
24
+ const guess = suggest(sub, SUBCOMMANDS);
25
+ console.error(c.gray("hint: ") + (guess
26
+ ? `did you mean \`doctrina intent ${guess}\`?`
27
+ : `available: ${SUBCOMMANDS.join(", ")}`));
28
+ return 2;
29
+ }
30
+ const projectRoot = process.cwd();
31
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
32
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
+ }
34
+ if (sub === "list") return intentList(projectRoot);
35
+ return intentAdd(projectRoot, positional.slice(1).join(" ").trim());
36
+ }
37
+
38
+ function productPathOf(projectRoot) {
39
+ return path.join(projectRoot, ".doctrina", "product.md");
40
+ }
41
+
42
+ // Every "[A-Z]+\d+" anchor at the head of a bullet, in document order —
43
+ // the same convention `trace` reads.
44
+ function collectAnchors(text) {
45
+ const out = [];
46
+ const seen = new Set();
47
+ const lines = text.split(/\r?\n/);
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+)(\d+)\]\s+(.*)$/);
50
+ if (m && !seen.has(m[1] + m[2])) {
51
+ seen.add(m[1] + m[2]);
52
+ out.push({ prefix: m[1], num: Number(m[2]), id: m[1] + m[2], text: m[3].trim(), line: i });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function intentList(projectRoot) {
59
+ const p = productPathOf(projectRoot);
60
+ if (!isFile(p)) {
61
+ console.error(c.red("error:") + " no .doctrina/product.md");
62
+ return 1;
63
+ }
64
+ const anchors = collectAnchors(read(p));
65
+ if (anchors.length === 0) {
66
+ console.log(c.gray("no intent anchors in product.md — tag bullets with `- [SC1] ...`, or add one: doctrina intent add \"<text>\""));
67
+ return 0;
68
+ }
69
+ console.log(c.bold("Intent anchors") + c.gray(" — product.md, document order:"));
70
+ console.log("");
71
+ for (const a of anchors) {
72
+ console.log(` ${c.cyan(a.id.padEnd(6))} ${a.text}`);
73
+ }
74
+ console.log("");
75
+ console.log(c.gray(`${anchors.length} anchor${anchors.length === 1 ? "" : "s"}. Provenance: doctrina trace · doctrina why <id>`));
76
+ return 0;
77
+ }
78
+
79
+ function intentAdd(projectRoot, textArg) {
80
+ if (!textArg) {
81
+ console.error(c.red("error:") + " intent add requires the intent text (quote it)");
82
+ console.error(c.gray("hint: ") + `example: doctrina intent add "Risk map answers 'what should we test next?' in one read"`);
83
+ return 2;
84
+ }
85
+ const p = productPathOf(projectRoot);
86
+ if (!isFile(p)) {
87
+ console.error(c.red("error:") + " no .doctrina/product.md — run `doctrina init` / `doctrina intake` first");
88
+ return 1;
89
+ }
90
+ const original = read(p);
91
+ const anchors = collectAnchors(original);
92
+
93
+ // Accept "SC15: text" to pin an explicit id; otherwise allocate the next
94
+ // number for the dominant prefix (SC by default).
95
+ let id = null;
96
+ let text = textArg;
97
+ const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
98
+ if (pinned) {
99
+ id = pinned[1] + pinned[2];
100
+ text = pinned[3].trim();
101
+ if (anchors.some((a) => a.id === id)) {
102
+ console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
103
+ return 1;
104
+ }
105
+ } else {
106
+ const prefix = anchors.length > 0 ? dominantPrefix(anchors) : "SC";
107
+ const maxNum = anchors.filter((a) => a.prefix === prefix).reduce((m, a) => Math.max(m, a.num), 0);
108
+ id = `${prefix}${maxNum + 1}`;
109
+ }
110
+
111
+ const lines = original.split(/\r?\n/);
112
+ const bullet = `- [${id}] ${text}`;
113
+
114
+ // Insertion point: after the LAST existing anchor bullet (keeps them
115
+ // contiguous); else at the end of "## Success criteria"; else appended as a
116
+ // new Success criteria section (a product.md without one gains it).
117
+ const samePrefix = anchors.length > 0;
118
+ let inserted = false;
119
+ if (samePrefix) {
120
+ const last = anchors[anchors.length - 1];
121
+ lines.splice(last.line + 1, 0, bullet);
122
+ inserted = true;
123
+ } else {
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (/^##\s+Success criteria\b/i.test(lines[i])) {
126
+ // Insert before the next "## " heading (or at EOF).
127
+ let j = i + 1;
128
+ while (j < lines.length && !/^##\s+/.test(lines[j])) j += 1;
129
+ // Trim trailing blank lines inside the section for a tidy append.
130
+ let k = j;
131
+ while (k > i + 1 && lines[k - 1].trim() === "") k -= 1;
132
+ lines.splice(k, 0, bullet);
133
+ inserted = true;
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ if (!inserted) {
139
+ lines.push("", "## Success criteria", "", bullet);
140
+ }
141
+
142
+ write(p, lines.join("\n"), { force: true });
143
+ console.log(c.green("added") + ` [${id}] to ${relPath(projectRoot, p)}`);
144
+ console.log("");
145
+ console.log(` ${c.cyan(id.padEnd(6))} ${text}`);
146
+ console.log("");
147
+ console.log(c.gray("Next: the spec that delivers this intent declares it —"));
148
+ console.log(` ${c.cyan(`**Realizes:** ${id}`)} ${c.gray("(in .doctrina/specs/<capability>/spec.md; `doctrina trace` closes the loop)")}`);
149
+ return 0;
150
+ }
151
+
152
+ function dominantPrefix(anchors) {
153
+ const counts = new Map();
154
+ for (const a of anchors) counts.set(a.prefix, (counts.get(a.prefix) ?? 0) + 1);
155
+ return [...counts.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
156
+ }
157
+
158
+ export const help = `
159
+ Usage: doctrina intent add "<text>"
160
+ doctrina intent add "SC15: <text>"
161
+ doctrina intent list
162
+
163
+ Post-intake intent evolution. \`add\` appends a new intent anchor bullet
164
+ (\`- [SC5] <text>\`) to product.md's Success criteria, allocating the next
165
+ number automatically (or pin one with the "SC15: ..." form). New
166
+ capabilities born after the intake — brainstorms, pivots — get an anchor
167
+ to Realize instead of inevitably landing at "Realizes: n/a", so
168
+ \`doctrina trace\` keeps seeing the whole system as it grows.
169
+
170
+ \`list\` prints every anchor in document order (the same convention trace
171
+ reads). The CLI allocates and appends; the intent's wording is yours,
172
+ verbatim (ADR 0005).
173
+ `;
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool, flagString } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "./work.js";
9
+ import { parseDependsOn } from "../lib/scan.js";
9
10
  import { summarize as coverageSummary } from "./coverage.js";
10
11
  import { summarize as traceSummary } from "./trace.js";
11
12
 
@@ -42,18 +43,38 @@ export async function run(_positional, flags) {
42
43
  const notes = []; // advisory observations
43
44
 
44
45
  // 1. Code changed under a capability whose spec was NOT touched — the spec
45
- // may have drifted from the code it describes.
46
+ // may have drifted from the code it describes. Touched = every capability
47
+ // matched by the code diff (UNCAPPED — the top-3 hint truncation is how
48
+ // this missed 5 of 8 in the 0.11.0 field session) UNIONED with every
49
+ // capability whose spec.md itself changed.
46
50
  const specChanged = new Set(
47
51
  all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
48
52
  .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
49
53
  );
50
- const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles);
54
+ const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
55
+ const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
51
56
  for (const m of ranked) {
52
57
  if (!specChanged.has(m.id)) {
53
58
  breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
54
59
  }
55
60
  }
56
61
 
62
+ // 1b. Dependents of touched capabilities (the machine-readable
63
+ // **Depends on:** header): a spec that builds on something you changed
64
+ // may silently no longer hold. Advisory — a pointer, not a verdict.
65
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
66
+ if (isDir(specsDir) && touched.size > 0) {
67
+ for (const cap of readdirSync(specsDir).sort()) {
68
+ if (touched.has(cap)) continue;
69
+ const specPath = path.join(specsDir, cap, "spec.md");
70
+ if (!isFile(specPath)) continue;
71
+ const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
72
+ if (deps.length > 0) {
73
+ notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
74
+ }
75
+ }
76
+ }
77
+
57
78
  // 2. New source files that map to no capability at all — code with no home
58
79
  // in any spec (a likely new, unspecced capability).
59
80
  if (ranked.length === 0 && sourceFiles.length > 0) {
@@ -83,8 +104,8 @@ export async function run(_positional, flags) {
83
104
 
84
105
  // --- Output ---
85
106
  console.log(c.gray(`${all.length} changed path(s); ${sourceFiles.length} source file(s) outside .doctrina/.`));
86
- if (ranked.length > 0) {
87
- console.log(c.gray("Capabilities touched: ") + ranked.map((m) => c.cyan(m.id)).join(", "));
107
+ if (touched.size > 0) {
108
+ console.log(c.gray("Capabilities touched: ") + [...touched].sort().map((id) => c.cyan(id)).join(", "));
88
109
  }
89
110
  console.log("");
90
111
 
@@ -61,9 +61,23 @@ function skillSuggest(args, flags) {
61
61
  const since = flagString(flags, "since");
62
62
 
63
63
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
64
- const existing = new Set(
65
- isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")).map((f) => path.basename(f, ".md")) : [],
66
- );
64
+ const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
65
+ const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
66
+ // A lesson may already be captured under a DIFFERENT slug (0.11.0 field
67
+ // review item 11: the human names the skill after the lesson, not after the
68
+ // change id — "tela-preta-crash-de-render..." for change 0049). Two extra
69
+ // dedup signals: an existing skill whose body cites the candidate's change
70
+ // id / commit hash, and token-overlap similarity between the slugs.
71
+ const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
72
+ const captured = (cand) => {
73
+ if (existing.has(cand.slug)) return true;
74
+ for (const sk of existingBodies) {
75
+ if (cand.id && sk.body.includes(cand.id)) return true;
76
+ if (cand.from && sk.body.includes(cand.from)) return true;
77
+ if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
78
+ }
79
+ return false;
80
+ };
67
81
 
68
82
  // Two deterministic sources, deduped by slug. `seen` guards against a lesson
69
83
  // appearing twice (e.g. a fix committed and later archived as a change).
@@ -78,18 +92,20 @@ function skillSuggest(args, flags) {
78
92
  const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
79
93
  if (!FIX_SHAPED.test(id)) continue;
80
94
  const slug = skillSlug(id);
81
- if (existing.has(slug) || seen.has(slug)) continue; // captured or already queued
95
+ if (seen.has(slug)) continue;
96
+ const cand = { id, slug, source: "change", from: name };
97
+ if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
82
98
  const proposal = path.join(archiveDir, name, "proposal.md");
83
- const why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
99
+ cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
84
100
  seen.add(slug);
85
- candidates.push({ id, slug, why, source: "change", from: name });
101
+ candidates.push(cand);
86
102
  }
87
103
  }
88
104
 
89
105
  // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
90
106
  // slug collision: a `## Why` paragraph is a richer seed than a subject line.
91
107
  for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
92
- if (existing.has(cand.slug) || seen.has(cand.slug)) continue;
108
+ if (seen.has(cand.slug) || captured(cand)) continue;
93
109
  seen.add(cand.slug);
94
110
  candidates.push(cand);
95
111
  }
@@ -216,6 +232,29 @@ function skillSlug(id) {
216
232
  return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
217
233
  }
218
234
 
235
+ // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
236
+ // tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
237
+ // error-boundary" share tela/preta/render → high overlap even though the
238
+ // human renamed the skill after the lesson. Deterministic; never semantic.
239
+ function slugSimilarity(a, b) {
240
+ const toks = (s) => new Set(
241
+ String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
242
+ );
243
+ const A = toks(a);
244
+ const B = toks(b);
245
+ if (A.size === 0 || B.size === 0) return 0;
246
+ let inter = 0;
247
+ for (const t of A) if (B.has(t)) inter += 1;
248
+ return inter / (A.size + B.size - inter);
249
+ }
250
+
251
+ // Fix-shaped verbs and connectives carry no lesson identity — without this,
252
+ // every "fix-..." candidate looks 30% similar to every "fix-..." skill.
253
+ const SLUG_NOISE = new Set([
254
+ "fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
255
+ "que", "nao", "por", "para", "una", "uma", "dos", "das",
256
+ ]);
257
+
219
258
  function firstWhyLine(proposalText) {
220
259
  const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
221
260
  if (!m) return "";