doctrina-cli 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ // Writing the intake and printing the bootstrap playbook.
3
+ //
4
+ // `init --intake` performs the same three operations `intake` does, and
5
+ // reached into `commands/intake.js` for them (audit finding F7). They are
6
+ // operations over the tree, not renderings of one command, so both
7
+ // entry points read them from here.
8
+ import path from "node:path";
9
+ import { exists, isFile, read, relPath, write } from "./fs-ops.js";
10
+ import { assessBrief } from "./clarity.js";
11
+ import { c } from "./colors.js";
12
+ import { printPlaybookTemplate } from "./playbook.js";
13
+ import { getHeader, setHeader } from "./doc-model.js";
14
+ import { stateWord } from "./spec-ops.js";
15
+ // Clarification gate (review Topic A). A description too thin to spec from is
16
+ // the moment to ask the user, not to let the agent invent requirements. Prints
17
+ // the specific gaps before the bootstrap playbook; advisory, never blocking —
18
+ // the intake is still captured verbatim (the playbook step 6 resolves it).
19
+ /**
20
+ * Does this argument read as a FILE PATH, or as the description itself?
21
+ *
22
+ * `doctrina intake "<description>"` is the form the surface block's wording
23
+ * invites — "store the intent" — and it answered `description file not found:`
24
+ * followed by the author's whole sentence, echoed back as if it were a
25
+ * filename, with no mention of `--text` (change 0071). Prose and a path are
26
+ * distinguishable without guessing: a path has no sentence in it.
27
+ *
28
+ * Deliberately conservative — anything that could be a path IS treated as one,
29
+ * so a real file is never mistaken for prose and read as a description.
30
+ *
31
+ * @param {string} value
32
+ * @returns {boolean}
33
+ */
34
+ export function looksLikePath(value) {
35
+ const v = String(value).trim();
36
+ if (v === "") return false;
37
+ if (/[/\\]/.test(v)) return true; // a separator settles it
38
+ if (/^[.~]/.test(v)) return true; // ./x, ../x, ~/x
39
+ if (!/\s/.test(v)) return true; // one token: assume a filename
40
+ if (/\.(md|txt|markdown|rst|adoc|org)$/i.test(v)) return true; // "my notes.txt"
41
+ return false;
42
+ }
43
+
44
+ // What `init` tells a person to do next (change 0173). The first run used to
45
+ // contradict itself three ways: `init` said "edit AGENTS.md and product.md",
46
+ // `next` asked for the description again as an intake, and AGENTS.md said the
47
+ // agent runs the commands while the human stays passive. One answer now,
48
+ // the same one `next` and AGENTS.md give.
49
+ //
50
+ // `intakeFrom` is "tty" when the description was typed at init's question
51
+ // (it is stored as the intake), or null when init got no intake at all.
52
+ export function nextStepAfterInit(intakeFrom) {
53
+ if (intakeFrom === "tty") {
54
+ return [
55
+ "Next: open your AI agent in this repository and tell it:",
56
+ " read AGENTS.md and run doctrina next",
57
+ "It turns your description into product.md and specs; you review and approve.",
58
+ ];
59
+ }
60
+ return [
61
+ "Next: give your AI agent the whole project description. It runs",
62
+ " doctrina intake --text \"<description>\"",
63
+ "and turns it into product.md and specs (AGENTS.md, \"Working from intent\");",
64
+ "for an existing codebase, `doctrina work --from-diff` backfills them from the code.",
65
+ ];
66
+ }
67
+
68
+ export function warnIfThinIntake(body) {
69
+ const assessment = assessBrief(body, { kind: "intake" });
70
+ if (!assessment.thin) return;
71
+ console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
72
+ for (const reason of assessment.reasons) console.log(` - ${reason}`);
73
+ console.log("");
74
+ }
75
+
76
+ // THE INTAKE'S STATUS IS A CONTROL VALUE, SO IT GETS AN ENUM AND AN OWNER.
77
+ //
78
+ // `next` branches on it: pending means the bootstrap is unfinished, and every
79
+ // other artifact's status in this framework is both written by a command and
80
+ // checked against an enum — `spec set` refuses "Status: nonsense", and
81
+ // `validate` calls it an error. The intake was the one header the playbook
82
+ // told an agent to edit BY HAND, and the one nothing read back.
83
+ //
84
+ // So any token that was not exactly "converted" meant pending, in silence:
85
+ // "convertido" typed in a Portuguese project, "done", or an empty value left
86
+ // by a botched edit. `validate` exited 0 on all of them and `next` went on
87
+ // asking for a bootstrap that had already happened.
88
+ export const INTAKE_STATUSES = ["pending", "converted"];
89
+
90
+ /** The intake's status word, note stripped and folded. Defaults to pending. */
91
+ export function intakeStatus(text) {
92
+ const raw = getHeader(String(text ?? ""), "Status");
93
+ if (raw === null) return "pending";
94
+ return stateWord(raw) || "";
95
+ }
96
+
97
+ /** null when the intake's status is a declared value; otherwise the error. */
98
+ export function intakeStatusError(text) {
99
+ const word = intakeStatus(text);
100
+ if (INTAKE_STATUSES.includes(word)) return null;
101
+ const got = getHeader(String(text ?? ""), "Status");
102
+ return `Status must be one of ${INTAKE_STATUSES.join("|")} `
103
+ + `(got "${String(got ?? "").trim()}") — every other value reads as pending, in silence`;
104
+ }
105
+
106
+ /**
107
+ * Flip the stored intake to `converted`. Returns the path written, or null
108
+ * when there is no intake (or no Status header) to flip.
109
+ */
110
+ export function markIntakeConverted(projectRoot) {
111
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
112
+ if (!exists(intakePath)) return null;
113
+ const next = setHeader(read(intakePath), "Status", "converted");
114
+ if (next === null) return null;
115
+ write(intakePath, next, { force: true });
116
+ return intakePath;
117
+ }
118
+
119
+ // Shared with `init --intake`. Writes the intake verbatim under a small
120
+ // status header; `doctrina intake --converted` flips it at the end of the
121
+ // playbook, which is how `next` knows the bootstrap is done.
122
+ export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
123
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
124
+ const content = [
125
+ `# Intake — ${projectName}`,
126
+ "",
127
+ "- **Status:** pending",
128
+ `- **Date:** ${date}`,
129
+ `- **Source:** ${source}`,
130
+ "",
131
+ "<!-- Raw project intent, stored verbatim. The bootstrap playbook",
132
+ " (doctrina intake) converts it into product.md and capability",
133
+ " specs. Close it with `doctrina intake --converted`; after that the",
134
+ " specs are the only source of truth; never edit this file to",
135
+ " change requirements. -->",
136
+ "",
137
+ "---",
138
+ "",
139
+ body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
140
+ ].join("\n");
141
+ write(intakePath, content, { force });
142
+ return intakePath;
143
+ }
144
+
145
+ // The bootstrap playbook, like the work and chore playbooks, is a TEMPLATE
146
+ // resolved project-over-bundled (audit finding F17). What stays here is the
147
+ // one conditional: a tree that never got its specs/ directory.
148
+ export function printBootstrapPlaybook(projectRoot) {
149
+ const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
150
+ printPlaybookTemplate(projectRoot, "bootstrap", {
151
+ MISSING_SPECS_WARNING: hasSpecs
152
+ ? ""
153
+ : "\n" + c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.",
154
+ });
155
+ }
@@ -27,14 +27,99 @@ export function wantsJson(flags) {
27
27
  return flags?.get?.("json") === true;
28
28
  }
