doctrina-cli 0.16.0 → 0.18.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 (71) hide show
  1. package/README.md +31 -73
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +3 -2
  4. package/src/commands/analyze.js +8 -0
  5. package/src/commands/change.js +86 -80
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +136 -34
  8. package/src/commands/completion.js +8 -2
  9. package/src/commands/context.js +6 -4
  10. package/src/commands/decision.js +12 -0
  11. package/src/commands/doctor.js +1 -1
  12. package/src/commands/hooks.js +6 -1
  13. package/src/commands/index-rebuild.js +52 -2
  14. package/src/commands/init.js +19 -4
  15. package/src/commands/intake.js +55 -7
  16. package/src/commands/intent.js +14 -1
  17. package/src/commands/metrics.js +13 -5
  18. package/src/commands/next.js +2 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/review.js +47 -8
  22. package/src/commands/skill.js +8 -139
  23. package/src/commands/spec.js +13 -7
  24. package/src/commands/status.js +39 -20
  25. package/src/commands/templates.js +4 -10
  26. package/src/commands/upgrade.js +31 -6
  27. package/src/commands/validate.js +32 -8
  28. package/src/commands/verify.js +59 -6
  29. package/src/commands/work.js +63 -8
  30. package/src/index.js +34 -6
  31. package/src/lib/actions.js +38 -28
  32. package/src/lib/args.js +14 -0
  33. package/src/lib/change-ops.js +95 -1
  34. package/src/lib/commands.js +96 -15
  35. package/src/lib/coverage-model.js +40 -1
  36. package/src/lib/doc-model.js +47 -6
  37. package/src/lib/docs-impact.js +74 -0
  38. package/src/lib/ears.js +24 -1
  39. package/src/lib/gates.js +53 -9
  40. package/src/lib/git.js +59 -2
  41. package/src/lib/index-json.js +40 -5
  42. package/src/lib/intake-model.js +71 -2
  43. package/src/lib/lexicon.js +38 -1
  44. package/src/lib/metrics-model.js +30 -0
  45. package/src/lib/playbook.js +1 -1
  46. package/src/lib/project.js +49 -1
  47. package/src/lib/runtime.js +6 -0
  48. package/src/lib/scan.js +139 -14
  49. package/src/lib/signoff.js +24 -0
  50. package/src/lib/skill-candidates.js +162 -0
  51. package/src/lib/snapshot.js +61 -2
  52. package/src/lib/spec-ops.js +141 -6
  53. package/src/lib/templates-model.js +35 -8
  54. package/src/lib/validation-model.js +117 -35
  55. package/src/lib/verify-cache.js +76 -0
  56. package/src/lib/views.js +10 -2
  57. package/src/lib/work-model.js +57 -12
  58. package/templates/AGENTS.md.template +3 -4
  59. package/templates/README.md +2 -0
  60. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  61. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  62. package/templates/change/proposal.md.template +15 -0
  63. package/templates/change/spec-delta.md.template +2 -0
  64. package/templates/change/tasks.md.template +3 -8
  65. package/templates/contract.md.template +2 -2
  66. package/templates/hooks/pre-commit.sample +10 -0
  67. package/templates/playbooks/bootstrap.md.template +6 -2
  68. package/templates/playbooks/chore.md.template +9 -4
  69. package/templates/playbooks/consolidate.md.template +28 -0
  70. package/templates/playbooks/work.md.template +13 -3
  71. package/src/commands/constitution.js +0 -68
