doctrina-cli 0.4.0 → 0.8.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
+ import { readdirSync } from "node:fs";
3
4
  import { spawnSync } from "node:child_process";
4
- import { exists, isFile, read, write, relPath } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
5
+ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
6
+ import { flagBool, flagString } from "../lib/args.js";
7
+ import { today } from "../lib/dates.js";
6
8
  import { c } from "../lib/colors.js";
7
9
 
8
10
  // The build/verify gate `validate` is not. `validate` checks the shape of
@@ -14,6 +16,7 @@ import { c } from "../lib/colors.js";
14
16
  // `.doctrina/verify.json`, and `verify` runs exactly that, in order.
15
17
 
16
18
  const CONFIG_REL = ".doctrina/verify.json";
19
+ const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
17
20
 
18
21
  const STARTER = {
19
22
  $comment:
@@ -32,6 +35,17 @@ export async function run(_positional, flags) {
32
35
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
36
  }
34
37
 
38
+ // --clean is the reproducibility lint (review G4): `verify` runs the
39
+ // declared checks against the *current* disk, so a repo that only works
40
+ // because a previous run generated a Prisma client or built a workspace's
41
+ // dist still passes — "verify green" ≠ "passes from a clean checkout".
42
+ // This is the static half of that gap: it flags the footguns that make a
43
+ // fresh `npm install` differ from the dirty machine, without doing a real
44
+ // (slow, package-manager-specific) clean install.
45
+ if (flagBool(flags, "clean", false)) {
46
+ return reproducibilityLint(projectRoot);
47
+ }
48
+
35
49
  const configPath = path.join(projectRoot, CONFIG_REL);
36
50
 
37
51
  // --init scaffolds a starter config so the gate is one edit away.
@@ -68,8 +82,11 @@ export async function run(_positional, flags) {
68
82
  return 1;
69
83
  }
70
84
  for (const ch of checks) {
71
- if (!ch || typeof ch.run !== "string" || ch.run.trim() === "") {
72
- console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string`);
85
+ // A "manual" check is the qualitative gate (review 2026-06-27 feature #7):
86
+ // a DoD item judged by a human/eval, recorded as a sign-off, not run as a
87
+ // command — so it needs no `run`. Every other check still needs one.
88
+ if (!ch || (ch.type !== "manual" && (typeof ch.run !== "string" || ch.run.trim() === ""))) {
89
+ console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string (or "type": "manual")`);
73
90
  return 1;
74
91
  }
75
92
  }
@@ -78,21 +95,69 @@ export async function run(_positional, flags) {
78
95
  return 1;
79
96
  }
80
97
 
98
+ // Sign-off store for manual checks: { "<check name>": { date, note } }.
99
+ const signoffPath = path.join(projectRoot, SIGNOFF_REL);
100
+ let signoffs = {};
101
+ if (isFile(signoffPath)) {
102
+ try { signoffs = JSON.parse(read(signoffPath)) ?? {}; } catch { signoffs = {}; }
103
+ }
104
+
105
+ // --signoff "<name>=<note>" records a manual check's human/eval sign-off
106
+ // (date stamped today) and exits. The name must be a declared manual check.
107
+ const signoffArg = flagString(flags, "signoff");
108
+ if (signoffArg !== undefined) {
109
+ const eq = String(signoffArg).indexOf("=");
110
+ const name = (eq >= 0 ? signoffArg.slice(0, eq) : signoffArg).trim();
111
+ const note = eq >= 0 ? signoffArg.slice(eq + 1).trim() : "";
112
+ const target = checks.find((ch) => (ch.name ?? "") === name && ch.type === "manual");
113
+ if (!target) {
114
+ console.error(c.red("error:") + ` no manual check named "${name}" in ${CONFIG_REL}`);
115
+ const manualNames = checks.filter((ch) => ch.type === "manual").map((ch) => ch.name);
116
+ console.error(c.gray("hint: ") + (manualNames.length
117
+ ? `manual checks: ${manualNames.join(", ")}`
118
+ : `add one: { "name": "${name || "quality"}", "type": "manual", "rubric": "<the question>" }`));
119
+ return 1;
120
+ }
121
+ signoffs[name] = { date: today(), note };
122
+ write(signoffPath, JSON.stringify(signoffs, null, 2) + "\n", { force: true });
123
+ console.log(c.green("signed off") + ` ${name} on ${today()}${note ? `: ${note}` : ""}`);
124
+ return 0;
125
+ }
126
+
81
127
  // --list prints the configured checks without running them.
