doctrina-cli 0.12.0 → 0.14.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 +76 -19
- package/src/commands/change.js +341 -44
- package/src/commands/clarify.js +40 -19
- package/src/commands/close.js +119 -11
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- 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 +152 -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 +14 -2
- 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 +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +277 -40
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +23 -12
- package/src/commands/validate.js +93 -3
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +69 -7
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +47 -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 +314 -3
- 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/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +228 -3
- 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/.gitkeep +0 -0
- package/templates/AGENTS.md.template +71 -75
- package/templates/change/spec-delta.md.template +13 -7
package/src/commands/upgrade.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists } from "../lib/fs-ops.js";
|
|
@@ -7,6 +8,7 @@ import { cliVersion } from "../lib/version.js";
|
|
|
7
8
|
import { flagBool } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
import { today } from "../lib/dates.js";
|
|
11
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
12
|
import * as templates from "./templates.js";
|
|
11
13
|
import * as validate from "./validate.js";
|
|
12
14
|
|
|
@@ -20,10 +22,15 @@ import * as validate from "./validate.js";
|
|
|
20
22
|
// step. Preview by default; --write applies. Never rewrites user content
|
|
21
23
|
// (the same additive-only bound as `templates update`).
|
|
22
24
|
|
|
25
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
26
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
27
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
28
|
+
export const flags = { boolean: ["json", "write"], string: [] };
|
|
29
|
+
|
|
23
30
|
export async function run(_positional, flags) {
|
|
24
31
|
const projectRoot = process.cwd();
|
|
25
32
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
-
throw
|
|
33
|
+
throw notADoctrinaProject();
|
|
27
34
|
}
|
|
28
35
|
const writeMode = flagBool(flags, "write", false);
|
|
29
36
|
const running = cliVersion();
|
|
@@ -82,10 +89,12 @@ export async function run(_positional, flags) {
|
|
|
82
89
|
}
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
// 4. Full structural check — surfaces what
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
92
|
+
// 4. Full structural check — surfaces what the upgrade cannot fix by
|
|
93
|
+
// itself (new validate checks the old scaffold fails, hand-authored
|
|
94
|
+
// drift). The command surface in AGENTS.md is NOT in this bucket any
|
|
95
|
+
// more: step 1 regenerates the doctrina:surface block from the
|
|
96
|
+
// installed catalog (ADR 0015). --fix under --write so the mechanical
|
|
97
|
+
// part self-heals.
|
|
89
98
|
console.log("");
|
|
90
99
|
console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
|
|
91
100
|
const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
|
|
@@ -102,7 +111,7 @@ export async function run(_positional, flags) {
|
|
|
102
111
|
}
|
|
103
112
|
if (vCode === 0) {
|
|
104
113
|
console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
|
|
105
|
-
console.log(c.gray("
|
|
114
|
+
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."));
|
|
106
115
|
} else {
|
|
107
116
|
console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
|
|
108
117
|
}
|
|
@@ -116,14 +125,16 @@ Bring an existing project up to the installed CLI after an npm update.
|
|
|
116
125
|
The project keeps the scaffold of the version that init-ed it; this is
|
|
117
126
|
the one command that closes the gap, orchestrating the existing pieces:
|
|
118
127
|
|
|
119
|
-
1. templates update —
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
1. templates update — regenerate the AGENTS.md doctrina:surface block
|
|
129
|
+
from the installed command catalog (the block is
|
|
130
|
+
CLI-owned, ADR 0015 — this is how agents reading
|
|
131
|
+
the hub discover commands added since init), and
|
|
132
|
+
append missing recommended sections / index.json
|
|
133
|
+
fields (additive-only outside the block)
|
|
122
134
|
2. index rebuild — regenerate index.json from the tree and migrate
|
|
123
135
|
the framework_version stamp to the running CLI
|
|
124
|
-
3. validate (--fix) — surface anything the
|
|
125
|
-
(
|
|
126
|
-
surface — refresh it so agents see new commands)
|
|
136
|
+
3. validate (--fix) — surface anything the upgrade cannot fix
|
|
137
|
+
(hand-authored drift, new validate checks)
|
|
127
138
|
|
|
128
139
|
Preview by default (exits 1 when steps are pending); --write applies.
|
|
129
140
|
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk , write} from "../lib/fs-ops.js";
|
|
5
6
|
import * as idx from "../lib/index-json.js";
|
|
6
7
|
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
7
8
|
import { cliVersion } from "../lib/version.js";
|
|
8
9
|
import { today } from "../lib/dates.js";
|
|
9
10
|
import { flagBool } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
12
|
+
import { kindFromPath, nonConformingHeaders, repairHeaders } from "../lib/doc-model.js";
|
|
13
|
+
import { emitJson } from "../lib/json-out.js";
|
|
14
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
15
|
import { parseFrontmatter } from "./skill.js";
|
|
12
16
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
13
17
|
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
@@ -19,8 +23,21 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
|
19
23
|
// `doctrina --help` and is never nagged about omissions.
|
|
20
24
|
const CATALOG_THRESHOLD = 8;
|
|
21
25
|
|
|
26
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
28
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
29
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
30
|
+
// wrap it in the generic envelope.
|
|
31
|
+
export const jsonNative = true;
|
|
32
|
+
|
|
33
|
+
export const flags = { boolean: ["fix", "json"], string: [] };
|
|
34
|
+
|
|
22
35
|
export async function run(_positional, flags) {
|
|
23
36
|
const projectRoot = process.cwd();
|
|
37
|
+
// Outside a Doctrina project this is a PRECONDITION, not a pile of
|
|
38
|
+
// gate failures about missing files: the remedy is `doctrina init`,
|
|
39
|
+
// not editing artifacts that do not exist yet (C7).
|
|
40
|
+
if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
|
|
24
41
|
const errors = [];
|
|
25
42
|
const warnings = [];
|
|
26
43
|
|
|
@@ -87,6 +104,45 @@ export async function run(_positional, flags) {
|
|
|
87
104
|
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
88
105
|
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
89
106
|
|
|
107
|
+
// Header repair (M3). One grammar means a non-canonical header can be
|
|
108
|
+
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
109
|
+
// `- **Status:** x`, and a spec written with list-style headers is
|
|
110
|
+
// normalised to the bare-bold form its kind uses. Content is never
|
|
111
|
+
// touched — only the header's own punctuation and style.
|
|
112
|
+
const doFix = flagBool(flags, "fix", false);
|
|
113
|
+
{
|
|
114
|
+
const artifacts = [];
|
|
115
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
116
|
+
for (const f of walk(dot)) {
|
|
117
|
+
if (!f.endsWith(".md")) continue;
|
|
118
|
+
const rel = relPath(projectRoot, f).replace(/\\/g, "/");
|
|
119
|
+
if (rel.includes("/changes/archive/")) continue; // history is immutable
|
|
120
|
+
artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
|
|
121
|
+
}
|
|
122
|
+
let repairedFiles = 0;
|
|
123
|
+
for (const a of artifacts) {
|
|
124
|
+
const text = read(a.file);
|
|
125
|
+
const bad = nonConformingHeaders(text, a.kind);
|
|
126
|
+
if (bad.length === 0) continue;
|
|
127
|
+
if (doFix) {
|
|
128
|
+
const { text: fixed, repaired } = repairHeaders(text, a.kind);
|
|
129
|
+
if (repaired > 0 && fixed !== text) {
|
|
130
|
+
write(a.file, fixed, { force: true });
|
|
131
|
+
repairedFiles += 1;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
for (const h of bad) {
|
|
135
|
+
warnings.push(
|
|
136
|
+
`${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (doFix && repairedFiles > 0) {
|
|
142
|
+
console.log(c.green("fixed") + ` normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
// 3. index.json
|
|
91
147
|
let index = null;
|
|
92
148
|
try {
|
|
@@ -254,7 +310,12 @@ export async function run(_positional, flags) {
|
|
|
254
310
|
}
|
|
255
311
|
}
|
|
256
312
|
|
|
257
|
-
// 7. Every open change has a proposal
|
|
313
|
+
// 7. Every open change has a proposal, and its delta files carry a
|
|
314
|
+
// well-formed **Operation:** header. The delta used to be the one
|
|
315
|
+
// 100% hand-authored file with no early check: a missing header passed
|
|
316
|
+
// silently at creation and exploded days later in the closing analyze
|
|
317
|
+
// (operator review 2026-07-19 §3.2). Warning-level — the change is
|
|
318
|
+
// still a draft — but now every `validate` run names it.
|
|
258
319
|
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
259
320
|
if (isDir(changesDir)) {
|
|
260
321
|
const { readdirSync } = await import("node:fs");
|
|
@@ -267,6 +328,35 @@ export async function run(_positional, flags) {
|
|
|
267
328
|
`(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
|
|
268
329
|
);
|
|
269
330
|
}
|
|
331
|
+
// A change whose tasks are still the scaffold placeholders was opened
|
|
332
|
+
// but never planned — the agent is (or will be) implementing with no
|
|
333
|
+
// recorded plan. Early, every-run signal; analyze/close hard-fail it.
|
|
334
|
+
const tasksPath = path.join(changesDir, entry, "tasks.md");
|
|
335
|
+
if (isFile(tasksPath)) {
|
|
336
|
+
const ph = (read(tasksPath).match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
337
|
+
if (ph > 0) {
|
|
338
|
+
warnings.push(
|
|
339
|
+
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
340
|
+
`the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
|
|
345
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
346
|
+
const text = read(deltaPath);
|
|
347
|
+
const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
|
|
348
|
+
if (!m) {
|
|
349
|
+
warnings.push(
|
|
350
|
+
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
351
|
+
`analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
352
|
+
);
|
|
353
|
+
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
354
|
+
warnings.push(
|
|
355
|
+
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
356
|
+
`analyze/apply will refuse it at close time`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
270
360
|
}
|
|
271
361
|
}
|
|
272
362
|
|
|
@@ -540,7 +630,7 @@ export async function run(_positional, flags) {
|
|
|
540
630
|
|
|
541
631
|
// Output
|
|
542
632
|
if (flagBool(flags, "json", false)) {
|
|
543
|
-
|
|
633
|
+
emitJson("validate", { ok: errors.length === 0, errors, warnings });
|
|
544
634
|
return errors.length === 0 ? 0 : 1;
|
|
545
635
|
}
|
|
546
636
|
for (const w of warnings) console.log(c.yellow("warn: ") + w);
|
package/src/commands/verify.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -6,6 +7,7 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
|
6
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
11
|
|
|
10
12
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
11
13
|
// the artifact tree; it never runs the project, so a repo that does not
|
|
@@ -29,10 +31,15 @@ const STARTER = {
|
|
|
29
31
|
],
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
35
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
36
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
37
|
+
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
|
|
38
|
+
|
|
32
39
|
export async function run(_positional, flags) {
|
|
33
40
|
const projectRoot = process.cwd();
|
|
34
41
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
35
|
-
throw
|
|
42
|
+
throw notADoctrinaProject();
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
// --clean is the reproducibility lint (review G4): `verify` runs the
|
|
@@ -66,7 +73,10 @@ export async function run(_positional, flags) {
|
|
|
66
73
|
// the false confidence this command exists to prevent. Fail loudly.
|
|
67
74
|
console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
|
|
68
75
|
console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
|
|
69
|
-
|
|
76
|
+
// PRECONDITION, not a gate failure: the work may be perfect; this
|
|
77
|
+
// project has simply never declared a build gate. An agent reading
|
|
78
|
+
// exit 1 would iterate on the code forever (C7).
|
|
79
|
+
return EXIT.PRECONDITION;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
let config;
|
package/src/commands/watch.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { watch as fsWatch } from "node:fs";
|
|
4
5
|
import { exists } from "../lib/fs-ops.js";
|
|
5
6
|
import { flagBool } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
8
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
9
|
import * as validate from "./validate.js";
|
|
8
10
|
import * as next from "./next.js";
|
|
9
11
|
|
|
@@ -15,11 +17,16 @@ import * as next from "./next.js";
|
|
|
15
17
|
// makes) so it never loops on its own output. `--once` runs a single pass and
|
|
16
18
|
// exits — the testable, scriptable form.
|
|
17
19
|
|
|
20
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
21
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
22
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
23
|
+
export const flags = { boolean: ["json", "once"], string: [] };
|
|
24
|
+
|
|
18
25
|
export async function run(_positional, flags) {
|
|
19
26
|
const projectRoot = process.cwd();
|
|
20
27
|
const dot = path.join(projectRoot, ".doctrina");
|
|
21
28
|
if (!exists(dot)) {
|
|
22
|
-
throw
|
|
29
|
+
throw notADoctrinaProject();
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
if (flagBool(flags, "once", false)) {
|
package/src/commands/why.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSectionParagraph } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { readdirSync } from "node:fs";
|
|
@@ -7,6 +9,7 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
|
7
9
|
import * as idx from "../lib/index-json.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
13
|
|
|
11
14
|
// Provenance explainer (review 2026-06-27 passive-user feature #5): answer
|
|
12
15
|
// "why does this capability exist / why was it built this way?" by assembling
|
|
@@ -16,11 +19,16 @@ import { suggest } from "../lib/suggest.js";
|
|
|
16
19
|
// asks; the tool answers from the artifacts, instead of grepping by hand.
|
|
17
20
|
// Read-only. Builds on the Realizes:/trace provenance (ADR 0006/0011).
|
|
18
21
|
|
|
22
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
24
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
26
|
+
|
|
19
27
|
export async function run(positional, _flags) {
|
|
20
28
|
const cap = positional[0];
|
|
21
29
|
const projectRoot = process.cwd();
|
|
22
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
23
|
-
throw
|
|
31
|
+
throw notADoctrinaProject();
|
|
24
32
|
}
|
|
25
33
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
26
34
|
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
@@ -72,7 +80,7 @@ export async function run(positional, _flags) {
|
|
|
72
80
|
const status = specHeader(text, "Status") ?? "—";
|
|
73
81
|
const impl = specHeader(text, "Implementation") ?? "—";
|
|
74
82
|
console.log(` ${c.gray("status:")} ${status} ${c.gray("implementation:")} ${impl}`);
|
|
75
|
-
const purpose =
|
|
83
|
+
const purpose = getSectionParagraph(text, "Purpose");
|
|
76
84
|
if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
|
|
77
85
|
|
|
78
86
|
// Dependency graph, both directions (the machine-readable **Depends on:**
|
|
@@ -229,28 +237,6 @@ function productAnchors(projectRoot) {
|
|
|
229
237
|
return out;
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
// First non-comment paragraph under a "## <name>" heading.
|
|
233
|
-
function sectionParagraph(text, name) {
|
|
234
|
-
const lines = text.split(/\r?\n/);
|
|
235
|
-
let inSection = false;
|
|
236
|
-
const buf = [];
|
|
237
|
-
for (const line of lines) {
|
|
238
|
-
if (/^##\s+/.test(line)) {
|
|
239
|
-
if (inSection) break;
|
|
240
|
-
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (!inSection) continue;
|
|
244
|
-
if (/^\s*<!--/.test(line) || /-->/.test(line)) continue;
|
|
245
|
-
if (line.trim() === "") {
|
|
246
|
-
if (buf.length) break;
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
buf.push(line.trim());
|
|
250
|
-
}
|
|
251
|
-
return buf.join(" ");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
240
|
// Accepted ADRs whose body names the capability (word-ish boundary).
|
|
255
241
|
function decisionsMentioning(projectRoot, cap) {
|
|
256
242
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
package/src/commands/work.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
5
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
|
+
import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
7
|
import { flagString, flagBool } from "../lib/args.js";
|
|
7
8
|
import * as idx from "../lib/index-json.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
import { assessBrief } from "../lib/clarity.js";
|
|
11
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
13
|
import { changeNew } from "./change.js";
|
|
11
14
|
|
|
12
15
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -17,11 +20,16 @@ import { changeNew } from "./change.js";
|
|
|
17
20
|
// unproven verification). The CLI's own language processing stops at
|
|
18
21
|
// slugging and term counting; everything semantic is the agent's job.
|
|
19
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", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
|
|
27
|
+
|
|
20
28
|
export async function run(positional, flags) {
|
|
21
29
|
const prompt = positional.join(" ").trim();
|
|
22
30
|
const projectRoot = process.cwd();
|
|
23
31
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
24
|
-
throw
|
|
32
|
+
throw notADoctrinaProject();
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
@@ -106,6 +114,26 @@ export async function run(positional, flags) {
|
|
|
106
114
|
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
// A pinned capability is an explicit statement of intent, so the delta —
|
|
118
|
+
// historically the only 100% hand-authored file in the flow, and the one
|
|
119
|
+
// whose missing **Operation:** header exploded days later at analyze
|
|
120
|
+
// (operator review 2026-07-19 §3.2) — is scaffolded from the template with
|
|
121
|
+
// the Operation prefilled: MODIFIED when the spec exists, ADDED when it
|
|
122
|
+
// does not. Never scaffolded from a ranked GUESS (that would put a wrong
|
|
123
|
+
// capability's delta in the change); only from --capability.
|
|
124
|
+
if (pinned && !chore) {
|
|
125
|
+
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", pinned, "delta.md");
|
|
126
|
+
if (!exists(deltaPath)) {
|
|
127
|
+
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
128
|
+
const op = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md")) ? "MODIFIED" : "ADDED";
|
|
129
|
+
const body = substitute(tpl, { CAPABILITY: pinned })
|
|
130
|
+
.replace(/^\*\*Operation:\*\*.*$/m, `**Operation:** ${op}`);
|
|
131
|
+
mkdirp(path.dirname(deltaPath));
|
|
132
|
+
write(deltaPath, body);
|
|
133
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, deltaPath)}` + c.gray(` (Operation: ${op} prefilled)`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
// Capability hint: term overlap for a prompt, changed-file overlap for a diff
|
|
110
138
|
// (review F10 — rank by what the working tree touched, not just prompt words).
|
|
111
139
|
const allChanged = fromDiff ? files : changedFiles(projectRoot);
|
|
@@ -114,6 +142,16 @@ export async function run(positional, flags) {
|
|
|
114
142
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
115
143
|
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
116
144
|
|
|
145
|
+
// --quiet: registering backlog, not starting now (operator review §3.7 — 19
|
|
146
|
+
// works printed 19 identical 50-line playbooks). One line per change; the
|
|
147
|
+
// playbook is reprintable on demand with --resume.
|
|
148
|
+
if (flagBool(flags, "quiet", false)) {
|
|
149
|
+
console.log("");
|
|
150
|
+
console.log(c.green("opened ") + c.cyan(id) +
|
|
151
|
+
c.gray(` — playbook on demand: doctrina work --resume ${id}`));
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
117
155
|
console.log("");
|
|
118
156
|
if (chore) {
|
|
119
157
|
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
@@ -339,6 +377,8 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
|
339
377
|
console.log("");
|
|
340
378
|
console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
341
379
|
console.log(" with small, checkable steps; implement them, checking each box.");
|
|
380
|
+
console.log(c.gray(" (Do it before implementing — analyze/close refuse leftover scaffold"));
|
|
381
|
+
console.log(c.gray(" placeholders, and `change tick` will not tick an empty box.)"));
|
|
342
382
|
console.log("2. Prove it (archive refuses unchecked boxes):");
|
|
343
383
|
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
344
384
|
console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
|
|
@@ -406,8 +446,14 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
406
446
|
console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
|
|
407
447
|
console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
|
|
408
448
|
console.log("");
|
|
409
|
-
|
|
410
|
-
|
|
449
|
+
if (pinned) {
|
|
450
|
+
console.log(`3. A delta is already scaffolded (Operation prefilled) at`);
|
|
451
|
+
console.log(` .doctrina/changes/${id}/specs/${pinned}/delta.md — fill its body.`);
|
|
452
|
+
console.log(" Add one more delta per additional affected capability:");
|
|
453
|
+
} else {
|
|
454
|
+
console.log("3. Write one delta per affected capability at");
|
|
455
|
+
console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
|
|
456
|
+
}
|
|
411
457
|
console.log(c.gray(" # Spec Delta — capability: <capability>"));
|
|
412
458
|
console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
|
|
413
459
|
console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
|
|
@@ -423,8 +469,12 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
423
469
|
console.log(c.gray(" bump-version minor"));
|
|
424
470
|
console.log(c.gray(" set-criterion 1: verified"));
|
|
425
471
|
console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
|
|
472
|
+
console.log(c.gray(" append-requirement event: When <trigger>, the system shall <action>."));
|
|
473
|
+
console.log(c.gray(" replace-requirement ubiquitous 2: The system shall <action>."));
|
|
426
474
|
console.log(c.gray(" ```"));
|
|
427
|
-
console.log(c.gray("
|
|
475
|
+
console.log(c.gray(" append-* ops number/position at APPLY time, so concurrent changes"));
|
|
476
|
+
console.log(c.gray(" appending to the same spec never collide. Only free-prose rewrites"));
|
|
477
|
+
console.log(c.gray(" (Purpose, Maturity, ...) stay a by-hand merge."));
|
|
428
478
|
if (fromDiff) {
|
|
429
479
|
console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
|
|
430
480
|
console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
|
|
@@ -432,7 +482,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
432
482
|
}
|
|
433
483
|
console.log("");
|
|
434
484
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
435
|
-
console.log(" with small, checkable implementation tasks (a few hours each, max)
|
|
485
|
+
console.log(" with small, checkable implementation tasks (a few hours each, max),");
|
|
486
|
+
console.log(" and record the change's What/Scope in its proposal.md. Do this BEFORE");
|
|
487
|
+
console.log(" implementing — analyze and close refuse a change whose scaffold");
|
|
488
|
+
console.log(" placeholders were never replaced, and `change tick` will not tick them.");
|
|
436
489
|
console.log("");
|
|
437
490
|
console.log("5. Implement task by task, checking each box as it lands. Advance the");
|
|
438
491
|
console.log(" spec's Implementation: planned → partial → implemented as code lands.");
|
|
@@ -442,6 +495,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
442
495
|
console.log(" (an architecture, a boundary, a trade-off a future session must");
|
|
443
496
|
console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
|
|
444
497
|
console.log(" before closing — the definition of done requires it recorded.");
|
|
498
|
+
console.log(c.gray(" (close re-checks this: it warns when the change touches capabilities"));
|
|
499
|
+
console.log(c.gray(" cited by an accepted ADR — amend via decision supersede, not silence.)"));
|
|
500
|
+
console.log(" Touching an integration surface (ports, env vars, public endpoints)?");
|
|
501
|
+
console.log(` Own it in a contract: ${c.cyan("doctrina contract new <id>")} · ${c.cyan("doctrina contract check")}.`);
|
|
445
502
|
console.log("");
|
|
446
503
|
console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
|
|
447
504
|
console.log(" at the first failure with the exact rerun command):");
|
|
@@ -476,7 +533,12 @@ named after that word (review G1). Use --resume to do so directly.
|
|
|
476
533
|
Options:
|
|
477
534
|
--title "<short>" Short display title: drives the slug and the proposal
|
|
478
535
|
H1; the full prompt still lands under ## Why
|
|
479
|
-
--capability <cap> Pin the capability instead of ranking matches
|
|
536
|
+
--capability <cap> Pin the capability instead of ranking matches. Also
|
|
537
|
+
scaffolds specs/<cap>/delta.md in the change with the
|
|
538
|
+
Operation header prefilled (MODIFIED when the spec
|
|
539
|
+
exists, ADDED when it does not)
|
|
540
|
+
--quiet Register the change and print one line — no playbook
|
|
541
|
+
(backlog entry; reprint later with --resume <id>)
|
|
480
542
|
--id <id> Override the derived change id
|
|
481
543
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
482
544
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { parseArgs } from "./lib/args.js";
|
|
4
5
|
import { c } from "./lib/colors.js";
|
|
5
6
|
import { suggest } from "./lib/suggest.js";
|
|
6
7
|
import { cliVersion } from "./lib/version.js";
|
|
7
|
-
import { surfaceHelp } from "./lib/commands.js";
|
|
8
|
+
import { surfaceHelp, OPERATIONS } from "./lib/commands.js";
|
|
9
|
+
import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
|
|
10
|
+
import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
11
|
+
import { recordUsage } from "./lib/usage.js";
|
|
12
|
+
import { wantsJson, emitJson, captureOutput, stripAnsi } from "./lib/json-out.js";
|
|
8
13
|
|
|
9
14
|
import * as init from "./commands/init.js";
|
|
10
15
|
import * as spec from "./commands/spec.js";
|
|
@@ -41,6 +46,7 @@ import * as report from "./commands/report.js";
|
|
|
41
46
|
import * as completion from "./commands/completion.js";
|
|
42
47
|
import * as intent from "./commands/intent.js";
|
|
43
48
|
import * as upgrade from "./commands/upgrade.js";
|
|
49
|
+
import * as adapter from "./commands/adapter.js";
|
|
44
50
|
|
|
45
51
|
const COMMANDS = {
|
|
46
52
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
@@ -48,7 +54,7 @@ const COMMANDS = {
|
|
|
48
54
|
intake, work, coverage, verify, contract, trace,
|
|
49
55
|
status, close, review, watch, why, constitution,
|
|
50
56
|
prime, handoff, show, doctor, report, completion,
|
|
51
|
-
intent, upgrade,
|
|
57
|
+
intent, upgrade, adapter,
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
const TOP_HELP = `
|
|
@@ -60,6 +66,10 @@ ${surfaceHelp()}
|
|
|
60
66
|
Global flags:
|
|
61
67
|
--help, -h Show this message (or per-command help if after a command)
|
|
62
68
|
--version, -v Print the version
|
|
69
|
+
--debug On an unexpected error, also print the stack trace
|
|
70
|
+
|
|
71
|
+
Exit codes (a contract — an agent reads these to decide what to do next):
|
|
72
|
+
${exitCodeHelp()}
|
|
63
73
|
`;
|
|
64
74
|
|
|
65
75
|
async function main(argv) {
|
|
@@ -69,8 +79,23 @@ async function main(argv) {
|
|
|
69
79
|
// version and exit, looking exactly like the spec's version had been set
|
|
70
80
|
// (0.11.0 field-review papercut). `-v` stays global either way.
|
|
71
81
|
const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
|
|
82
|
+
|
|
83
|
+
// Two passes. The first resolves only the command name, using the global
|
|
84
|
+
// flags; the second re-parses with THAT command's declared flags merged in.
|
|
85
|
+
//
|
|
86
|
+
// One global boolean list used to serve every command, and six flags read
|
|
87
|
+
// via flagBool were missing from it. An undeclared flag whose next token
|
|
88
|
+
// does not start with "-" swallows that token as its value, so
|
|
89
|
+
// `change new --chore ajuste-ci "Ajustar CI"` lost the id AND silently
|
|
90
|
+
// ignored the flag, reporting "requires a title" for a quoted title (C3).
|
|
91
|
+
// Declarations now live with the command, so a new command cannot
|
|
92
|
+
// reintroduce the gap by forgetting to edit this file.
|
|
93
|
+
const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
|
|
94
|
+
const commandModule = COMMANDS[bootstrap.positional[0]];
|
|
95
|
+
const spec = commandModule?.flags;
|
|
72
96
|
const { positional, flags } = parseArgs(argv, {
|
|
73
|
-
boolean: [
|
|
97
|
+
boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
|
|
98
|
+
string: [...GLOBAL_FLAGS.string, ...(spec?.string ?? [])],
|
|
74
99
|
});
|
|
75
100
|
|
|
76
101
|
if (versionOnly || flags.get("v") === true) {
|
|
@@ -93,7 +118,7 @@ async function main(argv) {
|
|
|
93
118
|
} else {
|
|
94
119
|
console.error(c.gray("hint: ") + "try `doctrina --help` for the command surface");
|
|
95
120
|
}
|
|
96
|
-
return
|
|
121
|
+
return EXIT.USAGE;
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
if (flags.get("help") || flags.get("h")) {
|
|
@@ -102,14 +127,43 @@ async function main(argv) {
|
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
try {
|
|
130
|
+
// --json on a command that builds no payload of its own still answers in
|
|
131
|
+
// JSON: its output is captured into a versioned envelope beside `ok` and
|
|
132
|
+
// `exit_code`. Branch on those; the lines are for completeness (M7).
|
|
133
|
+
if (wantsJson(flags) && !command.jsonNative) {
|
|
134
|
+
const { code, stdout, stderr } = await captureOutput(
|
|
135
|
+
() => command.run(positional.slice(1), flags),
|
|
136
|
+
);
|
|
137
|
+
emitJson([commandName, ...positional.slice(1)].join(" "), {
|
|
138
|
+
stdout: stdout.map(stripAnsi),
|
|
139
|
+
stderr: stderr.map(stripAnsi),
|
|
140
|
+
}, { ok: code === EXIT.OK, exitCode: code });
|
|
141
|
+
return code;
|
|
142
|
+
}
|
|
105
143
|
return await command.run(positional.slice(1), flags);
|
|
106
144
|
} catch (err) {
|
|
107
145
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
146
|
+
if (err.remedy) {
|
|
147
|
+
console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
|
|
148
|
+
}
|
|
108
149
|
if (flags.get("debug") && err.stack) {
|
|
109
150
|
console.error(c.gray(err.stack));
|
|
110
151
|
}
|
|
111
|
-
|
|
152
|
+
// A typed error carries its own class (precondition vs environment).
|
|
153
|
+
// An untyped throw is an unexpected failure of the work itself, which
|
|
154
|
+
// is the GATE class: something is wrong here, fix it and retry (C7).
|
|
155
|
+
return err.exitCode ?? EXIT.GATE;
|
|
112
156
|
}
|
|
113
157
|
}
|
|
114
158
|
|
|
115
|
-
main(process.argv.slice(2)).then((code) =>
|
|
159
|
+
main(process.argv.slice(2)).then((code) => {
|
|
160
|
+
// Record which operation ran, if and only if the operator asked for it
|
|
161
|
+
// by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
|
|
162
|
+
// no arguments captured, and never fatal. See lib/usage.js.
|
|
163
|
+
recordUsage(
|
|
164
|
+
process.argv.slice(2).filter((a) => !a.startsWith("-")),
|
|
165
|
+
code,
|
|
166
|
+
new Set(OPERATIONS.map((o) => o[0])),
|
|
167
|
+
);
|
|
168
|
+
process.exit(code ?? 0);
|
|
169
|
+
});
|