29
29
 
30
+ // A deprecation notice belongs in the ENVELOPE, not only on the terminal
31
+ // (change 0061). Change 0049 announced a superseded name on the real stderr,
32
+ // before capture — right for a piped stdout, which stays exactly what it
33
+ // was, but it meant the envelope's `stderr` array came back empty and the
34
+ // machine reading only that never learned the name it invoked is on its way
35
+ // out. Deprecation exists so consumers migrate, and this CLI's primary
36
+ // consumer is an agent reading JSON.
37
+ //
38
+ // Set once by the entrypoint, before the command runs, so BOTH json paths
39
+ // carry it: the captured envelope and a command that builds its own payload.
40
+ /** @type {{ use: string, since: string, why: string } | null} */
41
+ let deprecation = null;
42
+
43
+ /** @param {{ use: string, since: string, why: string } | null} record */
44
+ export function setDeprecation(record) {
45
+ deprecation = record;
46
+ }
47
+
48
+ // A command that builds its own payload calls `emitJson` BEFORE it returns —
49
+ // in `coverage.js` the call sits one line above `return jsonClean ? 0 :
50
+ // strict ? 1 : 0`. So the call site cannot know the exit code, and the
51
+ // optimistic defaults answered for it: `coverage --strict` exited 1 while
52
+ // its payload said `"ok": true, "exit_code": 0`, and `exit_code` was 0 in
53
+ // every native payload the CLI has ever emitted (change 0086).
54
+ //
55
+ // The entrypoint is the one place that knows the code, which is why the
56
+ // CAPTURED path (a command with no payload of its own) was always right: it
57
+ // emits after the run, with the real code. The native path now works the
58
+ // same way — `deferJson()` before the run holds the payload, `flushJson(code)`
59
+ // after it writes the envelope with the verdict the process actually reports.
60
+ /** @type {{ command: string, data: object, opts: object } | null} */
61
+ let held = null;
62
+ let deferring = false;
63
+
64
+ /** Hold the next payload instead of writing it, until `flushJson`. */
65
+ export function deferJson() {
66
+ deferring = true;
67
+ held = null;
68
+ }
69
+
70
+ /**
71
+ * Write the held payload with the exit code the command actually returned,
72
+ * and leave deferred mode. A no-op when the command emitted nothing.
73
+ */
74
+ export function flushJson(code) {
75
+ deferring = false;
76
+ if (!held) return;
77
+ const { command, data } = held;
78
+ held = null;
79
+ writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
80
+ }
81
+
30
82
  // Print a structured payload. `command` is the invocation, `data` whatever
