doctrina-cli 0.12.0 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -41,6 +41,16 @@ export async function run(positional, _flags) {
41
41
  } else {
42
42
  results.push(pass("tasks.md present"));
43
43
  const text = read(tasksPath);
44
+ // Scaffold placeholders never replaced ("- [ ]" with no text, checked or
45
+ // not) mean the change was OPENED but never PLANNED — the agent went
46
+ // straight to implementing on a hollow change and nothing barked
47
+ // (operator report 2026-07-19). A hard failure here blocks `change
48
+ // check` and `close` until the plan is real; ticking an empty box does
49
+ // not help, `change tick` refuses those too.
50
+ const placeholders = (text.match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
51
+ if (placeholders > 0) {
52
+ results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
53
+ }
44
54
  if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
45
55
  else results.push(info("tasks.md has no unchecked tasks (already done?)"));
46
56
  }
@@ -1,17 +1,18 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
3
+ import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
4
4
  import { diffLines, formatUnified } from "../lib/diff.js";
5
5
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
6
6
  import * as idx from "../lib/index-json.js";
7
7
  import { deriveIndex } from "../lib/scan.js";
8
8
  import { extractOps, applyOps } from "../lib/spec-ops.js";
9
+ import { printAdrCheckpoint } from "../lib/adr-guard.js";
9
10
  import { today } from "../lib/dates.js";
10
11
  import { flagBool, flagString } from "../lib/args.js";
11
12
  import { c } from "../lib/colors.js";
12
13
  import { suggest } from "../lib/suggest.js";
13
14
 
14
- const SUBCOMMANDS = ["new", "apply", "archive", "diff", "abandon"];
15
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
15
16
 
16
17
  export async function run(positional, flags) {
17
18
  const sub = positional[0];
@@ -19,9 +20,13 @@ export async function run(positional, flags) {
19
20
  case "new":
20
21
  return changeNew(positional.slice(1), flags);
21
22
  case "apply":
22
- return changeApply(positional.slice(1), flags);
23
+ return forEachId(positional.slice(1), "apply", (id) => changeApply([id], flags));
23
24
  case "archive":
24
- return changeArchive(positional.slice(1), flags);
25
+ return forEachId(positional.slice(1), "archive", (id) => changeArchive([id], flags));
26
+ case "check":
27
+ return forEachId(positional.slice(1), "check", (id) => changeCheck(id));
28
+ case "tick":
29
+ return changeTick(positional.slice(1), flags);
25
30
  case "diff":
26
31
  return changeDiff(positional.slice(1), flags);
27
32
  case "abandon":
@@ -36,6 +41,40 @@ export async function run(positional, flags) {
36
41
  }
37
42
  }
38
43
 
44
+ // Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
45
+ // accept multiple ids so a backlog closes without a bash loop. Each id runs
46
+ // independently — one failure does not stop the rest — and the exit code is
47
+ // the worst one seen. `new` and `abandon` stay single-id on purpose (creation
48
+ // wants a title; abandonment is destructive and deserves one deliberate call).
49
+ async function forEachId(ids, name, one) {
50
+ if (ids.length === 0) {
51
+ console.error(c.red("error:") + ` change ${name} requires at least one <id>`);
52
+ return 2;
53
+ }
54
+ let worst = 0;
55
+ for (const id of ids) {
56
+ if (ids.length > 1) {
57
+ console.log("");
58
+ console.log(c.bold(`──── change ${name} ${id}`));
59
+ }
60
+ let code;
61
+ try {
62
+ code = await one(id);
63
+ } catch (err) {
64
+ console.error(c.red("error:") + ` ${err.message}`);
65
+ code = 1;
66
+ }
67
+ worst = Math.max(worst, code);
68
+ }
69
+ if (ids.length > 1) {
70
+ console.log("");
71
+ console.log(worst === 0
72
+ ? c.green("ok") + ` all ${ids.length} changes passed ${name}`
73
+ : c.red("fail") + ` at least one change failed ${name}`);
74
+ }
75
+ return worst;
76
+ }
77
+
39
78
  function changeNew(args, flags) {
40
79
  const id = args[0];
41
80
  const title = args.slice(1).join(" ").trim();
@@ -246,6 +285,191 @@ function changeApply(args, _flags) {
246
285
  return errors > 0 ? 1 : 0;
247
286
  }
248
287
 
288
+ // Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
289
+ // apply, and archive would refuse, listed BEFORE any of them runs, with the
290
+ // remediation next to each finding. Read-only — the per-change `doctor`.
291
+ async function changeCheck(id) {
292
+ const projectRoot = process.cwd();
293
+ ensureDoctrinaProject(projectRoot);
294
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
295
+ if (!isDir(changeDir)) {
296
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
297
+ return 1;
298
+ }
299
+
300
+ let failures = 0;
301
+
302
+ // 1. Structural analysis — same checks analyze runs before an apply.
303
+ console.log(c.gray("──── 1/3 structure (analyze)"));
304
+ const analyze = await import("./analyze.js");
305
+ if ((await analyze.run([id], new Map())) !== 0) failures += 1;
306
+
307
+ // 2. Ops dry-run: execute every MODIFIED delta's ops block against the
308
+ // target spec in memory. An op that would fail at apply time (missing
309
+ // header, no such criterion, unknown verb) is reported here, not days
310
+ // later; a delta with no ops block is flagged as a manual merge so the
311
+ // close is planned around it instead of surprised by it.
312
+ console.log(c.gray("──── 2/3 ops dry-run (what apply would do)"));
313
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
314
+ let opsFindings = 0;
315
+ for (const deltaPath of deltaFiles) {
316
+ const text = read(deltaPath);
317
+ if (parseOperation(text) !== "MODIFIED") continue;
318
+ const capability = parseCapabilityFromDelta(text, deltaPath);
319
+ const rel = relPath(projectRoot, deltaPath);
320
+ const ops = extractOps(text);
321
+ if (ops.length === 0) {
322
+ console.log(c.yellow("⚠ ") + `${rel}: no ops block — apply will print a manual-merge pointer`);
323
+ continue;
324
+ }
325
+ const targetPath = path.join(projectRoot, ".doctrina", "specs", capability ?? "", "spec.md");
326
+ if (!capability || !exists(targetPath)) {
327
+ console.log(c.red("✗ ") + `${rel}: MODIFIED targets missing spec`);
328
+ opsFindings += 1;
329
+ continue;
330
+ }
331
+ const result = applyOps(read(targetPath), ops);
332
+ if (result.errors.length > 0) {
333
+ console.log(c.red("✗ ") + `${rel}: ${result.errors.length} op error${result.errors.length === 1 ? "" : "s"} (apply would refuse):`);
334
+ for (const e of result.errors) console.log(` - ${e}`);
335
+ opsFindings += 1;
336
+ } else {
337
+ console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
338
+ }
339
+ }
340
+ if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
341
+ if (opsFindings > 0) failures += 1;
342
+
343
+ // 3. Archive gate preview: what archive will refuse, listed with the fix.
344
+ console.log(c.gray("──── 3/3 archive gate"));
345
+ const blockers = collectArchiveBlockers(changeDir);
346
+ if (blockers.length === 0) {
347
+ console.log(c.green("✓ ") + "archive gate clear (all boxes checked)");
348
+ } else {
349
+ failures += 1;
350
+ for (const b of blockers) console.log(c.red("✗ ") + b);
351
+ console.log(c.gray(" check them off as they land, or in bulk: ") + c.cyan(`doctrina change tick ${id} --all`));
352
+ }
353
+
354
+ // Advisory (never gates): accepted ADRs citing the touched capabilities.
355
+ const caps = [...new Set(deltaFiles.map((p) => parseCapabilityFromDelta(read(p), p)).filter(Boolean))].sort();
356
+ console.log("");
357
+ printAdrCheckpoint(projectRoot, caps, { c });
358
+
359
+ console.log("");
360
+ if (failures === 0) {
361
+ console.log(c.green("ok") + ` ${id} is ready to close: ` + c.cyan(`doctrina close ${id}`));
362
+ return 0;
363
+ }
364
+ console.log(c.red("fail") + ` ${failures} area${failures === 1 ? "" : "s"} would block the close — fix them first`);
365
+ return 1;
366
+ }
367
+
368
+ // Bulk checkbox marking (operator review 2026-07-19 §3.5: "marcar checkbox
369
+ // não tem comando nenhum" — it was sed/Python by hand). With no args it
370
+ // LISTS the unchecked boxes with ordinals; `tick <id> 2 5` checks those;
371
+ // `tick <id> --all` checks everything (tasks.md + the proposal's
372
+ // ## Verification). Ticking is a claim of completion — the honest gate is
373
+ // still archive/verify; this only removes the mechanical friction.
374
+ function changeTick(args, flags) {
375
+ const id = args[0];
376
+ if (!id) {
377
+ console.error(c.red("error:") + " change tick requires <id> [ordinals... | --all]");
378
+ return 2;
379
+ }
380
+ const projectRoot = process.cwd();
381
+ ensureDoctrinaProject(projectRoot);
382
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
383
+ if (!isDir(changeDir)) {
384
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
385
+ return 1;
386
+ }
387
+
388
+ // Unified ordinal space over both files, in reading order: every unchecked
389
+ // `- [ ]` in tasks.md, then in the proposal's ## Verification section.
390
+ const files = [
391
+ { label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
392
+ { label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
393
+ ];
394
+ const boxes = [];
395
+ for (const f of files) {
396
+ if (!isFile(f.path)) continue;
397
+ const lines = read(f.path).split(/\r?\n/);
398
+ const inScope = f.section === null
399
+ ? () => true
400
+ : (() => {
401
+ const scoped = new Set();
402
+ let inSection = false;
403
+ for (let i = 0; i < lines.length; i++) {
404
+ if (/^##\s+/.test(lines[i])) inSection = new RegExp(`^##\\s+${f.section}\\b`, "i").test(lines[i]);
405
+ else if (inSection) scoped.add(i);
406
+ }
407
+ return (i) => scoped.has(i);
408
+ })();
409
+ for (let i = 0; i < lines.length; i++) {
410
+ if (/^\s*-\s*\[ \]/.test(lines[i]) && inScope(i)) {
411
+ boxes.push({ file: f, lineIndex: i, text: lines[i].replace(/^\s*-\s*\[ \]\s*/, "") });
412
+ }
413
+ }
414
+ }
415
+
416
+ if (boxes.length === 0) {
417
+ console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
418
+ return 0;
419
+ }
420
+
421
+ const all = flagBool(flags, "all", false);
422
+ const ordinals = args.slice(1).map(Number);
423
+ const isPlaceholder = (box) => box.text.trim() === "";
424
+ if (!all && ordinals.length === 0) {
425
+ console.log(c.bold(`Unchecked boxes in ${id}:`));
426
+ console.log("");
427
+ for (let i = 0; i < boxes.length; i++) {
428
+ const label = isPlaceholder(boxes[i])
429
+ ? c.yellow("(scaffold placeholder — write the real task, or delete the line)")
430
+ : boxes[i].text;
431
+ console.log(` ${String(i + 1).padStart(3)}. ${label} ${c.gray(`[${boxes[i].file.label}]`)}`);
432
+ }
433
+ console.log("");
434
+ console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} 1 3`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
435
+ return 0;
436
+ }
437
+
438
+ const picked = all ? boxes.map((_, i) => i + 1) : ordinals;
439
+ for (const n of picked) {
440
+ if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
441
+ console.error(c.red("error:") + ` no box #${n} (1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
442
+ return 2;
443
+ }
444
+ }
445
+ // Ticking an empty scaffold placeholder is a meaningless claim — it is how
446
+ // a hollow change games the archive gate. Refuse (all-or-nothing) and name
447
+ // the fix; analyze/close hard-fail on the placeholders regardless.
448
+ const empties = picked.filter((n) => isPlaceholder(boxes[n - 1]));
449
+ if (empties.length > 0) {
450
+ console.error(c.red("error:") + ` box${empties.length === 1 ? "" : "es"} ${empties.join(", ")} ${empties.length === 1 ? "is a" : "are"} scaffold placeholder${empties.length === 1 ? "" : "s"} with no text — nothing was ticked`);
451
+ console.error(c.gray("hint: ") + "write the real task on each line (or delete it), then tick");
452
+ return 2;
453
+ }
454
+
455
+ // Group edits per file so each file is read/written once.
456
+ const byPath = new Map();
457
+ for (const n of picked) {
458
+ const box = boxes[n - 1];
459
+ if (!byPath.has(box.file.path)) byPath.set(box.file.path, []);
460
+ byPath.get(box.file.path).push(box);
461
+ }
462
+ for (const [filePath, hits] of byPath) {
463
+ const lines = read(filePath).split(/\r?\n/);
464
+ for (const box of hits) {
465
+ lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
466
+ }
467
+ write(filePath, lines.join("\n"), { force: true });
468
+ }
469
+ console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
470
+ return 0;
471
+ }
472
+
249
473
  function changeArchive(args, flags) {
250
474
  const id = args[0];
251
475
  if (!id) {
@@ -589,7 +813,7 @@ Subcommands:
589
813
  (--chore / --no-spec: a spec-less change for infra /
590
814
  docs / build that still gets a proposal + ledger;
591
815
  --design: also scaffold design.md, opt-in)
592
- apply <id> Apply spec deltas: ADDED writes the full body (it
816
+ apply <id...> Apply spec deltas: ADDED writes the full body (it
593
817
  also REPLACES a target that is still the untouched
594
818
  \`spec new\` scaffold — the canonical new-capability
595
819
  flow; real content refuses), REMOVED deletes,
@@ -597,19 +821,34 @@ Subcommands:
597
821
  (set-header / bump-version / set-criterion / ...),
598
822
  MODIFIED without one prints a manual-merge pointer.
599
823
  On any spec write the index is rebuilt from the tree.
600
- archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
824
+ archive <id...> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
825
+ check <id...> Pre-close dry-run, read-only: analyze's structural
826
+ checks + every ops block executed in memory against
827
+ its target + the archive gate preview + an advisory
828
+ list of accepted ADRs citing the touched capabilities.
829
+ Everything close would refuse, listed BEFORE it runs.
830
+ tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
831
+ ## Verification) with ordinals; tick the given ones,
832
+ or every one with --all. No args = list only.
601
833
  abandon <id> Delete an open change folder and its index entry, and
602
834
  record the abandonment in the ledger ([--reason "..."]).
603
835
  diff <id> Preview every spec delta: line diff for MODIFIED,
604
836
  summary for ADDED/REMOVED. Read-only.
605
837
 
838
+ apply / archive / check accept multiple ids (batch close of a backlog); the
839
+ exit code is the worst per-id result.
840
+
606
841
  A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
607
842
  \`\`\`ops
608
843
  set-header Implementation: verified — durable adapter (\`src/db.ts\`)
609
844
  bump-version minor
610
845
  set-criterion 1: verified
611
846
  append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
847
+ append-requirement event: When <trigger>, the system shall <action>.
848
+ replace-requirement ubiquitous 2: The system shall <action>.
612
849
  \`\`\`
850
+ append-* ops resolve numbering/position at apply time, so concurrent open
851
+ changes appending to the same spec cannot collide.
613
852
 
614
853
  Options:
615
854
  --force Overwrite existing files (where applicable)
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
5
+ import { flagBool, flagString } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
7
 
8
8
  // Two lexicons, one linter (0.11.0 field review item 3: an English-only
@@ -78,16 +78,30 @@ function detectLang(text) {
78
78
  return pt > en ? "pt" : "en";
79
79
  }
80
80
 
81
- function rulesFor(projectRoot, text) {
82
- const declared = projectLanguage(projectRoot);
83
- const lang = declared ?? detectLang(text);
81
+ function rulesFor(projectRoot, text, forcedLang = null) {
82
+ const lang = forcedLang ?? projectLanguage(projectRoot) ?? detectLang(text);
84
83
  return lang === "pt" ? RULES_PT : RULES_EN;
85
84
  }
86
85
 
86
+ // --lang pt|en wins over the project config and the per-file heuristic
87
+ // (operator review 2026-07-19 §4.8: on a PT-BR project with no config.json,
88
+ // a file whose stopword count tips English gets the EN lexicon and "some" —
89
+ // the verb *sumir* — is flagged as the vague quantifier). One flag, zero
90
+ // guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
91
+ function forcedLang(flags) {
92
+ const raw = flagString(flags, "lang");
93
+ if (!raw || raw === true) return null;
94
+ const lang = String(raw).toLowerCase();
95
+ if (lang.startsWith("pt")) return "pt";
96
+ if (lang.startsWith("en")) return "en";
97
+ console.error(c.yellow("warn:") + ` unknown --lang "${raw}" (use pt|en) — falling back to detection`);
98
+ return null;
99
+ }
100
+
87
101
  export async function run(positional, flags) {
88
102
  const projectRoot = process.cwd();
89
103
 
90
- if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
104
+ if (flagBool(flags, "all", false)) return clarifyAll(projectRoot, forcedLang(flags));
91
105
 
92
106
  const target = positional[0];
93
107
  if (!target) {
@@ -103,7 +117,7 @@ export async function run(positional, flags) {
103
117
  console.log(`clarify ${relPath(projectRoot, fullPath)}`);
104
118
  console.log("");
105
119
 
106
- const smells = scanFile(fullPath);
120
+ const smells = scanFile(fullPath, projectRoot, forcedLang(flags));
107
121
  for (const s of smells) {
108
122
  console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
109
123
  }
@@ -122,7 +136,7 @@ export async function run(positional, flags) {
122
136
  // and clarify each. ADRs are immutable and the archive is history, so
123
137
  // neither is included; AGENTS.md is dense command text, a different
124
138
  // register from prose specs.
125
- function clarifyAll(projectRoot) {
139
+ function clarifyAll(projectRoot, lang = null) {
126
140
  if (!exists(path.join(projectRoot, ".doctrina"))) {
127
141
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
128
142
  }
@@ -149,7 +163,7 @@ function clarifyAll(projectRoot) {
149
163
 
150
164
  let total = 0;
151
165
  for (const f of files) {
152
- const smells = scanFile(f);
166
+ const smells = scanFile(f, projectRoot, lang);
153
167
  if (smells.length === 0) continue;
154
168
  total += smells.length;
155
169
  const rel = relPath(projectRoot, f);
@@ -167,10 +181,10 @@ function clarifyAll(projectRoot) {
167
181
  return 1;
168
182
  }
169
183
 
170
- function scanFile(fullPath, projectRoot = process.cwd()) {
184
+ function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
171
185
  const text = read(fullPath);
172
186
  const skippedRanges = collectSkippedRanges(text);
173
- const rules = rulesFor(projectRoot, text);
187
+ const rules = rulesFor(projectRoot, text, lang);
174
188
  const smells = [];
175
189
 
176
190
  // Per-line rules: regex pass with skip-range filter. A line carrying the
@@ -257,20 +271,20 @@ function findAcceptanceLine(text) {
257
271
  }
258
272
 
259
273
  export const help = `
260
- Usage: doctrina clarify <path>
261
- doctrina clarify --all
274
+ Usage: doctrina clarify <path> [--lang pt|en]
275
+ doctrina clarify --all [--lang pt|en]
262
276
 
263
277
  Read a Markdown file and report weasel words, vague quantifiers,
264
278
  placeholders, and empty Acceptance criteria sections. Exits 0 when
265
279
  no smells are found, 1 otherwise. Matches inside fenced code
266
280
  blocks, HTML comments, and inline backtick spans are skipped.
267
281
 
268
- Language-aware: declare it in .doctrina/config.json
269
- ({ "language": "pt-BR" }) or let a per-file stopword count decide.
270
- Portuguese mode uses a PT lexicon (bare TODO is the pronoun, only
271
- "TODO:" is a marker; "some"/"several" are not smells). A line ending
272
- in <!-- clarify:ok --> is author-accepted and never flagged — the
273
- escape hatch for a false positive.
282
+ Language-aware: --lang pt|en forces the lexicon; otherwise
283
+ .doctrina/config.json ({ "language": "pt-BR" }) decides, else a
284
+ per-file stopword count. Portuguese mode uses a PT lexicon (bare
285
+ TODO is the pronoun, only "TODO:" is a marker; "some"/"several" are
286
+ not smells). A line ending in <!-- clarify:ok --> is author-accepted
287
+ and never flagged — the escape hatch for a false positive.
274
288
 
275
289
  With --all, every living document is scanned in one pass:
276
290
  product.md, capability specs, open changes, and skills. ADRs
@@ -4,12 +4,14 @@ import { exists, isFile, read, walk } from "../lib/fs-ops.js";
4
4
  import { flagBool } from "../lib/args.js";
5
5
  import { c } from "../lib/colors.js";
6
6
  import { parseCapabilityFromDelta } from "./change.js";
7
+ import { printAdrCheckpoint } from "../lib/adr-guard.js";
7
8
  import * as analyze from "./analyze.js";
8
9
  import * as change from "./change.js";
9
10
  import * as verify from "./verify.js";
10
11
  import * as coverage from "./coverage.js";
11
12
  import * as trace from "./trace.js";
12
13
  import * as validate from "./validate.js";
14
+ import * as skill from "./skill.js";
13
15
 
14
16
  // One-command close (review 2026-06-27 passive-user feature #2). The work
15
17
  // playbook lists the closing sequence — analyze → apply → verify → coverage →
@@ -20,8 +22,7 @@ import * as validate from "./validate.js";
20
22
  // driver over the existing commands; it adds no new checks of its own.
21
23
 
22
24
  export async function run(positional, flags) {
23
- const id = positional[0];
24
- if (!id) {
25
+ if (positional.length === 0) {
25
26
  console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
26
27
  return 2;
27
28
  }
@@ -30,6 +31,27 @@ export async function run(positional, flags) {
30
31
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
31
32
  }
32
33
 
34
+ // Batch close (operator review 2026-07-19 §4.5): each id closes
35
+ // independently, worst exit code wins, so a 9-change implementation lot is
36
+ // one command, not nine.
37
+ let worst = 0;
38
+ for (const id of positional) {
39
+ if (positional.length > 1) {
40
+ console.log("");
41
+ console.log(c.bold(`════ close ${id}`));
42
+ }
43
+ worst = Math.max(worst, await closeOne(projectRoot, id, flags));
44
+ }
45
+ if (positional.length > 1) {
46
+ console.log("");
47
+ console.log(worst === 0
48
+ ? c.green("ok") + ` all ${positional.length} changes closed`
49
+ : c.red("fail") + " at least one close stopped — see above");
50
+ }
51
+ return worst;
52
+ }
53
+
54
+ async function closeOne(projectRoot, id, flags) {
33
55
  const force = flagBool(flags, "force", false);
34
56
  const archiveFlags = force ? new Map([["force", true]]) : new Map();
35
57
 
@@ -52,6 +74,20 @@ export async function run(positional, flags) {
52
74
  const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
53
75
  const steps = [
54
76
  { label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
77
+ // ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
78
+ // step was skippable in silence. Advisory — it names the accepted ADRs
79
+ // whose text cites the touched capabilities and the amend commands, but
80
+ // an ADR merely mentioning a capability is normal, so it never blocks.
81
+ {
82
+ label: "ADR checkpoint (advisory)",
83
+ rerun: "doctrina decision list",
84
+ run: async () => {
85
+ if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
86
+ console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
87
+ }
88
+ return 0;
89
+ },
90
+ },
55
91
  { label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
56
92
  verifyConfigured
57
93
  ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
@@ -68,7 +104,7 @@ export async function run(positional, flags) {
68
104
  { label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
69
105
  ];
70
106
 
71
- console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
107
+ console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → archive → validate"));
72
108
 
73
109
  for (let i = 0; i < steps.length; i++) {
74
110
  const step = steps[i];
@@ -95,6 +131,16 @@ export async function run(positional, flags) {
95
131
  }
96
132
  }
97
133
 
134
+ // Capture the lesson while it is fresh (operator review §4.7): surface the
135
+ // fix-shaped lessons not yet written as skills, right where the operator
136
+ // just proved they finish what they start. Advisory — a listing, never a
137
+ // gate, and a suggest failure never un-closes the change.
138
+ console.log("");
139
+ console.log(c.gray("──── skills (advisory)"));
140
+ try {
141
+ await skill.run(["suggest"], new Map());
142
+ } catch { /* advisory only */ }
143
+
98
144
  console.log("");
99
145
  console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
100
146
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
@@ -115,21 +161,28 @@ function touchedCapabilities(projectRoot, id) {
115
161
  }
116
162
 
117
163
  export const help = `
118
- Usage: doctrina close <id> [--force]
164
+ Usage: doctrina close <id...> [--force]
119
165
 
120
166
  Run the whole closing sequence for a change in one pass, stopping at the
121
167
  first failure with the exact command to rerun:
122
168
 
123
- analyze → change apply → verify → coverage --strict (scoped to the
124
- change's touched capabilities) → trace → change archive → validate
169
+ analyze → ADR checkpoint (advisory) → change apply → verify → coverage
170
+ --strict (scoped to the change's touched capabilities) → trace → change
171
+ archive → validate → skill suggest (advisory)
125
172
 
126
173
  The coverage gate is scoped to the capabilities the change's deltas touch,
127
174
  so a deliberately deferred spec elsewhere cannot block an unrelated close;
128
175
  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.
176
+ note) when no .doctrina/verify.json is declared; trace, the ADR checkpoint
177
+ (accepted ADRs citing the touched capabilities — amend via \`decision
178
+ supersede\`, not silence), and the closing skill-suggest listing are
179
+ advisory (reports, never blockers). This is a driver over the existing
180
+ commands so the agent makes one call instead of nine and the human
181
+ approves once.
182
+
183
+ Multiple ids close in sequence, each independently; the exit code is the
184
+ worst per-id result. Preview what close would refuse: \`doctrina change
185
+ check <id>\`.
133
186
 
134
187
  Options:
135
188
  --force Pass through to \`change archive\` (archive even if verification
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
4
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
5
+ import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
5
6
  import { today } from "../lib/dates.js";
6
7
  import { cliVersion } from "../lib/version.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
@@ -108,10 +109,13 @@ export async function run(_positional, flags) {
108
109
  FRAMEWORK_VERSION: cliVersion(),
109
110
  };
110
111
 
111
- // Root AGENTS.md — with optional --from base content prepended.
112
+ // Root AGENTS.md — with optional --from base content prepended. The
113
+ // doctrina:surface block is regenerated from the installed catalog at init
114
+ // time, so a freshly scaffolded hub can never document a stale surface even
115
+ // if the static template lags a release (ADR 0015).
112
116
  const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
113
117
  const agentsTemplateBody = read(agentsTemplatePath);
114
- const projectAgents = substitute(agentsTemplateBody, tokens);
118
+ const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
115
119
  const finalAgents = fromAgentsContent
116
120
  ? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
117
121
  : projectAgents;
@@ -178,6 +182,15 @@ export async function run(_positional, flags) {
178
182
  return 0;
179
183
  }
180
184
 
185
+ // Replace the template's doctrina:surface span with the one generated from
186
+ // the running CLI's catalog; pass the text through untouched when the
187
+ // template carries no markers (older custom templates).
188
+ function refreshSurfaceBlock(text) {
189
+ const block = findSurfaceBlock(text);
190
+ if (!block) return text;
191
+ return text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
192
+ }
193
+
181
194
  // First non-empty line of the intake, stripped of Markdown heading marks
182
195
  // and clipped, as the one-line description token for the templates.
183
196
  function firstLineSummary(text) {