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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -199
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- 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 +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -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/change-ops.js +131 -0
- package/src/lib/commands.js +109 -6
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +210 -24
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +238 -11
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -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 +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/contract.md.template +7 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/change.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getSection, getHeader } from "../lib/doc-model.js";
|
|
2
|
+
import { parseChecklist, getSection, getHeader, parseChangeTitle } from "../lib/doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
@@ -13,17 +13,20 @@ import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gat
|
|
|
13
13
|
import { today } from "../lib/dates.js";
|
|
14
14
|
import { flagBool, flagString } from "../lib/args.js";
|
|
15
15
|
import { c } from "../lib/colors.js";
|
|
16
|
+
import { appendLedgerLine, archivedLine, abandonedLine } from "../lib/ledger.js";
|
|
16
17
|
import { suggest } from "../lib/suggest.js";
|
|
17
18
|
import { confirm, isInteractive } from "../lib/prompt.js";
|
|
18
19
|
import { EXIT } from "../lib/exit-codes.js";
|
|
19
20
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
21
|
+
import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
22
|
+
import { changeNew } from "../lib/change-ops.js";
|
|
20
23
|
|
|
21
24
|
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
|
|
22
25
|
|
|
23
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
24
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
25
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
26
|
-
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec"], string: ["reason"] };
|
|
29
|
+
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason"] };
|
|
27
30
|
|
|
28
31
|
export async function run(positional, flags) {
|
|
29
32
|
const sub = positional[0];
|
|
@@ -35,7 +38,8 @@ export async function run(positional, flags) {
|
|
|
35
38
|
case "archive":
|
|
36
39
|
return forEachId(positional.slice(1), "archive", (id) => changeArchive([id], flags));
|
|
37
40
|
case "check":
|
|
38
|
-
return forEachId(positional.slice(1), "check", (id) =>
|
|
41
|
+
return forEachId(positional.slice(1), "check", (id) =>
|
|
42
|
+
changeCheck(id, { verbose: flagBool(flags, "verbose", false) }));
|
|
39
43
|
case "tick":
|
|
40
44
|
return changeTick(positional.slice(1), flags);
|
|
41
45
|
case "diff":
|
|
@@ -53,13 +57,23 @@ export async function run(positional, flags) {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
60
|
+
// One implementation, so every driver of a transition refuses — and
|
|
61
|
+
// explains, and forces — identically (C6).
|
|
62
|
+
//
|
|
63
|
+
// Returns `{ ok, forced }`. `forced` carries the blockers that were waved
|
|
64
|
+
// through, for the caller to record ONCE THE TRANSITION HAS ACTUALLY
|
|
65
|
+
// HAPPENED — see `recordForcedGap` below. This used to record here, at the
|
|
66
|
+
// moment the gate was overridden, which wrote history for an event that had
|
|
67
|
+
// not occurred yet and often never would: `change apply --force` on a
|
|
68
|
+
// change with an unappliable ops block logged "forced apply past 3
|
|
69
|
+
// blockers" while the apply wrote nothing, the spec stayed byte-identical
|
|
70
|
+
// and the proposal stayed `proposed` (third audit, finding 3). The ledger
|
|
71
|
+
// is the readable source of what happened to the tree; an attempt that
|
|
72
|
+
// changed nothing did not happen to it.
|
|
59
73
|
function enforceTransition(projectRoot, id, changeDir, transition, flags) {
|
|
60
74
|
const force = flagBool(flags, "force", false);
|
|
61
75
|
const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
|
|
62
|
-
if (ok) return true;
|
|
76
|
+
if (ok) return { ok: true, forced: null };
|
|
63
77
|
|
|
64
78
|
const label = TRANSITIONS[transition].label;
|
|
65
79
|
if (!force) {
|
|
@@ -67,12 +81,11 @@ function enforceTransition(projectRoot, id, changeDir, transition, flags) {
|
|
|
67
81
|
for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
|
|
68
82
|
const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
|
|
69
83
|
console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
|
|
70
|
-
return false;
|
|
84
|
+
return { ok: false, forced: null };
|
|
71
85
|
}
|
|
72
86
|
console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
|
|
73
87
|
for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
|
|
74
|
-
|
|
75
|
-
return true;
|
|
88
|
+
return { ok: true, forced: blockers };
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
// Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
|
|
@@ -109,76 +122,6 @@ async function forEachId(ids, name, one) {
|
|
|
109
122
|
return worst;
|
|
110
123
|
}
|
|
111
124
|
|
|
112
|
-
function changeNew(args, flags) {
|
|
113
|
-
const id = args[0];
|
|
114
|
-
const title = args.slice(1).join(" ").trim();
|
|
115
|
-
if (!id) {
|
|
116
|
-
console.error(c.red("error:") + " change new requires <id> and \"<title>\"");
|
|
117
|
-
return 2;
|
|
118
|
-
}
|
|
119
|
-
if (!title) {
|
|
120
|
-
console.error(c.red("error:") + " change new requires a title (quote it if it contains spaces)");
|
|
121
|
-
return 2;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const force = flagBool(flags, "force", false);
|
|
125
|
-
// A chore is a spec-less change (infra / docs / build / migration) — review
|
|
126
|
-
// G9. It runs the full proposal → apply → archive → ledger lifecycle (so the
|
|
127
|
-
// history shows it) without forcing a fake spec delta. The empty specs/ dir
|
|
128
|
-
// is kept so `change apply`'s zero-delta path flips it to applied unchanged.
|
|
129
|
-
const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
|
|
130
|
-
const projectRoot = process.cwd();
|
|
131
|
-
ensureDoctrinaProject(projectRoot);
|
|
132
|
-
|
|
133
|
-
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
134
|
-
if (exists(changeDir) && !force) {
|
|
135
|
-
console.error(c.red("error:") + ` change "${id}" already exists at ${relPath(projectRoot, changeDir)}`);
|
|
136
|
-
return 1;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const templatesDir = locateTemplatesDir();
|
|
140
|
-
const date = today();
|
|
141
|
-
const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
|
|
142
|
-
|
|
143
|
-
// design.md is opt-in (--design): in practice it scaffolded on every change
|
|
144
|
-
// and stayed empty — nine changes out of nine in the 0.11.0 field review. A
|
|
145
|
-
// change that needs a design doc asks for one; the rest stop carrying a
|
|
146
|
-
// blank file through apply/archive/ledger.
|
|
147
|
-
const wantDesign = flagBool(flags, "design", false);
|
|
148
|
-
const tree = loadTemplateTree(templatesDir, "change");
|
|
149
|
-
for (const entry of tree) {
|
|
150
|
-
if (entry.relativePath === "spec-delta.md.template") continue;
|
|
151
|
-
if (entry.relativePath === "design.md.template" && !wantDesign) continue;
|
|
152
|
-
const written = materialiseEntry(entry, changeDir, tokens, { force });
|
|
153
|
-
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
154
|
-
}
|
|
155
|
-
mkdirp(path.join(changeDir, "specs"));
|
|
156
|
-
|
|
157
|
-
// Stamp the proposal so a chore is honest in the artifact, not just the CLI.
|
|
158
|
-
if (chore) {
|
|
159
|
-
const proposalPath = path.join(changeDir, "proposal.md");
|
|
160
|
-
if (exists(proposalPath)) {
|
|
161
|
-
const txt = read(proposalPath);
|
|
162
|
-
const updated = txt.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, "$1 (none — chore)");
|
|
163
|
-
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const index = idx.load(projectRoot);
|
|
168
|
-
idx.addChange(index, { id, title, path: `.doctrina/changes/${id}`, status: "proposed", opened: date });
|
|
169
|
-
idx.touch(index, date);
|
|
170
|
-
idx.save(projectRoot, index);
|
|
171
|
-
|
|
172
|
-
console.log("");
|
|
173
|
-
if (chore) {
|
|
174
|
-
console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
|
|
175
|
-
c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
|
|
176
|
-
} else {
|
|
177
|
-
console.log(c.bold("Change opened.") + " Add spec deltas under " +
|
|
178
|
-
c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
|
|
179
|
-
}
|
|
180
|
-
return 0;
|
|
181
|
-
}
|
|
182
125
|
|
|
183
126
|
function changeApply(args, flags) {
|
|
184
127
|
const id = args[0];
|
|
@@ -192,7 +135,7 @@ function changeApply(args, flags) {
|
|
|
192
135
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
193
136
|
if (!isDir(changeDir)) {
|
|
194
137
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
195
|
-
return
|
|
138
|
+
return EXIT.USAGE;
|
|
196
139
|
}
|
|
197
140
|
|
|
198
141
|
// Gate parity (C6). `apply` used to mutate specs with no preconditions,
|
|
@@ -200,7 +143,8 @@ function changeApply(args, flags) {
|
|
|
200
143
|
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
201
144
|
// The preconditions now come from the shared map, so what guards a
|
|
202
145
|
// transition does not depend on which command drove it.
|
|
203
|
-
|
|
146
|
+
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
147
|
+
if (!applyGate.ok) return 1;
|
|
204
148
|
|
|
205
149
|
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
206
150
|
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
@@ -334,6 +278,12 @@ function changeApply(args, flags) {
|
|
|
334
278
|
}
|
|
335
279
|
|
|
336
280
|
console.log("");
|
|
281
|
+
// The gap is history only once the transition happened. An apply that
|
|
282
|
+
// errored wrote nothing and left the proposal `proposed`, so there is
|
|
283
|
+
// nothing for the ledger to record (change 0096).
|
|
284
|
+
if (applyGate.forced && errors === 0) {
|
|
285
|
+
recordForcedGap(projectRoot, id, "apply", applyGate.forced);
|
|
286
|
+
}
|
|
337
287
|
console.log(c.bold("Apply summary:") + ` ${writes} written, ${manual} manual, ${errors} errors.`);
|
|
338
288
|
if (manual > 0) {
|
|
339
289
|
console.log(`Resolve manual merges, then run ${c.cyan(`doctrina change archive ${id}`)}.`);
|
|
@@ -346,13 +296,13 @@ function changeApply(args, flags) {
|
|
|
346
296
|
// Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
|
|
347
297
|
// apply, and archive would refuse, listed BEFORE any of them runs, with the
|
|
348
298
|
// remediation next to each finding. Read-only — the per-change `doctor`.
|
|
349
|
-
async function changeCheck(id) {
|
|
299
|
+
async function changeCheck(id, { verbose = false } = {}) {
|
|
350
300
|
const projectRoot = process.cwd();
|
|
351
301
|
ensureDoctrinaProject(projectRoot);
|
|
352
302
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
353
303
|
if (!isDir(changeDir)) {
|
|
354
304
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
355
|
-
return
|
|
305
|
+
return EXIT.USAGE;
|
|
356
306
|
}
|
|
357
307
|
|
|
358
308
|
let failures = 0;
|
|
@@ -398,6 +348,15 @@ async function changeCheck(id) {
|
|
|
398
348
|
if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
|
|
399
349
|
if (opsFindings > 0) failures += 1;
|
|
400
350
|
|
|
351
|
+
// --verbose: the same per-delta preview `change diff` prints. The dry-run
|
|
352
|
+
// above says whether the ops WOULD apply; this says what the file would
|
|
353
|
+
// look like afterwards, which is the question the separate command existed
|
|
354
|
+
// to answer (change 0049).
|
|
355
|
+
if (verbose && deltaFiles.length > 0) {
|
|
356
|
+
console.log(c.gray("──── deltas in full (--verbose)"));
|
|
357
|
+
if (printDeltaPreview(projectRoot, changeDir, deltaFiles) > 0) failures += 1;
|
|
358
|
+
}
|
|
359
|
+
|
|
401
360
|
// 3. Archive gate preview: what archive will refuse, listed with the fix.
|
|
402
361
|
console.log(c.gray("──── 3/3 archive gate"));
|
|
403
362
|
// Preview through the SAME map the transitions enforce, so `check` can
|
|
@@ -442,7 +401,7 @@ function changeTick(args, flags) {
|
|
|
442
401
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
443
402
|
if (!isDir(changeDir)) {
|
|
444
403
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
445
|
-
return
|
|
404
|
+
return EXIT.USAGE;
|
|
446
405
|
}
|
|
447
406
|
|
|
448
407
|
// Unified ordinal space over both files, in reading order: every unchecked
|
|
@@ -451,57 +410,73 @@ function changeTick(args, flags) {
|
|
|
451
410
|
{ label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
|
|
452
411
|
{ label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
|
|
453
412
|
];
|
|
413
|
+
// STABLE ordinals (change 0104): every box in reading order, checked or
|
|
414
|
+
// not, so a number means the same box on every invocation. The ordinals
|
|
415
|
+
// used to run over the UNCHECKED boxes only, and renumbered after each
|
|
416
|
+
// tick: `tick 1`, `tick 2`, `tick 3`, `tick 4` in four calls ticked task 1,
|
|
417
|
+
// then "Apply the change", "Update index.json" and "acceptance criteria
|
|
418
|
+
// are met" — the closing steps and the proposal's Verification claims —
|
|
419
|
+
// while tasks 2-4 stayed open. A box is a claim; the number that names
|
|
420
|
+
// it cannot move.
|
|
454
421
|
const boxes = [];
|
|
455
422
|
for (const f of files) {
|
|
456
423
|
if (!isFile(f.path)) continue;
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
: (() => {
|
|
461
|
-
const scoped = new Set();
|
|
462
|
-
let inSection = false;
|
|
463
|
-
for (let i = 0; i < lines.length; i++) {
|
|
464
|
-
if (/^##\s+/.test(lines[i])) inSection = new RegExp(`^##\\s+${f.section}\\b`, "i").test(lines[i]);
|
|
465
|
-
else if (inSection) scoped.add(i);
|
|
466
|
-
}
|
|
467
|
-
return (i) => scoped.has(i);
|
|
468
|
-
})();
|
|
469
|
-
for (let i = 0; i < lines.length; i++) {
|
|
470
|
-
if (/^\s*-\s*\[ \]/.test(lines[i]) && inScope(i)) {
|
|
471
|
-
boxes.push({ file: f, lineIndex: i, text: lines[i].replace(/^\s*-\s*\[ \]\s*/, "") });
|
|
472
|
-
}
|
|
424
|
+
// One box grammar, from the document model (change 0067).
|
|
425
|
+
for (const box of parseChecklist(read(f.path), { section: f.section })) {
|
|
426
|
+
boxes.push({ file: f, lineIndex: box.line, text: box.text, placeholder: box.placeholder, checked: box.checked });
|
|
473
427
|
}
|
|
474
428
|
}
|
|
429
|
+
const open = boxes.filter((b) => !b.checked);
|
|
475
430
|
|
|
476
|
-
if (
|
|
431
|
+
if (open.length === 0) {
|
|
477
432
|
console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
|
|
478
433
|
return 0;
|
|
479
434
|
}
|
|
480
435
|
|
|
481
436
|
const all = flagBool(flags, "all", false);
|
|
482
|
-
const
|
|
483
|
-
const isPlaceholder = (box) => box.
|
|
484
|
-
if (!all &&
|
|
485
|
-
console.log(c.bold(`
|
|
437
|
+
const rawOrdinals = args.slice(1);
|
|
438
|
+
const isPlaceholder = (box) => box.placeholder;
|
|
439
|
+
if (!all && rawOrdinals.length === 0) {
|
|
440
|
+
console.log(c.bold(`Boxes in ${id}`) + c.gray(` (${open.length} of ${boxes.length} unchecked; numbers are stable — a ticked box keeps its number):`));
|
|
486
441
|
console.log("");
|
|
487
442
|
for (let i = 0; i < boxes.length; i++) {
|
|
488
|
-
const
|
|
443
|
+
const b = boxes[i];
|
|
444
|
+
const label = isPlaceholder(b)
|
|
489
445
|
? c.yellow("(scaffold placeholder — write the real task, or delete the line)")
|
|
490
|
-
:
|
|
491
|
-
|
|
446
|
+
: b.text;
|
|
447
|
+
const state = b.checked ? c.green("[x]") : "[ ]";
|
|
448
|
+
const line = ` ${String(i + 1).padStart(3)}. ${state} ${label} ${c.gray(`[${b.file.label}]`)}`;
|
|
449
|
+
console.log(b.checked ? c.gray(line) : line);
|
|
492
450
|
}
|
|
493
451
|
console.log("");
|
|
494
|
-
|
|
452
|
+
const sample = open.slice(0, 2).map((b) => boxes.indexOf(b) + 1).join(" ");
|
|
453
|
+
console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} ${sample}`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
495
454
|
return 0;
|
|
496
455
|
}
|
|
497
456
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
457
|
+
let picked;
|
|
458
|
+
if (all) {
|
|
459
|
+
picked = open.map((b) => boxes.indexOf(b) + 1);
|
|
460
|
+
} else {
|
|
461
|
+
picked = [];
|
|
462
|
+
for (const raw of rawOrdinals) {
|
|
463
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
464
|
+
if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
|
|
465
|
+
console.error(c.red("error:") + ` no box "${raw}" (a number 1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
picked.push(n);
|
|
503
469
|
}
|
|
504
470
|
}
|
|
471
|
+
// A box already ticked is a no-op, named: ticking it twice is not a
|
|
472
|
+
// second claim, and refusing would punish the stable numbering.
|
|
473
|
+
const already = picked.filter((n) => boxes[n - 1].checked);
|
|
474
|
+
for (const n of already) console.log(c.gray(`note: box ${n} is already ticked — left as is`));
|
|
475
|
+
picked = picked.filter((n) => !boxes[n - 1].checked);
|
|
476
|
+
if (picked.length === 0) {
|
|
477
|
+
console.log(c.green("ok") + " nothing to tick");
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
505
480
|
// Ticking an empty scaffold placeholder is a meaningless claim — it is how
|
|
506
481
|
// a hollow change games the archive gate. Refuse (all-or-nothing) and name
|
|
507
482
|
// the fix; analyze/close hard-fail on the placeholders regardless.
|
|
@@ -542,7 +517,7 @@ function changeArchive(args, flags) {
|
|
|
542
517
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
543
518
|
if (!isDir(changeDir)) {
|
|
544
519
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
545
|
-
return
|
|
520
|
+
return EXIT.USAGE;
|
|
546
521
|
}
|
|
547
522
|
|
|
548
523
|
// Verification gate. "Done" is a claim until it is checked. Archiving is
|
|
@@ -555,7 +530,8 @@ function changeArchive(args, flags) {
|
|
|
555
530
|
// `close` path always ran first, so the same state was guarded
|
|
556
531
|
// differently depending on the route taken. --force is the escape hatch
|
|
557
532
|
// for both, and records the gap.
|
|
558
|
-
|
|
533
|
+
const archiveGate = enforceTransition(projectRoot, id, changeDir, "archive", flags);
|
|
534
|
+
if (!archiveGate.ok) return 1;
|
|
559
535
|
|
|
560
536
|
const date = today();
|
|
561
537
|
const archiveName = `${date}-${id}`;
|
|
@@ -585,6 +561,13 @@ function changeArchive(args, flags) {
|
|
|
585
561
|
|
|
586
562
|
move(changeDir, archiveDir);
|
|
587
563
|
console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
|
|
564
|
+
// The move happened, so a waved-through blocker is now part of the
|
|
565
|
+
// history and the ledger says so (change 0096). Recorded here rather than
|
|
566
|
+
// at the gate, so a forced transition that then failed leaves no claim
|
|
567
|
+
// behind it.
|
|
568
|
+
if (archiveGate.forced) {
|
|
569
|
+
recordForcedGap(projectRoot, id, "archive", archiveGate.forced);
|
|
570
|
+
}
|
|
588
571
|
|
|
589
572
|
// Collect affected specs from delta files for the index entry
|
|
590
573
|
const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
@@ -599,30 +582,16 @@ function changeArchive(args, flags) {
|
|
|
599
582
|
// Title from proposal
|
|
600
583
|
const proposal = path.join(archiveDir, "proposal.md");
|
|
601
584
|
if (exists(proposal)) {
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
// The id itself may contain hyphens (NNNN-slug), so match it as \S+
|
|
606
|
-
// and split on the em-dash/hyphen separator that follows whitespace.
|
|
607
|
-
const m = firstLine.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/);
|
|
608
|
-
if (m) title = m[1].trim();
|
|
585
|
+
// One owner for the H1's grammar (ADR 0021): the id contains hyphens,
|
|
586
|
+
// and every copy of this parse got that wrong in a different way.
|
|
587
|
+
title = parseChangeTitle(read(proposal)) ?? title;
|
|
609
588
|
}
|
|
610
589
|
|
|
611
590
|
// Append a one-line summary to the archive ledger so history stays
|
|
612
591
|
// scannable without opening folders (the archive is out of the default
|
|
613
592
|
// read path; the ledger is the cheap way back in).
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
write(ledgerPath,
|
|
617
|
-
"# Change ledger\n\n" +
|
|
618
|
-
"One line per archived change, newest last. Appended by\n" +
|
|
619
|
-
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
620
|
-
}
|
|
621
|
-
const specsSummary = specsAffected.length > 0
|
|
622
|
-
? ` (specs: ${specsAffected.map((s) => `${s.capability} ${s.operation}`).join(", ")})`
|
|
623
|
-
: "";
|
|
624
|
-
write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — ${title}${specsSummary}\n`, { force: true });
|
|
625
|
-
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
|
|
593
|
+
const ledgerFile = appendLedgerLine(projectRoot, archivedLine(id, title, specsAffected, date));
|
|
594
|
+
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
|
|
626
595
|
|
|
627
596
|
const index = idx.load(projectRoot);
|
|
628
597
|
idx.moveChangeToArchive(index, id, {
|
|
@@ -655,7 +624,7 @@ async function changeAbandon(args, flags) {
|
|
|
655
624
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
656
625
|
if (!isDir(changeDir)) {
|
|
657
626
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
658
|
-
return
|
|
627
|
+
return EXIT.USAGE;
|
|
659
628
|
}
|
|
660
629
|
|
|
661
630
|
// Consent before destruction (audit item C9). This deletes work with no
|
|
@@ -690,16 +659,8 @@ async function changeAbandon(args, flags) {
|
|
|
690
659
|
console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
|
|
691
660
|
|
|
692
661
|
// Ledger line so the abandonment is part of the visible history.
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
write(ledgerPath,
|
|
696
|
-
"# Change ledger\n\n" +
|
|
697
|
-
"One line per archived change, newest last. Appended by\n" +
|
|
698
|
-
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
699
|
-
}
|
|
700
|
-
const reasonSuffix = reason ? ` — ${reason}` : "";
|
|
701
|
-
write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — abandoned${reasonSuffix}\n`, { force: true });
|
|
702
|
-
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
|
|
662
|
+
const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, reason, date));
|
|
663
|
+
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
|
|
703
664
|
|
|
704
665
|
// Drop the change entry; rebuild from the tree so nothing drifts.
|
|
705
666
|
const current = idx.load(projectRoot);
|
|
@@ -710,27 +671,15 @@ async function changeAbandon(args, flags) {
|
|
|
710
671
|
return 0;
|
|
711
672
|
}
|
|
712
673
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
723
|
-
if (!isDir(changeDir)) {
|
|
724
|
-
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
725
|
-
return 1;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
729
|
-
if (deltaFiles.length === 0) {
|
|
730
|
-
console.log(c.gray("no spec deltas in this change; nothing to diff"));
|
|
731
|
-
return 0;
|
|
732
|
-
}
|
|
733
|
-
|
|
674
|
+
// The per-delta preview: what applying this delta would do to its target.
|
|
675
|
+
// ADDED reports the body it would write, REMOVED the spec it would delete,
|
|
676
|
+
// MODIFIED a line diff against the current spec.
|
|
677
|
+
//
|
|
678
|
+
// One renderer, two callers (change 0049): `change diff` is this and nothing
|
|
679
|
+
// else, and `change check --verbose` prints it after its ops dry-run — which
|
|
680
|
+
// is what makes the merge honest rather than a claim. Returns the number of
|
|
681
|
+
// deltas it could not read.
|
|
682
|
+
export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
|
|
734
683
|
let errors = 0;
|
|
735
684
|
for (const deltaPath of deltaFiles) {
|
|
736
685
|
const rel = relPath(changeDir, deltaPath);
|
|
@@ -773,28 +722,35 @@ function changeDiff(args, _flags) {
|
|
|
773
722
|
console.log(out);
|
|
774
723
|
}
|
|
775
724
|
}
|
|
725
|
+
return errors;
|
|
726
|
+
}
|
|
776
727
|
|
|
728
|
+
function changeDiff(args, _flags) {
|
|
729
|
+
const id = args[0];
|
|
730
|
+
if (!id) {
|
|
731
|
+
console.error(c.red("error:") + " change diff requires <id>");
|
|
732
|
+
return 2;
|
|
733
|
+
}
|
|
734
|
+
const projectRoot = process.cwd();
|
|
735
|
+
ensureDoctrinaProject(projectRoot);
|
|
736
|
+
|
|
737
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
738
|
+
if (!isDir(changeDir)) {
|
|
739
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
740
|
+
return EXIT.USAGE;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
744
|
+
if (deltaFiles.length === 0) {
|
|
745
|
+
console.log(c.gray("no spec deltas in this change; nothing to diff"));
|
|
746
|
+
return 0;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const errors = printDeltaPreview(projectRoot, changeDir, deltaFiles);
|
|
777
750
|
console.log("");
|
|
778
751
|
return errors > 0 ? 1 : 0;
|
|
779
752
|
}
|
|
780
753
|
|
|
781
|
-
function parseOperation(text) {
|
|
782
|
-
const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
783
|
-
if (!m) return null;
|
|
784
|
-
const op = m[1];
|
|
785
|
-
if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
|
|
786
|
-
return null;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function parseCapabilityFromDelta(text, deltaPath) {
|
|
790
|
-
// Prefer the explicit header "# Spec Delta — capability: <name>"
|
|
791
|
-
const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
|
|
792
|
-
if (m) return m[1];
|
|
793
|
-
// Fall back to the parent directory name of the delta file
|
|
794
|
-
const parent = path.basename(path.dirname(deltaPath));
|
|
795
|
-
if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
|
|
796
|
-
return null;
|
|
797
|
-
}
|
|
798
754
|
|
|
799
755
|
function extractDeltaBody(text) {
|
|
800
756
|
// The delta separates headers from the spec body with a `---` line.
|
|
@@ -803,37 +759,6 @@ function extractDeltaBody(text) {
|
|
|
803
759
|
return text.slice(idxSep + 5).replace(/^\n+/, "");
|
|
804
760
|
}
|
|
805
761
|
|
|
806
|
-
// Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
|
|
807
|
-
// check: render the shipped capability template for the same capability and
|
|
808
|
-
// compare, ignoring the date-bearing "Last updated" line and whitespace
|
|
809
|
-
// normalisation. When the template cannot be located (unusual installs),
|
|
810
|
-
// fall back to the scaffold's own placeholder fingerprints — text no real
|
|
811
|
-
// spec keeps. Used by `change apply` so an ADDED delta can replace a
|
|
812
|
-
// scaffold (the canonical spec-new → delta flow) without ever clobbering a
|
|
813
|
-
// spec that carries real content.
|
|
814
|
-
function isUntouchedScaffold(specText, capability) {
|
|
815
|
-
const normalize = (s) =>
|
|
816
|
-
s.replace(/\r\n/g, "\n")
|
|
817
|
-
.split("\n")
|
|
818
|
-
.filter((line) => !/^\*\*Last updated:\*\*/.test(line))
|
|
819
|
-
.join("\n")
|
|
820
|
-
.trim();
|
|
821
|
-
try {
|
|
822
|
-
const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
|
|
823
|
-
const rendered = read(tplPath)
|
|
824
|
-
.replace(/\{\{CAPABILITY\}\}/g, capability)
|
|
825
|
-
.replace(/\{\{DATE\}\}/g, "");
|
|
826
|
-
if (normalize(rendered) === normalize(specText)) return true;
|
|
827
|
-
} catch {
|
|
828
|
-
// fall through to the fingerprint heuristic
|
|
829
|
-
}
|
|
830
|
-
// Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
|
|
831
|
-
// section survive only in a scaffold nobody edited.
|
|
832
|
-
return (
|
|
833
|
-
specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
|
|
834
|
-
/##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
|
|
835
|
-
);
|
|
836
|
-
}
|
|
837
762
|
|
|
838
763
|
// Reasons a change is not finished enough to archive. Counts unchecked
|
|
839
764
|
// GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
|
|
@@ -917,4 +842,5 @@ Options:
|
|
|
917
842
|
|
|
918
843
|
// Re-export parsers so scan.js (index rebuild) can reuse them, and the
|
|
919
844
|
// scaffold so `work` can open a change without duplicating the logic.
|
|
920
|
-
export { parseOperation, parseCapabilityFromDelta,
|
|
845
|
+
export { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
846
|
+
export { changeNew } from "../lib/change-ops.js";
|