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
@@ -1,15 +1,21 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
4
+ import { appendFileSync } from "node:fs";
3
5
  import { exists, isFile, read, walk } from "../lib/fs-ops.js";
4
6
  import { flagBool } from "../lib/args.js";
5
7
  import { c } from "../lib/colors.js";
6
8
  import { parseCapabilityFromDelta } from "./change.js";
9
+ import { printAdrCheckpoint } from "../lib/adr-guard.js";
10
+ import { checkDocsImpact } from "../lib/docs-impact.js";
11
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
7
12
  import * as analyze from "./analyze.js";
8
13
  import * as change from "./change.js";
9
14
  import * as verify from "./verify.js";
10
15
  import * as coverage from "./coverage.js";
11
16
  import * as trace from "./trace.js";
12
17
  import * as validate from "./validate.js";
18
+ import * as skill from "./skill.js";
13
19
 
14
20
  // One-command close (review 2026-06-27 passive-user feature #2). The work
15
21
  // playbook lists the closing sequence — analyze → apply → verify → coverage →
@@ -19,19 +25,48 @@ import * as validate from "./validate.js";
19
25
  // rerun, so the agent makes one call and the human approves once. It is a
20
26
  // driver over the existing commands; it adds no new checks of its own.
21
27
 
28
+ // Flags this command accepts. Declared HERE, with the command, so
29
+ // adding a command never requires editing the entrypoint — the gap that
30
+ // let six flags ship undeclared and silently swallow a positional (C3).
31
+ export const flags = { boolean: ["json", "force"], string: [] };
32
+
22
33
  export async function run(positional, flags) {
23
- const id = positional[0];
24
- if (!id) {
34
+ if (positional.length === 0) {
25
35
  console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
26
36
  return 2;
27
37
  }
28
38
  const projectRoot = process.cwd();
29
39
  if (!exists(path.join(projectRoot, ".doctrina"))) {
30
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
40
+ throw notADoctrinaProject();
31
41
  }
32
42
 
43
+ // Batch close (operator review 2026-07-19 §4.5): each id closes
44
+ // independently, worst exit code wins, so a 9-change implementation lot is
45
+ // one command, not nine.
46
+ let worst = 0;
47
+ for (const id of positional) {
48
+ if (positional.length > 1) {
49
+ console.log("");
50
+ console.log(c.bold(`════ close ${id}`));
51
+ }
52
+ worst = Math.max(worst, await closeOne(projectRoot, id, flags));
53
+ }
54
+ if (positional.length > 1) {
55
+ console.log("");
56
+ console.log(worst === 0
57
+ ? c.green("ok") + ` all ${positional.length} changes closed`
58
+ : c.red("fail") + " at least one close stopped — see above");
59
+ }
60
+ return worst;
61
+ }
62
+
63
+ async function closeOne(projectRoot, id, flags) {
33
64
  const force = flagBool(flags, "force", false);
34
65
  const archiveFlags = force ? new Map([["force", true]]) : new Map();
66
+ // Set by the docs gate when it fails, so a --force close can record the
67
+ // gap in the ledger after the archive lands.
68
+ /** @type {{ signals: string[] } | null} */
69
+ let docsGap = null;
35
70
 
36
71
  // Scope the coverage gate to the capabilities THIS change touches (its
37
72
  // deltas), so one deliberately deferred spec elsewhere in the tree cannot
@@ -39,6 +74,7 @@ export async function run(positional, flags) {
39
74
  // item 4). A change with no deltas (chore / metadata-only) falls back to
40
75
  // the whole-tree gate — there is no narrower honest scope for it.
41
76
  const touched = touchedCapabilities(projectRoot, id);
77
+ /** @type {FlagMap} */
42
78
  const coverageFlags = new Map([["strict", true]]);
43
79
  let coverageRerun = "doctrina coverage --strict";
44
80
  if (touched.length > 0) {
@@ -52,6 +88,20 @@ export async function run(positional, flags) {
52
88
  const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
53
89
  const steps = [
54
90
  { label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
91
+ // ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
92
+ // step was skippable in silence. Advisory — it names the accepted ADRs
93
+ // whose text cites the touched capabilities and the amend commands, but
94
+ // an ADR merely mentioning a capability is normal, so it never blocks.
95
+ {
96
+ label: "ADR checkpoint (advisory)",
97
+ rerun: "doctrina decision list",
98
+ run: async () => {
99
+ if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
100
+ console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
101
+ }
102
+ return 0;
103
+ },
104
+ },
55
105
  { label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
56
106
  verifyConfigured
57
107
  ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
@@ -64,11 +114,35 @@ export async function run(positional, flags) {
64
114
  // trace is advisory (provenance is a warning, not a hard gate): report it,
65
115
  // never let it block the close.
66
116
  { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
117
+ // Docs ship inside the change (D2): a change that alters a documented
118
+ // surface — a command, a flag, an exit code — closes only with the
119
+ // documentation that describes it. A blocking gate, because a docs
120
+ // phase scheduled after the work never happens; --force is the same
121
+ // escape hatch archive offers, and records the gap in the ledger.
122
+ {
123
+ label: "docs",
124
+ forceable: true,
125
+ rerun: "edit docs/ (EN + PT), then rerun",
126
+ run: async () => {
127
+ const r = checkDocsImpact(projectRoot, path.join(projectRoot, ".doctrina", "changes", id));
128
+ if (r.ok) {
129
+ console.log(c.green("ok") + ` ${r.reason}`);
130
+ return 0;
131
+ }
132
+ console.error(c.red("error:") + ` this change ${r.reason}:`);
133
+ for (const s of r.signals) console.error(` - ${s}`);
134
+ console.error(c.gray("hint: ") +
135
+ "document it in docs/en/ AND docs/pt/ (the `keep-docs-en-pt-parity` skill), " +
136
+ "or pass --force to close anyway (records the gap)");
137
+ docsGap = r;
138
+ return 1;
139
+ },
140
+ },
67
141
  { label: "archive", rerun: `doctrina change archive ${id}${force ? " --force" : ""}`, run: () => change.run(["archive", id], archiveFlags) },
68
142
  { label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
69
143
  ];
70
144
 
71
- console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
145
+ console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → docs → archive → validate"));
72
146
 
73
147
  for (let i = 0; i < steps.length; i++) {
74
148
  const step = steps[i];
@@ -86,6 +160,13 @@ export async function run(positional, flags) {
86
160
  console.error(c.red("error:") + ` ${err.message}`);
87
161
  }
88
162
  if (code !== 0) {
163
+ // A forceable gate under --force warns and continues, matching how
164
+ // `change archive --force` handles incomplete verification: the gap
165
+ // is recorded, not hidden.
166
+ if (code !== 0 && step.forceable && force) {
167
+ console.log(c.yellow("warn:") + ` proceeding past "${step.label}" (--force) — the gap is recorded in the ledger`);
168
+ continue;
169
+ }
89
170
  console.log("");
90
171
  console.log(c.red(`✗ close stopped at "${step.label}"`) + c.gray(` (step ${i + 1}/${steps.length})`));
91
172
  console.log(c.gray("Fix it, then rerun the step or the whole close:"));
@@ -95,6 +176,26 @@ export async function run(positional, flags) {
95
176
  }
96
177
  }
97
178
 
179
+ // Record a forced docs gap in the ledger, so history shows the change
180
+ // shipped without its documentation rather than showing nothing.
181
+ if (docsGap) {
182
+ const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
183
+ if (isFile(ledgerPath)) {
184
+ appendFileSync(ledgerPath, ` - docs gap: ${id} closed with --force; ${docsGap.signals.join("; ")} documented nowhere\n`);
185
+ console.log(c.yellow("ledger") + " recorded the docs gap");
186
+ }
187
+ }
188
+
189
+ // Capture the lesson while it is fresh (operator review §4.7): surface the
190
+ // fix-shaped lessons not yet written as skills, right where the operator
191
+ // just proved they finish what they start. Advisory — a listing, never a
192
+ // gate, and a suggest failure never un-closes the change.
193
+ console.log("");
194
+ console.log(c.gray("──── skills (advisory)"));
195
+ try {
196
+ await skill.run(["suggest"], new Map());
197
+ } catch { /* advisory only */ }
198
+
98
199
  console.log("");
99
200
  console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
100
201
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
@@ -115,21 +216,28 @@ function touchedCapabilities(projectRoot, id) {
115
216
  }
116
217
 
117
218
  export const help = `
118
- Usage: doctrina close <id> [--force]
219
+ Usage: doctrina close <id...> [--force]
119
220
 
120
221
  Run the whole closing sequence for a change in one pass, stopping at the
121
222
  first failure with the exact command to rerun:
122
223
 
123
- analyze → change apply → verify → coverage --strict (scoped to the
124
- change's touched capabilities) → trace → change archive → validate
224
+ analyze → ADR checkpoint (advisory) → change apply → verify → coverage
225
+ --strict (scoped to the change's touched capabilities) → trace → change
226
+ archive → validate → skill suggest (advisory)
125
227
 
126
228
  The coverage gate is scoped to the capabilities the change's deltas touch,
127
229
  so a deliberately deferred spec elsewhere cannot block an unrelated close;
128
230
  a change with no deltas gates on the whole tree. verify is skipped (with a
129
- note) when no .doctrina/verify.json is declared; trace is advisory (a
130
- report, never a blocker). This is a driver over the existing commands — it
131
- adds no checks of its own — so the agent makes one call instead of seven
132
- and the human approves once.
231
+ note) when no .doctrina/verify.json is declared; trace, the ADR checkpoint
232
+ (accepted ADRs citing the touched capabilities — amend via \`decision
233
+ supersede\`, not silence), and the closing skill-suggest listing are
234
+ advisory (reports, never blockers). This is a driver over the existing
235
+ commands so the agent makes one call instead of nine and the human
236
+ approves once.
237
+
238
+ Multiple ids close in sequence, each independently; the exit code is the
239
+ worst per-id result. Preview what close would refuse: \`doctrina change
240
+ check <id>\`.
133
241
 
134
242
  Options:
135
243
  --force Pass through to \`change archive\` (archive even if verification
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import { OPERATIONS, COMMAND_NAMES } from "../lib/commands.js";
2
3
  import { c } from "../lib/colors.js";
3
4
 
@@ -90,6 +91,11 @@ Register-ArgumentCompleter -Native -CommandName doctrina -ScriptBlock {
90
91
  `;
91
92
  }
92
93
 
94
+ // Flags this command accepts. Declared HERE, with the command, so
95
+ // adding a command never requires editing the entrypoint — the gap that
96
+ // let six flags ship undeclared and silently swallow a positional (C3).
97
+ export const flags = { boolean: ["json"], string: [] };
98
+
93
99
  export async function run(positional, _flags) {
94
100
  const shell = positional[0];
95
101
  const generators = { bash: bashScript, zsh: zshScript, pwsh: pwshScript, powershell: pwshScript };
@@ -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 } from "../lib/fs-ops.js";
5
6
  import { listHeader } from "../lib/scan.js";
6
7
  import { c } from "../lib/colors.js";
8
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
7
9
 
8
10
  // The project's standing rules in one read — Spec Kit parity for its
9
11
  // `constitution.md`, but ASSEMBLED, not a new home for facts. Doctrina's
@@ -16,10 +18,15 @@ import { c } from "../lib/colors.js";
16
18
  // To change a principle, supersede its ADR; to change a non-goal, edit
17
19
  // product.md. The command never writes — it has nothing of its own to own.
18
20
 
21
+ // Flags this command accepts. Declared HERE, with the command, so
22
+ // adding a command never requires editing the entrypoint — the gap that
23
+ // let six flags ship undeclared and silently swallow a positional (C3).
24
+ export const flags = { boolean: ["json"], string: [] };
25
+
19
26
  export async function run(_positional, _flags) {
20
27
  const projectRoot = process.cwd();
21
28
  if (!exists(path.join(projectRoot, ".doctrina"))) {
22
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
29
+ throw notADoctrinaProject();
23
30
  }
24
31
 
25
32
  const project = projectName(projectRoot);