doctrina-cli 0.13.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 (66) 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 +66 -19
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  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 +139 -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 +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +8 -1
  34. package/src/commands/validate.js +58 -2
  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 +8 -1
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +1 -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 +239 -26
  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 +94 -9
  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/AGENTS.md.template +59 -53
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.13.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
@@ -75,11 +131,11 @@ export async function run(positional, _flags) {
75
131
  const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
76
132
 
77
133
  if (!op) {
78
- results.push(fail(` ${rel}: Operation header missing or malformed`, " "));
134
+ results.push(fail(` ${rel}: Operation header missing or malformed`));
79
135
  continue;
80
136
  }
81
137
  if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
82
- 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`));
83
139
  continue;
84
140
  }
85
141
  const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
@@ -89,38 +145,29 @@ export async function run(positional, _flags) {
89
145
  // untouched `spec new` scaffold is the canonical flow (spec new →
90
146
  // ADDED delta), so it passes as a replacement; only real content fails.
91
147
  if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
92
- results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, " "));
148
+ results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`));
93
149
  } else if (exists(targetSpec)) {
94
- results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`, " "));
150
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
95
151
  } else {
96
- results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`, " "));
152
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
97
153
  }
98
154
  } else if (op === "MODIFIED") {
99
155
  if (!exists(targetSpec)) {
100
- results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, " "));
156
+ results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`));
101
157
  } else {
102
- results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`, " "));
158
+ results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
103
159
  }
