doctrina-cli 0.12.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 (68) 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 +76 -19
  5. package/src/commands/change.js +341 -44
  6. package/src/commands/clarify.js +40 -19
  7. package/src/commands/close.js +119 -11
  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 +152 -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 +277 -40
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +23 -12
  34. package/src/commands/validate.js +93 -3
  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 +69 -7
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +47 -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 +314 -3
  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 +228 -3
  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/.gitkeep +0 -0
  67. package/templates/AGENTS.md.template +71 -75
  68. package/templates/change/spec-delta.md.template +13 -7
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ import { getSection } from "./doc-model.js";
3
+ import path from "node:path";
4
+ import { appendFileSync, writeFileSync } from "node:fs";
5
+ import { exists, isFile, mkdirp, read } from "./fs-ops.js";
6
+ import { today } from "./dates.js";
7
+ import { collectAnalysis } from "../commands/analyze.js";
8
+
9
+ // The gate-to-transition map (audit item C6).
10
+ //
11
+ // Each command used to decide its own preconditions. `change archive` and
12
+ // `close` checked theirs; `change apply` checked nothing. So `analyze`
13
+ // could exit 1 on a change and `change apply` would mutate it anyway and
14
+ // exit 0 — and the README flowchart tells agents to follow exactly that
15
+ // analyze → apply path. An agent could reach, through one path, a state
16
+ // another path forbids.
17
+ //
18
+ // The fix is not "add a check to apply" but "declare once which gates
19
+ // guard which transition, and have every driver consult the declaration".
20
+ // Which specific gate guards which transition matters less than the
21
+ // guarantee that the answer does not depend on how you got there.
22
+
23
+ // A gate is a named precondition returning human-readable blockers.
24
+ export const GATES = {
25
+ // Structural validity: the change's own files parse and point at real
26
+ // targets. The same checks `doctrina analyze` reports.
27
+ structure: {
28
+ label: "structure",
29
+ rerun: (id) => `doctrina analyze ${id}`,
30
+ blockers(projectRoot, changeDir) {
31
+ return collectAnalysis(projectRoot, changeDir)
32
+ .filter((r) => r.kind === "fail")
33
+ .map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
34
+ },
35
+ },
36
+
37
+ // Verification: the work is claimed complete. Every checkbox in
38
+ // tasks.md (closing steps included) and in the proposal's
39
+ // "## Verification" section.
40
+ verification: {
41
+ label: "verification",
42
+ rerun: (id) => `doctrina change tick ${id}`,
43
+ blockers(projectRoot, changeDir) {
44
+ const out = [];
45
+ const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
46
+
47
+ const tasksPath = path.join(changeDir, "tasks.md");
48
+ if (isFile(tasksPath)) {
49
+ const n = countUnchecked(read(tasksPath));
50
+ if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
51
+ }
52
+ const proposalPath = path.join(changeDir, "proposal.md");
53
+ if (isFile(proposalPath)) {
54
+ const n = countUnchecked(getSection(read(proposalPath), "Verification"));
55
+ if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
56
+ }
57
+ return out;
58
+ },
59
+ },
60
+ };
61
+
62
+ // Which gates guard each lifecycle transition. The single declaration.
63
+ //
64
+ // `apply` requires structure: mutating specs from a change whose deltas do
65
+ // not parse is how a malformed delta reaches a spec file. This is the gate
66
+ // `analyze` reports and that `apply` used to ignore entirely (C6).
67
+ //
68
+ // `archive` requires verification, and deliberately NOT structure. The
69
+ // structure gate is a PRE-APPLY question — "is this safe to apply?" — and
70
+ // one of its checks is that an ADDED delta's target does not already hold
71
+ // real content. After a successful apply that target holds exactly the
72
+ // content the delta just wrote, so re-asking at archive time reports a
73
+ // conflict that is the proof the apply worked. A gate must be asked at the
74
+ // point its question is meaningful.
75
+ export const TRANSITIONS = {
76
+ apply: { label: "change apply", gates: ["structure"] },
77
+ archive: { label: "change archive", gates: ["verification"] },
78
+ };
79
+
80
+ // Evaluate a transition's gates. Returns { ok, blockers, gates } where
81
+ // blockers is a flat list of human-readable reasons.
82
+ export function checkTransition(projectRoot, changeDir, transition) {
83
+ const t = TRANSITIONS[transition];
84
+ if (!t) throw new Error(`unknown transition "${transition}"`);
85
+ const blockers = [];
86
+ for (const name of t.gates) {
87
+ for (const b of GATES[name].blockers(projectRoot, changeDir)) {
88
+ blockers.push({ gate: name, message: b });
89
+ }
90
+ }
91
+ return { ok: blockers.length === 0, blockers, gates: t.gates };
92
+ }
93
+
94
+ // Record a forced transition in the archive ledger, so a gap that was
95
+ // waved through is part of the visible history rather than nothing at all
96
+ // — the same posture `change archive --force` and `change abandon` take.
97
+ export function recordForcedGap(projectRoot, id, transition, blockers) {
98
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
99
+ const ledgerPath = path.join(archiveDir, "LEDGER.md");
100
+ // Create the ledger if this is the first entry. A forced transition can
101
+ // happen before any change has ever been archived, and a gap that goes
102
+ // unrecorded because the file did not exist yet is exactly the silence
103
+ // this is meant to break.
104
+ if (!exists(ledgerPath)) {
105
+ mkdirp(archiveDir);
106
+ writeFileSync(ledgerPath,
107
+ "# Change ledger\n\n" +
108
+ "One line per archived change, newest last. Appended by\n" +
109
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
110
+ }
111
+ const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
112
+ appendFileSync(
113
+ ledgerPath,
114
+ ` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
115
+ `blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
116
+ );
117
+ return true;
118
+ }
119
+
120
+ function stripAnsi(s) {
121
+ // eslint-disable-next-line no-control-regex
122
+ return String(s).replace(/\[[0-9;]*m/g, "");
123
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ // One place that knows how to ask git a question and how to interpret not
5
+ // getting an answer (audit item C8).
6
+ //
7
+ // Nine call sites each ran `spawnSync("git", ...)` and decided for
8
+ // themselves what a non-zero status meant. `metrics` decided it meant
9
+ // failure, so on a brand-new project — the most common state in which
10
+ // someone explores the CLI — it printed:
11
+ //
12
+ // error: git log failed: fatal: your current branch 'master' does not
13
+ // have any commits yet
14
+ //
15
+ // A repository with no commits is a valid state, not an error. So is a
16
+ // directory that is not a repository at all. What is genuinely an error
17
+ // is git being absent when a command cannot work without it — and that is
18
+ // an ENVIRONMENT condition (exit 4), not a gate failure.
19
+
20
+ export const GIT_STATE = Object.freeze({
21
+ /** git ran and answered. */
22
+ OK: "ok",
23
+ /** A repository with no commits yet — a valid first-run state. */
24
+ EMPTY: "empty",
25
+ /** Not a git repository. */
26
+ NOT_A_REPO: "not-a-repo",
27
+ /** git is not installed or not on PATH. */
28
+ ABSENT: "absent",
29
+ /** git ran and refused, for a reason this module does not classify. */
30
+ FAILED: "failed",
31
+ });
32
+
33
+ // Run a git command. Never throws. Returns
34
+ // { state, stdout, lines, stderr, code }.
35
+ export function git(cwd, args) {
36
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
37
+ if (r.error) {
38
+ // ENOENT here means the binary is missing, not that the command failed.
39
+ return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
40
+ }
41
+ const stderr = String(r.stderr ?? "");
42
+ if (r.status === 0) {
43
+ const stdout = String(r.stdout ?? "");
44
+ return { state: GIT_STATE.OK, stdout, lines: splitLines(stdout), stderr, code: 0 };
45
+ }
46
+ // git's own wording for the benign cases. Matched on the message because
47
+ // git reports them all with a generic non-zero status. "Needed a single
48
+ // revision" is what `rev-parse --verify HEAD` says on a repo with no
49
+ // commits, which is how the empty-history probe first fooled itself.
50
+ if (/does not have any commits yet|unknown revision or path not in the working tree|bad default revision|Needed a single revision|ambiguous argument 'HEAD'/i.test(stderr)) {
51
+ return { state: GIT_STATE.EMPTY, stdout: "", lines: [], stderr, code: r.status };
52
+ }
53
+ if (/not a git repository/i.test(stderr)) {
54
+ return { state: GIT_STATE.NOT_A_REPO, stdout: "", lines: [], stderr, code: r.status };
55
+ }
56
+ // Anything else that exited non-zero is a failure, NOT a success with an
57
+ // empty answer. Reporting it as OK is how a caller reads "no results"
58
+ // from a command that actually refused.
59
+ return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
60
+ }
61
+
62
+ // Is this a git repository at all?
63
+ export function isRepo(cwd) {
64
+ return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
65
+ }
66
+
67
+ // Does it have at least one commit? False for a fresh `git init`.
68
+ export function hasCommits(cwd) {
69
+ return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
70
+ }
71
+
72
+ // The state a command should report before trying to read history:
73
+ // { usable, state, reason } where `reason` is a sentence fit to print.
74
+ export function historyState(cwd) {
75
+ const probe = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
76
+ if (probe.state === GIT_STATE.ABSENT) {
77
+ return { usable: false, state: GIT_STATE.ABSENT, reason: "git is not installed or not on PATH" };
78
+ }
79
+ if (probe.state !== GIT_STATE.OK) {
80
+ return { usable: false, state: GIT_STATE.NOT_A_REPO, reason: "this is not a git repository" };
81
+ }
82
+ if (!hasCommits(cwd)) {
83
+ return { usable: false, state: GIT_STATE.EMPTY, reason: "this repository has no commits yet" };
84
+ }
85
+ return { usable: true, state: GIT_STATE.OK, reason: "" };
86
+ }
87
+
88
+ // Lines of output, trimmed and blank-filtered — what nearly every caller
89
+ // actually wanted from stdout.
90
+ export function gitLines(cwd, args) {
91
+ const r = git(cwd, args);
92
+ return r.state === GIT_STATE.OK ? r.lines : [];
93
+ }
94
+
95
+ function splitLines(s) {
96
+ return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
97
+ }
@@ -1,9 +1,22 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import { read, write, exists } from "./fs-ops.js";
3
4
  import { cliVersion } from "./version.js";
4
5
 
5
6
  export const SCHEMA_VERSION = "0.1.0";
6
7
 
8
+ // The artifact categories a well-formed index.json carries. ONE definition,
9
+ // consumed by `blank()` (what `init` writes) and by `templates check` /
10
+ // `templates update` (what a project is measured against).
11
+ //
12
+ // There used to be two: the shape `init` materialised from a template file
13
+ // and the shape `templates check` required. Nothing compared them, so when
14
+ // `contracts` was added to the expected shape and not to the template,
15
+ // every project was born needing a scaffold update (audit item C5).
16
+ export const ARTIFACT_CATEGORIES = Object.freeze([
17
+ "specs", "decisions", "changes", "changes_archive", "skills", "contracts",
18
+ ]);
19
+
7
20
  export function indexPath(projectRoot) {
8
21
  return path.join(projectRoot, ".doctrina", "index.json");
9
22
  }
@@ -46,12 +59,7 @@ export function blank(projectName, date) {
46
59
  version: "0.1.0",
47
60
  last_updated: date,
48
61
  },
49
- specs: [],
50
- decisions: [],
51
- changes: [],
52
- changes_archive: [],
53
- skills: [],
54
- contracts: [],
62
+ ...Object.fromEntries(ARTIFACT_CATEGORIES.map((cat) => [cat, []])),
55
63
  },
56
64
  };
57
65
  }
@@ -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/.