doctrina-cli 0.13.0 → 0.15.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 +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +120 -17
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +38 -3
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +201 -8
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +252 -27
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.15.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doctrina-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "node --test",
|
|
22
|
-
"prepack": "node scripts/copy-templates.js"
|
|
22
|
+
"prepack": "node scripts/copy-templates.js",
|
|
23
|
+
"test:e2e": "node ../../scripts/e2e-packed.mjs"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"src",
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { exists, isFile, mkdirp, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
5
|
+
import {
|
|
6
|
+
ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
|
|
7
|
+
} from "../lib/adapters.js";
|
|
8
|
+
import { flagBool } from "../lib/args.js";
|
|
9
|
+
import { today } from "../lib/dates.js";
|
|
10
|
+
import { c } from "../lib/colors.js";
|
|
11
|
+
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
|
|
14
|
+
// `doctrina adapter` — add, remove, and inventory agent adapters (audit
|
|
15
|
+
// item C1).
|
|
16
|
+
//
|
|
17
|
+
// Before this command the only way to add an adapter to an existing project
|
|
18
|
+
// was `doctrina init --agent <name> --force`, because `init` refuses to run
|
|
19
|
+
// twice. That path regenerated AGENTS.md and .doctrina/product.md from
|
|
20
|
+
// blank templates, silently destroying every hand-authored rule and the
|
|
21
|
+
// product definition. `adapter add` is strictly additive: it writes that
|
|
22
|
+
// adapter's own files and never reads or writes AGENTS.md, product.md, or
|
|
23
|
+
// any other project artifact.
|
|
24
|
+
|
|
25
|
+
const SUBCOMMANDS = ["list", "add", "remove"];
|
|
26
|
+
|
|
27
|
+
export const flags = { boolean: ["json", "force"], string: [] };
|
|
28
|
+
|
|
29
|
+
export async function run(positional, cmdFlags) {
|
|
30
|
+
const sub = positional[0];
|
|
31
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
32
|
+
console.error(c.red("error:") + ` unknown adapter subcommand "${sub ?? ""}"`);
|
|
33
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
34
|
+
console.error(c.gray("hint: ") + (guess
|
|
35
|
+
? `did you mean \`doctrina adapter ${guess}\`?`
|
|
36
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
const projectRoot = process.cwd();
|
|
40
|
+
ensureDoctrinaProject(projectRoot);
|
|
41
|
+
if (sub === "list") return adapterList(projectRoot);
|
|
42
|
+
if (sub === "add") return adapterAdd(projectRoot, positional[1], cmdFlags);
|
|
43
|
+
return adapterRemove(projectRoot, positional[1], cmdFlags);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function adapterList(projectRoot) {
|
|
47
|
+
const names = listAdapterNames(projectRoot);
|
|
48
|
+
if (names.length === 0) {
|
|
49
|
+
console.log(c.gray("no adapters available (no templates/adapters directory found)"));
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const rows = names.map((n) => describeAdapter(projectRoot, n));
|
|
53
|
+
const label = {
|
|
54
|
+
[ADAPTER_STATES.INSTALLED]: c.green("installed"),
|
|
55
|
+
[ADAPTER_STATES.AVAILABLE]: c.gray("available"),
|
|
56
|
+
[ADAPTER_STATES.NATIVE]: c.cyan("native "),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
console.log(c.bold("Agent adapters:"));
|
|
60
|
+
console.log("");
|
|
61
|
+
for (const r of rows) {
|
|
62
|
+
const suffix = r.state === ADAPTER_STATES.NATIVE
|
|
63
|
+
? c.gray("reads AGENTS.md directly — no file needed")
|
|
64
|
+
: c.gray(`${r.installed.length}/${r.files.length} file${r.files.length === 1 ? "" : "s"}` +
|
|
65
|
+
(r.source === "project" ? " · project template" : ""));
|
|
66
|
+
console.log(` ${label[r.state]} ${c.cyan(r.name.padEnd(10))} ${suffix}`);
|
|
67
|
+
}
|
|
68
|
+
console.log("");
|
|
69
|
+
const installed = rows.filter((r) => r.state === ADAPTER_STATES.INSTALLED).length;
|
|
70
|
+
const native = rows.filter((r) => r.state === ADAPTER_STATES.NATIVE).length;
|
|
71
|
+
console.log(c.gray(
|
|
72
|
+
`${installed} installed · ${rows.length - installed - native} available · ${native} native. ` +
|
|
73
|
+
"Add one: ") + c.cyan("doctrina adapter add <name>"));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function adapterAdd(projectRoot, name, cmdFlags) {
|
|
78
|
+
if (!name) {
|
|
79
|
+
console.error(c.red("error:") + " adapter add requires a name (see `doctrina adapter list`)");
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
const spec = adapterFiles(projectRoot, name);
|
|
83
|
+
if (!spec) {
|
|
84
|
+
console.error(c.red("error:") + ` unknown adapter "${name}"`);
|
|
85
|
+
const guess = suggest(name, listAdapterNames(projectRoot));
|
|
86
|
+
console.error(c.gray("hint: ") + (guess
|
|
87
|
+
? `did you mean \`doctrina adapter add ${guess}\`?`
|
|
88
|
+
: "run `doctrina adapter list` for the available adapters"));
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
if (spec.files.length === 0) {
|
|
92
|
+
console.log(c.cyan("native ") + ` ${name} reads AGENTS.md directly — nothing to install`);
|
|
93
|
+
console.log(c.gray("Its rules already reach the agent through the hub at AGENTS.md."));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const force = flagBool(cmdFlags, "force", false);
|
|
98
|
+
const tokens = {
|
|
99
|
+
PROJECT_NAME: path.basename(projectRoot),
|
|
100
|
+
PROJECT_DESCRIPTION: "",
|
|
101
|
+
DATE: today(),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Additive by construction: only this adapter's own files are touched,
|
|
105
|
+
// and an existing one is left alone unless --force is given. AGENTS.md
|
|
106
|
+
// and product.md are never read or written here.
|
|
107
|
+
let written = 0;
|
|
108
|
+
let skipped = 0;
|
|
109
|
+
for (const file of spec.files) {
|
|
110
|
+
const dest = path.join(projectRoot, file.relativePath);
|
|
111
|
+
if (exists(dest) && !force) {
|
|
112
|
+
console.log(c.yellow("skip ") + ` ${file.relativePath} (already present; --force overwrites)`);
|
|
113
|
+
skipped += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
mkdirp(path.dirname(dest));
|
|
117
|
+
write(dest, renderAdapterFile(file, tokens), { force: true });
|
|
118
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
|
|
119
|
+
written += 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
|
|
124
|
+
(skipped > 0 ? `, ${skipped} left alone` : "") +
|
|
125
|
+
(spec.source === "project" ? c.gray(" (from .doctrina/templates/adapters/)") : ""));
|
|
126
|
+
console.log(c.gray("Additive only: AGENTS.md and .doctrina/product.md were not touched."));
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function adapterRemove(projectRoot, name, cmdFlags) {
|
|
131
|
+
if (!name) {
|
|
132
|
+
console.error(c.red("error:") + " adapter remove requires a name (see `doctrina adapter list`)");
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
const spec = adapterFiles(projectRoot, name);
|
|
136
|
+
if (!spec) {
|
|
137
|
+
console.error(c.red("error:") + ` unknown adapter "${name}"`);
|
|
138
|
+
return 2;
|
|
139
|
+
}
|
|
140
|
+
if (spec.files.length === 0) {
|
|
141
|
+
console.log(c.cyan("native ") + ` ${name} installs no files — nothing to remove`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Only delete files this adapter created AND that still match what it
|
|
146
|
+
// would write. A file the user edited is theirs now; --force overrides.
|
|
147
|
+
const force = flagBool(cmdFlags, "force", false);
|
|
148
|
+
const tokens = { PROJECT_NAME: path.basename(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() };
|
|
149
|
+
let removed = 0;
|
|
150
|
+
let kept = 0;
|
|
151
|
+
for (const file of spec.files) {
|
|
152
|
+
const dest = path.join(projectRoot, file.relativePath);
|
|
153
|
+
if (!isFile(dest)) continue;
|
|
154
|
+
const pristine = normalise(read(dest)) === normalise(renderAdapterFile(file, tokens));
|
|
155
|
+
if (!pristine && !force) {
|
|
156
|
+
console.log(c.yellow("kept ") + ` ${file.relativePath} (edited since install; --force removes it)`);
|
|
157
|
+
kept += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
remove(dest);
|
|
161
|
+
console.log(c.green("removed") + ` ${file.relativePath}`);
|
|
162
|
+
removed += 1;
|
|
163
|
+
}
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(`${c.bold("Adapter " + name)} — ${removed} file${removed === 1 ? "" : "s"} removed` +
|
|
166
|
+
(kept > 0 ? `, ${kept} kept (edited)` : ""));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalise(text) {
|
|
171
|
+
return text.replace(/\r\n/g, "\n").trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ensureDoctrinaProject(projectRoot) {
|
|
175
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
176
|
+
throw notADoctrinaProject();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const help = `
|
|
181
|
+
Usage: doctrina adapter <list|add|remove> [name] [--force]
|
|
182
|
+
|
|
183
|
+
Add, remove, and inventory the per-agent adapter files that point at
|
|
184
|
+
AGENTS.md. Adding an adapter is strictly additive: only that adapter's own
|
|
185
|
+
files are written, and AGENTS.md / .doctrina/product.md are never touched.
|
|
186
|
+
|
|
187
|
+
Subcommands:
|
|
188
|
+
list Every adapter with its state:
|
|
189
|
+
installed — its files are present
|
|
190
|
+
available — it ships files and none are installed
|
|
191
|
+
native — the agent reads AGENTS.md directly and
|
|
192
|
+
needs no file at all
|
|
193
|
+
add <name> Install one adapter's files. An existing file is left
|
|
194
|
+
alone unless --force is given.
|
|
195
|
+
remove <name> Delete the files this adapter created. A file edited
|
|
196
|
+
since install is kept unless --force is given.
|
|
197
|
+
|
|
198
|
+
Custom adapters: a directory at .doctrina/templates/adapters/<name>/ is
|
|
199
|
+
installable by name and takes precedence over a bundled adapter of the
|
|
200
|
+
same name.
|
|
201
|
+
|
|
202
|
+
Options:
|
|
203
|
+
--force With add, overwrite an existing file; with remove,
|
|
204
|
+
delete a file that was edited after install.
|
|
205
|
+
`;
|
package/src/commands/analyze.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
5
|
import { c } from "../lib/colors.js";
|
|
5
6
|
import { isUntouchedScaffold } from "./change.js";
|
|
7
|
+
import { collectBudgets } from "../lib/runtime.js";
|
|
8
|
+
|
|
9
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
10
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
11
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
12
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
6
13
|
|
|
7
14
|
export async function run(positional, _flags) {
|
|
8
15
|
const id = positional[0];
|
|
@@ -21,6 +28,25 @@ export async function run(positional, _flags) {
|
|
|
21
28
|
console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
|
|
22
29
|
console.log("");
|
|
23
30
|
|
|
31
|
+
const results = collectAnalysis(projectRoot, changeDir);
|
|
32
|
+
for (const r of results) console.log(r.line);
|
|
33
|
+
console.log("");
|
|
34
|
+
|
|
35
|
+
const failed = results.filter((r) => r.kind === "fail").length;
|
|
36
|
+
if (failed === 0) {
|
|
37
|
+
console.log(c.green("ok") + ` ready to apply`);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
|
|
40
|
+
}
|
|
41
|
+
return failed === 0 ? 0 : 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The structural findings for a change, as data. Exported so the shared
|
|
45
|
+
// gate map (`lib/gates.js`) can enforce the SAME checks on every
|
|
46
|
+
// transition that needs them, instead of each command deciding for itself
|
|
47
|
+
// — `change apply` used to mutate a change that `analyze` had just
|
|
48
|
+
// refused (audit item C6).
|
|
49
|
+
export function collectAnalysis(projectRoot, changeDir) {
|
|
24
50
|
const results = [];
|
|
25
51
|
|
|
26
52
|
// proposal.md
|
|
@@ -32,6 +58,37 @@ export async function run(positional, _flags) {
|
|
|
32
58
|
const text = read(proposalPath);
|
|
33
59
|
if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
|
|
34
60
|
else results.push(fail(`proposal.md missing "## Why" section`));
|
|
61
|
+
|
|
62
|
+
// A section that still holds only its scaffold comment was never
|
|
63
|
+
// written. The check above only proved the HEADING survived, which is
|
|
64
|
+
// why six changes closed in one session with every rationale section
|
|
65
|
+
// empty: the planning step wrote to the file with a pattern that did
|
|
66
|
+
// not match its line endings, and nothing downstream looked inside.
|
|
67
|
+
// For a framework whose whole premise is recoverable provenance, an
|
|
68
|
+
// archived change that cannot say why it happened is the defect.
|
|
69
|
+
// Only `Why` and `What` are required. They are what makes a change
|
|
70
|
+
// recoverable a year later — the reason it exists and the shape it
|
|
71
|
+
// took. `Scope boundaries` and `Open questions` are legitimately empty
|
|
72
|
+
// on a change that has neither, and demanding the word "None." there
|
|
73
|
+
// is friction that buys nothing.
|
|
74
|
+
const hollow = [];
|
|
75
|
+
for (const m of text.matchAll(/^##\s+(.+?)[ \t]*\r?$/gm)) {
|
|
76
|
+
const heading = m[1].trim();
|
|
77
|
+
if (!/^(Why|What)$/i.test(heading)) continue;
|
|
78
|
+
const start = m.index + m[0].length;
|
|
79
|
+
const next = text.slice(start).search(/^##\s+/m);
|
|
80
|
+
const body = (next < 0 ? text.slice(start) : text.slice(start, start + next));
|
|
81
|
+
// Strip comments; anything left that is not whitespace is real prose.
|
|
82
|
+
if (body.replace(/<!--[\s\S]*?-->/g, "").trim() === "") hollow.push(heading);
|
|
83
|
+
}
|
|
84
|
+
if (hollow.length > 0) {
|
|
85
|
+
results.push(fail(
|
|
86
|
+
`proposal.md has ${hollow.length} unwritten section${hollow.length === 1 ? "" : "s"} ` +
|
|
87
|
+
`(${hollow.join(", ")}) — a heading that survived is not a section that was written`,
|
|
88
|
+
));
|
|
89
|
+
} else {
|
|
90
|
+
results.push(pass("proposal.md states why and what"));
|
|
91
|
+
}
|
|
35
92
|
}
|
|
36
93
|
|
|
37
94
|
// tasks.md
|
|
@@ -75,11 +132,11 @@ export async function run(positional, _flags) {
|
|
|
75
132
|
const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
|
|
76
133
|
|
|
77
134
|
if (!op) {
|
|
78
|
-
results.push(fail(` ${rel}: Operation header missing or malformed
|
|
135
|
+
results.push(fail(` ${rel}: Operation header missing or malformed`));
|
|
79
136
|
continue;
|
|
80
137
|
}
|
|
81
138
|
if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
|
|
82
|
-
results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED
|
|
139
|
+
results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
|
|
83
140
|
continue;
|
|
84
141
|
}
|
|
85
142
|
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
@@ -89,38 +146,84 @@ export async function run(positional, _flags) {
|
|
|
89
146
|
// untouched `spec new` scaffold is the canonical flow (spec new →
|
|
90
147
|
// ADDED delta), so it passes as a replacement; only real content fails.
|
|
91
148
|
if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
|
|
92
|
-
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first
|
|
149
|
+
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`));
|
|
93
150
|
} else if (exists(targetSpec)) {
|
|
94
|
-
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)
|
|
151
|
+
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
|
|
95
152
|
} else {
|
|
96
|
-
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)
|
|
153
|
+
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
|
|
97
154
|
}
|
|
98
155
|
} else if (op === "MODIFIED") {
|
|
99
156
|
if (!exists(targetSpec)) {
|
|
100
|
-
results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist
|
|
157
|
+
results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`));
|
|
101
158
|
} else {
|
|
102
|
-
results.push(pass(` ${cap} (MODIFIED) → ${targetRel}
|
|
159
|
+
results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
|
|
103
160
|
}
|
|
104
161
|
} else {
|
|
105
162
|
if (!exists(targetSpec)) {
|
|
106
|
-
results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist
|
|
163
|
+
results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`));
|
|
107
164
|
} else {
|
|
108
|
-
results.push(pass(` ${cap} (REMOVED) → ${targetRel}
|
|
165
|
+
results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
|
|
109
166
|
}
|
|
110
167
|
}
|
|
111
168
|
}
|
|
112
169
|
}
|
|
113
170
|
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
// BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
|
|
172
|
+
// a preference — and the tempting fix for "the output blew the limit" is
|
|
173
|
+
// to raise the limit. That buys headroom by truncating what mattered
|
|
174
|
+
// instead of sending less, and it is the discussion that comes back every
|
|
175
|
+
// quarter because nothing ever recorded that it had been settled. Raising
|
|
176
|
+
// an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
|
|
177
|
+
// resolve an overflow is the move this refuses.
|
|
178
|
+
for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
|
|
116
179
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Compare the ceilings a change's proposal/deltas propose against the ones
|
|
184
|
+
// the contracts declare today. Text-scanned: a change states a new value in
|
|
185
|
+
// prose long before any code moves, which is exactly when it should be
|
|
186
|
+
// argued about.
|
|
187
|
+
function checkBudgetRaises(projectRoot, changeDir) {
|
|
188
|
+
const results = [];
|
|
189
|
+
let declared;
|
|
190
|
+
try {
|
|
191
|
+
declared = collectBudgets(projectRoot);
|
|
192
|
+
} catch {
|
|
193
|
+
return results;
|
|
122
194
|
}
|
|
123
|
-
|
|
195
|
+
if (declared.size === 0) return results;
|
|
196
|
+
|
|
197
|
+
const texts = [];
|
|
198
|
+
for (const f of walk(changeDir)) {
|
|
199
|
+
if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const [name, budget] of declared) {
|
|
203
|
+
if (budget.direction !== "output") continue;
|
|
204
|
+
for (const { rel, text } of texts) {
|
|
205
|
+
// "<limit> ... <number>" on one line: the shape a proposal uses when
|
|
206
|
+
// it restates a ceiling ("raise ai-summary to 2000").
|
|
207
|
+
const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
|
|
208
|
+
for (const m of text.matchAll(re)) {
|
|
209
|
+
const proposed = Number.parseInt(m[1], 10);
|
|
210
|
+
if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
|
|
211
|
+
results.push(fail(
|
|
212
|
+
`${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
|
|
213
|
+
`(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
|
|
214
|
+
`truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
|
|
215
|
+
));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A limit name comes from a contract table cell, so it is user text and
|
|
224
|
+
// must not be spliced into a pattern unescaped.
|
|
225
|
+
function escapeRe(s) {
|
|
226
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
124
227
|
}
|
|
125
228
|
|
|
126
229
|
function pass(msg) {
|
package/src/commands/change.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSection, getHeader } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
@@ -7,13 +9,22 @@ import * as idx from "../lib/index-json.js";
|
|
|
7
9
|
import { deriveIndex } from "../lib/scan.js";
|
|
8
10
|
import { extractOps, applyOps } from "../lib/spec-ops.js";
|
|
9
11
|
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
12
|
+
import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
|
|
10
13
|
import { today } from "../lib/dates.js";
|
|
11
14
|
import { flagBool, flagString } from "../lib/args.js";
|
|
12
15
|
import { c } from "../lib/colors.js";
|
|
13
16
|
import { suggest } from "../lib/suggest.js";
|
|
17
|
+
import { confirm, isInteractive } from "../lib/prompt.js";
|
|
18
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
19
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
14
20
|
|
|
15
21
|
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
|
|
16
22
|
|
|
23
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
24
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
25
|
+
// 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"] };
|
|
27
|
+
|
|
17
28
|
export async function run(positional, flags) {
|
|
18
29
|
const sub = positional[0];
|
|
19
30
|
switch (sub) {
|
|
@@ -30,7 +41,7 @@ export async function run(positional, flags) {
|
|
|
30
41
|
case "diff":
|
|
31
42
|
return changeDiff(positional.slice(1), flags);
|
|
32
43
|
case "abandon":
|
|
33
|
-
return changeAbandon(positional.slice(1), flags);
|
|
44
|
+
return await changeAbandon(positional.slice(1), flags);
|
|
34
45
|
default:
|
|
35
46
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
36
47
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -41,6 +52,29 @@ export async function run(positional, flags) {
|
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
56
|
+
// Returns true when the transition may proceed. One implementation, so
|
|
57
|
+
// every driver of a transition refuses — and explains, and forces —
|
|
58
|
+
// identically (C6).
|
|
59
|
+
function enforceTransition(projectRoot, id, changeDir, transition, flags) {
|
|
60
|
+
const force = flagBool(flags, "force", false);
|
|
61
|
+
const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
|
|
62
|
+
if (ok) return true;
|
|
63
|
+
|
|
64
|
+
const label = TRANSITIONS[transition].label;
|
|
65
|
+
if (!force) {
|
|
66
|
+
console.error(c.red("error:") + ` refusing to ${transition} "${id}" — ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}:`);
|
|
67
|
+
for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
|
|
68
|
+
const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
|
|
69
|
+
console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
|
|
73
|
+
for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
|
|
74
|
+
recordForcedGap(projectRoot, id, transition, blockers);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
44
78
|
// Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
|
|
45
79
|
// accept multiple ids so a backlog closes without a bash loop. Each id runs
|
|
46
80
|
// independently — one failure does not stop the rest — and the exit code is
|
|
@@ -146,7 +180,7 @@ function changeNew(args, flags) {
|
|
|
146
180
|
return 0;
|
|
147
181
|
}
|
|
148
182
|
|
|
149
|
-
function changeApply(args,
|
|
183
|
+
function changeApply(args, flags) {
|
|
150
184
|
const id = args[0];
|
|
151
185
|
if (!id) {
|
|
152
186
|
console.error(c.red("error:") + " change apply requires <id>");
|
|
@@ -161,6 +195,30 @@ function changeApply(args, _flags) {
|
|
|
161
195
|
return 1;
|
|
162
196
|
}
|
|
163
197
|
|
|
198
|
+
// Gate parity (C6). `apply` used to mutate specs with no preconditions,
|
|
199
|
+
// so `analyze` could exit 1 on a change and `apply` would write it anyway
|
|
200
|
+
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
201
|
+
// The preconditions now come from the shared map, so what guards a
|
|
202
|
+
// transition does not depend on which command drove it.
|
|
203
|
+
if (!enforceTransition(projectRoot, id, changeDir, "apply", flags)) return 1;
|
|
204
|
+
|
|
205
|
+
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
206
|
+
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
207
|
+
// duplicates every requirement and criterion the delta carries and bumps
|
|
208
|
+
// the version again. `apply` stamps the proposal `applied` on success, so
|
|
209
|
+
// that stamp is the guard. (Found by `close` re-running apply on an
|
|
210
|
+
// already-applied change and doubling 15 requirements on this repo.)
|
|
211
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
212
|
+
const alreadyApplied = isFile(proposalPath)
|
|
213
|
+
&& (getHeader(read(proposalPath), "Status") ?? "").trim().toLowerCase() === "applied";
|
|
214
|
+
if (alreadyApplied && !flagBool(flags, "force", false)) {
|
|
215
|
+
console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
|
|
216
|
+
console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
|
|
217
|
+
console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
|
|
218
|
+
console.log(c.gray("only after reverting the specs to their pre-apply state."));
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
164
222
|
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
165
223
|
const date = today();
|
|
166
224
|
let errors = 0;
|
|
@@ -342,12 +400,14 @@ async function changeCheck(id) {
|
|
|
342
400
|
|
|
343
401
|
// 3. Archive gate preview: what archive will refuse, listed with the fix.
|
|
344
402
|
console.log(c.gray("──── 3/3 archive gate"));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
403
|
+
// Preview through the SAME map the transitions enforce, so `check` can
|
|
404
|
+
// never disagree with what `apply` / `archive` will actually do (C6).
|
|
405
|
+
const archiveGate = checkTransition(projectRoot, changeDir, "archive");
|
|
406
|
+
if (archiveGate.ok) {
|
|
407
|
+
console.log(c.green("✓ ") + `archive gate clear (${archiveGate.gates.join(" + ")})`);
|
|
348
408
|
} else {
|
|
349
409
|
failures += 1;
|
|
350
|
-
for (const b of blockers) console.log(c.red("✗ ") + b);
|
|
410
|
+
for (const b of archiveGate.blockers) console.log(c.red("✗ ") + `[${b.gate}] ${b.message}`);
|
|
351
411
|
console.log(c.gray(" check them off as they land, or in bulk: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
352
412
|
}
|
|
353
413
|
|
|
@@ -490,22 +550,12 @@ function changeArchive(args, flags) {
|
|
|
490
550
|
// task (including closing steps) or any declared verification item is
|
|
491
551
|
// still unchecked. This is the difference between "boxes marked" and
|
|
492
552
|
// "verification passed" the framework was faulted for collapsing.
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
for (const b of blockers) console.error(` - ${b}`);
|
|
500
|
-
console.error(
|
|
501
|
-
c.gray("hint: ") +
|
|
502
|
-
"finish and check the items, or pass --force to archive anyway (records the gap)",
|
|
503
|
-
);
|
|
504
|
-
return 1;
|
|
505
|
-
}
|
|
506
|
-
console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
|
|
507
|
-
for (const b of blockers) console.log(c.yellow(" - ") + b);
|
|
508
|
-
}
|
|
553
|
+
// Preconditions from the shared map (C6): structure AND verification.
|
|
554
|
+
// Reaching archive directly used to skip the structural check that the
|
|
555
|
+
// `close` path always ran first, so the same state was guarded
|
|
556
|
+
// differently depending on the route taken. --force is the escape hatch
|
|
557
|
+
// for both, and records the gap.
|
|
558
|
+
if (!enforceTransition(projectRoot, id, changeDir, "archive", flags)) return 1;
|
|
509
559
|
|
|
510
560
|
const date = today();
|
|
511
561
|
const archiveName = `${date}-${id}`;
|
|
@@ -593,7 +643,7 @@ function changeArchive(args, flags) {
|
|
|
593
643
|
// and record the abandonment in the ledger so the history shows the dead end
|
|
594
644
|
// instead of a silent gap. This is the missing inverse of `change new` — the
|
|
595
645
|
// review had to delete a stray folder and hand-edit index.json (G6).
|
|
596
|
-
function changeAbandon(args, flags) {
|
|
646
|
+
async function changeAbandon(args, flags) {
|
|
597
647
|
const id = args[0];
|
|
598
648
|
if (!id) {
|
|
599
649
|
console.error(c.red("error:") + " change abandon requires <id>");
|
|
@@ -608,6 +658,31 @@ function changeAbandon(args, flags) {
|
|
|
608
658
|
return 1;
|
|
609
659
|
}
|
|
610
660
|
|
|
661
|
+
// Consent before destruction (audit item C9). This deletes work with no
|
|
662
|
+
// undo — the ledger line is good practice and is not a substitute for
|
|
663
|
+
// being asked. Off a terminal there is nobody to ask, so `--force` is
|
|
664
|
+
// required: silence is not consent.
|
|
665
|
+
const force = flagBool(flags, "force", false);
|
|
666
|
+
if (!force) {
|
|
667
|
+
const doomed = walk(changeDir);
|
|
668
|
+
console.log(c.yellow("About to delete") + ` ${relPath(projectRoot, changeDir)} — ${doomed.length} file${doomed.length === 1 ? "" : "s"}:`);
|
|
669
|
+
for (const f of doomed.slice(0, 10)) console.log(` ${c.gray(relPath(changeDir, f))}`);
|
|
670
|
+
if (doomed.length > 10) console.log(c.gray(` … and ${doomed.length - 10} more`));
|
|
671
|
+
console.log(c.gray("This cannot be undone (the change is deleted, not archived)."));
|
|
672
|
+
|
|
673
|
+
if (!isInteractive()) {
|
|
674
|
+
console.error("");
|
|
675
|
+
console.error(c.red("error:") + " refusing to delete without confirmation");
|
|
676
|
+
console.error(c.gray("hint: ") + `re-run with ${c.cyan("--force")} to abandon non-interactively`);
|
|
677
|
+
return EXIT.USAGE;
|
|
678
|
+
}
|
|
679
|
+
const ok = await confirm(`Abandon "${id}"?`, { defaultYes: false, whenNonInteractive: false });
|
|
680
|
+
if (!ok) {
|
|
681
|
+
console.log(c.gray("aborted — nothing was deleted"));
|
|
682
|
+
return EXIT.OK;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
611
686
|
const reason = flagString(flags, "reason") ?? "";
|
|
612
687
|
const date = today();
|
|
613
688
|
|
|
@@ -776,32 +851,15 @@ function collectArchiveBlockers(changeDir) {
|
|
|
776
851
|
|
|
777
852
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
778
853
|
if (exists(proposalPath)) {
|
|
779
|
-
const n = countUnchecked(
|
|
854
|
+
const n = countUnchecked(getSection(read(proposalPath), "Verification"));
|
|
780
855
|
if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
781
856
|
}
|
|
782
857
|
return blockers;
|
|
783
858
|
}
|
|
784
859
|
|
|
785
|
-
// Return the body of a "## <name>" section (lines after the heading up to
|
|
786
|
-
// the next "## " heading). Empty string when the section is absent.
|
|
787
|
-
function extractSection(text, name) {
|
|
788
|
-
const lines = text.split(/\r?\n/);
|
|
789
|
-
const head = new RegExp(`^##\\s+${name}\\b`, "i");
|
|
790
|
-
let inSection = false;
|
|
791
|
-
const out = [];
|
|
792
|
-
for (const line of lines) {
|
|
793
|
-
if (/^##\s+/.test(line)) {
|
|
794
|
-
inSection = head.test(line);
|
|
795
|
-
continue;
|
|
796
|
-
}
|
|
797
|
-
if (inSection) out.push(line);
|
|
798
|
-
}
|
|
799
|
-
return out.join("\n");
|
|
800
|
-
}
|
|
801
|
-
|
|
802
860
|
function ensureDoctrinaProject(projectRoot) {
|
|
803
861
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
804
|
-
throw
|
|
862
|
+
throw notADoctrinaProject();
|
|
805
863
|
}
|
|
806
864
|
}
|
|
807
865
|
|