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
package/src/commands/change.js
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
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";
|
|
6
6
|
import { diffLines, formatUnified } from "../lib/diff.js";
|
|
7
7
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
|
|
8
8
|
import * as idx from "../lib/index-json.js";
|
|
9
|
-
import { deriveIndex } from "../lib/scan.js";
|
|
9
|
+
import { archivedChangeEntry, deriveIndex } from "../lib/scan.js";
|
|
10
10
|
import { extractOps, applyOps } from "../lib/spec-ops.js";
|
|
11
11
|
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
12
12
|
import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
|
|
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
|
-
import {
|
|
20
|
+
import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
|
|
21
|
+
import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
22
|
+
import { changeNew } from "../lib/change-ops.js";
|
|
20
23
|
|
|
21
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "
|
|
24
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "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,11 +38,10 @@ 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
|
-
case "diff":
|
|
42
|
-
return changeDiff(positional.slice(1), flags);
|
|
43
45
|
case "abandon":
|
|
44
46
|
return await changeAbandon(positional.slice(1), flags);
|
|
45
47
|
default:
|
|
@@ -53,13 +55,23 @@ export async function run(positional, flags) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
58
|
+
// One implementation, so every driver of a transition refuses — and
|
|
59
|
+
// explains, and forces — identically (C6).
|
|
60
|
+
//
|
|
61
|
+
// Returns `{ ok, forced }`. `forced` carries the blockers that were waved
|
|
62
|
+
// through, for the caller to record ONCE THE TRANSITION HAS ACTUALLY
|
|
63
|
+
// HAPPENED — see `recordForcedGap` below. This used to record here, at the
|
|
64
|
+
// moment the gate was overridden, which wrote history for an event that had
|
|
65
|
+
// not occurred yet and often never would: `change apply --force` on a
|
|
66
|
+
// change with an unappliable ops block logged "forced apply past 3
|
|
67
|
+
// blockers" while the apply wrote nothing, the spec stayed byte-identical
|
|
68
|
+
// and the proposal stayed `proposed` (third audit, finding 3). The ledger
|
|
69
|
+
// is the readable source of what happened to the tree; an attempt that
|
|
70
|
+
// changed nothing did not happen to it.
|
|
59
71
|
function enforceTransition(projectRoot, id, changeDir, transition, flags) {
|
|
60
72
|
const force = flagBool(flags, "force", false);
|
|
61
73
|
const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
|
|
62
|
-
if (ok) return true;
|
|
74
|
+
if (ok) return { ok: true, forced: null };
|
|
63
75
|
|
|
64
76
|
const label = TRANSITIONS[transition].label;
|
|
65
77
|
if (!force) {
|
|
@@ -67,12 +79,11 @@ function enforceTransition(projectRoot, id, changeDir, transition, flags) {
|
|
|
67
79
|
for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
|
|
68
80
|
const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
|
|
69
81
|
console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
|
|
70
|
-
return false;
|
|
82
|
+
return { ok: false, forced: null };
|
|
71
83
|
}
|
|
72
84
|
console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
|
|
73
85
|
for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
|
|
74
|
-
|
|
75
|
-
return true;
|
|
86
|
+
return { ok: true, forced: blockers };
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
// Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
|
|
@@ -87,6 +98,8 @@ async function forEachId(ids, name, one) {
|
|
|
87
98
|
}
|
|
88
99
|
let worst = 0;
|
|
89
100
|
for (const id of ids) {
|
|
101
|
+
const refused = refuseChangeRef(id);
|
|
102
|
+
if (refused !== null) { worst = Math.max(worst, refused); continue; }
|
|
90
103
|
if (ids.length > 1) {
|
|
91
104
|
console.log("");
|
|
92
105
|
console.log(c.bold(`──── change ${name} ${id}`));
|
|
@@ -95,6 +108,14 @@ async function forEachId(ids, name, one) {
|
|
|
95
108
|
try {
|
|
96
109
|
code = await one(id);
|
|
97
110
|
} catch (err) {
|
|
111
|
+
// A TYPED error is a condition of the RUN, not a verdict on this id:
|
|
112
|
+
// "not a Doctrina project" is equally true of every id, and the class
|
|
113
|
+
// it carries is the whole point of the contract. Flattening it to 1
|
|
114
|
+
// here made `change apply|archive|check` answer "fix your work and
|
|
115
|
+
// retry" where `change new`, one switch arm away, answered "run the
|
|
116
|
+
// setup command first" — same condition, same sentence, two codes.
|
|
117
|
+
// The entrypoint owns the mapping and prints the remedy; let it.
|
|
118
|
+
if (err && typeof err.exitCode === "number") throw err;
|
|
98
119
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
99
120
|
code = 1;
|
|
100
121
|
}
|
|
@@ -109,76 +130,6 @@ async function forEachId(ids, name, one) {
|
|
|
109
130
|
return worst;
|
|
110
131
|
}
|
|
111
132
|
|
|
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
133
|
|
|
183
134
|
function changeApply(args, flags) {
|
|
184
135
|
const id = args[0];
|
|
@@ -192,7 +143,7 @@ function changeApply(args, flags) {
|
|
|
192
143
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
193
144
|
if (!isDir(changeDir)) {
|
|
194
145
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
195
|
-
return
|
|
146
|
+
return EXIT.USAGE;
|
|
196
147
|
}
|
|
197
148
|
|
|
198
149
|
// Gate parity (C6). `apply` used to mutate specs with no preconditions,
|
|
@@ -200,7 +151,8 @@ function changeApply(args, flags) {
|
|
|
200
151
|
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
201
152
|
// The preconditions now come from the shared map, so what guards a
|
|
202
153
|
// transition does not depend on which command drove it.
|
|
203
|
-
|
|
154
|
+
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
155
|
+
if (!applyGate.ok) return 1;
|
|
204
156
|
|
|
205
157
|
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
206
158
|
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
@@ -334,6 +286,12 @@ function changeApply(args, flags) {
|
|
|
334
286
|
}
|
|
335
287
|
|
|
336
288
|
console.log("");
|
|
289
|
+
// The gap is history only once the transition happened. An apply that
|
|
290
|
+
// errored wrote nothing and left the proposal `proposed`, so there is
|
|
291
|
+
// nothing for the ledger to record (change 0096).
|
|
292
|
+
if (applyGate.forced && errors === 0) {
|
|
293
|
+
recordForcedGap(projectRoot, id, "apply", applyGate.forced);
|
|
294
|
+
}
|
|
337
295
|
console.log(c.bold("Apply summary:") + ` ${writes} written, ${manual} manual, ${errors} errors.`);
|
|
338
296
|
if (manual > 0) {
|
|
339
297
|
console.log(`Resolve manual merges, then run ${c.cyan(`doctrina change archive ${id}`)}.`);
|
|
@@ -346,19 +304,19 @@ function changeApply(args, flags) {
|
|
|
346
304
|
// Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
|
|
347
305
|
// apply, and archive would refuse, listed BEFORE any of them runs, with the
|
|
348
306
|
// remediation next to each finding. Read-only — the per-change `doctor`.
|
|
349
|
-
async function changeCheck(id) {
|
|
307
|
+
async function changeCheck(id, { verbose = false } = {}) {
|
|
350
308
|
const projectRoot = process.cwd();
|
|
351
309
|
ensureDoctrinaProject(projectRoot);
|
|
352
310
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
353
311
|
if (!isDir(changeDir)) {
|
|
354
312
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
355
|
-
return
|
|
313
|
+
return EXIT.USAGE;
|
|
356
314
|
}
|
|
357
315
|
|
|
358
316
|
let failures = 0;
|
|
359
317
|
|
|
360
318
|
// 1. Structural analysis — same checks analyze runs before an apply.
|
|
361
|
-
console.log(c.gray("──── 1/3 structure
|
|
319
|
+
console.log(c.gray("──── 1/3 structure"));
|
|
362
320
|
const analyze = await import("./analyze.js");
|
|
363
321
|
if ((await analyze.run([id], new Map())) !== 0) failures += 1;
|
|
364
322
|
|
|
@@ -398,6 +356,15 @@ async function changeCheck(id) {
|
|
|
398
356
|
if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
|
|
399
357
|
if (opsFindings > 0) failures += 1;
|
|
400
358
|
|
|
359
|
+
// --verbose: the per-delta preview the removed `change diff` printed. The dry-run
|
|
360
|
+
// above says whether the ops WOULD apply; this says what the file would
|
|
361
|
+
// look like afterwards, which is the question the separate command existed
|
|
362
|
+
// to answer (change 0049).
|
|
363
|
+
if (verbose && deltaFiles.length > 0) {
|
|
364
|
+
console.log(c.gray("──── deltas in full (--verbose)"));
|
|
365
|
+
if (printDeltaPreview(projectRoot, changeDir, deltaFiles) > 0) failures += 1;
|
|
366
|
+
}
|
|
367
|
+
|
|
401
368
|
// 3. Archive gate preview: what archive will refuse, listed with the fix.
|
|
402
369
|
console.log(c.gray("──── 3/3 archive gate"));
|
|
403
370
|
// Preview through the SAME map the transitions enforce, so `check` can
|
|
@@ -437,12 +404,14 @@ function changeTick(args, flags) {
|
|
|
437
404
|
console.error(c.red("error:") + " change tick requires <id> [ordinals... | --all]");
|
|
438
405
|
return 2;
|
|
439
406
|
}
|
|
407
|
+
const refused = refuseChangeRef(id);
|
|
408
|
+
if (refused !== null) return refused;
|
|
440
409
|
const projectRoot = process.cwd();
|
|
441
410
|
ensureDoctrinaProject(projectRoot);
|
|
442
411
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
443
412
|
if (!isDir(changeDir)) {
|
|
444
413
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
445
|
-
return
|
|
414
|
+
return EXIT.USAGE;
|
|
446
415
|
}
|
|
447
416
|
|
|
448
417
|
// Unified ordinal space over both files, in reading order: every unchecked
|
|
@@ -451,57 +420,73 @@ function changeTick(args, flags) {
|
|
|
451
420
|
{ label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
|
|
452
421
|
{ label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
|
|
453
422
|
];
|
|
423
|
+
// STABLE ordinals (change 0104): every box in reading order, checked or
|
|
424
|
+
// not, so a number means the same box on every invocation. The ordinals
|
|
425
|
+
// used to run over the UNCHECKED boxes only, and renumbered after each
|
|
426
|
+
// tick: `tick 1`, `tick 2`, `tick 3`, `tick 4` in four calls ticked task 1,
|
|
427
|
+
// then "Apply the change", "Update index.json" and "acceptance criteria
|
|
428
|
+
// are met" — the closing steps and the proposal's Verification claims —
|
|
429
|
+
// while tasks 2-4 stayed open. A box is a claim; the number that names
|
|
430
|
+
// it cannot move.
|
|
454
431
|
const boxes = [];
|
|
455
432
|
for (const f of files) {
|
|
456
433
|
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
|
-
}
|
|
434
|
+
// One box grammar, from the document model (change 0067).
|
|
435
|
+
for (const box of parseChecklist(read(f.path), { section: f.section })) {
|
|
436
|
+
boxes.push({ file: f, lineIndex: box.line, text: box.text, placeholder: box.placeholder, checked: box.checked });
|
|
473
437
|
}
|
|
474
438
|
}
|
|
439
|
+
const open = boxes.filter((b) => !b.checked);
|
|
475
440
|
|
|
476
|
-
if (
|
|
441
|
+
if (open.length === 0) {
|
|
477
442
|
console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
|
|
478
443
|
return 0;
|
|
479
444
|
}
|
|
480
445
|
|
|
481
446
|
const all = flagBool(flags, "all", false);
|
|
482
|
-
const
|
|
483
|
-
const isPlaceholder = (box) => box.
|
|
484
|
-
if (!all &&
|
|
485
|
-
console.log(c.bold(`
|
|
447
|
+
const rawOrdinals = args.slice(1);
|
|
448
|
+
const isPlaceholder = (box) => box.placeholder;
|
|
449
|
+
if (!all && rawOrdinals.length === 0) {
|
|
450
|
+
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
451
|
console.log("");
|
|
487
452
|
for (let i = 0; i < boxes.length; i++) {
|
|
488
|
-
const
|
|
453
|
+
const b = boxes[i];
|
|
454
|
+
const label = isPlaceholder(b)
|
|
489
455
|
? c.yellow("(scaffold placeholder — write the real task, or delete the line)")
|
|
490
|
-
:
|
|
491
|
-
|
|
456
|
+
: b.text;
|
|
457
|
+
const state = b.checked ? c.green("[x]") : "[ ]";
|
|
458
|
+
const line = ` ${String(i + 1).padStart(3)}. ${state} ${label} ${c.gray(`[${b.file.label}]`)}`;
|
|
459
|
+
console.log(b.checked ? c.gray(line) : line);
|
|
492
460
|
}
|
|
493
461
|
console.log("");
|
|
494
|
-
|
|
462
|
+
const sample = open.slice(0, 2).map((b) => boxes.indexOf(b) + 1).join(" ");
|
|
463
|
+
console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} ${sample}`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
495
464
|
return 0;
|
|
496
465
|
}
|
|
497
466
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
467
|
+
let picked;
|
|
468
|
+
if (all) {
|
|
469
|
+
picked = open.map((b) => boxes.indexOf(b) + 1);
|
|
470
|
+
} else {
|
|
471
|
+
picked = [];
|
|
472
|
+
for (const raw of rawOrdinals) {
|
|
473
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
474
|
+
if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
|
|
475
|
+
console.error(c.red("error:") + ` no box "${raw}" (a number 1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
|
|
476
|
+
return 2;
|
|
477
|
+
}
|
|
478
|
+
picked.push(n);
|
|
503
479
|
}
|
|
504
480
|
}
|
|
481
|
+
// A box already ticked is a no-op, named: ticking it twice is not a
|
|
482
|
+
// second claim, and refusing would punish the stable numbering.
|
|
483
|
+
const already = picked.filter((n) => boxes[n - 1].checked);
|
|
484
|
+
for (const n of already) console.log(c.gray(`note: box ${n} is already ticked — left as is`));
|
|
485
|
+
picked = picked.filter((n) => !boxes[n - 1].checked);
|
|
486
|
+
if (picked.length === 0) {
|
|
487
|
+
console.log(c.green("ok") + " nothing to tick");
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
505
490
|
// Ticking an empty scaffold placeholder is a meaningless claim — it is how
|
|
506
491
|
// a hollow change games the archive gate. Refuse (all-or-nothing) and name
|
|
507
492
|
// the fix; analyze/close hard-fail on the placeholders regardless.
|
|
@@ -542,12 +527,12 @@ function changeArchive(args, flags) {
|
|
|
542
527
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
543
528
|
if (!isDir(changeDir)) {
|
|
544
529
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
545
|
-
return
|
|
530
|
+
return EXIT.USAGE;
|
|
546
531
|
}
|
|
547
532
|
|
|
548
533
|
// Verification gate. "Done" is a claim until it is checked. Archiving is
|
|
549
534
|
// the act of declaring a change finished, so it must refuse while any
|
|
550
|
-
// task
|
|
535
|
+
// task or any declared verification item is
|
|
551
536
|
// still unchecked. This is the difference between "boxes marked" and
|
|
552
537
|
// "verification passed" the framework was faulted for collapsing.
|
|
553
538
|
// Preconditions from the shared map (C6): structure AND verification.
|
|
@@ -555,7 +540,8 @@ function changeArchive(args, flags) {
|
|
|
555
540
|
// `close` path always ran first, so the same state was guarded
|
|
556
541
|
// differently depending on the route taken. --force is the escape hatch
|
|
557
542
|
// for both, and records the gap.
|
|
558
|
-
|
|
543
|
+
const archiveGate = enforceTransition(projectRoot, id, changeDir, "archive", flags);
|
|
544
|
+
if (!archiveGate.ok) return 1;
|
|
559
545
|
|
|
560
546
|
const date = today();
|
|
561
547
|
const archiveName = `${date}-${id}`;
|
|
@@ -585,6 +571,13 @@ function changeArchive(args, flags) {
|
|
|
585
571
|
|
|
586
572
|
move(changeDir, archiveDir);
|
|
587
573
|
console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
|
|
574
|
+
// The move happened, so a waved-through blocker is now part of the
|
|
575
|
+
// history and the ledger says so (change 0096). Recorded here rather than
|
|
576
|
+
// at the gate, so a forced transition that then failed leaves no claim
|
|
577
|
+
// behind it.
|
|
578
|
+
if (archiveGate.forced) {
|
|
579
|
+
recordForcedGap(projectRoot, id, "archive", archiveGate.forced);
|
|
580
|
+
}
|
|
588
581
|
|
|
589
582
|
// Collect affected specs from delta files for the index entry
|
|
590
583
|
const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
@@ -599,40 +592,22 @@ function changeArchive(args, flags) {
|
|
|
599
592
|
// Title from proposal
|
|
600
593
|
const proposal = path.join(archiveDir, "proposal.md");
|
|
601
594
|
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();
|
|
595
|
+
// One owner for the H1's grammar (ADR 0021): the id contains hyphens,
|
|
596
|
+
// and every copy of this parse got that wrong in a different way.
|
|
597
|
+
title = parseChangeTitle(read(proposal)) ?? title;
|
|
609
598
|
}
|
|
610
599
|
|
|
611
600
|
// Append a one-line summary to the archive ledger so history stays
|
|
612
601
|
// scannable without opening folders (the archive is out of the default
|
|
613
602
|
// 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)}`);
|
|
603
|
+
const ledgerFile = appendLedgerLine(projectRoot, archivedLine(id, title, specsAffected, date));
|
|
604
|
+
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
|
|
626
605
|
|
|
627
606
|
const index = idx.load(projectRoot);
|
|
628
|
-
idx.moveChangeToArchive(index, id,
|
|
629
|
-
|
|
630
|
-
title,
|
|
631
|
-
|
|
632
|
-
status: "applied",
|
|
633
|
-
applied: date,
|
|
634
|
-
specs_affected: specsAffected,
|
|
635
|
-
});
|
|
607
|
+
idx.moveChangeToArchive(index, id, archivedChangeEntry(
|
|
608
|
+
exists(proposal) ? read(proposal) : "",
|
|
609
|
+
{ id, archiveName, applied: date, specsAffected, title },
|
|
610
|
+
));
|
|
636
611
|
idx.touch(index, date);
|
|
637
612
|
idx.save(projectRoot, index);
|
|
638
613
|
console.log(c.green("indexed") + " change archived");
|
|
@@ -649,13 +624,15 @@ async function changeAbandon(args, flags) {
|
|
|
649
624
|
console.error(c.red("error:") + " change abandon requires <id>");
|
|
650
625
|
return 2;
|
|
651
626
|
}
|
|
627
|
+
const refused = refuseChangeRef(id);
|
|
628
|
+
if (refused !== null) return refused;
|
|
652
629
|
const projectRoot = process.cwd();
|
|
653
630
|
ensureDoctrinaProject(projectRoot);
|
|
654
631
|
|
|
655
632
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
656
633
|
if (!isDir(changeDir)) {
|
|
657
634
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
658
|
-
return
|
|
635
|
+
return EXIT.USAGE;
|
|
659
636
|
}
|
|
660
637
|
|
|
661
638
|
// Consent before destruction (audit item C9). This deletes work with no
|
|
@@ -690,16 +667,8 @@ async function changeAbandon(args, flags) {
|
|
|
690
667
|
console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
|
|
691
668
|
|
|
692
669
|
// 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)}`);
|
|
670
|
+
const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, reason, date));
|
|
671
|
+
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
|
|
703
672
|
|
|
704
673
|
// Drop the change entry; rebuild from the tree so nothing drifts.
|
|
705
674
|
const current = idx.load(projectRoot);
|
|
@@ -710,27 +679,15 @@ async function changeAbandon(args, flags) {
|
|
|
710
679
|
return 0;
|
|
711
680
|
}
|
|
712
681
|
|
|
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
|
-
|
|
682
|
+
// The per-delta preview: what applying this delta would do to its target.
|
|
683
|
+
// ADDED reports the body it would write, REMOVED the spec it would delete,
|
|
684
|
+
// MODIFIED a line diff against the current spec.
|
|
685
|
+
//
|
|
686
|
+
// `change check --verbose` prints this after its ops dry-run. It was also
|
|
687
|
+
// the whole of `change diff`, until that alias was retired (changes 0049,
|
|
688
|
+
// 0175) — the one renderer is what made the merge honest. Returns the number of
|
|
689
|
+
// deltas it could not read.
|
|
690
|
+
export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
|
|
734
691
|
let errors = 0;
|
|
735
692
|
for (const deltaPath of deltaFiles) {
|
|
736
693
|
const rel = relPath(changeDir, deltaPath);
|
|
@@ -773,27 +730,7 @@ function changeDiff(args, _flags) {
|
|
|
773
730
|
console.log(out);
|
|
774
731
|
}
|
|
775
732
|
}
|
|
776
|
-
|
|
777
|
-
console.log("");
|
|
778
|
-
return errors > 0 ? 1 : 0;
|
|
779
|
-
}
|
|
780
|
-
|
|
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;
|
|
733
|
+
return errors;
|
|
797
734
|
}
|
|
798
735
|
|
|
799
736
|
function extractDeltaBody(text) {
|
|
@@ -803,65 +740,6 @@ function extractDeltaBody(text) {
|
|
|
803
740
|
return text.slice(idxSep + 5).replace(/^\n+/, "");
|
|
804
741
|
}
|
|
805
742
|
|
|
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
|
-
|
|
838
|
-
// Reasons a change is not finished enough to archive. Counts unchecked
|
|
839
|
-
// GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
|
|
840
|
-
// closing steps) and in the proposal's "## Verification" section. Returns
|
|
841
|
-
// a list of human-readable blocker strings; empty means clear to archive.
|
|
842
|
-
function collectArchiveBlockers(changeDir) {
|
|
843
|
-
const blockers = [];
|
|
844
|
-
const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
|
|
845
|
-
|
|
846
|
-
const tasksPath = path.join(changeDir, "tasks.md");
|
|
847
|
-
if (exists(tasksPath)) {
|
|
848
|
-
const n = countUnchecked(read(tasksPath));
|
|
849
|
-
if (n > 0) blockers.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const proposalPath = path.join(changeDir, "proposal.md");
|
|
853
|
-
if (exists(proposalPath)) {
|
|
854
|
-
const n = countUnchecked(getSection(read(proposalPath), "Verification"));
|
|
855
|
-
if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
856
|
-
}
|
|
857
|
-
return blockers;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function ensureDoctrinaProject(projectRoot) {
|
|
861
|
-
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
862
|
-
throw notADoctrinaProject();
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
743
|
|
|
866
744
|
export const help = `
|
|
867
745
|
Usage: doctrina change <subcommand> [args]
|
|
@@ -880,7 +758,7 @@ Subcommands:
|
|
|
880
758
|
MODIFIED without one prints a manual-merge pointer.
|
|
881
759
|
On any spec write the index is rebuilt from the tree.
|
|
882
760
|
archive <id...> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
|
|
883
|
-
check <id...> Pre-close dry-run, read-only:
|
|
761
|
+
check <id...> Pre-close dry-run, read-only: the structural
|
|
884
762
|
checks + every ops block executed in memory against
|
|
885
763
|
its target + the archive gate preview + an advisory
|
|
886
764
|
list of accepted ADRs citing the touched capabilities.
|
|
@@ -890,8 +768,6 @@ Subcommands:
|
|
|
890
768
|
or every one with --all. No args = list only.
|
|
891
769
|
abandon <id> Delete an open change folder and its index entry, and
|
|
892
770
|
record the abandonment in the ledger ([--reason "..."]).
|
|
893
|
-
diff <id> Preview every spec delta: line diff for MODIFIED,
|
|
894
|
-
summary for ADDED/REMOVED. Read-only.
|
|
895
771
|
|
|
896
772
|
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
897
773
|
exit code is the worst per-id result.
|
|
@@ -917,4 +793,5 @@ Options:
|
|
|
917
793
|
|
|
918
794
|
// Re-export parsers so scan.js (index rebuild) can reuse them, and the
|
|
919
795
|
// scaffold so `work` can open a change without duplicating the logic.
|
|
920
|
-
export { parseOperation, parseCapabilityFromDelta,
|
|
796
|
+
export { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
797
|
+
export { changeNew } from "../lib/change-ops.js";
|