104
160
  } else {
105
161
  if (!exists(targetSpec)) {
106
- results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, " "));
162
+ results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`));
107
163
  } else {
108
- results.push(pass(` ${cap} (REMOVED) → ${targetRel}`, " "));
164
+ results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
109
165
  }
110
166
  }
111
167
  }
112
168
  }
113
169
 
114
- for (const r of results) console.log(r.line);
115
- console.log("");
116
-
117
- const failed = results.filter((r) => r.kind === "fail").length;
118
- if (failed === 0) {
119
- console.log(c.green("ok") + ` ready to apply`);
120
- } else {
121
- console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
122
- }
123
- return failed === 0 ? 0 : 1;
170
+ return results;
124
171
  }
125
172
 
126
173
  function pass(msg) {
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+ import { getSection, getHeader } from "../lib/doc-model.js";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
@@ -7,13 +9,22 @@ import * as idx from "../lib/index-json.js";
7
9
  import { deriveIndex } from "../lib/scan.js";
8
10
  import { extractOps, applyOps } from "../lib/spec-ops.js";
9
11
  import { printAdrCheckpoint } from "../lib/adr-guard.js";
12
+ import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
10
13
  import { today } from "../lib/dates.js";
11
14
  import { flagBool, flagString } from "../lib/args.js";
12
15
  import { c } from "../lib/colors.js";
13
16
  import { suggest } from "../lib/suggest.js";
17
+ import { confirm, isInteractive } from "../lib/prompt.js";
18
+ import { EXIT } from "../lib/exit-codes.js";
19
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
14
20
 
15
21
  const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
16
22
 
23
+ // Flags this command accepts. Declared HERE, with the command, so
24
+ // adding a command never requires editing the entrypoint — the gap that
25
+ // let six flags ship undeclared and silently swallow a positional (C3).
26
+ export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec"], string: ["reason"] };
27
+
17
28
  export async function run(positional, flags) {
18
29
  const sub = positional[0];
19
30
  switch (sub) {
@@ -30,7 +41,7 @@ export async function run(positional, flags) {
30
41
  case "diff":
31
42
  return changeDiff(positional.slice(1), flags);
32
43
  case "abandon":
33
- return changeAbandon(positional.slice(1), flags);
44
+ return await changeAbandon(positional.slice(1), flags);
34
45
  default:
35
46
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
36
47
  const guess = suggest(sub, SUBCOMMANDS);
@@ -41,6 +52,29 @@ export async function run(positional, flags) {
41
52
  }
42
53
  }
43
54
 
55
+ // Enforce a lifecycle transition's preconditions from the shared gate map.
56
+ // Returns true when the transition may proceed. One implementation, so
57
+ // every driver of a transition refuses — and explains, and forces —
58
+ // identically (C6).
59
+ function enforceTransition(projectRoot, id, changeDir, transition, flags) {
60
+ const force = flagBool(flags, "force", false);
61
+ const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
62
+ if (ok) return true;
63
+
64
+ const label = TRANSITIONS[transition].label;
65
+ if (!force) {
66
+ console.error(c.red("error:") + ` refusing to ${transition} "${id}" — ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}:`);
67
+ for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
68
+ const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
69
+ console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
70
+ return false;
71
+ }
72
+ console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
73
+ for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
74
+ recordForcedGap(projectRoot, id, transition, blockers);
75
+ return true;
76
+ }
77
+
44
78
  // Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
45
79
  // accept multiple ids so a backlog closes without a bash loop. Each id runs
46
80
  // independently — one failure does not stop the rest — and the exit code is
@@ -146,7 +180,7 @@ function changeNew(args, flags) {
146
180
  return 0;
147
181
  }
148
182
 
149
- function changeApply(args, _flags) {
183
+ function changeApply(args, flags) {
150
184
  const id = args[0];
151
185
  if (!id) {
152
186
  console.error(c.red("error:") + " change apply requires <id>");
@@ -161,6 +195,30 @@ function changeApply(args, _flags) {
161
195
  return 1;
162
196
  }
163
197
 
198
+ // Gate parity (C6). `apply` used to mutate specs with no preconditions,
199
+ // so `analyze` could exit 1 on a change and `apply` would write it anyway
200
+ // and exit 0 — through the very analyze → apply path the docs prescribe.
201
+ // The preconditions now come from the shared map, so what guards a
202
+ // transition does not depend on which command drove it.
203
+ if (!enforceTransition(projectRoot, id, changeDir, "apply", flags)) return 1;
204
+
205
+ // Applying twice is silent corruption. The ops verbs are ADDITIVE —
206
+ // `append-requirement` appends, `bump-version` bumps — so a second pass
207
+ // duplicates every requirement and criterion the delta carries and bumps
208
+ // the version again. `apply` stamps the proposal `applied` on success, so
209
+ // that stamp is the guard. (Found by `close` re-running apply on an
210
+ // already-applied change and doubling 15 requirements on this repo.)
211
+ const proposalPath = path.join(changeDir, "proposal.md");
212
+ const alreadyApplied = isFile(proposalPath)
213
+ && (getHeader(read(proposalPath), "Status") ?? "").trim().toLowerCase() === "applied";
214
+ if (alreadyApplied && !flagBool(flags, "force", false)) {
215
+ console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
216
+ console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
217
+ console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
218
+ console.log(c.gray("only after reverting the specs to their pre-apply state."));
219
+ return 0;
220
+ }
221
+
164
222
  const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
165
223
  const date = today();
166
224
  let errors = 0;
@@ -342,12 +400,14 @@ async function changeCheck(id) {
342
400
 
343
401
  // 3. Archive gate preview: what archive will refuse, listed with the fix.
344
402
  console.log(c.gray("──── 3/3 archive gate"));
345
- const blockers = collectArchiveBlockers(changeDir);
346
- if (blockers.length === 0) {
347
- console.log(c.green("✓ ") + "archive gate clear (all boxes checked)");
403
+ // Preview through the SAME map the transitions enforce, so `check` can
404
+ // never disagree with what `apply` / `archive` will actually do (C6).
405
+ const archiveGate = checkTransition(projectRoot, changeDir, "archive");
406
+ if (archiveGate.ok) {
407
+ console.log(c.green("✓ ") + `archive gate clear (${archiveGate.gates.join(" + ")})`);
348
408
  } else {
349
409
  failures += 1;
350
- for (const b of blockers) console.log(c.red("✗ ") + b);
410
+ for (const b of archiveGate.blockers) console.log(c.red("✗ ") + `[${b.gate}] ${b.message}`);
351
411
  console.log(c.gray(" check them off as they land, or in bulk: ") + c.cyan(`doctrina change tick ${id} --all`));
352
412
  }
353
413
 
@@ -490,22 +550,12 @@ function changeArchive(args, flags) {
490
550
  // task (including closing steps) or any declared verification item is
491
551
  // still unchecked. This is the difference between "boxes marked" and
492
552
  // "verification passed" the framework was faulted for collapsing.
493
- // --force is the escape hatch: it archives anyway and records the gap.
494
- const force = flagBool(flags, "force", false);
495
- const blockers = collectArchiveBlockers(changeDir);
496
- if (blockers.length > 0) {
497
- if (!force) {
498
- console.error(c.red("error:") + ` refusing to archive "${id}" — verification incomplete:`);
499
- for (const b of blockers) console.error(` - ${b}`);
500
- console.error(
501
- c.gray("hint: ") +
502
- "finish and check the items, or pass --force to archive anyway (records the gap)",
503
- );
504
- return 1;
505
- }
506
- console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
507
- for (const b of blockers) console.log(c.yellow(" - ") + b);
508
- }
553
+ // Preconditions from the shared map (C6): structure AND verification.
554
+ // Reaching archive directly used to skip the structural check that the
555
+ // `close` path always ran first, so the same state was guarded
556
+ // differently depending on the route taken. --force is the escape hatch
557
+ // for both, and records the gap.
558
+ if (!enforceTransition(projectRoot, id, changeDir, "archive", flags)) return 1;
509
559
 
510
560
  const date = today();
511
561
  const archiveName = `${date}-${id}`;
@@ -593,7 +643,7 @@ function changeArchive(args, flags) {
593
643
  // and record the abandonment in the ledger so the history shows the dead end
594
644
  // instead of a silent gap. This is the missing inverse of `change new` — the
595
645
  // review had to delete a stray folder and hand-edit index.json (G6).
596
- function changeAbandon(args, flags) {
646
+ async function changeAbandon(args, flags) {
597
647
  const id = args[0];
598
648
  if (!id) {
599
649
  console.error(c.red("error:") + " change abandon requires <id>");
@@ -608,6 +658,31 @@ function changeAbandon(args, flags) {
608
658
  return 1;
609
659
  }
610
660
 
661
+ // Consent before destruction (audit item C9). This deletes work with no
662
+ // undo — the ledger line is good practice and is not a substitute for
663
+ // being asked. Off a terminal there is nobody to ask, so `--force` is
664
+ // required: silence is not consent.
665
+ const force = flagBool(flags, "force", false);
666
+ if (!force) {
667
+ const doomed = walk(changeDir);
668
+ console.log(c.yellow("About to delete") + ` ${relPath(projectRoot, changeDir)} — ${doomed.length} file${doomed.length === 1 ? "" : "s"}:`);
669
+ for (const f of doomed.slice(0, 10)) console.log(` ${c.gray(relPath(changeDir, f))}`);
670
+ if (doomed.length > 10) console.log(c.gray(` … and ${doomed.length - 10} more`));
671
+ console.log(c.gray("This cannot be undone (the change is deleted, not archived)."));
672
+
673
+ if (!isInteractive()) {
674
+ console.error("");
675
+ console.error(c.red("error:") + " refusing to delete without confirmation");
676
+ console.error(c.gray("hint: ") + `re-run with ${c.cyan("--force")} to abandon non-interactively`);
677
+ return EXIT.USAGE;
678
+ }
679
+ const ok = await confirm(`Abandon "${id}"?`, { defaultYes: false, whenNonInteractive: false });
680
+ if (!ok) {
681
+ console.log(c.gray("aborted — nothing was deleted"));
682
+ return EXIT.OK;
683
+ }
684
+ }
685
+
611
686
  const reason = flagString(flags, "reason") ?? "";
612
687
  const date = today();
613
688
 
@@ -776,32 +851,15 @@ function collectArchiveBlockers(changeDir) {
776
851
 
777
852
  const proposalPath = path.join(changeDir, "proposal.md");
778
853
  if (exists(proposalPath)) {
779
- const n = countUnchecked(extractSection(read(proposalPath), "Verification"));
854
+ const n = countUnchecked(getSection(read(proposalPath), "Verification"));
780
855
  if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
781
856
  }
782
857
  return blockers;
783
858
  }
784
859
 
785
- // Return the body of a "## <name>" section (lines after the heading up to
786
- // the next "## " heading). Empty string when the section is absent.
787
- function extractSection(text, name) {
788
- const lines = text.split(/\r?\n/);
789
- const head = new RegExp(`^##\\s+${name}\\b`, "i");
790
- let inSection = false;
791
- const out = [];
792
- for (const line of lines) {
793
- if (/^##\s+/.test(line)) {
794
- inSection = head.test(line);
795
- continue;
796
- }
797
- if (inSection) out.push(line);
798
- }
799
- return out.join("\n");
800
- }
801
-
802
860
  function ensureDoctrinaProject(projectRoot) {
803
861
  if (!exists(path.join(projectRoot, ".doctrina"))) {
804
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
862
+ throw notADoctrinaProject();
805
863
  }
806
864
  }
807
865
 
@@ -1,9 +1,11 @@
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 { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
6
  import { flagBool, flagString } from "../lib/args.js";
6
7
  import { c } from "../lib/colors.js";
8
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
7
9
 
8
10
  // Two lexicons, one linter (0.11.0 field review item 3: an English-only
9
11
  // clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
@@ -98,6 +100,11 @@ function forcedLang(flags) {
98
100
  return null;
99
101
  }
100
102
 
103
+ // Flags this command accepts. Declared HERE, with the command, so
104
+ // adding a command never requires editing the entrypoint — the gap that
105
+ // let six flags ship undeclared and silently swallow a positional (C3).
106
+ export const flags = { boolean: ["json", "all"], string: ["lang"] };
107
+
101
108
  export async function run(positional, flags) {
102
109
  const projectRoot = process.cwd();
103
110
 
@@ -138,7 +145,7 @@ export async function run(positional, flags) {
138
145
  // register from prose specs.
139
146
  function clarifyAll(projectRoot, lang = null) {
140
147
  if (!exists(path.join(projectRoot, ".doctrina"))) {
141
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
148
+ throw notADoctrinaProject();
142
149
  }
143
150
  const files = [];
144
151
  const productPath = path.join(projectRoot, ".doctrina", "product.md");