@@ -19,6 +19,7 @@ import { collectFindings, agentsSectionCost, AGENTS_SECTIONS, PRODUCT_SECTIONS,
19
19
  // too (audit finding F7); this command renders and fixes them.
20
20
  export { collectFindings } from "../lib/templates-model.js";
21
21
  import { suggest } from "../lib/suggest.js";
22
+ import { ensureDoctrinaProject } from "../lib/project.js";
22
23
 
23
24
  const SUBCOMMANDS = ["list", "check", "update"];
24
25
 
@@ -50,10 +51,7 @@ export async function run(positional, flags) {
50
51
  function updateTemplates(flags) {
51
52
  const writeMode = flagBool(flags, "write", false);
52
53
  const projectRoot = process.cwd();
53
- if (!isDir(path.join(projectRoot, ".doctrina"))) {
54
- console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
55
- return 1;
56
- }
54
+ ensureDoctrinaProject(projectRoot);
57
55
 
58
56
  const plan = [];
59
57
  // Updates this run declines to make, each with the reason and the number.
@@ -176,7 +174,7 @@ function updateTemplates(flags) {
176
174
  let updated = read(filePath);
177
175
  if (!updated.endsWith("\n")) updated += "\n";
178
176
  for (const h of missing) {
179
- updated += `\n${h}\n\n<!-- added by doctrina templates update — fill in -->\n`;
177
+ updated += `\n${h}\n\n<!-- added by doctrina upgrade — fill in -->\n`;
180
178
  }
181
179
  write(filePath, updated, { force: true });
182
180
  },
@@ -287,11 +285,7 @@ function listTemplates() {
287
285
 
288
286
  function checkTemplates() {
289
287
  const projectRoot = process.cwd();
290
- if (!isDir(path.join(projectRoot, ".doctrina"))) {
291
- console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
292
- console.error(c.gray("hint: ") + "run `doctrina init` first");
293
- return 1;
294
- }
288
+ ensureDoctrinaProject(projectRoot);
295
289
  const { findings, ok } = collectFindings(projectRoot);
296
290
 
297
291
  return report(findings, ok);
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { collectFindings } from "../lib/templates-model.js";
12
13
  import * as templates from "./templates.js";
13
14
  import * as validate from "./validate.js";
14
15
 
@@ -56,11 +57,23 @@ export async function run(_positional, flags) {
56
57
  // 2. Additive template updates (missing recommended sections in AGENTS.md /
57
58
  // product.md, missing index.json fields). Reuses `templates update`
58
59
  // verbatim: preview without --write, apply with it.
59
- console.log(c.gray("──── 1/3 scaffold shape (templates update)"));
60
+ console.log(c.gray("──── 1/3 scaffold shape"));
60
61
  const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
61
62
  const tplCode = await templates.run(["update"], tplFlags);
62
63
  if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
63
64
 
65
+ // What the additive update cannot fix — a drifted adapter, a broken
66
+ // playbook, an index schema it does not know — used to be visible only to
67
+ // `templates check`. Printed here, each with its own remedy, so the one
68
+ // command that brings a project up to date also says what is left for a
69
+ // person (change 0178: the check and the update became this step).
70
+ const manual = collectFindings(projectRoot).findings
71
+ .filter((f) => !/upgrade --write|index rebuild/.test(f.remedy ?? ""));
72
+ for (const f of manual) {
73
+ console.log(c.yellow("✗ ") + f.message);
74
+ console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
75
+ }
76
+
64
77
  // 3. Index: rebuild from the tree, which also migrates the framework stamp.
65
78
  console.log("");
66
79
  console.log(c.gray("──── 2/3 index + framework stamp"));
@@ -101,14 +114,22 @@ export async function run(_positional, flags) {
101
114
  const vCode = await validate.run([], vFlags);
102
115
 
103
116
  console.log("");
117
+ if (manual.length > 0) {
118
+ console.log(c.yellow("manual") + ` ${manual.length} finding${manual.length === 1 ? "" : "s"} the upgrade cannot repair — see step 1`);
119
+ }
104
120
  if (!writeMode) {
105
- if (pending > 0) {
121
+ if (pending > 0 || manual.length > 0) {
122
+ if (pending === 0) return 1;
106
123
  console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
107
124
  return 1;
108
125
  }
109
126
  console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
110
127
  return vCode;
111
128
  }
129
+ if (vCode === 0 && manual.length > 0) {
130
+ console.log(c.yellow(`upgraded to ${running} with manual repairs left`) + " — each finding in step 1 names its fix.");
131
+ return 1;
132
+ }
112
133
  if (vCode === 0) {
113
134
  console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
114
135
  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."));
@@ -125,16 +146,20 @@ Bring an existing project up to the installed CLI after an npm update.
125
146
  The project keeps the scaffold of the version that init-ed it; this is
126
147
  the one command that closes the gap, orchestrating the existing pieces:
127
148
 
128
- 1. templates update — regenerate the AGENTS.md doctrina:surface block
149
+ 1. scaffold shape — regenerate the AGENTS.md doctrina:surface block
129
150
  from the installed command catalog (the block is
130
151
  CLI-owned, ADR 0015 — this is how agents reading
131
- the hub discover commands added since init), and
152
+ the hub discover commands added since init),
132
153
  append missing recommended sections / index.json
133
- fields (additive-only outside the block)
154
+ fields (additive-only outside the block), and
155
+ list what no command can repair (a drifted
156
+ adapter, a broken playbook), each with its fix
134
157
  2. index rebuild — regenerate index.json from the tree and migrate
135
158
  the framework_version stamp to the running CLI
136
159
  3. validate (--fix) — surface anything the upgrade cannot fix
137
160
  (hand-authored drift, new validate checks)
138
161
 
139
- Preview by default (exits 1 when steps are pending); --write applies.
162
+ Preview by default (exits 1 when steps are pending or a manual repair is
163
+ left); --write applies. The preview is what \`templates check\` reported
164
+ and --write what \`templates update --write\` did — both deprecated.
140
165
  `;
@@ -20,7 +20,7 @@ import { collectValidation } from "../lib/validation-model.js";
20
20
  // wrap it in the generic envelope.
21
21
  export const jsonNative = true;
22
22
 
23
- export const flags = { boolean: ["fix", "json", "runtime"], string: [] };
23
+ export const flags = { boolean: ["fix", "json", "runtime", "strict"], string: [] };
24
24
 
25
25
  export async function run(_positional, flags) {
26
26
  const projectRoot = process.cwd();
@@ -34,10 +34,26 @@ export async function run(_positional, flags) {
34
34
  runtime: flagBool(flags, "runtime", false),
35
35
  });
36
36
 
37
+ // --strict makes a warning count against the exit code, matching what
38
+ // `--strict` already means on `coverage` and `trace`: fail on any gap.
39
+ //
40
+ // The default has to stay lenient — a warning is advice, and advice that
41
+ // blocks a commit stops being read. But that leniency left a gate that
42
+ // COULD NOT REPROVE: the CI step that validates the shipped examples runs
43
+ // this command, both examples had drifted to warnings, and the step passed
44
+ // green through every run. The EARS defect the retrofit example exists to
45
+ // teach against was reintroduced and sat there, twice, because nothing
46
+ // downstream of `validate` could say no.
47
+ //
48
+ // So the choice belongs to the caller. A human at a terminal wants the
49
+ // advice; a gate wants a verdict.
50
+ const strict = flagBool(flags, "strict", false);
51
+ const failed = errors.length > 0 || (strict && warnings.length > 0);
52
+
37
53
  // Output
38
54
  if (flagBool(flags, "json", false)) {
39
- emitJson("validate", { errors, warnings });
40
- return errors.length === 0 ? 0 : 1;
55
+ emitJson("validate", { errors, warnings, strict });
56
+ return failed ? 1 : 0;
41
57
  }
42
58
  // The repairs print first and in the order they were made: --fix changes
43
59
  // the tree, and what it changed must be visible before what it then found.
@@ -50,17 +66,21 @@ export async function run(_positional, flags) {
50
66
  console.log(c.green("ok") + " all validation checks passed");
51
67
  } else {
52
68
  const summary = `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`;
53
- console.log((errors.length === 0 ? c.green("ok") : c.red("fail")) + " " + summary);
69
+ console.log((failed ? c.red("fail") : c.green("ok")) + " " + summary);
70
+ // Say WHY a run with no errors is failing, or the exit code reads as a bug.
71
+ if (strict && errors.length === 0 && warnings.length > 0) {
72
+ console.log(c.gray(" --strict: warnings count against the exit code"));
73
+ }
54
74
  }
55
- return errors.length === 0 ? 0 : 1;
75
+ return failed ? 1 : 0;
56
76
  }
57
77
 
58
78
  export const help = `
59
- Usage: doctrina validate [--fix] [--runtime] [--json]
79
+ Usage: doctrina validate [--fix] [--runtime] [--strict] [--json]
60
80
 
61
81
  Run schema, artifact-existence, and structural checks against the
62
82
  .doctrina/ tree in the current working directory. Exits 0 if no errors
63
- (warnings allowed), 1 otherwise.
83
+ (warnings allowed), 1 otherwise. With --strict, a warning fails it too.
64
84
 
65
85
  Two structural checks worth naming:
66
86
 
@@ -84,5 +104,9 @@ Flags:
84
104
  selectors checked against the workflows and code that are
85
105
  supposed to honour them (the same checks as \`contract check\`).
86
106
  Opt-in because it reads files outside .doctrina/.
87
- --json Emit { ok, errors, warnings } as JSON (stable shape for agents/CI).
107
+ --strict Treat warnings as failures, the way \`coverage --strict\` and
108
+ \`trace --strict\` already do. The default stays lenient because
109
+ a warning is advice; a GATE wants a verdict, so anything that
110
+ must be able to reprove passes this.
111
+ --json Emit { ok, errors, warnings, strict } as JSON (stable shape for agents/CI).
88
112
  `;
@@ -8,9 +8,10 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
8
8
  import { flagBool, flagString } from "../lib/args.js";
9
9
  import { today } from "../lib/dates.js";
10
10
  import { c } from "../lib/colors.js";
11
- import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL } from "../lib/signoff.js";
11
+ import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL, declaredPaths, workingChanges } from "../lib/signoff.js";
12
12
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { collectReproducibility } from "../lib/reproducibility.js";
14
+ import { verifyFingerprint, recordGreen, reusableGreen } from "../lib/verify-cache.js";
14
15
 
15
16
  // The build/verify gate `validate` is not. `validate` checks the shape of
16
17
  // the artifact tree; it never runs the project, so a repo that does not
@@ -40,7 +41,7 @@ const STARTER = {
40
41
  // Flags this command accepts. Declared HERE, with the command, so
41
42
  // adding a command never requires editing the entrypoint — the gap that
42
43
  // let six flags ship undeclared and silently swallow a positional (C3).
43
- export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
44
+ export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict", "reuse", "affected"], string: ["signoff"] };
44
45
 
45
46
  export async function run(_positional, flags) {
46
47
  const projectRoot = process.cwd();
@@ -182,6 +183,22 @@ export async function run(_positional, flags) {
182
183
  }
183
184
 
184
185
  const strict = flagBool(flags, "strict", false);
186
+ const affected = flagBool(flags, "affected", false);
187
+ // The fingerprint of the code these checks are about to run on, taken
188
+ // BEFORE they run (change 0203): what a green run proves is the tree it
189
+ // started from. Null outside git — then nothing is remembered or reused.
190
+ const fingerprint = verifyFingerprint(projectRoot, read(configPath));
191
+ if (flagBool(flags, "reuse", false) && fingerprint !== null) {
192
+ const green = reusableGreen(projectRoot, fingerprint);
193
+ if (green) {
194
+ console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
195
+ console.log("");
196
+ console.log(c.green("ok") + ` reused the green run of ${green.at.replace("T", " ").slice(0, 16)} UTC — ` +
197
+ `the code and ${CONFIG_REL} are byte-identical to what it ran on (${green.checks.join(", ")})`);
198
+ console.log(c.gray(" `doctrina verify` runs the checks again"));
199
+ return 0;
200
+ }
201
+ }
185
202
  console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
186
203
  const results = [];
187
204
  for (const ch of checks) {
@@ -220,6 +237,20 @@ export async function run(_positional, flags) {
220
237
  continue;
221
238
  }
222
239
 
240
+ // --affected (change 0204): a check that declares the "paths" it covers
241
+ // runs only when the uncommitted work touches one of them. The inner
242
+ // loop of a change — not the close, which always runs every check. A
243
+ // check that declares no paths covers everything and always runs; a
244
+ // question git cannot answer runs the check.
245
+ if (affected && declaredPaths(ch).length > 0) {
246
+ const touched = workingChanges(projectRoot, declaredPaths(ch));
247
+ if (touched !== null && touched.length === 0) {
248
+ console.log(c.gray(`──── ${name}: `) + c.gray(`skip — the uncommitted work touches none of ${declaredPaths(ch).join(", ")}`));
249
+ results.push({ name, ok: true, kind: "command", skipped: true });
250
+ continue;
251
+ }
252
+ }
253
+
223
254
  const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
224
255
  console.log(c.gray(`──── ${name}: `) + ch.run + where);
225
256
  // Run through the shell so declared commands can use pipes, &&, env,
@@ -287,9 +318,18 @@ export async function run(_positional, flags) {
287
318
  // manual check still awaiting sign-off. Pending manual checks are otherwise
288
319
  // a non-blocking report.
289
320
  const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
290
- const pendNote = pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "";
321
+ const skipped = results.filter((r) => r.skipped);
322
+ const pendNote = (pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "") +
323
+ (skipped.length > 0 ? c.gray(` (${skipped.length} skipped by --affected: ${skipped.map((r) => r.name).join(", ")})`) : "");
291
324
  if (!failGate) {
292
- console.log(c.green("ok") + ` ${passed}/${results.length} checks passed${pendNote}`);
325
+ const ran = passed - skipped.length;
326
+ console.log(c.green("ok") + ` ${ran}/${results.length - skipped.length} checks passed${pendNote}`);
327
+ // Remembered only when EVERY check passed — a run green with a manual
328
+ // check still pending is a report, not something a close may stand on,
329
+ // and neither is a run --affected cut short.
330
+ if (fingerprint !== null && pending.length === 0 && skipped.length === 0) {
331
+ recordGreen(projectRoot, fingerprint, results.map((r) => r.name));
332
+ }
293
333
  return 0;
294
334
  }
295
335
  const reasons = [];
@@ -403,7 +443,7 @@ function renderReproducibility(projectRoot) {
403
443
  }
404
444
 
405
445
  export const help = `
406
- Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
446
+ Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--reuse] [--affected] [--signoff "<name>=<note>"] [--force]
407
447
 
408
448
  Run the project-declared build/verify checks from ${CONFIG_REL} in order,
409
449
  streaming their output, and exit non-zero if any check fails. This is the
@@ -414,13 +454,18 @@ root, targets a sub-package in a monorepo; a "manual" check is the
414
454
  qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
415
455
  {
416
456
  "checks": [
417
- { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
457
+ { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api",
458
+ "paths": ["packages/api"] },
418
459
  { "name": "test", "run": "npm test" },
419
460
  { "name": "build", "run": "npm run build" },
420
461
  { "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
421
462
  ]
422
463
  }
423
464
 
465
+ "paths" (git pathspecs, relative to the project root) says what a check
466
+ covers: --affected runs it only when the uncommitted work touches one, and a
467
+ manual check's sign-off expires when one changes.
468
+
424
469
  A manual check passes when it has been signed off and is otherwise reported
425
470
  as "pending" — non-blocking by default, failing only under --strict (so CI
426
471
  can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
@@ -450,6 +495,14 @@ Flags:
450
495
  prepare/prepack; a Prisma dep with no generate on install) so
451
496
  "verify green" can't hide a clean checkout that won't build.
452
497
  --strict Fail the gate when a manual check is still pending sign-off
498
+ --reuse Skip the checks when the last fully green run covered this exact
499
+ code: the working tree (tracked, untracked, edits) minus
500
+ .doctrina/, plus ${CONFIG_REL}, are byte-identical. \`close\` runs
501
+ verify this way; a plain \`doctrina verify\` always runs them.
502
+ --affected Run a check that declares "paths" only when the uncommitted work
503
+ (edits against HEAD, untracked files) touches one of them —
504
+ the inner loop of a change. Checks with no "paths" always run;
505
+ \`close\` always runs every check. Never remembered by --reuse.
453
506
  --signoff "<name>=<note>"
454
507
  Record today's sign-off for a manual check, then exit
455
508
  --force With --init, overwrite an existing config
@@ -9,13 +9,14 @@ import { c } from "../lib/colors.js";
9
9
  import { assessBrief } from "../lib/clarity.js";
10
10
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
11
11
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
- import { changeNew, reindexChange } from "../lib/change-ops.js";
12
+ import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
13
13
  import { classify } from "../lib/triage-model.js";
14
14
  import { printPlaybookTemplate } from "../lib/playbook.js";
15
+ import { specShape } from "../lib/spec-ops.js";
15
16
  import { changedFiles } from "../lib/git.js";
16
17
  import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
17
18
  import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
18
- import { isChangeId } from "../lib/project.js";
19
+ import { isChangeId, refuseChangeRef } from "../lib/project.js";
19
20
  export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
20
21
 
21
22
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -29,14 +30,36 @@ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
29
30
  // Flags this command accepts. Declared HERE, with the command, so
30
31
  // adding a command never requires editing the entrypoint — the gap that
31
32
  // let six flags ship undeclared and silently swallow a positional (C3).
32
- export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
33
+ export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "id", "resume", "title"] };
33
34
 
34
35
  export async function run(positional, flags) {
35
- const prompt = positional.join(" ").trim();
36
36
  const projectRoot = process.cwd();
37
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
38
38
  throw notADoctrinaProject();
39
39
  }
40
+ // --consolidate <cap> (change 0206): a spec only ever grew — every change
41
+ // appended, nothing took a requirement out — and a field review of 0.17
42
+ // asked for "a command to consolidate a spec from time to time". It is an
43
+ // ordinary change pinned to the capability, with its own playbook: the
44
+ // agent decides what is redundant or superseded (ADR 0005), and the ops
45
+ // take it out.
46
+ const consolidate = flags.has("consolidate") ? flagString(flags, "consolidate") : null;
47
+ if (consolidate !== null) {
48
+ if (!consolidate || /** @type {unknown} */ (consolidate) === true || !/^[a-z][a-z0-9-]*$/.test(consolidate)) {
49
+ console.error(c.red("error:") + " --consolidate needs the capability whose spec to consolidate");
50
+ return EXIT.USAGE;
51
+ }
52
+ if (!isFile(path.join(projectRoot, ".doctrina", "specs", consolidate, "spec.md"))) {
53
+ console.error(c.red("error:") + ` capability "${consolidate}" has no spec at .doctrina/specs/${consolidate}/spec.md`);
54
+ return EXIT.USAGE;
55
+ }
56
+ flags.set("capability", consolidate);
57
+ flags.set("force", true);
58
+ if (!flags.has("title")) flags.set("title", `consolidate ${consolidate}`);
59
+ }
60
+ const prompt = consolidate
61
+ ? `Consolidate the ${consolidate} spec: merge requirements and criteria that say the same thing, drop what a later one superseded — no behaviour changes.`
62
+ : positional.join(" ").trim();
40
63
 
41
64
  // --resume <id>: reprint the playbook for an existing open change rather than
42
65
  // open a new one (review G1 — `work "continue"` used to slug the word and
@@ -205,6 +228,12 @@ export async function run(positional, flags) {
205
228
  ? null
206
229
  : scaffoldDelta(projectRoot, { id, capability, guess, runnerUp: promptMatches[1] ?? null });
207
230
 
231
+ // A guessed delta names the capability as surely as a pinned one: the
232
+ // header says what the folder says (change 0207). It stayed empty for
233
+ // every unpinned `work`, so the proposal claimed no spec while its delta
234
+ // targeted one.
235
+ if (scaffolded) addAffectedSpec(projectRoot, id, scaffolded.capability);
236
+
208
237
  // The proposal is written in two passes — `changeNew` scaffolds it, the
209
238
  // lines above stamp the lane and the pinned specs — so its index entry is
210
239
  // re-derived HERE, from the finished file. Indexing at the end of the first
@@ -223,7 +252,9 @@ export async function run(positional, flags) {
223
252
  }
224
253
 
225
254
  console.log("");
226
- if (chore) {
255
+ if (consolidate) {
256
+ printConsolidatePlaybook(projectRoot, { id, capability: consolidate });
257
+ } else if (chore) {
227
258
  printChorePlaybook(projectRoot, { id, prompt: effPrompt });
228
259
  } else {
229
260
  printPlaybook(projectRoot, {
@@ -387,6 +418,8 @@ function resumeChange(projectRoot, resumeId) {
387
418
  if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
388
419
  return 2;
389
420
  }
421
+ const refused = refuseChangeRef(resumeId);
422
+ if (refused !== null) return refused;
390
423
  const entry = open.find((ch) => ch.id === resumeId);
391
424
  if (!entry && !isDir(path.join(projectRoot, ".doctrina", "changes", resumeId))) {
392
425
  console.error(c.red("error:") + ` no open change "${resumeId}"`);
@@ -438,6 +471,21 @@ export function slugify(text) {
438
471
  // part that is not prose: computing the variable blocks. Each is PRE-RENDERED
439
472
  // into a token, so `substitute` stays a plain string map and the template
440
473
  // stays a document rather than a language with conditionals in it.
474
+ // The consolidation playbook: the spec's shape, measured, then the steps.
475
+ // Measuring is the CLI's part; deciding which requirement is redundant is
476
+ // the agent's (ADR 0005).
477
+ function printConsolidatePlaybook(projectRoot, { id, capability }) {
478
+ const text = read(path.join(projectRoot, ".doctrina", "specs", capability, "spec.md"));
479
+ const shape = specShape(text);
480
+ const sections = Object.entries(shape.sections).map(([k, n]) => `${k} ${n}`).join(" · ");
481
+ printPlaybookTemplate(projectRoot, "consolidate", {
482
+ CHANGE_ID: id,
483
+ CAPABILITY: capability,
484
+ SHAPE: `${shape.lines} lines · ${shape.requirements} requirements (${sections || "none"}) · ` +
485
+ `${shape.criteria} acceptance criteria`,
486
+ });
487
+ }
488
+
441
489
  function printChorePlaybook(projectRoot, { id, prompt }) {
442
490
  printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
443
491
  }
@@ -515,9 +563,10 @@ change id (NNNN-<slug>) from the prompt, opens the change folder,
515
563
  records the prompt under the proposal's "## Why", ranks existing specs
516
564
  by term overlap as a capability hint, flags a thin/under-specified prompt
517
565
  so you clarify before writing deltas (advisory), and prints the steps:
518
- context → spec delta → tasks → implement → analyze → apply → verify
519
- (verify + coverage) → archive → validate. No natural-language
520
- interpretation happens in the CLI.
566
+ context, spec delta, tasks, implement, then \`doctrina change check <id>\`
567
+ to preview the close and \`doctrina close <id>\` to finish, which runs the
568
+ whole closing sequence in one pass. No natural-language interpretation
569
+ happens in the CLI.
521
570
 
522
571
  The change's spec delta is scaffolded with **Operation:** prefilled
523
572
  whenever the CLI can name the capability: from --capability, or from the
@@ -540,8 +589,14 @@ Options:
540
589
  --resume <id> Reprint the playbook for an open change; create nothing
541
590
  --from-diff Backfill: scaffold from the working-tree changes (no
542
591
  prompt needed) and print a code-first playbook (F8)
592
+ --consolidate <cap> Open a change that consolidates <cap>'s spec — merge
593
+ what says the same thing, remove what was superseded
594
+ (remove-requirement / remove-criterion) — with its own
595
+ playbook; no prompt needed, no behaviour changes
543
596
  --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
544
597
  playbook that skips the spec-delta steps (F9)
598
+ --design Also scaffold design.md, for a change with non-trivial
599
+ choices (opt-in, as with \`change new --design\`)
545
600
  --force Open a new change even when one is open, overwrite an
546
601
  existing change folder, and proceed past the lane hold
547
602
  below
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ import { parseArgs } from "./lib/args.js";
5
5
  import { c } from "./lib/colors.js";
6
6
  import { suggest } from "./lib/suggest.js";
7
7
  import { cliVersion } from "./lib/version.js";
8
- import { surfaceHelp, OPERATIONS, deprecationFor } from "./lib/commands.js";
8
+ import { surfaceHelp, OPERATIONS, deprecationFor, removalFor } from "./lib/commands.js";
9
9
  import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
10
10
  import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
11
11
  import { recordUsage, operationOf } from "./lib/usage.js";
@@ -37,7 +37,6 @@ import * as close from "./commands/close.js";
37
37
  import * as review from "./commands/review.js";
38
38
  import * as watch from "./commands/watch.js";
39
39
  import * as why from "./commands/why.js";
40
- import * as constitution from "./commands/constitution.js";
41
40
  import * as prime from "./commands/prime.js";
42
41
  import * as handoff from "./commands/handoff.js";
43
42
  import * as show from "./commands/show.js";
@@ -54,15 +53,14 @@ const COMMANDS = {
54
53
  init, spec, change, decision, validate, hooks, analyze, clarify,
55
54
  templates, skill, index: indexCmd, next, metrics, context, search,
56
55
  intake, work, coverage, verify, contract, trace,
57
- status, close, review, watch, why, constitution,
56
+ status, close, review, watch, why,
58
57
  prime, handoff, show, doctor, report, completion,
59
58
  intent, upgrade, adapter, triage, ci,
60
59
  };
61
60
 
62
61
  const TOP_HELP = `
63
- Usage: doctrina <command> [options]
62
+ Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
64
63
 
65
- Commands:
66
64
  ${surfaceHelp()}
67
65
 
68
66
  Global flags:
@@ -112,6 +110,17 @@ async function main(argv) {
112
110
 
113
111
  const commandName = positional[0];
114
112
  const command = COMMANDS[commandName];
113
+ const removed = removalFor(positional);
114
+ if (removed) {
115
+ const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
116
+ console.error(c.red("error:") + ` \`doctrina ${name}\` was removed in ${removed.since}`);
117
+ console.error(c.gray("hint: ") + `use \`${removed.use}\` — it prints what the removed command printed`);
118
+ if (wantsJson(flags)) {
119
+ emitJson(operationName(positional), { stderr: [`error: \`doctrina ${name}\` was removed in ${removed.since}`, `hint: use \`${removed.use}\``] },
120
+ { ok: false, exitCode: EXIT.USAGE, args: positional.slice(1) });
121
+ }
122
+ return EXIT.USAGE;
123
+ }
115
124
  if (!command) {
116
125
  console.error(c.red("error:") + ` unknown command "${commandName}"`);
117
126
  const guess = suggest(commandName, Object.keys(COMMANDS));
@@ -265,5 +274,24 @@ main(process.argv.slice(2)).then((code) => {
265
274
  code,
266
275
  new Set(OPERATIONS.map((o) => o[0])),
267
276
  );
268
- process.exit(code ?? 0);
277
+ // SET THE CODE; DO NOT CALL process.exit().
278
+ //
279
+ // When stdout is a PIPE — every `doctrina ... | less`, every agent reading
280
+ // this CLI, every `spawnSync` in the suite — Node may not have handed the
281
+ // bytes to the OS yet when this callback runs. `process.exit()` tears the
282
+ // process down immediately and whatever is still buffered is simply lost.
283
+ // Setting `exitCode` lets Node exit on its own once the work is done, and
284
+ // flushing stdout is part of that work.
285
+ //
286
+ // This is not theoretical. Four tests failed on macOS with Node 20.12 and
287
+ // on no other leg of the matrix, all of them `--concat` runs looking for
288
+ // content near the END of a long output. The decisive one printed exactly
289
+ // one section: `product.md` was on disk and in the pack listing, and the
290
+ // concat output stopped before reaching it. Pipe buffer sizes differ per
291
+ // platform, which is precisely why a truncation bug picks one and hides on
292
+ // the others.
293
+ //
294
+ // `--concat` even carries the comment "Keep the pack pipeable". Losing the
295
+ // tail of a pipe is the one way to break that promise.
296
+ process.exitCode = code ?? 0;
269
297
  });
@@ -155,6 +155,34 @@ export function computeActions(projectRoot, gates = {}) {
155
155
  }));
156
156
  }
157
157
 
158
+ // Index drift comes right after the intake (change 0193). It used to be
159
+ // surfaced LAST as "silent rot" — but `validate` reports it as an error,
160
+ // the close's index-drift step blocks on it, the fix is one runnable
161
+ // command, and the actions below read the very index that drifted. Ranked
162
+ // fifth, under advisory nudges, it told the agent to polish product.md
163
+ // before running the one command that unblocked everything.
164
+ try {
165
+ const current = idx.load(projectRoot);
166
+ if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
167
+ actions.push(action({
168
+ id: "index-drift",
169
+ command: "index rebuild",
170
+ gate: "index",
171
+ runnable: true,
172
+ why: "index.json has drifted from the tree",
173
+ }));
174
+ }
175
+ } catch {
176
+ actions.push(action({
177
+ id: "index-unreadable",
178
+ command: "index rebuild",
179
+ gate: "index",
180
+ runnable: true,
181
+ why: "index.json is missing or unreadable",
182
+ }));
183
+ }
184
+
185
+
158
186
  // Open changes drive the loop: finish what is started before opening more.
159
187
  const changesDir = path.join(projectRoot, ".doctrina", "changes");
160
188
  if (isDir(changesDir)) {
@@ -171,9 +199,13 @@ export function computeActions(projectRoot, gates = {}) {
171
199
  }
172
200
  const status = listHeader(read(proposalPath), "Status") ?? "proposed";
173
201
  if (status === "applied") {
202
+ // The close, not the archive (change 0201). 0.17 retired the
203
+ // hand-driven tail — `close` skips the apply it already did and
204
+ // still runs verify, coverage and the docs gate — but this line kept
205
+ // teaching `change archive`, the one path past every one of them.
174
206
  actions.push(action({
175
207
  id: "change-archive-pending",
176
- command: "change archive",
208
+ command: "close",
177
209
  args: [id],
178
210
  gate: "archive",
179
211
  runnable: true,
@@ -200,22 +232,22 @@ export function computeActions(projectRoot, gates = {}) {
200
232
  if (deltas.length > 0) {
201
233
  actions.push(action({
202
234
  id: "change-apply-ready",
203
- command: "analyze",
235
+ command: "change check",
204
236
  args: [id],
205
237
  gate: "structure",
206
238
  runnable: true,
207
239
  why: `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
208
- text: `doctrina analyze ${id}, then doctrina change apply ${id} — ` +
240
+ text: `doctrina change check ${id}, then doctrina close ${id} — ` +
209
241
  `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
210
242
  }));
211
243
  } else {
212
244
  actions.push(action({
213
245
  id: "change-deltas-missing",
214
- command: "change apply",
246
+ command: "close",
215
247
  args: [id],
216
- why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
248
+ why: `add spec deltas under .doctrina/changes/${id}/specs/, or close it as metadata-only`,
217
249
  text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
218
- `or apply as metadata-only: doctrina change apply ${id}`,
250
+ `or close it as metadata-only: doctrina close ${id}`,
219
251
  }));
220
252
  }
221
253
  }
@@ -377,28 +409,6 @@ export function computeActions(projectRoot, gates = {}) {
377
409
  }));
378
410
  }
379
411
 
380
- // Index drift is silent rot; surface it last.
381
- try {
382
- const current = idx.load(projectRoot);
383
- if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
384
- actions.push(action({
385
- id: "index-drift",
386
- command: "index rebuild",
387
- gate: "index",
388
- runnable: true,
389
- why: "index.json has drifted from the tree",
390
- }));
391
- }
392
- } catch {
393
- actions.push(action({
394
- id: "index-unreadable",
395
- command: "index rebuild",
396
- gate: "index",
397
- runnable: true,
398
- why: "index.json is missing or unreadable",
399
- }));
400
- }
401
-
402
412
  return actions;
403
413
  }
404
414