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/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.16.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
package/package.json
CHANGED
package/src/commands/adapter.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { exists, isFile, mkdirp, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
5
5
|
import {
|
|
6
6
|
ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
|
|
7
7
|
} from "../lib/adapters.js";
|
|
@@ -148,6 +148,10 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
148
148
|
const tokens = { PROJECT_NAME: path.basename(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() };
|
|
149
149
|
let removed = 0;
|
|
150
150
|
let kept = 0;
|
|
151
|
+
// Directories a removed file lived in, innermost first. Only these are
|
|
152
|
+
// considered for pruning: a directory the adapter never wrote into is not
|
|
153
|
+
// this command's to tidy.
|
|
154
|
+
const vacated = new Set();
|
|
151
155
|
for (const file of spec.files) {
|
|
152
156
|
const dest = path.join(projectRoot, file.relativePath);
|
|
153
157
|
if (!isFile(dest)) continue;
|
|
@@ -160,9 +164,28 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
160
164
|
remove(dest);
|
|
161
165
|
console.log(c.green("removed") + ` ${file.relativePath}`);
|
|
162
166
|
removed += 1;
|
|
167
|
+
vacated.add(path.dirname(dest));
|
|
163
168
|
}
|
|
169
|
+
|
|
170
|
+
// `add` calls mkdirp on the way in, so `remove` unmakes what it made on
|
|
171
|
+
// the way out. Without this the six files went and `.claude/` plus
|
|
172
|
+
// `.claude/commands/` stayed — and an empty `.claude/` is not neutral: it
|
|
173
|
+
// is a configuration root the next agent finds and treats as present.
|
|
174
|
+
// Only EMPTY directories are pruned, walking up and stopping at the first
|
|
175
|
+
// one that still holds anything, so a kept (edited) file or anything of
|
|
176
|
+
// the user's keeps its whole directory chain alive.
|
|
177
|
+
const pruned = [];
|
|
178
|
+
for (const dir of [...vacated].sort((a, b) => b.length - a.length)) {
|
|
179
|
+
for (const gone of pruneEmptyDirs(dir, projectRoot)) {
|
|
180
|
+
pruned.push(relPath(projectRoot, gone).replaceAll("\\", "/"));
|
|
181
|
+
console.log(c.green("removed") + ` ${relPath(projectRoot, gone).replaceAll("\\", "/")}/ ` +
|
|
182
|
+
c.gray("(empty)"));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
164
186
|
console.log("");
|
|
165
187
|
console.log(`${c.bold("Adapter " + name)} — ${removed} file${removed === 1 ? "" : "s"} removed` +
|
|
188
|
+
(pruned.length > 0 ? `, ${pruned.length} empty director${pruned.length === 1 ? "y" : "ies"} pruned` : "") +
|
|
166
189
|
(kept > 0 ? `, ${kept} kept (edited)` : ""));
|
|
167
190
|
return 0;
|
|
168
191
|
}
|
package/src/commands/analyze.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
4
|
+
import { isDir, relPath } from "../lib/fs-ops.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { collectAnalysis } from "../lib/analysis.js";
|
|
7
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
8
|
+
|
|
9
|
+
// The findings themselves live in lib/analysis.js, where the gate map reads
|
|
10
|
+
// them; this command is their renderer (audit finding F7).
|
|
11
|
+
export { collectAnalysis } from "../lib/analysis.js";
|
|
8
12
|
|
|
9
13
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
10
14
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -22,7 +26,7 @@ export async function run(positional, _flags) {
|
|
|
22
26
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
23
27
|
if (!isDir(changeDir)) {
|
|
24
28
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
25
|
-
return
|
|
29
|
+
return EXIT.USAGE;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
|
|
@@ -41,201 +45,6 @@ export async function run(positional, _flags) {
|
|
|
41
45
|
return failed === 0 ? 0 : 1;
|
|
42
46
|
}
|
|
43
47
|
|
|
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) {
|
|
50
|
-
const results = [];
|
|
51
|
-
|
|
52
|
-
// proposal.md
|
|
53
|
-
const proposalPath = path.join(changeDir, "proposal.md");
|
|
54
|
-
if (!isFile(proposalPath)) {
|
|
55
|
-
results.push(fail("proposal.md missing"));
|
|
56
|
-
} else {
|
|
57
|
-
results.push(pass("proposal.md present"));
|
|
58
|
-
const text = read(proposalPath);
|
|
59
|
-
if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
|
|
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
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// tasks.md
|
|
95
|
-
const tasksPath = path.join(changeDir, "tasks.md");
|
|
96
|
-
if (!isFile(tasksPath)) {
|
|
97
|
-
results.push(fail("tasks.md missing"));
|
|
98
|
-
} else {
|
|
99
|
-
results.push(pass("tasks.md present"));
|
|
100
|
-
const text = read(tasksPath);
|
|
101
|
-
// Scaffold placeholders never replaced ("- [ ]" with no text, checked or
|
|
102
|
-
// not) mean the change was OPENED but never PLANNED — the agent went
|
|
103
|
-
// straight to implementing on a hollow change and nothing barked
|
|
104
|
-
// (operator report 2026-07-19). A hard failure here blocks `change
|
|
105
|
-
// check` and `close` until the plan is real; ticking an empty box does
|
|
106
|
-
// not help, `change tick` refuses those too.
|
|
107
|
-
const placeholders = (text.match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
108
|
-
if (placeholders > 0) {
|
|
109
|
-
results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
|
|
110
|
-
}
|
|
111
|
-
if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
|
|
112
|
-
else results.push(info("tasks.md has no unchecked tasks (already done?)"));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// design.md (optional)
|
|
116
|
-
const designPath = path.join(changeDir, "design.md");
|
|
117
|
-
if (isFile(designPath)) results.push(info("design.md present"));
|
|
118
|
-
else results.push(info("design.md absent (optional)"));
|
|
119
|
-
|
|
120
|
-
// Spec deltas
|
|
121
|
-
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
122
|
-
if (deltaFiles.length === 0) {
|
|
123
|
-
results.push(info("0 spec deltas (metadata-only change)"));
|
|
124
|
-
} else {
|
|
125
|
-
results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
|
|
126
|
-
for (const deltaPath of deltaFiles) {
|
|
127
|
-
const rel = relPath(projectRoot, deltaPath);
|
|
128
|
-
const text = read(deltaPath);
|
|
129
|
-
const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
130
|
-
const op = opMatch ? opMatch[1] : null;
|
|
131
|
-
const capMatch = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
|
|
132
|
-
const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
|
|
133
|
-
|
|
134
|
-
if (!op) {
|
|
135
|
-
results.push(fail(` ${rel}: Operation header missing or malformed`));
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
|
|
139
|
-
results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
143
|
-
const targetRel = relPath(projectRoot, targetSpec);
|
|
144
|
-
if (op === "ADDED") {
|
|
145
|
-
// Mirrors `change apply`: an existing target that is still the
|
|
146
|
-
// untouched `spec new` scaffold is the canonical flow (spec new →
|
|
147
|
-
// ADDED delta), so it passes as a replacement; only real content fails.
|
|
148
|
-
if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
|
|
149
|
-
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`));
|
|
150
|
-
} else if (exists(targetSpec)) {
|
|
151
|
-
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
|
|
152
|
-
} else {
|
|
153
|
-
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
|
|
154
|
-
}
|
|
155
|
-
} else if (op === "MODIFIED") {
|
|
156
|
-
if (!exists(targetSpec)) {
|
|
157
|
-
results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`));
|
|
158
|
-
} else {
|
|
159
|
-
results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
|
|
160
|
-
}
|
|
161
|
-
} else {
|
|
162
|
-
if (!exists(targetSpec)) {
|
|
163
|
-
results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`));
|
|
164
|
-
} else {
|
|
165
|
-
results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
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);
|
|
179
|
-
|
|
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;
|
|
194
|
-
}
|
|
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, "\\$&");
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function pass(msg) {
|
|
230
|
-
return { kind: "pass", line: c.green("✓ ") + msg };
|
|
231
|
-
}
|
|
232
|
-
function fail(msg) {
|
|
233
|
-
return { kind: "fail", line: c.red("✗ ") + msg };
|
|
234
|
-
}
|
|
235
|
-
function info(msg) {
|
|
236
|
-
return { kind: "info", line: c.gray("- ") + msg };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
48
|
export const help = `
|
|
240
49
|
Usage: doctrina analyze <change-id>
|
|
241
50
|
|