doctrina-cli 0.16.0 → 0.18.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 +31 -73
- package/package.json +3 -3
- package/src/commands/adapter.js +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +86 -80
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +136 -34
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +2 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +8 -139
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +63 -8
- package/src/index.js +34 -6
- package/src/lib/actions.js +38 -28
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +95 -1
- package/src/lib/commands.js +96 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/git.js +59 -2
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/playbook.js +1 -1
- package/src/lib/project.js +49 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +139 -14
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +61 -2
- package/src/lib/spec-ops.js +141 -6
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +117 -35
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +10 -2
- package/src/lib/work-model.js +57 -12
- package/templates/AGENTS.md.template +3 -4
- package/templates/README.md +2 -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 +15 -0
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +13 -3
- package/src/commands/constitution.js +0 -68
|
@@ -19,6 +19,7 @@ import { collectFindings, agentsSectionCost, AGENTS_SECTIONS, PRODUCT_SECTIONS,
|
|
|
19
19
|
// too (audit finding F7); this command renders and fixes them.
|
|
20
20
|
export { collectFindings } from "../lib/templates-model.js";
|
|
21
21
|
import { suggest } from "../lib/suggest.js";
|
|
22
|
+
import { ensureDoctrinaProject } from "../lib/project.js";
|
|
22
23
|
|
|
23
24
|
const SUBCOMMANDS = ["list", "check", "update"];
|
|
24
25
|
|
|
@@ -50,10 +51,7 @@ export async function run(positional, flags) {
|
|
|
50
51
|
function updateTemplates(flags) {
|
|
51
52
|
const writeMode = flagBool(flags, "write", false);
|
|
52
53
|
const projectRoot = process.cwd();
|
|
53
|
-
|
|
54
|
-
console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
|
|
55
|
-
return 1;
|
|
56
|
-
}
|
|
54
|
+
ensureDoctrinaProject(projectRoot);
|
|
57
55
|
|
|
58
56
|
const plan = [];
|
|
59
57
|
// Updates this run declines to make, each with the reason and the number.
|
|
@@ -176,7 +174,7 @@ function updateTemplates(flags) {
|
|
|
176
174
|
let updated = read(filePath);
|
|
177
175
|
if (!updated.endsWith("\n")) updated += "\n";
|
|
178
176
|
for (const h of missing) {
|
|
179
|
-
updated += `\n${h}\n\n<!-- added by doctrina
|
|
177
|
+
updated += `\n${h}\n\n<!-- added by doctrina upgrade — fill in -->\n`;
|
|
180
178
|
}
|
|
181
179
|
write(filePath, updated, { force: true });
|
|
182
180
|
},
|
|
@@ -287,11 +285,7 @@ function listTemplates() {
|
|
|
287
285
|
|
|
288
286
|
function checkTemplates() {
|
|
289
287
|
const projectRoot = process.cwd();
|
|
290
|
-
|
|
291
|
-
console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
|
|
292
|
-
console.error(c.gray("hint: ") + "run `doctrina init` first");
|
|
293
|
-
return 1;
|
|
294
|
-
}
|
|
288
|
+
ensureDoctrinaProject(projectRoot);
|
|
295
289
|
const { findings, ok } = collectFindings(projectRoot);
|
|
296
290
|
|
|
297
291
|
return report(findings, ok);
|
package/src/commands/upgrade.js
CHANGED
|
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
|
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { today } from "../lib/dates.js";
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
+
import { collectFindings } from "../lib/templates-model.js";
|
|
12
13
|
import * as templates from "./templates.js";
|
|
13
14
|
import * as validate from "./validate.js";
|
|
14
15
|
|
|
@@ -56,11 +57,23 @@ export async function run(_positional, flags) {
|
|
|
56
57
|
// 2. Additive template updates (missing recommended sections in AGENTS.md /
|
|
57
58
|
// product.md, missing index.json fields). Reuses `templates update`
|
|
58
59
|
// verbatim: preview without --write, apply with it.
|
|
59
|
-
console.log(c.gray("──── 1/3 scaffold shape
|
|
60
|
+
console.log(c.gray("──── 1/3 scaffold shape"));
|
|
60
61
|
const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
|
|
61
62
|
const tplCode = await templates.run(["update"], tplFlags);
|
|
62
63
|
if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
|
|
63
64
|
|
|
65
|
+
// What the additive update cannot fix — a drifted adapter, a broken
|
|
66
|
+
// playbook, an index schema it does not know — used to be visible only to
|
|
67
|
+
// `templates check`. Printed here, each with its own remedy, so the one
|
|
68
|
+
// command that brings a project up to date also says what is left for a
|
|
69
|
+
// person (change 0178: the check and the update became this step).
|
|
70
|
+
const manual = collectFindings(projectRoot).findings
|
|
71
|
+
.filter((f) => !/upgrade --write|index rebuild/.test(f.remedy ?? ""));
|
|
72
|
+
for (const f of manual) {
|
|
73
|
+
console.log(c.yellow("✗ ") + f.message);
|
|
74
|
+
console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
// 3. Index: rebuild from the tree, which also migrates the framework stamp.
|
|
65
78
|
console.log("");
|
|
66
79
|
console.log(c.gray("──── 2/3 index + framework stamp"));
|
|
@@ -101,14 +114,22 @@ export async function run(_positional, flags) {
|
|
|
101
114
|
const vCode = await validate.run([], vFlags);
|
|
102
115
|
|
|
103
116
|
console.log("");
|
|
117
|
+
if (manual.length > 0) {
|
|
118
|
+
console.log(c.yellow("manual") + ` ${manual.length} finding${manual.length === 1 ? "" : "s"} the upgrade cannot repair — see step 1`);
|
|
119
|
+
}
|
|
104
120
|
if (!writeMode) {
|
|
105
|
-
if (pending > 0) {
|
|
121
|
+
if (pending > 0 || manual.length > 0) {
|
|
122
|
+
if (pending === 0) return 1;
|
|
106
123
|
console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
|
|
107
124
|
return 1;
|
|
108
125
|
}
|
|
109
126
|
console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
|
|
110
127
|
return vCode;
|
|
111
128
|
}
|
|
129
|
+
if (vCode === 0 && manual.length > 0) {
|
|
130
|
+
console.log(c.yellow(`upgraded to ${running} with manual repairs left`) + " — each finding in step 1 names its fix.");
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
112
133
|
if (vCode === 0) {
|
|
113
134
|
console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
|
|
114
135
|
console.log(c.gray("AGENTS.md's doctrina:surface block was regenerated from the installed catalog, so agents reading the hub now discover the current commands."));
|
|
@@ -125,16 +146,20 @@ Bring an existing project up to the installed CLI after an npm update.
|
|
|
125
146
|
The project keeps the scaffold of the version that init-ed it; this is
|
|
126
147
|
the one command that closes the gap, orchestrating the existing pieces:
|
|
127
148
|
|
|
128
|
-
1.
|
|
149
|
+
1. scaffold shape — regenerate the AGENTS.md doctrina:surface block
|
|
129
150
|
from the installed command catalog (the block is
|
|
130
151
|
CLI-owned, ADR 0015 — this is how agents reading
|
|
131
|
-
the hub discover commands added since init),
|
|
152
|
+
the hub discover commands added since init),
|
|
132
153
|
append missing recommended sections / index.json
|
|
133
|
-
fields (additive-only outside the block)
|
|
154
|
+
fields (additive-only outside the block), and
|
|
155
|
+
list what no command can repair (a drifted
|
|
156
|
+
adapter, a broken playbook), each with its fix
|
|
134
157
|
2. index rebuild — regenerate index.json from the tree and migrate
|
|
135
158
|
the framework_version stamp to the running CLI
|
|
136
159
|
3. validate (--fix) — surface anything the upgrade cannot fix
|
|
137
160
|
(hand-authored drift, new validate checks)
|
|
138
161
|
|
|
139
|
-
Preview by default (exits 1 when steps are pending
|
|
162
|
+
Preview by default (exits 1 when steps are pending or a manual repair is
|
|
163
|
+
left); --write applies. The preview is what \`templates check\` reported
|
|
164
|
+
and --write what \`templates update --write\` did — both deprecated.
|
|
140
165
|
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -20,7 +20,7 @@ import { collectValidation } from "../lib/validation-model.js";
|
|
|
20
20
|
// wrap it in the generic envelope.
|
|
21
21
|
export const jsonNative = true;
|
|
22
22
|
|
|
23
|
-
export const flags = { boolean: ["fix", "json", "runtime"], string: [] };
|
|
23
|
+
export const flags = { boolean: ["fix", "json", "runtime", "strict"], string: [] };
|
|
24
24
|
|
|
25
25
|
export async function run(_positional, flags) {
|
|
26
26
|
const projectRoot = process.cwd();
|
|
@@ -34,10 +34,26 @@ export async function run(_positional, flags) {
|
|
|
34
34
|
runtime: flagBool(flags, "runtime", false),
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
// --strict makes a warning count against the exit code, matching what
|
|
38
|
+
// `--strict` already means on `coverage` and `trace`: fail on any gap.
|
|
39
|
+
//
|
|
40
|
+
// The default has to stay lenient — a warning is advice, and advice that
|
|
41
|
+
// blocks a commit stops being read. But that leniency left a gate that
|
|
42
|
+
// COULD NOT REPROVE: the CI step that validates the shipped examples runs
|
|
43
|
+
// this command, both examples had drifted to warnings, and the step passed
|
|
44
|
+
// green through every run. The EARS defect the retrofit example exists to
|
|
45
|
+
// teach against was reintroduced and sat there, twice, because nothing
|
|
46
|
+
// downstream of `validate` could say no.
|
|
47
|
+
//
|
|
48
|
+
// So the choice belongs to the caller. A human at a terminal wants the
|
|
49
|
+
// advice; a gate wants a verdict.
|
|
50
|
+
const strict = flagBool(flags, "strict", false);
|
|
51
|
+
const failed = errors.length > 0 || (strict && warnings.length > 0);
|
|
52
|
+
|
|
37
53
|
// Output
|
|
38
54
|
if (flagBool(flags, "json", false)) {
|
|
39
|
-
emitJson("validate", { errors, warnings });
|
|
40
|
-
return
|
|
55
|
+
emitJson("validate", { errors, warnings, strict });
|
|
56
|
+
return failed ? 1 : 0;
|
|
41
57
|
}
|
|
42
58
|
// The repairs print first and in the order they were made: --fix changes
|
|
43
59
|
// the tree, and what it changed must be visible before what it then found.
|
|
@@ -50,17 +66,21 @@ export async function run(_positional, flags) {
|
|
|
50
66
|
console.log(c.green("ok") + " all validation checks passed");
|
|
51
67
|
} else {
|
|
52
68
|
const summary = `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`;
|
|
53
|
-
console.log((
|
|
69
|
+
console.log((failed ? c.red("fail") : c.green("ok")) + " " + summary);
|
|
70
|
+
// Say WHY a run with no errors is failing, or the exit code reads as a bug.
|
|
71
|
+
if (strict && errors.length === 0 && warnings.length > 0) {
|
|
72
|
+
console.log(c.gray(" --strict: warnings count against the exit code"));
|
|
73
|
+
}
|
|
54
74
|
}
|
|
55
|
-
return
|
|
75
|
+
return failed ? 1 : 0;
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
export const help = `
|
|
59
|
-
Usage: doctrina validate [--fix] [--runtime] [--json]
|
|
79
|
+
Usage: doctrina validate [--fix] [--runtime] [--strict] [--json]
|
|
60
80
|
|
|
61
81
|
Run schema, artifact-existence, and structural checks against the
|
|
62
82
|
.doctrina/ tree in the current working directory. Exits 0 if no errors
|
|
63
|
-
(warnings allowed), 1 otherwise.
|
|
83
|
+
(warnings allowed), 1 otherwise. With --strict, a warning fails it too.
|
|
64
84
|
|
|
65
85
|
Two structural checks worth naming:
|
|
66
86
|
|
|
@@ -84,5 +104,9 @@ Flags:
|
|
|
84
104
|
selectors checked against the workflows and code that are
|
|
85
105
|
supposed to honour them (the same checks as \`contract check\`).
|
|
86
106
|
Opt-in because it reads files outside .doctrina/.
|
|
87
|
-
--
|
|
107
|
+
--strict Treat warnings as failures, the way \`coverage --strict\` and
|
|
108
|
+
\`trace --strict\` already do. The default stays lenient because
|
|
109
|
+
a warning is advice; a GATE wants a verdict, so anything that
|
|
110
|
+
must be able to reprove passes this.
|
|
111
|
+
--json Emit { ok, errors, warnings, strict } as JSON (stable shape for agents/CI).
|
|
88
112
|
`;
|
package/src/commands/verify.js
CHANGED
|
@@ -8,9 +8,10 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
|
8
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
9
|
import { today } from "../lib/dates.js";
|
|
10
10
|
import { c } from "../lib/colors.js";
|
|
11
|
-
import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL } from "../lib/signoff.js";
|
|
11
|
+
import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL, declaredPaths, workingChanges } from "../lib/signoff.js";
|
|
12
12
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
13
|
import { collectReproducibility } from "../lib/reproducibility.js";
|
|
14
|
+
import { verifyFingerprint, recordGreen, reusableGreen } from "../lib/verify-cache.js";
|
|
14
15
|
|
|
15
16
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
16
17
|
// the artifact tree; it never runs the project, so a repo that does not
|
|
@@ -40,7 +41,7 @@ const STARTER = {
|
|
|
40
41
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
41
42
|
// adding a command never requires editing the entrypoint — the gap that
|
|
42
43
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
43
|
-
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
|
|
44
|
+
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict", "reuse", "affected"], string: ["signoff"] };
|
|
44
45
|
|
|
45
46
|
export async function run(_positional, flags) {
|
|
46
47
|
const projectRoot = process.cwd();
|
|
@@ -182,6 +183,22 @@ export async function run(_positional, flags) {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
const strict = flagBool(flags, "strict", false);
|
|
186
|
+
const affected = flagBool(flags, "affected", false);
|
|
187
|
+
// The fingerprint of the code these checks are about to run on, taken
|
|
188
|
+
// BEFORE they run (change 0203): what a green run proves is the tree it
|
|
189
|
+
// started from. Null outside git — then nothing is remembered or reused.
|
|
190
|
+
const fingerprint = verifyFingerprint(projectRoot, read(configPath));
|
|
191
|
+
if (flagBool(flags, "reuse", false) && fingerprint !== null) {
|
|
192
|
+
const green = reusableGreen(projectRoot, fingerprint);
|
|
193
|
+
if (green) {
|
|
194
|
+
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
195
|
+
console.log("");
|
|
196
|
+
console.log(c.green("ok") + ` reused the green run of ${green.at.replace("T", " ").slice(0, 16)} UTC — ` +
|
|
197
|
+
`the code and ${CONFIG_REL} are byte-identical to what it ran on (${green.checks.join(", ")})`);
|
|
198
|
+
console.log(c.gray(" `doctrina verify` runs the checks again"));
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
185
202
|
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
186
203
|
const results = [];
|
|
187
204
|
for (const ch of checks) {
|
|
@@ -220,6 +237,20 @@ export async function run(_positional, flags) {
|
|
|
220
237
|
continue;
|
|
221
238
|
}
|
|
222
239
|
|
|
240
|
+
// --affected (change 0204): a check that declares the "paths" it covers
|
|
241
|
+
// runs only when the uncommitted work touches one of them. The inner
|
|
242
|
+
// loop of a change — not the close, which always runs every check. A
|
|
243
|
+
// check that declares no paths covers everything and always runs; a
|
|
244
|
+
// question git cannot answer runs the check.
|
|
245
|
+
if (affected && declaredPaths(ch).length > 0) {
|
|
246
|
+
const touched = workingChanges(projectRoot, declaredPaths(ch));
|
|
247
|
+
if (touched !== null && touched.length === 0) {
|
|
248
|
+
console.log(c.gray(`──── ${name}: `) + c.gray(`skip — the uncommitted work touches none of ${declaredPaths(ch).join(", ")}`));
|
|
249
|
+
results.push({ name, ok: true, kind: "command", skipped: true });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
223
254
|
const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
|
|
224
255
|
console.log(c.gray(`──── ${name}: `) + ch.run + where);
|
|
225
256
|
// Run through the shell so declared commands can use pipes, &&, env,
|
|
@@ -287,9 +318,18 @@ export async function run(_positional, flags) {
|
|
|
287
318
|
// manual check still awaiting sign-off. Pending manual checks are otherwise
|
|
288
319
|
// a non-blocking report.
|
|
289
320
|
const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
|
|
290
|
-
const
|
|
321
|
+
const skipped = results.filter((r) => r.skipped);
|
|
322
|
+
const pendNote = (pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "") +
|
|
323
|
+
(skipped.length > 0 ? c.gray(` (${skipped.length} skipped by --affected: ${skipped.map((r) => r.name).join(", ")})`) : "");
|
|
291
324
|
if (!failGate) {
|
|
292
|
-
|
|
325
|
+
const ran = passed - skipped.length;
|
|
326
|
+
console.log(c.green("ok") + ` ${ran}/${results.length - skipped.length} checks passed${pendNote}`);
|
|
327
|
+
// Remembered only when EVERY check passed — a run green with a manual
|
|
328
|
+
// check still pending is a report, not something a close may stand on,
|
|
329
|
+
// and neither is a run --affected cut short.
|
|
330
|
+
if (fingerprint !== null && pending.length === 0 && skipped.length === 0) {
|
|
331
|
+
recordGreen(projectRoot, fingerprint, results.map((r) => r.name));
|
|
332
|
+
}
|
|
293
333
|
return 0;
|
|
294
334
|
}
|
|
295
335
|
const reasons = [];
|
|
@@ -403,7 +443,7 @@ function renderReproducibility(projectRoot) {
|
|
|
403
443
|
}
|
|
404
444
|
|
|
405
445
|
export const help = `
|
|
406
|
-
Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
|
|
446
|
+
Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--reuse] [--affected] [--signoff "<name>=<note>"] [--force]
|
|
407
447
|
|
|
408
448
|
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
409
449
|
streaming their output, and exit non-zero if any check fails. This is the
|
|
@@ -414,13 +454,18 @@ root, targets a sub-package in a monorepo; a "manual" check is the
|
|
|
414
454
|
qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
|
|
415
455
|
{
|
|
416
456
|
"checks": [
|
|
417
|
-
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api"
|
|
457
|
+
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api",
|
|
458
|
+
"paths": ["packages/api"] },
|
|
418
459
|
{ "name": "test", "run": "npm test" },
|
|
419
460
|
{ "name": "build", "run": "npm run build" },
|
|
420
461
|
{ "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
|
|
421
462
|
]
|
|
422
463
|
}
|
|
423
464
|
|
|
465
|
+
"paths" (git pathspecs, relative to the project root) says what a check
|
|
466
|
+
covers: --affected runs it only when the uncommitted work touches one, and a
|
|
467
|
+
manual check's sign-off expires when one changes.
|
|
468
|
+
|
|
424
469
|
A manual check passes when it has been signed off and is otherwise reported
|
|
425
470
|
as "pending" — non-blocking by default, failing only under --strict (so CI
|
|
426
471
|
can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
|
|
@@ -450,6 +495,14 @@ Flags:
|
|
|
450
495
|
prepare/prepack; a Prisma dep with no generate on install) so
|
|
451
496
|
"verify green" can't hide a clean checkout that won't build.
|
|
452
497
|
--strict Fail the gate when a manual check is still pending sign-off
|
|
498
|
+
--reuse Skip the checks when the last fully green run covered this exact
|
|
499
|
+
code: the working tree (tracked, untracked, edits) minus
|
|
500
|
+
.doctrina/, plus ${CONFIG_REL}, are byte-identical. \`close\` runs
|
|
501
|
+
verify this way; a plain \`doctrina verify\` always runs them.
|
|
502
|
+
--affected Run a check that declares "paths" only when the uncommitted work
|
|
503
|
+
(edits against HEAD, untracked files) touches one of them —
|
|
504
|
+
the inner loop of a change. Checks with no "paths" always run;
|
|
505
|
+
\`close\` always runs every check. Never remembered by --reuse.
|
|
453
506
|
--signoff "<name>=<note>"
|
|
454
507
|
Record today's sign-off for a manual check, then exit
|
|
455
508
|
--force With --init, overwrite an existing config
|
package/src/commands/work.js
CHANGED
|
@@ -9,13 +9,14 @@ import { c } from "../lib/colors.js";
|
|
|
9
9
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
10
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
11
11
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
-
import { changeNew, reindexChange } from "../lib/change-ops.js";
|
|
12
|
+
import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
|
|
13
13
|
import { classify } from "../lib/triage-model.js";
|
|
14
14
|
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
15
|
+
import { specShape } from "../lib/spec-ops.js";
|
|
15
16
|
import { changedFiles } from "../lib/git.js";
|
|
16
17
|
import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
|
|
17
18
|
import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
18
|
-
import { isChangeId } from "../lib/project.js";
|
|
19
|
+
import { isChangeId, refuseChangeRef } from "../lib/project.js";
|
|
19
20
|
export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
20
21
|
|
|
21
22
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -29,14 +30,36 @@ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
|
29
30
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
30
31
|
// adding a command never requires editing the entrypoint — the gap that
|
|
31
32
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
32
|
-
export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
|
|
33
|
+
export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "id", "resume", "title"] };
|
|
33
34
|
|
|
34
35
|
export async function run(positional, flags) {
|
|
35
|
-
const prompt = positional.join(" ").trim();
|
|
36
36
|
const projectRoot = process.cwd();
|
|
37
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
38
38
|
throw notADoctrinaProject();
|
|
39
39
|
}
|
|
40
|
+
// --consolidate <cap> (change 0206): a spec only ever grew — every change
|
|
41
|
+
// appended, nothing took a requirement out — and a field review of 0.17
|
|
42
|
+
// asked for "a command to consolidate a spec from time to time". It is an
|
|
43
|
+
// ordinary change pinned to the capability, with its own playbook: the
|
|
44
|
+
// agent decides what is redundant or superseded (ADR 0005), and the ops
|
|
45
|
+
// take it out.
|
|
46
|
+
const consolidate = flags.has("consolidate") ? flagString(flags, "consolidate") : null;
|
|
47
|
+
if (consolidate !== null) {
|
|
48
|
+
if (!consolidate || /** @type {unknown} */ (consolidate) === true || !/^[a-z][a-z0-9-]*$/.test(consolidate)) {
|
|
49
|
+
console.error(c.red("error:") + " --consolidate needs the capability whose spec to consolidate");
|
|
50
|
+
return EXIT.USAGE;
|
|
51
|
+
}
|
|
52
|
+
if (!isFile(path.join(projectRoot, ".doctrina", "specs", consolidate, "spec.md"))) {
|
|
53
|
+
console.error(c.red("error:") + ` capability "${consolidate}" has no spec at .doctrina/specs/${consolidate}/spec.md`);
|
|
54
|
+
return EXIT.USAGE;
|
|
55
|
+
}
|
|
56
|
+
flags.set("capability", consolidate);
|
|
57
|
+
flags.set("force", true);
|
|
58
|
+
if (!flags.has("title")) flags.set("title", `consolidate ${consolidate}`);
|
|
59
|
+
}
|
|
60
|
+
const prompt = consolidate
|
|
61
|
+
? `Consolidate the ${consolidate} spec: merge requirements and criteria that say the same thing, drop what a later one superseded — no behaviour changes.`
|
|
62
|
+
: positional.join(" ").trim();
|
|
40
63
|
|
|
41
64
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
42
65
|
// open a new one (review G1 — `work "continue"` used to slug the word and
|
|
@@ -205,6 +228,12 @@ export async function run(positional, flags) {
|
|
|
205
228
|
? null
|
|
206
229
|
: scaffoldDelta(projectRoot, { id, capability, guess, runnerUp: promptMatches[1] ?? null });
|
|
207
230
|
|
|
231
|
+
// A guessed delta names the capability as surely as a pinned one: the
|
|
232
|
+
// header says what the folder says (change 0207). It stayed empty for
|
|
233
|
+
// every unpinned `work`, so the proposal claimed no spec while its delta
|
|
234
|
+
// targeted one.
|
|
235
|
+
if (scaffolded) addAffectedSpec(projectRoot, id, scaffolded.capability);
|
|
236
|
+
|
|
208
237
|
// The proposal is written in two passes — `changeNew` scaffolds it, the
|
|
209
238
|
// lines above stamp the lane and the pinned specs — so its index entry is
|
|
210
239
|
// re-derived HERE, from the finished file. Indexing at the end of the first
|
|
@@ -223,7 +252,9 @@ export async function run(positional, flags) {
|
|
|
223
252
|
}
|
|
224
253
|
|
|
225
254
|
console.log("");
|
|
226
|
-
if (
|
|
255
|
+
if (consolidate) {
|
|
256
|
+
printConsolidatePlaybook(projectRoot, { id, capability: consolidate });
|
|
257
|
+
} else if (chore) {
|
|
227
258
|
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
228
259
|
} else {
|
|
229
260
|
printPlaybook(projectRoot, {
|
|
@@ -387,6 +418,8 @@ function resumeChange(projectRoot, resumeId) {
|
|
|
387
418
|
if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
|
|
388
419
|
return 2;
|
|
389
420
|
}
|
|
421
|
+
const refused = refuseChangeRef(resumeId);
|
|
422
|
+
if (refused !== null) return refused;
|
|
390
423
|
const entry = open.find((ch) => ch.id === resumeId);
|
|
391
424
|
if (!entry && !isDir(path.join(projectRoot, ".doctrina", "changes", resumeId))) {
|
|
392
425
|
console.error(c.red("error:") + ` no open change "${resumeId}"`);
|
|
@@ -438,6 +471,21 @@ export function slugify(text) {
|
|
|
438
471
|
// part that is not prose: computing the variable blocks. Each is PRE-RENDERED
|
|
439
472
|
// into a token, so `substitute` stays a plain string map and the template
|
|
440
473
|
// stays a document rather than a language with conditionals in it.
|
|
474
|
+
// The consolidation playbook: the spec's shape, measured, then the steps.
|
|
475
|
+
// Measuring is the CLI's part; deciding which requirement is redundant is
|
|
476
|
+
// the agent's (ADR 0005).
|
|
477
|
+
function printConsolidatePlaybook(projectRoot, { id, capability }) {
|
|
478
|
+
const text = read(path.join(projectRoot, ".doctrina", "specs", capability, "spec.md"));
|
|
479
|
+
const shape = specShape(text);
|
|
480
|
+
const sections = Object.entries(shape.sections).map(([k, n]) => `${k} ${n}`).join(" · ");
|
|
481
|
+
printPlaybookTemplate(projectRoot, "consolidate", {
|
|
482
|
+
CHANGE_ID: id,
|
|
483
|
+
CAPABILITY: capability,
|
|
484
|
+
SHAPE: `${shape.lines} lines · ${shape.requirements} requirements (${sections || "none"}) · ` +
|
|
485
|
+
`${shape.criteria} acceptance criteria`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
441
489
|
function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
442
490
|
printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
|
|
443
491
|
}
|
|
@@ -515,9 +563,10 @@ change id (NNNN-<slug>) from the prompt, opens the change folder,
|
|
|
515
563
|
records the prompt under the proposal's "## Why", ranks existing specs
|
|
516
564
|
by term overlap as a capability hint, flags a thin/under-specified prompt
|
|
517
565
|
so you clarify before writing deltas (advisory), and prints the steps:
|
|
518
|
-
context
|
|
519
|
-
|
|
520
|
-
|
|
566
|
+
context, spec delta, tasks, implement, then \`doctrina change check <id>\`
|
|
567
|
+
to preview the close and \`doctrina close <id>\` to finish, which runs the
|
|
568
|
+
whole closing sequence in one pass. No natural-language interpretation
|
|
569
|
+
happens in the CLI.
|
|
521
570
|
|
|
522
571
|
The change's spec delta is scaffolded with **Operation:** prefilled
|
|
523
572
|
whenever the CLI can name the capability: from --capability, or from the
|
|
@@ -540,8 +589,14 @@ Options:
|
|
|
540
589
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
541
590
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
542
591
|
prompt needed) and print a code-first playbook (F8)
|
|
592
|
+
--consolidate <cap> Open a change that consolidates <cap>'s spec — merge
|
|
593
|
+
what says the same thing, remove what was superseded
|
|
594
|
+
(remove-requirement / remove-criterion) — with its own
|
|
595
|
+
playbook; no prompt needed, no behaviour changes
|
|
543
596
|
--chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
|
|
544
597
|
playbook that skips the spec-delta steps (F9)
|
|
598
|
+
--design Also scaffold design.md, for a change with non-trivial
|
|
599
|
+
choices (opt-in, as with \`change new --design\`)
|
|
545
600
|
--force Open a new change even when one is open, overwrite an
|
|
546
601
|
existing change folder, and proceed past the lane hold
|
|
547
602
|
below
|
package/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { parseArgs } from "./lib/args.js";
|
|
|
5
5
|
import { c } from "./lib/colors.js";
|
|
6
6
|
import { suggest } from "./lib/suggest.js";
|
|
7
7
|
import { cliVersion } from "./lib/version.js";
|
|
8
|
-
import { surfaceHelp, OPERATIONS, deprecationFor } from "./lib/commands.js";
|
|
8
|
+
import { surfaceHelp, OPERATIONS, deprecationFor, removalFor } from "./lib/commands.js";
|
|
9
9
|
import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
|
|
10
10
|
import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
11
11
|
import { recordUsage, operationOf } from "./lib/usage.js";
|
|
@@ -37,7 +37,6 @@ import * as close from "./commands/close.js";
|
|
|
37
37
|
import * as review from "./commands/review.js";
|
|
38
38
|
import * as watch from "./commands/watch.js";
|
|
39
39
|
import * as why from "./commands/why.js";
|
|
40
|
-
import * as constitution from "./commands/constitution.js";
|
|
41
40
|
import * as prime from "./commands/prime.js";
|
|
42
41
|
import * as handoff from "./commands/handoff.js";
|
|
43
42
|
import * as show from "./commands/show.js";
|
|
@@ -54,15 +53,14 @@ const COMMANDS = {
|
|
|
54
53
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
55
54
|
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
56
55
|
intake, work, coverage, verify, contract, trace,
|
|
57
|
-
status, close, review, watch, why,
|
|
56
|
+
status, close, review, watch, why,
|
|
58
57
|
prime, handoff, show, doctor, report, completion,
|
|
59
58
|
intent, upgrade, adapter, triage, ci,
|
|
60
59
|
};
|
|
61
60
|
|
|
62
61
|
const TOP_HELP = `
|
|
63
|
-
Usage: doctrina <command> [options]
|
|
62
|
+
Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
|
|
64
63
|
|
|
65
|
-
Commands:
|
|
66
64
|
${surfaceHelp()}
|
|
67
65
|
|
|
68
66
|
Global flags:
|
|
@@ -112,6 +110,17 @@ async function main(argv) {
|
|
|
112
110
|
|
|
113
111
|
const commandName = positional[0];
|
|
114
112
|
const command = COMMANDS[commandName];
|
|
113
|
+
const removed = removalFor(positional);
|
|
114
|
+
if (removed) {
|
|
115
|
+
const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
|
|
116
|
+
console.error(c.red("error:") + ` \`doctrina ${name}\` was removed in ${removed.since}`);
|
|
117
|
+
console.error(c.gray("hint: ") + `use \`${removed.use}\` — it prints what the removed command printed`);
|
|
118
|
+
if (wantsJson(flags)) {
|
|
119
|
+
emitJson(operationName(positional), { stderr: [`error: \`doctrina ${name}\` was removed in ${removed.since}`, `hint: use \`${removed.use}\``] },
|
|
120
|
+
{ ok: false, exitCode: EXIT.USAGE, args: positional.slice(1) });
|
|
121
|
+
}
|
|
122
|
+
return EXIT.USAGE;
|
|
123
|
+
}
|
|
115
124
|
if (!command) {
|
|
116
125
|
console.error(c.red("error:") + ` unknown command "${commandName}"`);
|
|
117
126
|
const guess = suggest(commandName, Object.keys(COMMANDS));
|
|
@@ -265,5 +274,24 @@ main(process.argv.slice(2)).then((code) => {
|
|
|
265
274
|
code,
|
|
266
275
|
new Set(OPERATIONS.map((o) => o[0])),
|
|
267
276
|
);
|
|
268
|
-
process.exit(
|
|
277
|
+
// SET THE CODE; DO NOT CALL process.exit().
|
|
278
|
+
//
|
|
279
|
+
// When stdout is a PIPE — every `doctrina ... | less`, every agent reading
|
|
280
|
+
// this CLI, every `spawnSync` in the suite — Node may not have handed the
|
|
281
|
+
// bytes to the OS yet when this callback runs. `process.exit()` tears the
|
|
282
|
+
// process down immediately and whatever is still buffered is simply lost.
|
|
283
|
+
// Setting `exitCode` lets Node exit on its own once the work is done, and
|
|
284
|
+
// flushing stdout is part of that work.
|
|
285
|
+
//
|
|
286
|
+
// This is not theoretical. Four tests failed on macOS with Node 20.12 and
|
|
287
|
+
// on no other leg of the matrix, all of them `--concat` runs looking for
|
|
288
|
+
// content near the END of a long output. The decisive one printed exactly
|
|
289
|
+
// one section: `product.md` was on disk and in the pack listing, and the
|
|
290
|
+
// concat output stopped before reaching it. Pipe buffer sizes differ per
|
|
291
|
+
// platform, which is precisely why a truncation bug picks one and hides on
|
|
292
|
+
// the others.
|
|
293
|
+
//
|
|
294
|
+
// `--concat` even carries the comment "Keep the pack pipeable". Losing the
|
|
295
|
+
// tail of a pipe is the one way to break that promise.
|
|
296
|
+
process.exitCode = code ?? 0;
|
|
269
297
|
});
|
package/src/lib/actions.js
CHANGED
|
@@ -155,6 +155,34 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
155
155
|
}));
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// Index drift comes right after the intake (change 0193). It used to be
|
|
159
|
+
// surfaced LAST as "silent rot" — but `validate` reports it as an error,
|
|
160
|
+
// the close's index-drift step blocks on it, the fix is one runnable
|
|
161
|
+
// command, and the actions below read the very index that drifted. Ranked
|
|
162
|
+
// fifth, under advisory nudges, it told the agent to polish product.md
|
|
163
|
+
// before running the one command that unblocked everything.
|
|
164
|
+
try {
|
|
165
|
+
const current = idx.load(projectRoot);
|
|
166
|
+
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
167
|
+
actions.push(action({
|
|
168
|
+
id: "index-drift",
|
|
169
|
+
command: "index rebuild",
|
|
170
|
+
gate: "index",
|
|
171
|
+
runnable: true,
|
|
172
|
+
why: "index.json has drifted from the tree",
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
actions.push(action({
|
|
177
|
+
id: "index-unreadable",
|
|
178
|
+
command: "index rebuild",
|
|
179
|
+
gate: "index",
|
|
180
|
+
runnable: true,
|
|
181
|
+
why: "index.json is missing or unreadable",
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
158
186
|
// Open changes drive the loop: finish what is started before opening more.
|
|
159
187
|
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
160
188
|
if (isDir(changesDir)) {
|
|
@@ -171,9 +199,13 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
171
199
|
}
|
|
172
200
|
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
173
201
|
if (status === "applied") {
|
|
202
|
+
// The close, not the archive (change 0201). 0.17 retired the
|
|
203
|
+
// hand-driven tail — `close` skips the apply it already did and
|
|
204
|
+
// still runs verify, coverage and the docs gate — but this line kept
|
|
205
|
+
// teaching `change archive`, the one path past every one of them.
|
|
174
206
|
actions.push(action({
|
|
175
207
|
id: "change-archive-pending",
|
|
176
|
-
command: "
|
|
208
|
+
command: "close",
|
|
177
209
|
args: [id],
|
|
178
210
|
gate: "archive",
|
|
179
211
|
runnable: true,
|
|
@@ -200,22 +232,22 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
200
232
|
if (deltas.length > 0) {
|
|
201
233
|
actions.push(action({
|
|
202
234
|
id: "change-apply-ready",
|
|
203
|
-
command: "
|
|
235
|
+
command: "change check",
|
|
204
236
|
args: [id],
|
|
205
237
|
gate: "structure",
|
|
206
238
|
runnable: true,
|
|
207
239
|
why: `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
|
|
208
|
-
text: `doctrina
|
|
240
|
+
text: `doctrina change check ${id}, then doctrina close ${id} — ` +
|
|
209
241
|
`tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
|
|
210
242
|
}));
|
|
211
243
|
} else {
|
|
212
244
|
actions.push(action({
|
|
213
245
|
id: "change-deltas-missing",
|
|
214
|
-
command: "
|
|
246
|
+
command: "close",
|
|
215
247
|
args: [id],
|
|
216
|
-
why: `add spec deltas under .doctrina/changes/${id}/specs/, or
|
|
248
|
+
why: `add spec deltas under .doctrina/changes/${id}/specs/, or close it as metadata-only`,
|
|
217
249
|
text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
|
|
218
|
-
`or
|
|
250
|
+
`or close it as metadata-only: doctrina close ${id}`,
|
|
219
251
|
}));
|
|
220
252
|
}
|
|
221
253
|
}
|
|
@@ -377,28 +409,6 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
377
409
|
}));
|
|
378
410
|
}
|
|
379
411
|
|
|
380
|
-
// Index drift is silent rot; surface it last.
|
|
381
|
-
try {
|
|
382
|
-
const current = idx.load(projectRoot);
|
|
383
|
-
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
384
|
-
actions.push(action({
|
|
385
|
-
id: "index-drift",
|
|
386
|
-
command: "index rebuild",
|
|
387
|
-
gate: "index",
|
|
388
|
-
runnable: true,
|
|
389
|
-
why: "index.json has drifted from the tree",
|
|
390
|
-
}));
|
|
391
|
-
}
|
|
392
|
-
} catch {
|
|
393
|
-
actions.push(action({
|
|
394
|
-
id: "index-unreadable",
|
|
395
|
-
command: "index rebuild",
|
|
396
|
-
gate: "index",
|
|
397
|
-
runnable: true,
|
|
398
|
-
why: "index.json is missing or unreadable",
|
|
399
|
-
}));
|
|
400
|
-
}
|
|
401
|
-
|
|
402
412
|
return actions;
|
|
403
413
|
}
|
|
404
414
|
|