doctrina-cli 0.15.0 → 0.16.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 (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ // Drafting the agent-facing changelog from what actually shipped.
3
+ //
4
+ // `AGENT_CHANGELOG` in lib/commands.js is the block `upgrade --write` writes
5
+ // into AGENTS.md: at most five bullets telling an arriving agent what it must
6
+ // now DO differently. It is an object literal somebody edits by hand at every
7
+ // release, in parallel with a 57 KB prose CHANGELOG describing the same
8
+ // changes for people — and the comment above it already admits the approach
9
+ // "worked once and does not scale" (audit finding F22).
10
+ //
11
+ // This module does not write it. It DRAFTS candidates, from the signals the
12
+ // docs gate already extracts per archived change — the commands, flags and
13
+ // exit codes that change said it touched. Authorship stays human, because
14
+ // what an agent must do differently is a judgement about behaviour, and the
15
+ // CLI does not make those (ADR 0005). What it removes is the remembering.
16
+ import path from "node:path";
17
+ import { isDir } from "./fs-ops.js";
18
+ import { readLedger } from "./ledger.js";
19
+ import { documentedSurfaceSignals } from "./docs-impact.js";
20
+ import { AGENT_CHANGELOG_MAX_BULLETS } from "./commands.js";
21
+ import { lastTagDate, windowCutoff } from "./git.js";
22
+
23
+ /**
24
+ * Which window a draft covers, and why that window.
25
+ *
26
+ * "Since the last tag" is the honest default: the block describes the
27
+ * release being cut, and the last tag is where the previous one ended. With
28
+ * no tags (a young project, a shallow clone) it falls back to a day window
29
+ * and SAYS which it used — a draft that quietly covered the wrong period
30
+ * would be worse than one that names its edges.
31
+ *
32
+ * @param {string} projectRoot
33
+ * @param {{days?: number|null}} [options]
34
+ * @returns {{since: string, basis: string}}
35
+ */
36
+ export function draftWindow(projectRoot, { days = null } = {}) {
37
+ if (days !== null) return { since: windowCutoff(days), basis: `the last ${days} days` };
38
+ const tag = lastTagDate(projectRoot);
39
+ if (tag) return { since: tag, basis: "changes archived since the last tag" };
40
+ return { since: windowCutoff(30), basis: "the last 30 days (no tag to measure from)" };
41
+ }
42
+
43
+ /**
44
+ * Candidate bullets for the agent changelog, newest first.
45
+ *
46
+ * One candidate per archived change in the window that touched a documented
47
+ * surface. A change that touched none produces nothing — the block is for
48
+ * what an agent must do differently, and a change no agent can observe is
49
+ * not that.
50
+ *
51
+ * @param {string} projectRoot
52
+ * @param {{days?: number|null, max?: number}} [options]
53
+ * @returns {{
54
+ * since: string, basis: string, max: number,
55
+ * candidates: {id: string, date: string, title: string, signals: string[], bullet: string}[],
56
+ * silent: {id: string, date: string, title: string}[],
57
+ * truncated: number
58
+ * }}
59
+ */
60
+ export function draftAgentChangelog(projectRoot, { days = null, max = AGENT_CHANGELOG_MAX_BULLETS } = {}) {
61
+ const { since, basis } = draftWindow(projectRoot, { days });
62
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
63
+
64
+ const candidates = [];
65
+ const silent = [];
66
+ // The ledger is the record of what landed and when (change 0046); the
67
+ // archived folder is where the change's own artifacts still are.
68
+ const landed = readLedger(projectRoot).entries
69
+ .filter((e) => e.kind === "archived" && e.date >= since)
70
+ .sort((a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id));
71
+
72
+ for (const entry of landed) {
73
+ const dir = path.join(archiveDir, `${entry.date}-${entry.id}`);
74
+ const signals = isDir(dir) ? documentedSurfaceSignals(dir, projectRoot) : [];
75
+ if (signals.length === 0) {
76
+ silent.push({ id: entry.id, date: entry.date, title: entry.title });
77
+ continue;
78
+ }
79
+ candidates.push({
80
+ id: entry.id,
81
+ date: entry.date,
82
+ title: entry.title,
83
+ signals,
84
+ bullet: `${entry.title} (${signals.join("; ")}).`,
85
+ });
86
+ }
87
+
88
+ const truncated = Math.max(0, candidates.length - max);
89
+ return { since, basis, max, candidates: candidates.slice(0, max), silent, truncated };
90
+ }
91
+
92
+ /**
93
+ * The draft, rendered as the block a human edits into lib/commands.js.
94
+ *
95
+ * Deliberately shaped as the literal it will become, so the step is "cut and
96
+ * reword", not "translate". The bullets are drafts of the SHAPE — a title and
97
+ * the surface it touched — and every one of them is expected to be rewritten
98
+ * into what an agent must now do.
99
+ *
100
+ * @param {ReturnType<typeof draftAgentChangelog>} draft
101
+ * @param {string} version
102
+ * @returns {string[]} lines
103
+ */
104
+ export function renderDraft(draft, version) {
105
+ const out = [];
106
+ out.push(`# Agent changelog draft for ${version} — ${draft.basis} (since ${draft.since})`);
107
+ out.push("");
108
+ if (draft.candidates.length === 0) {
109
+ out.push("No archived change in this window touched a documented surface.");
110
+ out.push("Nothing to tell an arriving agent — which is a valid answer, not an empty one.");
111
+ return out;
112
+ }
113
+ out.push("Proposed, newest first. REWRITE each into what an agent must now DO;");
114
+ out.push("this draft only knows what surface the change touched.");
115
+ out.push("");
116
+ out.push(` "${version}": [`);
117
+ for (const cand of draft.candidates) {
118
+ out.push(` // ${cand.date} ${cand.id}`);
119
+ out.push(` ${JSON.stringify(cand.bullet)},`);
120
+ }
121
+ out.push(" ],");
122
+ if (draft.truncated > 0) {
123
+ out.push("");
124
+ out.push(`${draft.truncated} further candidate${draft.truncated === 1 ? "" : "s"} did not fit the ${draft.max}-bullet cap.`);
125
+ out.push("The cap is AGENTS.md's line budget, not a preference: choose, do not raise it.");
126
+ }
127
+ if (draft.silent.length > 0) {
128
+ out.push("");
129
+ out.push(`${draft.silent.length} change${draft.silent.length === 1 ? "" : "s"} touched no documented surface and are not proposed:`);
130
+ for (const s of draft.silent) out.push(` ${s.date} ${s.id}`);
131
+ }
132
+ return out;
133
+ }
@@ -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
+ }
@@ -0,0 +1,131 @@
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
+ console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
100
+ c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
101
+ } else {
102
+ console.log(c.bold("Change opened.") + " Add spec deltas under " +
103
+ c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
104
+ }
105
+ return 0;
106
+ }
107
+
108
+ /**
109
+ * Register — or re-derive — one open change's index entry from its proposal.
110
+ *
111
+ * `work` writes the proposal in two passes: `changeNew` scaffolds it, then the
112
+ * command stamps the lane it classified and the specs it pinned. Indexing at
113
+ * the end of the FIRST pass reads a proposal that is not finished yet, which
114
+ * is why every `doctrina work` used to be followed by a `validate` error and
115
+ * an `index rebuild` nobody asked for. Calling this again after the stamping
116
+ * costs one file write and makes the tree honest on the next command.
117
+ *
118
+ * @param {string} projectRoot
119
+ * @param {string} id The change id (its directory name).
120
+ * @param {string} [date] Fallback for a proposal carrying no Date header.
121
+ */
122
+ export function reindexChange(projectRoot, id, date = today()) {
123
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
124
+ const proposal = isFile(proposalPath) ? read(proposalPath) : "";
125
+ const index = idx.load(projectRoot);
126
+ const prev = index.artifacts.changes.find((ch) => ch.id === id) ?? null;
127
+ index.artifacts.changes = index.artifacts.changes.filter((ch) => ch.id !== id);
128
+ index.artifacts.changes.push(changeEntry(proposal, id, prev, date));
129
+ idx.touch(index, date);
130
+ idx.save(projectRoot, index);
131
+ }