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,264 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
4
+ import { c } from "./colors.js";
5
+ import { checklistProgress, isUntouchedScaffold } from "./doc-model.js";
6
+ import { collectBudgets } from "./runtime.js";
7
+ import { extractOps, applyOps } from "./spec-ops.js";
8
+
9
+ // The structural ANALYSIS of a change folder, as data.
10
+ //
11
+ // `lib/gates.js` enforces these findings on every lifecycle transition, so
12
+ // the library depended on a command module to compute them — a lib->command
13
+ // edge that made the dependency graph a cycle waiting to happen (audit
14
+ // finding F7). The findings are what the gate map is built on; they belong
15
+ // here, and `commands/analyze.js` is the renderer that prints them.
16
+
17
+ // The structural findings for a change, as data. Exported so the shared
18
+ // gate map (`lib/gates.js`) can enforce the SAME checks on every
19
+ // transition that needs them, instead of each command deciding for itself
20
+ // — `change apply` used to mutate a change that `analyze` had just
21
+ // refused (audit item C6).
22
+ export function collectAnalysis(projectRoot, changeDir) {
23
+ const results = [];
24
+
25
+ // proposal.md
26
+ const proposalPath = path.join(changeDir, "proposal.md");
27
+ if (!isFile(proposalPath)) {
28
+ results.push(fail("proposal.md missing"));
29
+ } else {
30
+ results.push(pass("proposal.md present"));
31
+ const text = read(proposalPath);
32
+ if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
33
+ else results.push(fail(`proposal.md missing "## Why" section`));
34
+
35
+ // A section that still holds only its scaffold comment was never
36
+ // written. The check above only proved the HEADING survived, which is
37
+ // why six changes closed in one session with every rationale section
38
+ // empty: the planning step wrote to the file with a pattern that did
39
+ // not match its line endings, and nothing downstream looked inside.
40
+ // For a framework whose whole premise is recoverable provenance, an
41
+ // archived change that cannot say why it happened is the defect.
42
+ // Only `Why` and `What` are required. They are what makes a change
43
+ // recoverable a year later — the reason it exists and the shape it
44
+ // took. `Scope boundaries` and `Open questions` are legitimately empty
45
+ // on a change that has neither, and demanding the word "None." there
46
+ // is friction that buys nothing.
47
+ const hollow = [];
48
+ for (const m of text.matchAll(/^##\s+(.+?)[ \t]*\r?$/gm)) {
49
+ const heading = m[1].trim();
50
+ if (!/^(Why|What)$/i.test(heading)) continue;
51
+ const start = m.index + m[0].length;
52
+ const next = text.slice(start).search(/^##\s+/m);
53
+ const body = (next < 0 ? text.slice(start) : text.slice(start, start + next));
54
+ // Strip comments; anything left that is not whitespace is real prose.
55
+ if (body.replace(/<!--[\s\S]*?-->/g, "").trim() === "") hollow.push(heading);
56
+ }
57
+ if (hollow.length > 0) {
58
+ results.push(fail(
59
+ `proposal.md has ${hollow.length} unwritten section${hollow.length === 1 ? "" : "s"} ` +
60
+ `(${hollow.join(", ")}) — a heading that survived is not a section that was written`,
61
+ ));
62
+ } else {
63
+ results.push(pass("proposal.md states why and what"));
64
+ }
65
+ }
66
+
67
+ // tasks.md
68
+ const tasksPath = path.join(changeDir, "tasks.md");
69
+ if (!isFile(tasksPath)) {
70
+ results.push(fail("tasks.md missing"));
71
+ } else {
72
+ results.push(pass("tasks.md present"));
73
+ const text = read(tasksPath);
74
+ // Scaffold placeholders never replaced ("- [ ]" with no text, checked or
75
+ // not) mean the change was OPENED but never PLANNED — the agent went
76
+ // straight to implementing on a hollow change and nothing barked
77
+ // (operator report 2026-07-19). A hard failure here blocks `change
78
+ // check` and `close` until the plan is real; ticking an empty box does
79
+ // not help, `change tick` refuses those too.
80
+ const placeholders = checklistProgress(text).placeholders;
81
+ if (placeholders > 0) {
82
+ results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
83
+ }
84
+ if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
85
+ else results.push(info("tasks.md has no unchecked tasks (already done?)"));
86
+ }
87
+
88
+ // design.md (optional)
89
+ const designPath = path.join(changeDir, "design.md");
90
+ if (isFile(designPath)) results.push(info("design.md present"));
91
+ else results.push(info("design.md absent (optional)"));
92
+
93
+ // Spec deltas
94
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
95
+ if (deltaFiles.length === 0) {
96
+ results.push(info("0 spec deltas (metadata-only change)"));
97
+ } else {
98
+ results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
99
+ for (const deltaPath of deltaFiles) {
100
+ const rel = relPath(projectRoot, deltaPath);
101
+ const text = read(deltaPath);
102
+ const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
103
+ const op = opMatch ? opMatch[1] : null;
104
+ const capMatch = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
105
+ const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
106
+
107
+ if (!op) {
108
+ results.push(fail(` ${rel}: Operation header missing or malformed`));
109
+ continue;
110
+ }
111
+ if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
112
+ results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
113
+ continue;
114
+ }
115
+ const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
116
+ const targetRel = relPath(projectRoot, targetSpec);
117
+ if (op === "ADDED") {
118
+ // Mirrors `change apply`: an existing target that is still the
119
+ // untouched `spec new` scaffold is the canonical flow (spec new →
120
+ // ADDED delta), so it passes as a replacement; only real content fails.
121
+ if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
122
+ results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, "pre-apply"));
123
+ } else if (exists(targetSpec)) {
124
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
125
+ } else {
126
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
127
+ }
128
+ } else if (op === "MODIFIED") {
129
+ if (!exists(targetSpec)) {
130
+ results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, "pre-apply"));
131
+ } else {
132
+ results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
133
+ // THE OPS, EXECUTED (third audit, finding 2). Until now this said
134
+ // "ready to apply" for a delta whose ops block `apply` then refused:
135
+ // the pre-flight inspected everything about the delta except the one
136
+ // part that does the work. `change check` already ran this dry-run,
137
+ // so the answer existed — the gate `close` and `apply` consult was
138
+ // simply not the one asking.
139
+ //
140
+ // Scoped pre-apply: after a successful apply the target holds what
141
+ // these ops just wrote, and re-running them against it is a question
142
+ // with no meaning. `archive`'s integrity gate excludes this scope for
143
+ // exactly that reason.
144
+ for (const line of opsFindings(read(targetSpec), text, rel)) results.push(line);
145
+ }
146
+ } else {
147
+ if (!exists(targetSpec)) {
148
+ results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, "pre-apply"));
149
+ } else {
150
+ results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
157
+ // a preference — and the tempting fix for "the output blew the limit" is
158
+ // to raise the limit. That buys headroom by truncating what mattered
159
+ // instead of sending less, and it is the discussion that comes back every
160
+ // quarter because nothing ever recorded that it had been settled. Raising
161
+ // an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
162
+ // resolve an overflow is the move this refuses.
163
+ for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
164
+
165
+ return results;
166
+ }
167
+
168
+ // Compare the ceilings a change's proposal/deltas propose against the ones
169
+ // the contracts declare today. Text-scanned: a change states a new value in
170
+ // prose long before any code moves, which is exactly when it should be
171
+ // argued about.
172
+ function checkBudgetRaises(projectRoot, changeDir) {
173
+ const results = [];
174
+ let declared;
175
+ try {
176
+ declared = collectBudgets(projectRoot);
177
+ } catch {
178
+ return results;
179
+ }
180
+ if (declared.size === 0) return results;
181
+
182
+ const texts = [];
183
+ for (const f of walk(changeDir)) {
184
+ if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
185
+ }
186
+
187
+ for (const [name, budget] of declared) {
188
+ if (budget.direction !== "output") continue;
189
+ for (const { rel, text } of texts) {
190
+ // "<limit> ... <number>" on one line: the shape a proposal uses when
191
+ // it restates a ceiling ("raise ai-summary to 2000").
192
+ const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
193
+ for (const m of text.matchAll(re)) {
194
+ const proposed = Number.parseInt(m[1], 10);
195
+ if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
196
+ results.push(fail(
197
+ `${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
198
+ `(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
199
+ `truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
200
+ ));
201
+ break;
202
+ }
203
+ }
204
+ }
205
+ return results;
206
+ }
207
+
208
+ // A limit name comes from a contract table cell, so it is user text and
209
+ // must not be spliced into a pattern unescaped.
210
+ function escapeRe(s) {
211
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
212
+ }
213
+
214
+ /**
215
+ * Run a MODIFIED delta's ops block against its target spec IN MEMORY and
216
+ * report what `apply` would report.
217
+ *
218
+ * The whole finding behind this: `apply` executes these ops all-or-none and
219
+ * refuses on the first error, so an unappliable block is knowable before
220
+ * anything is touched — and the pre-flight was not looking. A delta with no
221
+ * ops block is not a failure: `apply` prints a manual-merge pointer for it,
222
+ * and saying so here is what lets a close be planned around it rather than
223
+ * surprised by it.
224
+ *
225
+ * @returns {Array<{kind: string, line: string, scope?: string}>}
226
+ */
227
+ function opsFindings(specText, deltaText, rel) {
228
+ const ops = extractOps(deltaText);
229
+ if (ops.length === 0) {
230
+ return [info(` ${rel}: no ops block — apply will print a manual-merge pointer`)];
231
+ }
232
+ const result = applyOps(specText, ops);
233
+ if (result.errors.length === 0) {
234
+ const n = result.applied.length;
235
+ return [pass(` ${rel}: ${n} op${n === 1 ? "" : "s"} would apply cleanly`)];
236
+ }
237
+ return [
238
+ fail(` ${rel}: ${result.errors.length} op error${result.errors.length === 1 ? "" : "s"} ` +
239
+ `(apply would refuse): ${result.errors.join("; ")}`, "pre-apply"),
240
+ ];
241
+ }
242
+
243
+ function pass(msg) {
244
+ return { kind: "pass", line: c.green("✓ ") + msg };
245
+ }
246
+ // `scope` says WHEN a failure is a real answer.
247
+ //
248
+ // Most of these questions are true at every point in a change's life: a
249
+ // proposal with an unwritten `## What` is hollow before an apply and just as
250
+ // hollow after one. A few are only meaningful BEFORE the apply — "an ADDED
251
+ // delta's target must not already hold real content" is proof of a problem
252
+ // beforehand and proof the apply WORKED afterwards.
253
+ //
254
+ // The gate map used to exclude the whole structure gate from `archive` to
255
+ // dodge that handful, which also dropped the hollow-proposal check — the
256
+ // exact defect this repository's own history records as having "shipped six
257
+ // hollow proposals past every gate". Tagging the pre-apply ones lets
258
+ // `archive` keep the rest.
259
+ function fail(msg, scope = "always") {
260
+ return { kind: "fail", scope, line: c.red("✗ ") + msg };
261
+ }
262
+ function info(msg) {
263
+ return { kind: "info", line: c.gray("- ") + msg };
264
+ }
package/src/lib/args.js CHANGED
@@ -74,9 +74,23 @@ export function parseArgs(argv, opts = {}) {
74
74
  function isNextTokenFlag(token) {
75
75
  if (token === undefined) return true;
76
76
  if (typeof token !== "string") return true;
77
+ // `--budget -5` is a value, not a flag named "5": no flag name starts with
78
+ // a digit, and reading it as one answered "unknown flag --5, did you mean
79
+ // --h?" instead of letting the command say the number is out of range.
80
+ if (/^-\d/.test(token)) return false;
77
81
  return token.startsWith("-");
78
82
  }
79
83
 
84
+ // A count read off a flag: digits only, above zero, or null. `parseInt`
85
+ // reads "7x" as 7 and "1.5" as 1, so a typo became a different request that
86
+ // ran without a word.
87
+ export function parsePositiveInt(raw) {
88
+ const s = String(raw ?? "").trim();
89
+ if (!/^\d+$/.test(s)) return null;
90
+ const n = Number(s);
91
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
92
+ }
93
+
80
94
  export function flagString(flags, name, fallback = undefined) {
81
95
  const v = flags.get(name);
82
96
  if (v === undefined || v === true) return fallback;
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ // Scaffolding a change folder.
3
+ //
4
+ // `work` opens a change as its first act and called into `commands/change.js`
5
+ // to do it (audit finding F7). Creating the folder is an OPERATION over the
6
+ // tree, not a rendering of the `change` command, so both entry points perform
7
+ // it from here.
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "./fs-ops.js";
11
+ import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "./templates.js";
12
+ import * as idx from "./index-json.js";
13
+ import { today } from "./dates.js";
14
+ import { flagBool, flagString } from "./args.js";
15
+ import { c } from "./colors.js";
16
+ import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
17
+ import { changeEntry } from "./scan.js";
18
+ export function changeNew(args, flags) {
19
+ const id = args[0];
20
+ const title = args.slice(1).join(" ").trim();
21
+ if (!id) {
22
+ console.error(c.red("error:") + " change new requires <id> and \"<title>\"");
23
+ return 2;
24
+ }
25
+ if (!title) {
26
+ console.error(c.red("error:") + " change new requires a title (quote it if it contains spaces)");
27
+ return 2;
28
+ }
29
+ // The id names a DIRECTORY, so it obeys the same grammar every other
30
+ // authoring command enforces on its argument (third audit, finding 1).
31
+ // Without this `change new ../../../elsewhere/evil` scaffolded outside the
32
+ // project, and `0003-com espaco` was accepted here and then carried by
33
+ // `validate`, `index rebuild` and `next` as a legitimate id.
34
+ if (!isChangeId(id)) {
35
+ console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
36
+ console.error(c.gray("hint: ") + "ids look like `0042-short-slug`; `doctrina work \"<prompt>\"` derives one for you");
37
+ return 2;
38
+ }
39
+
40
+ const force = flagBool(flags, "force", false);
41
+ // A chore is a spec-less change (infra / docs / build / migration) — review
42
+ // G9. It runs the full proposal → apply → archive → ledger lifecycle (so the
43
+ // history shows it) without forcing a fake spec delta. The empty specs/ dir
44
+ // is kept so `change apply`'s zero-delta path flips it to applied unchanged.
45
+ const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
46
+ const projectRoot = process.cwd();
47
+ ensureDoctrinaProject(projectRoot);
48
+
49
+ // Belt and braces: the grammar above already refuses a traversing id, and
50
+ // this holds if a future caller reaches this line another way.
51
+ const changeDir = resolveWithinProject(projectRoot, ".doctrina", "changes", id);
52
+ if (exists(changeDir) && !force) {
53
+ console.error(c.red("error:") + ` change "${id}" already exists at ${relPath(projectRoot, changeDir)}`);
54
+ return 1;
55
+ }
56
+
57
+ const templatesDir = locateTemplatesDir();
58
+ const date = today();
59
+ const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
60
+
61
+ // design.md is opt-in (--design): in practice it scaffolded on every change
62
+ // and stayed empty — nine changes out of nine in the 0.11.0 field review. A
63
+ // change that needs a design doc asks for one; the rest stop carrying a
64
+ // blank file through apply/archive/ledger.
65
+ const wantDesign = flagBool(flags, "design", false);
66
+ const tree = loadTemplateTree(templatesDir, "change");
67
+ for (const entry of tree) {
68
+ if (entry.relativePath === "spec-delta.md.template") continue;
69
+ if (entry.relativePath === "design.md.template" && !wantDesign) continue;
70
+ const written = materialiseEntry(entry, changeDir, tokens, { force });
71
+ console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
72
+ }
73
+ // No empty `specs/` here (change 0081). The directory used to be created
74
+ // unconditionally as somewhere "ready" for deltas, and when the CLI could
75
+ // not name a capability — a new prompt, a project whose specs do not match
76
+ // — nothing was ever written into it. An empty directory is not an absence:
77
+ // it says deltas live here, or lived here, and the next reader spends
78
+ // attention checking. The delta writer creates its own path when there is
79
+ // a delta to put in it.
80
+
81
+ // Stamp the proposal so a chore is honest in the artifact, not just the CLI.
82
+ if (chore) {
83
+ const proposalPath = path.join(changeDir, "proposal.md");
84
+ if (exists(proposalPath)) {
85
+ const txt = read(proposalPath);
86
+ const updated = txt.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, "$1 (none — chore)");
87
+ if (updated !== txt) write(proposalPath, updated, { force: true });
88
+ }
89
+ }
90
+
91
+ // Derive the entry from the proposal on disk rather than assembling one
92
+ // here: a field the deriver knows about and this writer does not is index
93
+ // drift the moment the change is opened (change 0076 — that is exactly what
94
+ // `lane` did). One constructor, in `scan.js`, for one record shape.
95
+ reindexChange(projectRoot, id, date);
96
+
97
+ console.log("");
98
+ if (chore) {
99
+ // A chore is still a change: it closes through the one definition of
100
+ // done, not a hand-run apply and archive (change 0180).
101
+ console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
102
+ c.cyan(`doctrina close ${id}`) + ".");
103
+ } else {
104
+ console.log(c.bold("Change opened.") + " Add spec deltas under " +
105
+ c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
106
+ }
107
+ return 0;
108
+ }
109
+
110
+ /**
111
+ * Register — or re-derive — one open change's index entry from its proposal.
112
+ *
113
+ * `work` writes the proposal in two passes: `changeNew` scaffolds it, then the
114
+ * command stamps the lane it classified and the specs it pinned. Indexing at
115
+ * the end of the FIRST pass reads a proposal that is not finished yet, which
116
+ * is why every `doctrina work` used to be followed by a `validate` error and
117
+ * an `index rebuild` nobody asked for. Calling this again after the stamping
118
+ * costs one file write and makes the tree honest on the next command.
119
+ *
120
+ * @param {string} projectRoot
121
+ * @param {string} id The change id (its directory name).
122
+ * @param {string} [date] Fallback for a proposal carrying no Date header.
123
+ */
124
+ export function reindexChange(projectRoot, id, date = today()) {
125
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
126
+ const proposal = isFile(proposalPath) ? read(proposalPath) : "";
127
+ const index = idx.load(projectRoot);
128
+ const prev = index.artifacts.changes.find((ch) => ch.id === id) ?? null;
129
+ index.artifacts.changes = index.artifacts.changes.filter((ch) => ch.id !== id);
130
+ index.artifacts.changes.push(changeEntry(proposal, id, prev, date));
131
+ idx.touch(index, date);
132
+ idx.save(projectRoot, index);
133
+ }