31
83
  // that command has to say.
32
- export function emitJson(command, data, { ok = true, exitCode = EXIT.OK } = {}) {
84
+ /**
85
+ * @param {string} command The OPERATION, never the operation plus arguments.
86
+ * @param {Record<string, unknown>} data
87
+ * @param {{ ok?: boolean, exitCode?: number, args?: string[] }} [opts]
88
+ */
89
+ export function emitJson(command, data, opts = {}) {
90
+ if (deferring) {
91
+ held = { command, data, opts };
92
+ return;
93
+ }
94
+ writeEnvelope(command, data, opts);
95
+ }
96
+
97
+ /**
98
+ * `EXIT` is frozen, so its members infer as literal types — without this the
99
+ * default `exitCode = EXIT.OK` narrows the parameter to `0`.
100
+ *
101
+ * @param {string} command
102
+ * @param {Record<string, unknown>} data
103
+ * @param {{ ok?: boolean, exitCode?: number, args?: string[] }} [opts]
104
+ */
105
+ function writeEnvelope(command, data, { ok = true, exitCode = EXIT.OK, args } = {}) {
33
106
  process.stdout.write(JSON.stringify({
34
107
  $schema_version: JSON_SCHEMA_VERSION,
108
+ // The OPERATION, never the operation plus its arguments. `command` used
109
+ // to carry `"why carteira"`, so the field a consumer branches on took a
110
+ // different value for every capability — while `next --json` documents
111
+ // `command`/`args` as the contract (third audit, finding 6). A
112
+ // sub-operation like `spec list` IS the operation and stays whole; the
113
+ // catalog is what tells the two apart.
35
114
  command,
36
115
  ok,
37
116
  exit_code: exitCode,
117
+ // Present when the invocation carried arguments, so it stays
118
+ // reconstructible without parsing the name.
119
+ ...(args && args.length > 0 ? { args } : {}),
120
+ // Present only when the invoked name is superseded, so a consumer can
121
+ // branch on its presence rather than on a string.
122
+ ...(deprecation ? { deprecated: deprecation } : {}),
38
123
  ...data,
39
124
  }, null, 2) + "\n");
40
125
  }
@@ -53,8 +138,14 @@ export async function captureOutput(fn) {
53
138
  // only console.* let that output escape the envelope and corrupt the
54
139
  // JSON on stdout, so the raw stream is captured too.
55
140
  const origWrite = process.stdout.write.bind(process.stdout);
141
+ // The raw stderr stream is captured too (change 0114): `verify` tees a
142
+ // child's stderr straight to process.stderr, so a failing check's
143
+ // diagnostics landed on the real stderr while the envelope said
144
+ // `"stderr": []`. Carriage returns are stripped — a child on Windows
145
+ // writes CRLF, and `"ok\r"` is not a line.
146
+ const origErrWrite = process.stderr.write.bind(process.stderr);
56
147
  const pushLines = (target, chunk) => {
57
- for (const line of String(chunk).replace(/\n$/, "").split("\n")) target.push(line);
148
+ for (const line of String(chunk).replace(/\r?\n$/, "").split(/\r?\n/)) target.push(line.replace(/\r$/, ""));
58
149
  };
59
150
  console.log = (...args) => stdout.push(args.map(String).join(" "));
60
151
  console.error = (...args) => stderr.push(args.map(String).join(" "));
@@ -65,6 +156,12 @@ export async function captureOutput(fn) {
65
156
  if (cb) cb();
66
157
  return true;
67
158
  };
159
+ process.stderr.write = (chunk, ...rest) => {
160
+ pushLines(stderr, chunk);
161
+ const cb = rest.find((r) => typeof r === "function");
162
+ if (cb) cb();
163
+ return true;
164
+ };
68
165
  try {
69
166
  const code = await fn();
70
167
  return { code: code ?? EXIT.OK, stdout, stderr };
@@ -73,6 +170,7 @@ export async function captureOutput(fn) {
73
170
  console.error = origError;
74
171
  console.warn = origWarn;
75
172
  process.stdout.write = origWrite;
173
+ process.stderr.write = origErrWrite;
76
174
  }
77
175
  }
78
176
 
@@ -0,0 +1,192 @@
1
+ // @ts-check
2
+ // The archive ledger, read as well as written.
3
+ //
4
+ // `.doctrina/changes/archive/LEDGER.md` is a structured log — date, change
5
+ // id, title, and the capabilities the change touched with the operation it
6
+ // performed on each — and for two releases exactly two things read it: an
7
+ // id set in `decision scope`, and a cross-check in `validate`. Everything
8
+ // else that wanted to know "what has been happening here" derived it from
9
+ // git, which knows about files and nothing about capabilities (audit
10
+ // findings F13, F14).
11
+ //
12
+ // The parse is DELIBERATELY tolerant. The file's own header invites humans
13
+ // to edit it and promises the CLI only appends, so a line that does not
14
+ // match the grammar is not a corrupt ledger — it is a person writing a note.
15
+ // Such lines are counted, never thrown, and never rewritten.
16
+ import path from "node:path";
17
+ import { appendFileSync } from "node:fs";
18
+ import { exists, isFile, mkdirp, read, write } from "./fs-ops.js";
19
+ import { today } from "./dates.js";
20
+
21
+ export const LEDGER_REL = ".doctrina/changes/archive/LEDGER.md";
22
+
23
+ const HEADER =
24
+ "# Change ledger\n\n" +
25
+ "One line per archived change, newest last. Appended by\n" +
26
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n";
27
+
28
+ export function ledgerPath(projectRoot) {
29
+ return path.join(projectRoot, ...LEDGER_REL.split("/"));
30
+ }
31
+
32
+ // One entry per recognised line. `kind` separates the three things the CLI
33
+ // records, because they mean different things to a reader: a change that
34
+ // LANDED, one that was discarded, and a gate that was waved through.
35
+ /**
36
+ * @typedef {{
37
+ * date: string, id: string, kind: "archived"|"abandoned"|"gap",
38
+ * title: string, specs: {capability: string, operation: string}[],
39
+ * line: number, raw: string
40
+ * }} LedgerEntry
41
+ */
42
+
43
+ // `- YYYY-MM-DD — <id> — <rest>`, with either an em dash or a hyphen as the
44
+ // separator and any indentation (a gap line is indented). The date's month
45
+ // and day are range-checked: a bullet a person typed by hand is far more
46
+ // likely to be prose that happens to start with digits than an entry, and
47
+ // admitting it would put a fictional date into a churn window.
48
+ const LINE_RE = /^\s*-\s+(\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/;
49
+ const SPECS_RE = /\(specs:\s*([^)]*)\)\s*$/;
50
+
51
+ /**
52
+ * Parse ledger text into entries.
53
+ *
54
+ * @param {string} text
55
+ * @returns {{entries: LedgerEntry[], unparsed: number}}
56
+ */
57
+ export function parseLedger(text) {
58
+ const entries = [];
59
+ let unparsed = 0;
60
+ const lines = String(text ?? "").split(/\r?\n/);
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const raw = lines[i];
63
+ if (raw.trim() === "") continue;
64
+ const m = raw.match(LINE_RE);
65
+ if (!m) {
66
+ // Prose, the file's own header, a hand-written note: not an entry, and
67
+ // not an error either.
68
+ if (/^\s*-\s/.test(raw)) unparsed += 1;
69
+ continue;
70
+ }
71
+ const [, date, id, rest] = m;
72
+ const trimmed = rest.trim();
73
+ let kind = /** @type {"archived"|"abandoned"|"gap"} */ ("archived");
74
+ if (/^abandoned\b/i.test(trimmed)) kind = "abandoned";
75
+ else if (/^(?:forced|docs gap)\b/i.test(trimmed)) kind = "gap";
76
+
77
+ const specsMatch = trimmed.match(SPECS_RE);
78
+ const specs = specsMatch ? parseSpecs(specsMatch[1]) : [];
79
+ const title = specsMatch ? trimmed.slice(0, specsMatch.index).trim() : trimmed;
80
+ entries.push({ date, id, kind, title, specs, line: i + 1, raw });
81
+ }
82
+ return { entries, unparsed };
83
+ }
84
+
85
+ // "cli MODIFIED, gates MODIFIED" → [{capability, operation}]. An entry whose
86
+ // operation is missing keeps the capability: the capability is the fact the
87
+ // consumers need, and dropping the pair over a missing verb would lose it.
88
+ function parseSpecs(list) {
89
+ const out = [];
90
+ for (const part of list.split(",")) {
91
+ const t = part.trim();
92
+ if (!t) continue;
93
+ const m = t.match(/^([a-z][a-z0-9-]*)(?:\s+([A-Z]+))?$/);
94
+ if (!m) continue;
95
+ out.push({ capability: m[1], operation: m[2] ?? "" });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Read and parse the project's ledger.
102
+ *
103
+ * @param {string} projectRoot
104
+ * @returns {{path: string, exists: boolean, entries: LedgerEntry[], unparsed: number}}
105
+ */
106
+ export function readLedger(projectRoot) {
107
+ const p = ledgerPath(projectRoot);
108
+ if (!isFile(p)) return { path: p, exists: false, entries: [], unparsed: 0 };
109
+ const { entries, unparsed } = parseLedger(read(p));
110
+ return { path: p, exists: true, entries, unparsed };
111
+ }
112
+
113
+ /**
114
+ * How often each capability was touched, newest first by count.
115
+ *
116
+ * The number is reported, never judged: a spec that changes often may be
117
+ * badly drawn or may simply be where the work is, and the CLI has no way to
118
+ * tell those apart (ADR 0005). Only landed changes count — an abandoned
119
+ * change touched nothing, and a forced-gap line records a waived gate rather
120
+ * than a spec edit.
121
+ *
122
+ * @param {LedgerEntry[]} entries
123
+ * @param {{since?: string|null}} [options] since — ISO date, inclusive
124
+ * @returns {{capability: string, changes: number, last: string}[]}
125
+ */
126
+ export function churnByCapability(entries, { since = null } = {}) {
127
+ const seen = new Map();
128
+ for (const e of entries) {
129
+ if (e.kind !== "archived") continue;
130
+ if (since && e.date < since) continue;
131
+ for (const s of e.specs) {
132
+ const row = seen.get(s.capability) ?? { capability: s.capability, changes: 0, last: "" };
133
+ row.changes += 1;
134
+ if (e.date > row.last) row.last = e.date;
135
+ seen.set(s.capability, row);
136
+ }
137
+ }
138
+ return [...seen.values()].sort((a, b) => b.changes - a.changes || a.capability.localeCompare(b.capability));
139
+ }
140
+
141
+ /**
142
+ * Append one line, creating the ledger when this is its first entry.
143
+ *
144
+ * The three things that write the ledger — archive, abandon, and a forced
145
+ * transition — used to carry their own copy of this header and their own
146
+ * append, so the format the parser above reads had three authors. A forced
147
+ * transition can happen before any change has ever been archived, and a gap
148
+ * that goes unrecorded because the file did not exist yet is exactly the
149
+ * silence the ledger exists to break.
150
+ *
151
+ * @param {string} projectRoot
152
+ * @param {string} line the entry, without the trailing newline
153
+ */
154
+ export function appendLedgerLine(projectRoot, line) {
155
+ const p = ledgerPath(projectRoot);
156
+ if (!exists(p)) {
157
+ mkdirp(path.dirname(p));
158
+ write(p, HEADER);
159
+ }
160
+ appendFileSync(p, `${line}\n`);
161
+ return p;
162
+ }
163
+
164
+ /** The canonical line for an archived change. */
165
+ export function archivedLine(id, title, specsAffected = [], date = today()) {
166
+ const specs = specsAffected.length > 0
167
+ ? ` (specs: ${specsAffected.map((s) => `${s.capability} ${s.operation}`).join(", ")})`
168
+ : "";
169
+ return `- ${date} — ${id} — ${title}${specs}`;
170
+ }
171
+
172
+ /** The canonical line for an abandoned change. */
173
+ export function abandonedLine(id, reason = "", date = today()) {
174
+ return `- ${date} — ${id} — abandoned${reason ? ` — ${reason}` : ""}`;
175
+ }
176
+
177
+ /** The canonical line for a lifecycle gate that was waved through. */
178
+ export function forcedLine(id, transition, blockers, date = today()) {
179
+ const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
180
+ return ` - ${date} — ${id} — forced ${transition} past ${blockers.length} ` +
181
+ `blocker${blockers.length === 1 ? "" : "s"} (${summary})`;
182
+ }
183
+
184
+ /**
185
+ * The canonical line for a docs gap closed with --force.
186
+ *
187
+ * It follows the entry grammar rather than being free prose, because a
188
+ * waived gate that no reader can find is the same as an unrecorded one.
189
+ */
190
+ export function docsGapLine(id, signals, date = today()) {
191
+ return ` - ${date} — ${id} — docs gap: closed with --force; ${signals.join("; ")} documented nowhere`;
192
+ }