doctrina-cli 0.13.0 → 0.14.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 (66) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +66 -19
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +14 -2
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +16 -6
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +8 -1
  34. package/src/commands/validate.js +58 -2
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +8 -1
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +1 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +239 -26
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +94 -9
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/AGENTS.md.template +59 -53
@@ -0,0 +1,83 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { EXIT } from "./exit-codes.js";
4
+
5
+ // `--json` on every command, with a versioned envelope (audit item M7).
6
+ //
7
+ // Five of thirty-five commands spoke JSON. Everything else emitted
8
+ // ANSI-coloured prose, while the primary consumer is a machine. Paired with
9
+ // the exit-code contract (ADR 0018), the two form the machine interface:
10
+ // structured output plus a meaningful status. Without both, an autonomous
11
+ // loop is parsing English.
12
+ //
13
+ // Two levels of support, and the difference is stated rather than hidden:
14
+ //
15
+ // STRUCTURED — the command builds a payload describing its result. These
16
+ // are the read and report commands, where a result has real shape.
17
+ // ENVELOPE — the command's human output is captured and returned inside
18
+ // a versioned envelope alongside `ok` and `exitCode`. Branch on those;
19
+ // the lines are there for completeness, not for parsing.
20
+ //
21
+ // A command with nothing better to say is still machine-consumable, which
22
+ // is what makes "--json on every command" a claim rather than an intention.
23
+
24
+ export const JSON_SCHEMA_VERSION = "1.0.0";
25
+
26
+ export function wantsJson(flags) {
27
+ return flags?.get?.("json") === true;
28
+ }
29
+
30
+ // Print a structured payload. `command` is the invocation, `data` whatever
31
+ // that command has to say.
32
+ export function emitJson(command, data, { ok = true, exitCode = EXIT.OK } = {}) {
33
+ process.stdout.write(JSON.stringify({
34
+ $schema_version: JSON_SCHEMA_VERSION,
35
+ command,
36
+ ok,
37
+ exit_code: exitCode,
38
+ ...data,
39
+ }, null, 2) + "\n");
40
+ }
41
+
42
+ // Run `fn` with console output captured, so a command that has no
43
+ // structured payload can still answer in JSON. Returns { code, stdout,
44
+ // stderr } with the lines it would have printed.
45
+ export async function captureOutput(fn) {
46
+ const stdout = [];
47
+ const stderr = [];
48
+ const origLog = console.log;
49
+ const origError = console.error;
50
+ const origWarn = console.warn;
51
+ // Some commands write to the stream directly rather than through console
52
+ // — `completion` emits a shell script meant for redirection. Capturing
53
+ // only console.* let that output escape the envelope and corrupt the
54
+ // JSON on stdout, so the raw stream is captured too.
55
+ const origWrite = process.stdout.write.bind(process.stdout);
56
+ const pushLines = (target, chunk) => {
57
+ for (const line of String(chunk).replace(/\n$/, "").split("\n")) target.push(line);
58
+ };
59
+ console.log = (...args) => stdout.push(args.map(String).join(" "));
60
+ console.error = (...args) => stderr.push(args.map(String).join(" "));
61
+ console.warn = (...args) => stderr.push(args.map(String).join(" "));
62
+ process.stdout.write = (chunk, ...rest) => {
63
+ pushLines(stdout, chunk);
64
+ const cb = rest.find((r) => typeof r === "function");
65
+ if (cb) cb();
66
+ return true;
67
+ };
68
+ try {
69
+ const code = await fn();
70
+ return { code: code ?? EXIT.OK, stdout, stderr };
71
+ } finally {
72
+ console.log = origLog;
73
+ console.error = origError;
74
+ console.warn = origWarn;
75
+ process.stdout.write = origWrite;
76
+ }
77
+ }
78
+
79
+ // Strip ANSI so captured lines are usable as data even when colour was on.
80
+ export function stripAnsi(s) {
81
+ // eslint-disable-next-line no-control-regex
82
+ return String(s).replace(/\[[0-9;]*m/g, "");
83
+ }
package/src/lib/prompt.js CHANGED
@@ -1,4 +1,15 @@
1
+ // @ts-check
1
2
  import readline from "node:readline";
3
+ import process from "node:process";
4
+
5
+ // Is there a real person on the other end? Every prompt must ask this
6
+ // first. `init` checked it before the ADAPTER prompt but not before the
7
+ // project-description prompt, so a non-interactive caller that forgot
8
+ // --non-interactive got a project with a blank description and no warning
9
+ // (audit item C9).
10
+ export function isInteractive() {
11
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
+ }
2
13
 
3
14
  export async function ask(question, { defaultValue = "" } = {}) {
4
15
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -11,7 +22,13 @@ export async function ask(question, { defaultValue = "" } = {}) {
11
22
  }
12
23
  }
13
24
 
14
- export async function confirm(question, { defaultYes = false } = {}) {
25
+ // Ask for consent. Off a terminal there is nobody to ask, so the answer is
26
+ // `whenNonInteractive` — which callers set to false for destructive
27
+ // operations: silence is not consent.
28
+ export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
29
+ if (!isInteractive()) {
30
+ return whenNonInteractive === null ? defaultYes : whenNonInteractive;
31
+ }
15
32
  const suffix = defaultYes ? "Y/n" : "y/N";
16
33
  const ans = await ask(`${question} (${suffix})`);
17
34
  if (!ans) return defaultYes;
package/src/lib/scan.js CHANGED
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+ import { getHeader, getSection } from "./doc-model.js";
1
3
  import path from "node:path";
2
4
  import { readdirSync } from "node:fs";
3
5
  import { isDir, isFile, read, walk } from "./fs-ops.js";
@@ -5,29 +7,18 @@ import { today } from "./dates.js";
5
7
  import { parseFrontmatter } from "../commands/skill.js";
6
8
  import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
7
9
 
8
- // Parse a metadata header line, tolerating BOTH conventions:
9
- // **Name:** value (spec style)
10
- // - **Name:** value (ADR / proposal / intake style)
11
- // The leading "- " is optional so a one-character convention slip (an
12
- // agent hand-authoring an ADR with the bare spec form, or vice versa)
13
- // still parses instead of silently falling back to a default value.
14
- const HEADER_PREFIX = "(?:-\\s+)?";
15
-
16
- // Parse a spec-style header line: **Name:** value (dash optional).
10
+ // Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
11
+ // These two names survive because dozens of call sites use them and the
12
+ // distinction they once encoded — spec style vs list style — is now the
13
+ // document model's job, not the caller's.
17
14
  export function specHeader(text, name) {
18
- const m = text.match(new RegExp(`^${HEADER_PREFIX}\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
19
- return m ? m[1].trim() : null;
15
+ return getHeader(text, name);
20
16
  }
21
17
 
22
- // Parse an ADR/proposal-style header line: - **Name:** value (dash optional).
23
18
  export function listHeader(text, name) {
24
- const m = text.match(new RegExp(`^${HEADER_PREFIX}\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
25
- return m ? m[1].trim() : null;
19
+ return getHeader(text, name);
26
20
  }
27
21
 
28
- // The machine-readable "**Depends on:** defects, decisions" spec header: the
29
- // sibling capabilities this spec builds on. Returns [] when absent, "n/a", or
30
- // empty. One parser so index, why, context, and review agree.
31
22
  export function parseDependsOn(text) {
32
23
  const raw = specHeader(text, "Depends on");
33
24
  if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
@@ -39,6 +30,72 @@ function dirEntries(dir) {
39
30
  return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
40
31
  }
41
32
 
33
+
34
+ // The capabilities an ADR governs, from its optional "Scope:" header
35
+ // (M4). Returns [] when absent, "n/a", or "—" — an unscoped ADR is global
36
+ // and belongs in every pack, which is what keeps this backward compatible.
37
+ export function parseAdrScope(text) {
38
+ const raw = listHeader(text, "Scope");
39
+ if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
40
+ return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
41
+ }
42
+
43
+ // One sentence describing what an ADR decided, taken from its "## Decision"
44
+ // section. This is what a budget-constrained pack falls back to before it
45
+ // drops an ADR entirely: a title and a sentence still carry the decision,
46
+ // where an omission carries nothing.
47
+ export function adrSummary(text) {
48
+ const body = getSection(text, "Decision");
49
+ if (!body) return null;
50
+ for (const line of body.split("\n")) {
51
+ const s = line.trim();
52
+ if (!s || s.startsWith("<!--") || s.startsWith("#") || s.startsWith("|")) continue;
53
+ // Take the first sentence, capped so the index stays scannable.
54
+ const plain = s.replace(/^[-*\d.]+\s*/, "").replace(/\*\*/g, "");
55
+ const sentence = (plain.match(/^.*?[.!?](?=\s|$)/) ?? [plain])[0].trim();
56
+ if (sentence.length < 12) continue;
57
+ return sentence.length > 220 ? sentence.slice(0, 217).trimEnd() + "..." : sentence;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // The index entry for ONE decision, derived from its file. This is the
63
+ // single definition of a decision record: `deriveIndex` builds the whole
64
+ // list from it, and `doctrina decision new` registers a new ADR through it
65
+ // rather than assembling a look-alike by hand. Two constructors for one
66
+ // record shape is how a field added to the deriver (M4's `summary`) turns
67
+ // every freshly created ADR into index drift the moment it is written.
68
+ export function decisionEntry(text, basename, prev, date) {
69
+ const id = basename.match(/^(\d{4})-/)?.[1] ?? prev?.id ?? "0000";
70
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
71
+ const entry = {
72
+ id,
73
+ path: `.doctrina/decisions/${basename}`,
74
+ title: titleMatch ? titleMatch[1].trim() : prev?.title ?? basename,
75
+ status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
76
+ date: listHeader(text, "Date") ?? prev?.date ?? date,
77
+ };
78
+ const supersedes = listHeader(text, "Supersedes");
79
+ if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
80
+ const supersededBy = listHeader(text, "Superseded by");
81
+ if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
82
+ const landed = listHeader(text, "Landed");
83
+ if (landed && landed !== "—") entry.landed = landed;
84
+ // Scope (M4): the capabilities this decision governs. ADRs are immutable
85
+ // and never retire, so without a scope every accepted ADR loads into every
86
+ // context pack forever — the pack grows O(project age) with no decay. An
87
+ // ADR with no Scope: is GLOBAL by definition, the backward-compatible
88
+ // default.
89
+ const scope = parseAdrScope(text);
90
+ if (scope.length > 0) entry.scope = scope;
91
+ // Summary: one sentence from the Decision section, so a pack over budget
92
+ // can degrade an ADR to title + summary instead of dropping it outright.
93
+ const summary = adrSummary(text);
94
+ if (summary) entry.summary = summary;
95
+ return entry;
96
+ }
97
+
98
+
42
99
  // Regenerate the index object from the artifacts on disk. The files are
43
100
  // the source of truth; fields with no on-disk source (project name,
44
101
  // framework_version, product metadata) are carried over from `current`.
@@ -52,6 +109,12 @@ export function deriveIndex(projectRoot, current) {
52
109
  project: current?.project ?? path.basename(projectRoot),
53
110
  framework_version: current?.framework_version ?? "0.0.0",
54
111
  last_updated: current?.last_updated ?? date,
112
+ // Project settings, carried over verbatim. `deriveIndex` rebuilds the
113
+ // artifact graph FROM DISK, so anything it does not explicitly carry is
114
+ // silently erased on the next `index rebuild` — config has no on-disk
115
+ // source to be rederived from, which is exactly why it must be listed
116
+ // here alongside project and framework_version. Absent means defaults.
117
+ ...(current?.config !== undefined ? { config: current.config } : {}),
55
118
  artifacts: {
56
119
  product: cur.product ?? {
57
120
  path: ".doctrina/product.md",
@@ -112,23 +175,8 @@ export function deriveIndex(projectRoot, current) {
112
175
  const base = path.basename(f);
113
176
  const m = base.match(/^(\d{4})-.*\.md$/);
114
177
  if (!m) continue;
115
- const text = read(f);
116
178
  const prev = (cur.decisions ?? []).find((d) => d.id === m[1]);
117
- const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
118
- const entry = {
119
- id: m[1],
120
- path: `.doctrina/decisions/${base}`,
121
- title: titleMatch ? titleMatch[1].trim() : prev?.title ?? base,
122
- status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
123
- date: listHeader(text, "Date") ?? prev?.date ?? date,
124
- };
125
- const supersedes = listHeader(text, "Supersedes");
126
- if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
127
- const supersededBy = listHeader(text, "Superseded by");
128
- if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
129
- const landed = listHeader(text, "Landed");
130
- if (landed && landed !== "—") entry.landed = landed;
131
- out.artifacts.decisions.push(entry);
179
+ out.artifacts.decisions.push(decisionEntry(read(f), base, prev, date));
132
180
  }
133
181
 
134
182
  // Open changes — every directory except archive/.
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Structured spec operations (ADR 0007 / review F3). A MODIFIED spec delta
2
3
  // historically carried prose ("flip criterion 2 to verified", "bump to
3
4
  // 0.3.0") that a human merged by hand — the single most error-prone step in
@@ -32,6 +33,9 @@
32
33
  // no ops block, apply falls back to the manual-merge pointer (backward
33
34
  // compatible).
34
35
 
36
+ // The read grammar is the document model's (M3); this local pattern exists
37
+ // only because the ops verbs REWRITE headers in place and need the prefix
38
+ // captured to preserve it.
35
39
  const HEADER_RE = (name) =>
36
40
  new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
37
41
 
@@ -57,14 +61,28 @@ export function extractOps(deltaText) {
57
61
 
58
62
  // Find the body of the first ```ops fenced block, or null if absent. The
59
63
  // info string must be exactly "ops" (case-insensitive) so a normal code
60
- // fence in the prose is never mistaken for operations. HTML comments are
61
- // stripped first: the scaffolded delta template carries an EXAMPLE ops block
62
- // inside its instructional comment, and executing an example against a real
63
- // spec is exactly the surprise this guard removes.
64
+ // fence in the prose is never mistaken for operations.
65
+ //
66
+ // A fence sitting INSIDE an HTML comment is skipped: the scaffolded delta
67
+ // template carries an EXAMPLE ops block in its instructional comment, and
68
+ // executing an example against a real spec is exactly the surprise this
69
+ // guard removes. The comment is skipped by POSITION, never by deleting the
70
+ // comment text first — stripping mutated ops values that legitimately
71
+ // contain a comment (an `<!-- illustrative -->` marker inside an
72
+ // append-criterion landed in the spec with the marker silently gutted).
64
73
  function matchOpsBlock(text) {
65
- const visible = text.replace(/<!--[\s\S]*?-->/g, "");
66
- const m = visible.match(/^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/m);
67
- return m ? m[1] : null;
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);
79
+
80
+ const fenceRe = /^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
81
+ let m;
82
+ while ((m = fenceRe.exec(text))) {
83
+ if (!insideComment(m.index)) return m[1];
84
+ }
85
+ return null;
68
86
  }
69
87
 
70
88
  function parseOpLine(line) {
@@ -322,15 +340,82 @@ function endOfItem(lines, startIndex) {
322
340
  return i;
323
341
  }
324
342
 
343
+ // The EARS subsections in the order the spec template lays them out. A
344
+ // created subsection goes where a reader expects it, not at the end.
345
+ const EARS_ORDER = ["ubiquitous", "event", "state", "unwanted", "optional"];
346
+ const EARS_HEADINGS = {
347
+ ubiquitous: "### Ubiquitous",
348
+ event: "### Event-driven",
349
+ state: "### State-driven",
350
+ unwanted: "### Unwanted-behavior (must-not)",
351
+ optional: "### Optional",
352
+ };
353
+
354
+ // Create an absent `### <EARS>` subsection under `## Requirements (EARS)`,
355
+ // carrying its first requirement, in canonical order: after the last
356
+ // existing subsection that precedes it, or ahead of them all when none
357
+ // does. The requirement goes in with the heading rather than in a second
358
+ // pass, so the blank lines around the new block are exact.
359
+ function createEarsSubsection(loc, section, value) {
360
+ const heading = EARS_HEADINGS[section];
361
+ if (!heading) return { error: `append-requirement: unknown EARS subsection "${section}"` };
362
+ const lines = [...loc.lines];
363
+
364
+ let insertAt = null;
365
+ const rank = EARS_ORDER.indexOf(section);
366
+ for (const token of EARS_ORDER.slice(0, rank).reverse()) {
367
+ const prior = loc.sections[token];
368
+ if (prior) {
369
+ // End of that subsection, trimmed back past the blank lines it trails.
370
+ let end = Math.min(prior.endIndex, lines.length);
371
+ while (end > prior.headingIndex + 1 && lines[end - 1].trim() === "") end -= 1;
372
+ insertAt = end;
373
+ break;
374
+ }
375
+ }
376
+ if (insertAt === null) {
377
+ // Nothing precedes it: place it ahead of whichever subsection currently
378
+ // comes first, or directly under the Requirements heading.
379
+ const first = EARS_ORDER.map((t) => loc.sections[t]).find(Boolean);
380
+ if (first) {
381
+ insertAt = first.headingIndex;
382
+ } else {
383
+ const reqIndex = lines.findIndex((l) => /^##\s+Requirements\b/i.test(l));
384
+ if (reqIndex < 0) return { error: "append-requirement: spec has no '## Requirements (EARS)' section" };
385
+ insertAt = reqIndex + 1;
386
+ }
387
+ }
388
+
389
+ // Exactly one blank line on each side, borrowing whichever the
390
+ // insertion point already provides rather than adding a second.
391
+ const block = [heading, "", `- ${value}`];
392
+ if (insertAt > 0 && lines[insertAt - 1].trim() !== "") block.unshift("");
393
+ if (lines[insertAt] !== undefined && lines[insertAt].trim() !== "") block.push("");
394
+ lines.splice(insertAt, 0, ...block);
395
+ return { text: lines.join("\n") };
396
+ }
397
+
325
398
  // append-requirement <section>: <text> — add "- <text>" at the end of the
326
399
  // EARS subsection. A lone "-" placeholder bullet (the untouched scaffold) is
327
400
  // replaced instead of appended after, so the first real requirement does not
328
401
  // land under an empty stub.
329
402
  export function appendRequirement(text, section, value) {
330
- const loc = locateRequirements(text);
403
+ let loc = locateRequirements(text);
331
404
  if (!loc) return { error: "append-requirement: spec has no '## Requirements (EARS)' section" };
405
+ if (!loc.sections[section]) {
406
+ // The subsection does not exist yet. Refusing here is a dead end: the
407
+ // author cannot hand-edit the spec (AGENTS.md forbids editing specs
408
+ // outside a delta) and no op creates a subsection, so a spec that was
409
+ // split — losing the EARS headings it had no requirements for — could
410
+ // never receive one again. Create it, in canonical EARS order.
411
+ const created = createEarsSubsection(loc, section, value);
412
+ if (created.error) return created;
413
+ return {
414
+ text: created.text,
415
+ summary: `append-requirement ${section} (created subsection): ${value.slice(0, 44)}`,
416
+ };
417
+ }
332
418
  const sec = loc.sections[section];
333
- if (!sec) return { error: `append-requirement: no '### ${section}...' subsection found under Requirements` };
334
419
  const placeholder = sec.bullets.length === 1 && /^\s*-\s*$/.test(loc.lines[sec.bullets[0]]);
335
420
  if (placeholder) {
336
421
  loc.lines[sec.bullets[0]] = `- ${value}`;
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Tiny Levenshtein distance + "did you mean" suggester. Zero deps.
2
3
 
3
4
  export function distance(a, b) {
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { read, toPosix, walk, write } from "./fs-ops.js";
@@ -35,6 +36,94 @@ export function locateTemplatesDir() {
35
36
  );
36
37
  }
37
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // The project-local override chain (audit item M1).
41
+ //
42
+ // `doctrina init` creates `.doctrina/templates/` containing a single
43
+ // `.gitkeep`, and nothing ever read it or wrote to it. The resolver above
44
+ // answers only "where did the CLI install its templates?", so a directory
45
+ // the framework creates in every project was inert — a ghost.
46
+ //
47
+ // Resolution is now a CHAIN, per file: a template present under the
48
+ // project's `.doctrina/templates/` wins; anything absent falls back to the
49
+ // bundled copy. That turns the ghost into the customisation point, and it
50
+ // is the same wiring the custom-adapter support needs (ADR 0016).
51
+ //
52
+ // Deliberately per FILE, not per directory: a team that wants its own
53
+ // `spec.md.template` should not have to vendor the whole tree and then
54
+ // maintain every other template forever.
55
+
56
+ export function projectTemplatesDir(projectRoot) {
57
+ return path.join(projectRoot, ".doctrina", "templates");
58
+ }
59
+
60
+ // Where a named template resolves for this project.
61
+ // Returns { path, source: "project" | "bundled" } or null when neither has
62
+ // it. `relativePath` is POSIX-ish and relative to a templates root, e.g.
63
+ // "spec.md.template" or "change/proposal.md.template".
64
+ export function resolveTemplate(projectRoot, relativePath) {
65
+ const rel = relativePath.split("/").join(path.sep);
66
+ if (projectRoot) {
67
+ const local = path.join(projectTemplatesDir(projectRoot), rel);
68
+ if (fileExists(local)) return { path: local, source: "project" };
69
+ }
70
+ try {
71
+ const bundled = path.join(locateTemplatesDir(), rel);
72
+ if (fileExists(bundled)) return { path: bundled, source: "bundled" };
73
+ } catch { /* no bundled tree (unusual install) */ }
74
+ return null;
75
+ }
76
+
77
+ // Read a template through the chain. Throws with both candidates named
78
+ // when neither side has it, because "template not found" with no path is
79
+ // the least actionable error a scaffolder can give.
80
+ export function readTemplate(projectRoot, relativePath) {
81
+ const resolved = resolveTemplate(projectRoot, relativePath);
82
+ if (!resolved) {
83
+ throw new Error(
84
+ `template "${relativePath}" not found in the project override ` +
85
+ `(${projectTemplatesDir(projectRoot)}) or in the installed CLI`,
86
+ );
87
+ }
88
+ return { ...resolved, body: read(resolved.path) };
89
+ }
90
+
91
+ // Every template the chain can serve, with where each one resolved from —
92
+ // what `templates list` prints so the source is never a guess.
93
+ export function listResolvedTemplates(projectRoot) {
94
+ const seen = new Map();
95
+ let bundledRoot = null;
96
+ try {
97
+ bundledRoot = locateTemplatesDir();
98
+ } catch { /* none */ }
99
+
100
+ if (bundledRoot) {
101
+ for (const f of walk(bundledRoot)) {
102
+ const rel = toPosix(path.relative(bundledRoot, f));
103
+ seen.set(rel, { relativePath: rel, path: f, source: "bundled" });
104
+ }
105
+ }
106
+ const localRoot = projectRoot ? projectTemplatesDir(projectRoot) : null;
107
+ if (localRoot) {
108
+ for (const f of walk(localRoot)) {
109
+ const rel = toPosix(path.relative(localRoot, f));
110
+ if (rel === ".gitkeep") continue;
111
+ const overrides = seen.has(rel);
112
+ seen.set(rel, { relativePath: rel, path: f, source: "project", overrides });
113
+ }
114
+ }
115
+ return [...seen.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
116
+ }
117
+
118
+ function fileExists(p) {
119
+ try {
120
+ read(p);
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
38
127
  // Single-pass {{TOKEN}} substitution. Tokens are uppercase, digits, or
39
128
  // underscore inside double curly braces.
40
129
  export function substitute(text, tokens) {
@@ -0,0 +1,89 @@
1
+ // @ts-check
2
+ // Audit item M8: instrument the command surface before shrinking it.
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
8
+ // `doctor` were nearly cut in 0.13.0, right before the operator review
9
+ // found they had gone unused only because AGENTS.md never named them.
10
+ //
11
+ // The design constraints this is built under:
12
+ //
13
+ // OFF unless asked. No file appears, and nothing is recorded, until the
14
+ // operator sets DOCTRINA_USAGE_LOG. A tool that starts logging because
15
+ // it was updated has broken faith regardless of what it logs.
16
+ // LOCAL only. One append-only JSONL file at a path the operator names.
17
+ // The CLI makes no network calls at all (a spec requirement), and this
18
+ // changes nothing about that.
19
+ // NO ARGUMENTS. The command and the sub-operation, never the values.
20
+ // `change apply 0021-secret-project` records `change apply`. Paths,
21
+ // titles, prompts and ids are the user's, not the instrument's.
22
+ // NEVER FATAL. A failure to record is silent. Losing a usage sample
23
+ // matters less than nothing, and losing the command's real work to an
24
+ // instrumentation error would be absurd.
25
+
26
+ import { appendFileSync } from "node:fs";
27
+ import process from "node:process";
28
+
29
+ export const USAGE_ENV = "DOCTRINA_USAGE_LOG";
30
+
31
+ // Which operation was invoked, with no user data attached.
32
+ //
33
+ // The second positional is kept ONLY when the catalog says this command has
34
+ // that sub-operation. Shape alone is not enough to tell a sub-command from
35
+ // an argument: `context cli` and `spec new` are identical in shape, but the
36
+ // first is a capability name. Guessing recorded `context cli` as an
37
+ // operation and left the real `context` looking unused — which is exactly
38
+ // the wrong answer from an instrument whose whole job is deciding what
39
+ // nobody uses.
40
+ export function operationOf(positional, knownOperations) {
41
+ const command = positional[0];
42
+ if (!command) return null;
43
+ const next = positional[1];
44
+ if (typeof next !== "string" || !/^[a-z][a-z-]{1,20}$/.test(next)) return command;
45
+ const pair = `${command} ${next}`;
46
+ return knownOperations?.has(pair) ? pair : command;
47
+ }
48
+
49
+ // Append one sample. Returns true when it recorded, false otherwise — the
50
+ // tests assert on that rather than on the file, so the "off by default"
51
+ // guarantee is checked directly.
52
+ export function recordUsage(positional, exitCode, knownOperations, env = process.env) {
53
+ const target = env[USAGE_ENV];
54
+ if (!target) return false;
55
+ const operation = operationOf(positional, knownOperations);
56
+ if (!operation) return false;
57
+ try {
58
+ appendFileSync(target, JSON.stringify({
59
+ at: new Date().toISOString(),
60
+ operation,
61
+ exit: exitCode ?? 0,
62
+ }) + "\n");
63
+ return true;
64
+ } catch {
65
+ // Instrumentation never breaks the command it is measuring.
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // Summarise a usage log: how often each operation ran, and — the number the
71
+ // shrink decision actually turns on — which catalog operations never ran at
72
+ // all. An operation with zero samples is a CANDIDATE for cutting or
73
+ // folding, never a verdict: a command used once a quarter (`init`) and a
74
+ // command nobody wants look identical over one week.
75
+ export function summarise(logText, catalogOperations) {
76
+ const counts = new Map();
77
+ let samples = 0;
78
+ for (const line of String(logText).split("\n")) {
79
+ if (!line.trim()) continue;
80
+ let row;
81
+ try { row = JSON.parse(line); } catch { continue; }
82
+ if (!row?.operation) continue;
83
+ counts.set(row.operation, (counts.get(row.operation) ?? 0) + 1);
84
+ samples += 1;
85
+ }
86
+ const used = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
87
+ const unused = catalogOperations.filter((op) => !counts.has(op)).sort();
88
+ return { samples, used, unused };
89
+ }
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { readFileSync } from "node:fs";