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.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- 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
|
+
}
|