82
128
  if (flagBool(flags, "list", false)) {
83
129
  console.log(c.bold("Verify checks") + c.gray(` (from ${CONFIG_REL}):`));
84
130
  console.log("");
85
131
  checks.forEach((ch, i) => {
86
- console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${c.gray(ch.run)}`);
132
+ const what = ch.type === "manual" ? c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`) : c.gray(ch.run);
133
+ console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${what}`);
87
134
  });
88
135
  return 0;
89
136
  }
90
137
 
138
+ const strict = flagBool(flags, "strict", false);
91
139
  console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
92
140
  const results = [];
93
141
  for (const ch of checks) {
94
142
  const name = ch.name ?? "check";
95
143
  console.log("");
144
+
145
+ // Manual (qualitative) check: pass if signed off, else pending. Pending is
146
+ // non-blocking by default (a report) and fails only under --strict, so CI
147
+ // can require the human/eval sign-off without blocking local runs.
148
+ if (ch.type === "manual") {
149
+ console.log(c.gray(`──── ${name}: `) + c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`));
150
+ const so = signoffs[name];
151
+ if (so && so.date) {
152
+ console.log(c.green(`✓ ${name}`) + c.gray(` — signed off ${so.date}${so.note ? `: ${so.note}` : ""}`));
153
+ results.push({ name, ok: true, kind: "manual" });
154
+ } else {
155
+ console.log(c.yellow(`○ ${name} — pending sign-off`) + c.gray(` (doctrina verify --signoff "${name}=<note>")`));
156
+ results.push({ name, ok: false, kind: "manual", pending: true });
157
+ }
158
+ continue;
159
+ }
160
+
96
161
  const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
97
162
  console.log(c.gray(`──── ${name}: `) + ch.run + where);
98
163
  // Run through the shell so declared commands can use pipes, &&, env,
@@ -113,41 +178,197 @@ export async function run(_positional, flags) {
113
178
  } else {
114
179
  console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
115
180
  }
116
- results.push({ name, ok });
181
+ results.push({ name, ok, kind: "command" });
117
182
  // Fail fast is tempting, but running every check surfaces all breakage
118
183
  // in one pass — more useful for an agent fixing the gap.
119
184
  }
120
185
 
186
+ const cmdFailed = results.filter((r) => r.kind === "command" && !r.ok);
187
+ const pending = results.filter((r) => r.pending);
121
188
  const passed = results.filter((r) => r.ok).length;
122
189
  console.log("");
