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,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 new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
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 an automatic, additive upgrade
86
- // cannot fix: an AGENTS.md documenting a stale command surface, new
87
- // validate checks the old scaffold fails, and so on. --fix under --write
88
- // so the mechanical part self-heals.
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("If AGENTS.md lists the command surface, refresh that section so agents see the new commands."));
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 — append missing recommended sections to AGENTS.md
120
- / product.md and missing index.json fields
121
- (additive-only; never rewrites your content)
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 additive upgrade cannot fix
125
- (e.g. an AGENTS.md documenting a stale command
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
  `;
@@ -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
- console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
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);
@@ -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 new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
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
- return 1;
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;
@@ -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 new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
29
+ throw notADoctrinaProject();
23
30
  }
24
31
 
25
32
  if (flagBool(flags, "once", false)) {
@@ -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 new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
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 = sectionParagraph(text, "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");
@@ -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 new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
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
- console.log("3. Write one delta per affected capability at");
410
- console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
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(" Only prose rewrites stay a by-hand merge."));
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: ["help", "h", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init", "fix", "once", "clean", "json"],
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 2;
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
- return 1;
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) => process.exit(code ?? 0));
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
+ });