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.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +76 -19
  5. package/src/commands/change.js +341 -44
  6. package/src/commands/clarify.js +40 -19
  7. package/src/commands/close.js +119 -11
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +152 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +14 -2
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +16 -6
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +277 -40
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +23 -12
  34. package/src/commands/validate.js +93 -3
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +69 -7
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +47 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +314 -3
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +228 -3
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/.gitkeep +0 -0
  67. package/templates/AGENTS.md.template +71 -75
  68. package/templates/change/spec-delta.md.template +13 -7
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.11.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.14.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "scripts": {
21
21
  "test": "node --test",
22
- "prepack": "node scripts/copy-templates.js"
22
+ "prepack": "node scripts/copy-templates.js",
23
+ "test:e2e": "node ../../scripts/e2e-packed.mjs"
23
24
  },
24
25
  "files": [
25
26
  "src",
@@ -0,0 +1,205 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { exists, isFile, mkdirp, read, relPath, remove, write } from "../lib/fs-ops.js";
5
+ import {
6
+ ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
7
+ } from "../lib/adapters.js";
8
+ import { flagBool } from "../lib/args.js";
9
+ import { today } from "../lib/dates.js";
10
+ import { c } from "../lib/colors.js";
11
+ import { suggest } from "../lib/suggest.js";
12
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
13
+
14
+ // `doctrina adapter` — add, remove, and inventory agent adapters (audit
15
+ // item C1).
16
+ //
17
+ // Before this command the only way to add an adapter to an existing project
18
+ // was `doctrina init --agent <name> --force`, because `init` refuses to run
19
+ // twice. That path regenerated AGENTS.md and .doctrina/product.md from
20
+ // blank templates, silently destroying every hand-authored rule and the
21
+ // product definition. `adapter add` is strictly additive: it writes that
22
+ // adapter's own files and never reads or writes AGENTS.md, product.md, or
23
+ // any other project artifact.
24
+
25
+ const SUBCOMMANDS = ["list", "add", "remove"];
26
+
27
+ export const flags = { boolean: ["json", "force"], string: [] };
28
+
29
+ export async function run(positional, cmdFlags) {
30
+ const sub = positional[0];
31
+ if (!SUBCOMMANDS.includes(sub)) {
32
+ console.error(c.red("error:") + ` unknown adapter subcommand "${sub ?? ""}"`);
33
+ const guess = suggest(sub, SUBCOMMANDS);
34
+ console.error(c.gray("hint: ") + (guess
35
+ ? `did you mean \`doctrina adapter ${guess}\`?`
36
+ : `available: ${SUBCOMMANDS.join(", ")}`));
37
+ return 2;
38
+ }
39
+ const projectRoot = process.cwd();
40
+ ensureDoctrinaProject(projectRoot);
41
+ if (sub === "list") return adapterList(projectRoot);
42
+ if (sub === "add") return adapterAdd(projectRoot, positional[1], cmdFlags);
43
+ return adapterRemove(projectRoot, positional[1], cmdFlags);
44
+ }
45
+
46
+ function adapterList(projectRoot) {
47
+ const names = listAdapterNames(projectRoot);
48
+ if (names.length === 0) {
49
+ console.log(c.gray("no adapters available (no templates/adapters directory found)"));
50
+ return 0;
51
+ }
52
+ const rows = names.map((n) => describeAdapter(projectRoot, n));
53
+ const label = {
54
+ [ADAPTER_STATES.INSTALLED]: c.green("installed"),
55
+ [ADAPTER_STATES.AVAILABLE]: c.gray("available"),
56
+ [ADAPTER_STATES.NATIVE]: c.cyan("native "),
57
+ };
58
+
59
+ console.log(c.bold("Agent adapters:"));
60
+ console.log("");
61
+ for (const r of rows) {
62
+ const suffix = r.state === ADAPTER_STATES.NATIVE
63
+ ? c.gray("reads AGENTS.md directly — no file needed")
64
+ : c.gray(`${r.installed.length}/${r.files.length} file${r.files.length === 1 ? "" : "s"}` +
65
+ (r.source === "project" ? " · project template" : ""));
66
+ console.log(` ${label[r.state]} ${c.cyan(r.name.padEnd(10))} ${suffix}`);
67
+ }
68
+ console.log("");
69
+ const installed = rows.filter((r) => r.state === ADAPTER_STATES.INSTALLED).length;
70
+ const native = rows.filter((r) => r.state === ADAPTER_STATES.NATIVE).length;
71
+ console.log(c.gray(
72
+ `${installed} installed · ${rows.length - installed - native} available · ${native} native. ` +
73
+ "Add one: ") + c.cyan("doctrina adapter add <name>"));
74
+ return 0;
75
+ }
76
+
77
+ function adapterAdd(projectRoot, name, cmdFlags) {
78
+ if (!name) {
79
+ console.error(c.red("error:") + " adapter add requires a name (see `doctrina adapter list`)");
80
+ return 2;
81
+ }
82
+ const spec = adapterFiles(projectRoot, name);
83
+ if (!spec) {
84
+ console.error(c.red("error:") + ` unknown adapter "${name}"`);
85
+ const guess = suggest(name, listAdapterNames(projectRoot));
86
+ console.error(c.gray("hint: ") + (guess
87
+ ? `did you mean \`doctrina adapter add ${guess}\`?`
88
+ : "run `doctrina adapter list` for the available adapters"));
89
+ return 2;
90
+ }
91
+ if (spec.files.length === 0) {
92
+ console.log(c.cyan("native ") + ` ${name} reads AGENTS.md directly — nothing to install`);
93
+ console.log(c.gray("Its rules already reach the agent through the hub at AGENTS.md."));
94
+ return 0;
95
+ }
96
+
97
+ const force = flagBool(cmdFlags, "force", false);
98
+ const tokens = {
99
+ PROJECT_NAME: path.basename(projectRoot),
100
+ PROJECT_DESCRIPTION: "",
101
+ DATE: today(),
102
+ };
103
+
104
+ // Additive by construction: only this adapter's own files are touched,
105
+ // and an existing one is left alone unless --force is given. AGENTS.md
106
+ // and product.md are never read or written here.
107
+ let written = 0;
108
+ let skipped = 0;
109
+ for (const file of spec.files) {
110
+ const dest = path.join(projectRoot, file.relativePath);
111
+ if (exists(dest) && !force) {
112
+ console.log(c.yellow("skip ") + ` ${file.relativePath} (already present; --force overwrites)`);
113
+ skipped += 1;
114
+ continue;
115
+ }
116
+ mkdirp(path.dirname(dest));
117
+ write(dest, renderAdapterFile(file, tokens), { force: true });
118
+ console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
119
+ written += 1;
120
+ }
121
+
122
+ console.log("");
123
+ console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
124
+ (skipped > 0 ? `, ${skipped} left alone` : "") +
125
+ (spec.source === "project" ? c.gray(" (from .doctrina/templates/adapters/)") : ""));
126
+ console.log(c.gray("Additive only: AGENTS.md and .doctrina/product.md were not touched."));
127
+ return 0;
128
+ }
129
+
130
+ function adapterRemove(projectRoot, name, cmdFlags) {
131
+ if (!name) {
132
+ console.error(c.red("error:") + " adapter remove requires a name (see `doctrina adapter list`)");
133
+ return 2;
134
+ }
135
+ const spec = adapterFiles(projectRoot, name);
136
+ if (!spec) {
137
+ console.error(c.red("error:") + ` unknown adapter "${name}"`);
138
+ return 2;
139
+ }
140
+ if (spec.files.length === 0) {
141
+ console.log(c.cyan("native ") + ` ${name} installs no files — nothing to remove`);
142
+ return 0;
143
+ }
144
+
145
+ // Only delete files this adapter created AND that still match what it
146
+ // would write. A file the user edited is theirs now; --force overrides.
147
+ const force = flagBool(cmdFlags, "force", false);
148
+ const tokens = { PROJECT_NAME: path.basename(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() };
149
+ let removed = 0;
150
+ let kept = 0;
151
+ for (const file of spec.files) {
152
+ const dest = path.join(projectRoot, file.relativePath);
153
+ if (!isFile(dest)) continue;
154
+ const pristine = normalise(read(dest)) === normalise(renderAdapterFile(file, tokens));
155
+ if (!pristine && !force) {
156
+ console.log(c.yellow("kept ") + ` ${file.relativePath} (edited since install; --force removes it)`);
157
+ kept += 1;
158
+ continue;
159
+ }
160
+ remove(dest);
161
+ console.log(c.green("removed") + ` ${file.relativePath}`);
162
+ removed += 1;
163
+ }
164
+ console.log("");
165
+ console.log(`${c.bold("Adapter " + name)} — ${removed} file${removed === 1 ? "" : "s"} removed` +
166
+ (kept > 0 ? `, ${kept} kept (edited)` : ""));
167
+ return 0;
168
+ }
169
+
170
+ function normalise(text) {
171
+ return text.replace(/\r\n/g, "\n").trim();
172
+ }
173
+
174
+ function ensureDoctrinaProject(projectRoot) {
175
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
176
+ throw notADoctrinaProject();
177
+ }
178
+ }
179
+
180
+ export const help = `
181
+ Usage: doctrina adapter <list|add|remove> [name] [--force]
182
+
183
+ Add, remove, and inventory the per-agent adapter files that point at
184
+ AGENTS.md. Adding an adapter is strictly additive: only that adapter's own
185
+ files are written, and AGENTS.md / .doctrina/product.md are never touched.
186
+
187
+ Subcommands:
188
+ list Every adapter with its state:
189
+ installed — its files are present
190
+ available — it ships files and none are installed
191
+ native — the agent reads AGENTS.md directly and
192
+ needs no file at all
193
+ add <name> Install one adapter's files. An existing file is left
194
+ alone unless --force is given.
195
+ remove <name> Delete the files this adapter created. A file edited
196
+ since install is kept unless --force is given.
197
+
198
+ Custom adapters: a directory at .doctrina/templates/adapters/<name>/ is
199
+ installable by name and takes precedence over a bundled adapter of the
200
+ same name.
201
+
202
+ Options:
203
+ --force With add, overwrite an existing file; with remove,
204
+ delete a file that was edited after install.
205
+ `;
@@ -1,9 +1,15 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
4
5
  import { c } from "../lib/colors.js";