123
- if (passed === results.length) {
124
- console.log(c.green("ok") + ` ${passed}/${results.length} checks passed`);
190
+ // The gate fails on any failed command check, or (only under --strict) any
191
+ // manual check still awaiting sign-off. Pending manual checks are otherwise
192
+ // a non-blocking report.
193
+ const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
194
+ const pendNote = pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "";
195
+ if (!failGate) {
196
+ console.log(c.green("ok") + ` ${passed}/${results.length} checks passed${pendNote}`);
197
+ return 0;
198
+ }
199
+ const reasons = [];
200
+ if (cmdFailed.length > 0) reasons.push(`failed: ${cmdFailed.map((r) => r.name).join(", ")}`);
201
+ if (strict && pending.length > 0) reasons.push(`pending sign-off: ${pending.map((r) => r.name).join(", ")}`);
202
+ console.log(c.red("fail") + ` ${passed}/${results.length} checks passed — ${reasons.join("; ")}`);
203
+ return 1;
204
+ }
205
+
206
+ // Static reproducibility lint: walk the project's package.json files and
207
+ // report the two "works on my machine" footguns the review hit — a package
208
+ // whose entry points live in a build-output dir with nothing that builds it
209
+ // on install, and a codegen dependency (Prisma) with no install hook that
210
+ // generates. Returns 0 when clean, 1 when any risk is found. Pure read.
211
+ function reproducibilityLint(projectRoot) {
212
+ const pkgPaths = findPackageJsons(projectRoot);
213
+ console.log(c.bold("doctrina verify --clean") + c.gray(" — reproducibility lint (static, not a real fresh install)"));
214
+ console.log("");
215
+
216
+ if (pkgPaths.length === 0) {
217
+ console.log(c.gray("no package.json found — nothing this lint can check (it knows Node/npm footguns)"));
218
+ return 0;
219
+ }
220
+
221
+ const findings = [];
222
+ for (const pkgPath of pkgPaths) {
223
+ const rel = relPath(projectRoot, pkgPath);
224
+ let pkg;
225
+ try {
226
+ pkg = JSON.parse(read(pkgPath));
227
+ } catch (err) {
228
+ findings.push(`${rel}: not valid JSON (${err.message})`);
229
+ continue;
230
+ }
231
+ const scripts = pkg.scripts ?? {};
232
+ const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
233
+
234
+ // 1. Entry point into a build output with nothing to build it on install.
235
+ const built = entryIntoBuildDir(pkg);
236
+ if (built && !hasInstallBuild) {
237
+ findings.push(
238
+ `${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
239
+ `script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
240
+ );
241
+ }
242
+
243
+ // 2. Codegen dependency with no install hook that generates.
244
+ const deps = {
245
+ ...pkg.dependencies, ...pkg.devDependencies,
246
+ ...pkg.peerDependencies, ...pkg.optionalDependencies,
247
+ };
248
+ for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
249
+ if (!(dep in deps)) continue;
250
+ const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
251
+ if (!gen.test(installHook)) {
252
+ findings.push(
253
+ `${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
254
+ `(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
255
+ );
256
+ }
257
+ }
258
+ }
259
+
260
+ if (findings.length === 0) {
261
+ console.log(c.green("ok") + ` ${pkgPaths.length} package.json file${pkgPaths.length === 1 ? "" : "s"} — no reproducibility risks found`);
125
262
  return 0;
126
263
  }
127
- const failed = results.filter((r) => !r.ok).map((r) => r.name).join(", ");
128
- console.log(c.red("fail") + ` ${passed}/${results.length} checks passed — failed: ${failed}`);
264
+ for (const f of findings) console.log(` ${c.yellow("!")} ${f}`);
265
+ console.log("");
266
+ console.log(c.red("fail") + ` ${findings.length} reproducibility risk${findings.length === 1 ? "" : "s"} — a clean checkout may not build`);
129
267
  return 1;
130
268
  }
131
269
 