5
6
  import { isUntouchedScaffold } from "./change.js";
6
7
 
8
+ // Flags this command accepts. Declared HERE, with the command, so
9
+ // adding a command never requires editing the entrypoint — the gap that
10
+ // let six flags ship undeclared and silently swallow a positional (C3).
11
+ export const flags = { boolean: ["json"], string: [] };
12
+
7
13
  export async function run(positional, _flags) {
8
14
  const id = positional[0];
9
15
  if (!id) {
@@ -21,6 +27,25 @@ export async function run(positional, _flags) {
21
27
  console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
22
28
  console.log("");
23
29
 
30
+ const results = collectAnalysis(projectRoot, changeDir);
31
+ for (const r of results) console.log(r.line);
32
+ console.log("");
33
+
34
+ const failed = results.filter((r) => r.kind === "fail").length;
35
+ if (failed === 0) {
36
+ console.log(c.green("ok") + ` ready to apply`);
37
+ } else {
38
+ console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
39
+ }
40
+ return failed === 0 ? 0 : 1;
41
+ }
42
+
43
+ // The structural findings for a change, as data. Exported so the shared
44
+ // gate map (`lib/gates.js`) can enforce the SAME checks on every
45
+ // transition that needs them, instead of each command deciding for itself
46
+ // — `change apply` used to mutate a change that `analyze` had just
47
+ // refused (audit item C6).
48
+ export function collectAnalysis(projectRoot, changeDir) {
24
49
  const results = [];
25
50
 
26
51
  // proposal.md
@@ -32,6 +57,37 @@ export async function run(positional, _flags) {
32
57
  const text = read(proposalPath);
33
58
  if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
34
59
  else results.push(fail(`proposal.md missing "## Why" section`));
60
+
61
+ // A section that still holds only its scaffold comment was never
62
+ // written. The check above only proved the HEADING survived, which is
63
+ // why six changes closed in one session with every rationale section
64
+ // empty: the planning step wrote to the file with a pattern that did
65
+ // not match its line endings, and nothing downstream looked inside.
66
+ // For a framework whose whole premise is recoverable provenance, an
67
+ // archived change that cannot say why it happened is the defect.
68
+ // Only `Why` and `What` are required. They are what makes a change
69
+ // recoverable a year later — the reason it exists and the shape it
70
+ // took. `Scope boundaries` and `Open questions` are legitimately empty
71
+ // on a change that has neither, and demanding the word "None." there
72
+ // is friction that buys nothing.
73
+ const hollow = [];
74
+ for (const m of text.matchAll(/^##\s+(.+?)[ \t]*\r?$/gm)) {
75
+ const heading = m[1].trim();
76
+ if (!/^(Why|What)$/i.test(heading)) continue;
77
+ const start = m.index + m[0].length;
78
+ const next = text.slice(start).search(/^##\s+/m);
79
+ const body = (next < 0 ? text.slice(start) : text.slice(start, start + next));
80
+ // Strip comments; anything left that is not whitespace is real prose.
81
+ if (body.replace(/<!--[\s\S]*?-->/g, "").trim() === "") hollow.push(heading);
82
+ }
83
+ if (hollow.length > 0) {
84
+ results.push(fail(
85
+ `proposal.md has ${hollow.length} unwritten section${hollow.length === 1 ? "" : "s"} ` +
86
+ `(${hollow.join(", ")}) — a heading that survived is not a section that was written`,
87
+ ));
88
+ } else {
89
+ results.push(pass("proposal.md states why and what"));
90
+ }
35
91
  }
36
92
 
37
93
  // tasks.md
@@ -41,6 +97,16 @@ export async function run(positional, _flags) {
41
97
  } else {
42
98
  results.push(pass("tasks.md present"));
43
99
  const text = read(tasksPath);
100
+ // Scaffold placeholders never replaced ("- [ ]" with no text, checked or
101
+ // not) mean the change was OPENED but never PLANNED — the agent went
102
+ // straight to implementing on a hollow change and nothing barked
103
+ // (operator report 2026-07-19). A hard failure here blocks `change
104
+ // check` and `close` until the plan is real; ticking an empty box does
105
+ // not help, `change tick` refuses those too.
106
+ const placeholders = (text.match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
107
+ if (placeholders > 0) {
108
+ 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)`));
109
+ }
44
110
  if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
45
111
  else results.push(info("tasks.md has no unchecked tasks (already done?)"));
46
112
  }
@@ -65,11 +131,11 @@ export async function run(positional, _flags) {
65
131
  const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
66
132
 
67
133
  if (!op) {
68
- results.push(fail(` ${rel}: Operation header missing or malformed`, " "));
134
+ results.push(fail(` ${rel}: Operation header missing or malformed`));
69
135
  continue;
70
136
  }
71
137
  if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
72
- results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`, " "));
138
+ results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
73
139
  continue;
74
140
  }
75
141
  const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
@@ -79,38 +145,29 @@ export async function run(positional, _flags) {
79
145
  // untouched `spec new` scaffold is the canonical flow (spec new →
80
146
  // ADDED delta), so it passes as a replacement; only real content fails.
81
147
  if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
82
- results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, " "));
148
+ results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`));
83
149
  } else if (exists(targetSpec)) {
84
- results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`, " "));
150
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
85
151
  } else {
86
- results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`, " "));
152
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
87
153
  }
88
154
  } else if (op === "MODIFIED") {
89
155
  if (!exists(targetSpec)) {
90
- results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, " "));
156
+ results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`));
91
157
  } else {
92
- results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`, " "));
158
+ results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
93
159
  }
94
160
  } else {
95
161
  if (!exists(targetSpec)) {
96
- results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, " "));
162
+ results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`));
97
163
  } else {
98
- results.push(pass(` ${cap} (REMOVED) → ${targetRel}`, " "));
164
+ results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
99
165
  }
100
166
  }
101
167
  }
102
168
  }
103
169
 
104
- for (const r of results) console.log(r.line);
105
- console.log("");
106
-
107
- const failed = results.filter((r) => r.kind === "fail").length;
108
- if (failed === 0) {
109
- console.log(c.green("ok") + ` ready to apply`);
110
- } else {
111
- console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
112
- }
113
- return failed === 0 ? 0 : 1;
170
+ return results;
114
171
  }
115
172
 
116
173
  function pass(msg) {