270
+ // Known codegen dependencies → the command an install hook must run so a
271
+ // fresh install produces their generated output.
272
+ const CODEGEN_DEPS = {
273
+ "@prisma/client": /prisma\s+generate/,
274
+ "prisma": /prisma\s+generate/,
275
+ };
276
+
277
+ const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
278
+
279
+ // The first entry-point field that points into a build-output directory, or
280
+ // null. Scans main/module/types/typings, bin (string or map), and exports
281
+ // (recursively, string leaves only).
282
+ function entryIntoBuildDir(pkg) {
283
+ for (const field of ["main", "module", "types", "typings"]) {
284
+ if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
285
+ return { field, value: pkg[field] };
286
+ }
287
+ }
288
+ if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
289
+ if (pkg.bin && typeof pkg.bin === "object") {
290
+ for (const v of Object.values(pkg.bin)) {
291
+ if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
292
+ }
293
+ }
294
+ const fromExports = scanExports(pkg.exports);
295
+ if (fromExports) return { field: "exports", value: fromExports };
296
+ return null;
297
+ }
298
+
299
+ function scanExports(node) {
300
+ if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
301
+ if (node && typeof node === "object") {
302
+ for (const v of Object.values(node)) {
303
+ const hit = scanExports(v);
304
+ if (hit) return hit;
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+
310
+ // Bounded walk for package.json files: skip dependency, build, and VCS
311
+ // directories so the lint stays fast and ignores vendored manifests.
312
+ const LINT_SKIP_DIRS = new Set([
313
+ ".git", "node_modules", "dist", "build", "out", "vendor", ".next",
314
+ "coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
315
+ ]);
316
+
317
+ function findPackageJsons(projectRoot) {
318
+ const found = [];
319
+ const stack = [projectRoot];
320
+ while (stack.length) {
321
+ const dir = stack.pop();
322
+ let entries;
323
+ try {
324
+ entries = readdirSync(dir, { withFileTypes: true });
325
+ } catch {
326
+ continue;
327
+ }
328
+ for (const entry of entries) {
329
+ const full = path.join(dir, entry.name);
330
+ if (entry.isDirectory()) {
331
+ if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
332
+ } else if (entry.name === "package.json") {
333
+ found.push(full);
334
+ }
335
+ }
336
+ }
337
+ return found.sort();
338
+ }
339
+
132
340
  export const help = `
133
- Usage: doctrina verify [--list] [--init] [--force]
341
+ Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
134
342
 
135
343
  Run the project-declared build/verify checks from ${CONFIG_REL} in order,
136
344
  streaming their output, and exit non-zero if any check fails. This is the
137
345
  real "does the code work" gate, separate from the structural \`validate\`.
138
346
 
139
347
  ${CONFIG_REL} shape (an optional per-check "cwd", relative to the project
140
- root, targets a sub-package in a monorepo):
348
+ root, targets a sub-package in a monorepo; a "manual" check is the
349
+ qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
141
350
  {
142
351
  "checks": [
143
352
  { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
144
353
  { "name": "test", "run": "npm test" },
145
- { "name": "build", "run": "npm run build" }
354
+ { "name": "build", "run": "npm run build" },
355
+ { "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
146
356
  ]
147
357
  }
148
358
 
359
+ A manual check passes when it has been signed off and is otherwise reported
360
+ as "pending" — non-blocking by default, failing only under --strict (so CI
361
+ can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
362
+
149
363
  Flags:
150
364
  --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
151
365
  --list Print the configured checks without running them
366
+ --clean Don't run checks; lint the project's package.json files for
367
+ reproducibility footguns (entry points in a build dir with no
368
+ prepare/prepack; a Prisma dep with no generate on install) so
369
+ "verify green" can't hide a clean checkout that won't build.
370
+ --strict Fail the gate when a manual check is still pending sign-off
371
+ --signoff "<name>=<note>"
372
+ Record today's sign-off for a manual check, then exit
152
373
  --force With --init, overwrite an existing config
153
374
  `;
@@ -0,0 +1,97 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { watch as fsWatch } from "node:fs";
4
+ import { exists } from "../lib/fs-ops.js";
5
+ import { flagBool } from "../lib/args.js";
6
+ import { c } from "../lib/colors.js";
7
+ import * as validate from "./validate.js";
8
+ import * as next from "./next.js";
9
+
10
+ // Continuous keep-in-sync (review 2026-06-27 passive-user feature #4): re-run
11
+ // `validate --fix` (heal drift, migrate the stamp) and reprint `doctrina next`
12
+ // whenever an artifact changes, so the index stays synced and the agent stays
13
+ // oriented without anyone invoking a command. Passive by construction. The
14
+ // pass is debounced and ignores writes to index.json (which `--fix` itself
15
+ // makes) so it never loops on its own output. `--once` runs a single pass and
16
+ // exits — the testable, scriptable form.
17
+
18
+ export async function run(_positional, flags) {
19
+ const projectRoot = process.cwd();
20
+ const dot = path.join(projectRoot, ".doctrina");
21
+ if (!exists(dot)) {
22
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
23
+ }
24
+
25
+ if (flagBool(flags, "once", false)) {
26
+ return pass(projectRoot);
27
+ }
28
+
29
+ console.log(c.bold("doctrina watch") + c.gray(" — validate --fix + next on every change. Ctrl-C to stop."));
30
+ await pass(projectRoot);
31
+
32
+ let timer = null;
33
+ let running = false;
34
+ const schedule = () => {
35
+ if (timer) clearTimeout(timer);
36
+ timer = setTimeout(async () => {
37
+ if (running) return;
38
+ running = true;
39
+ console.log("");
40
+ console.log(c.gray(`──── ${new Date().toLocaleTimeString()} change detected`));
41
+ await pass(projectRoot);
42
+ running = false;
43
+ }, 300);
44
+ };
45
+
46
+ let watcher;
47
+ try {
48
+ watcher = fsWatch(dot, { recursive: true }, (_event, filename) => {
49
+ const name = (filename ?? "").replace(/\\/g, "/");
50
+ // Ignore the index the fix itself rewrites (would self-trigger forever).
51
+ if (name.endsWith("index.json")) return;
52
+ schedule();
53
+ });
54
+ } catch (err) {
55
+ console.error(c.red("error:") + ` could not start the watcher: ${err.message}`);
56
+ return 1;
57
+ }
58
+
59
+ // Keep the process alive until interrupted.
60
+ return await new Promise((resolve) => {
61
+ const stop = () => {
62
+ if (watcher) watcher.close();
63
+ console.log("");
64
+ console.log(c.gray("watch stopped."));
65
+ resolve(0);
66
+ };
67
+ process.on("SIGINT", stop);
68
+ process.on("SIGTERM", stop);
69
+ });
70
+ }
71
+
72
+ // One pass: heal drift, then reprint the next actions. Never throws.
73
+ async function pass(projectRoot) {
74
+ try {
75
+ await validate.run([], new Map([["fix", true]]));
76
+ console.log("");
77
+ await next.run([], new Map());
78
+ } catch (err) {
79
+ console.error(c.red("error:") + ` ${err.message}`);
80
+ return 1;
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ export const help = `
86
+ Usage: doctrina watch [--once]
87
+
88
+ Watch the .doctrina/ tree and, on every change, run \`doctrina validate
89
+ --fix\` (heal index drift, migrate the framework stamp) and reprint
90
+ \`doctrina next\` — so the index stays in sync and the agent stays oriented
91
+ without anyone invoking a command. Debounced; ignores the index.json the
92
+ fix itself rewrites. Runs until interrupted (Ctrl-C).
93
+
94
+ Options:
95
+ --once Run a single pass (validate --fix + next) and exit, instead of
96
+ watching. The scriptable / testable form.
97
+ `;
@@ -0,0 +1,182 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
5
+ import { specHeader, listHeader } from "../lib/scan.js";
6
+ import { c } from "../lib/colors.js";
7
+ import { suggest } from "../lib/suggest.js";
8
+
9
+ // Provenance explainer (review 2026-06-27 passive-user feature #5): answer
10
+ // "why does this capability exist / why was it built this way?" by assembling
11
+ // the chain the framework already records — product intent (the [SC1] anchors
12
+ // it Realizes) → the capability's purpose → the acceptance criteria that prove
13
+ // it → the accepted ADRs that decided it — into one read. The human (or agent)
14
+ // asks; the tool answers from the artifacts, instead of grepping by hand.
15
+ // Read-only. Builds on the Realizes:/trace provenance (ADR 0006/0011).
16
+
17
+ export async function run(positional, _flags) {
18
+ const cap = positional[0];
19
+ const projectRoot = process.cwd();
20
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
21
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
22
+ }
23
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
24
+ const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
25
+
26
+ if (!cap) {
27
+ console.error(c.red("error:") + " why requires a <capability>");
28
+ if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
29
+ return 2;
30
+ }
31
+ const specPath = path.join(specsDir, cap, "spec.md");
32
+ if (!isFile(specPath)) {
33
+ console.error(c.red("error:") + ` no spec for capability "${cap}"`);
34
+ const guess = suggest(cap, known);
35
+ if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
36
+ else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
37
+ return 1;
38
+ }
39
+
40
+ const text = read(specPath);
41
+ console.log(c.bold(`Why "${cap}"`) + c.gray(" — provenance chain (intent → capability → proof → decisions)"));
42
+ console.log("");
43
+
44
+ // 1. Product intent it realizes.
45
+ const realizesRaw = specHeader(text, "Realizes");
46
+ const anchorIds = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : [];
47
+ console.log(c.bold(" Product intent"));
48
+ if (realizesRaw && /^n\/a\b/i.test(realizesRaw.trim())) {
49
+ console.log(` ${c.gray("n/a — " + realizesRaw.trim().replace(/^n\/a\s*[—-]?\s*/i, ""))}`);
50
+ } else if (anchorIds.length === 0) {
51
+ console.log(` ${c.yellow("none declared")} ${c.gray("— add a **Realizes:** header (doctrina trace)")}`);
52
+ } else {
53
+ const anchors = productAnchors(projectRoot);
54
+ for (const id of anchorIds) {
55
+ const line = anchors.get(id);
56
+ console.log(` ${c.cyan(id)} ${line ? line : c.yellow("(no matching anchor in product.md)")}`);
57
+ }
58
+ }
59
+
60
+ // 2. The capability itself.
61
+ console.log("");
62
+ console.log(c.bold(" Capability"));
63
+ const status = specHeader(text, "Status") ?? "—";
64
+ const impl = specHeader(text, "Implementation") ?? "—";
65
+ console.log(` ${c.gray("status:")} ${status} ${c.gray("implementation:")} ${impl}`);
66
+ const purpose = sectionParagraph(text, "Purpose");
67
+ if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
68
+
69
+ // 3. The proof (acceptance criteria with cited evidence).
70
+ console.log("");
71
+ console.log(c.bold(" Proof"));
72
+ const criteria = acceptanceWithEvidence(text);
73
+ if (criteria.length === 0) {
74
+ console.log(` ${c.gray("no acceptance criteria declared")}`);
75
+ } else {
76
+ for (const cr of criteria) {
77
+ const proof = cr.proof.length ? c.gray(cr.proof.map((p) => `\`${p}\``).join(", ")) : c.yellow("no evidence cited");
78
+ console.log(` ${cr.mark} #${cr.n} ${proof}`);
79
+ }
80
+ }
81
+
82
+ // 4. The decisions that shaped it (accepted ADRs naming the capability).
83
+ console.log("");
84
+ console.log(c.bold(" Decisions"));
85
+ const adrs = decisionsMentioning(projectRoot, cap);
86
+ if (adrs.length === 0) {
87
+ console.log(` ${c.gray("no accepted ADR names this capability")}`);
88
+ } else {
89
+ for (const a of adrs) console.log(` ${c.cyan("ADR " + a.id)} ${a.title}`);
90
+ }
91
+
92
+ return 0;
93
+ }
94
+
95
+ // Map of anchor id -> the product.md bullet text that declares it.
96
+ function productAnchors(projectRoot) {
97
+ const out = new Map();
98
+ const p = path.join(projectRoot, ".doctrina", "product.md");
99
+ if (!isFile(p)) return out;
100
+ for (const line of read(p).split(/\r?\n/)) {
101
+ const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+(.+)$/);
102
+ if (m && !out.has(m[1])) out.set(m[1], m[2].trim());
103
+ }
104
+ return out;
105
+ }
106
+
107
+ // First non-comment paragraph under a "## <name>" heading.
108
+ function sectionParagraph(text, name) {
109
+ const lines = text.split(/\r?\n/);
110
+ let inSection = false;
111
+ const buf = [];
112
+ for (const line of lines) {
113
+ if (/^##\s+/.test(line)) {
114
+ if (inSection) break;
115
+ if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
116
+ continue;
117
+ }
118
+ if (!inSection) continue;
119
+ if (/^\s*<!--/.test(line) || /-->/.test(line)) continue;
120
+ if (line.trim() === "") {
121
+ if (buf.length) break;
122
+ continue;
123
+ }
124
+ buf.push(line.trim());
125
+ }
126
+ return buf.join(" ");
127
+ }
128
+
129
+ // Numbered acceptance criteria with their [mark] and any backtick proof paths.
130
+ function acceptanceWithEvidence(text) {
131
+ const lines = text.split(/\r?\n/);
132
+ let inSection = false;
133
+ const out = [];
134
+ for (const line of lines) {
135
+ if (/^##\s+/.test(line)) {
136
+ inSection = /^##\s+Acceptance criteria\b/i.test(line);
137
+ continue;
138
+ }
139
+ if (!inSection) continue;
140
+ const m = line.match(/^\s*(\d+)\.\s+(.*)$/);
141
+ if (!m) continue;
142
+ const body = m[2];
143
+ const markMatch = body.match(/^\[([^\]]+)\]/);
144
+ const mark = markMatch
145
+ ? (/^verified/i.test(markMatch[1]) ? c.green("✓") : c.yellow("○"))
146
+ : c.gray("·");
147
+ const proof = [...body.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter((s) => s.includes("/") || /\.[a-z0-9]{1,8}$/i.test(s));
148
+ out.push({ n: m[1], mark, proof });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // Accepted ADRs whose body names the capability (word-ish boundary).
154
+ function decisionsMentioning(projectRoot, cap) {
155
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
156
+ const out = [];
157
+ if (!isDir(adrDir)) return out;
158
+ const re = new RegExp(`\\b${cap.replace(/[-]/g, "[- ]?")}\\b`, "i");
159
+ for (const f of readdirSync(adrDir).sort()) {
160
+ const m = f.match(/^(\d{4})-.*\.md$/);
161
+ if (!m) continue;
162
+ const text = read(path.join(adrDir, f));
163
+ const status = (listHeader(text, "Status") ?? "").toLowerCase();
164
+ if (status !== "accepted") continue;
165
+ if (!re.test(text)) continue;
166
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
167
+ out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
168
+ }
169
+ return out;
170
+ }
171
+
172
+ export const help = `
173
+ Usage: doctrina why <capability>
174
+
175
+ Explain a capability's provenance by assembling the chain Doctrina already
176
+ records into one read: the product intent it **Realizes:** ([SC1] anchors),
177
+ the capability's purpose and status, the acceptance criteria that prove it
178
+ (with cited evidence), and the accepted ADRs that name it. Read-only.
179
+
180
+ Builds on the intent provenance (ADR 0006/0011) — answer "why was X built,
181
+ and built this way?" without grepping the tree by hand.
182
+ `;