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.
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
4
- import { locateTemplatesDir } from "../lib/templates.js";
4
+ import { locateTemplatesDir, loadTemplateTree } from "../lib/templates.js";
5
+ import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
5
6
  import { flagBool } from "../lib/args.js";
6
7
  import { c } from "../lib/colors.js";
7
8
  import { suggest } from "../lib/suggest.js";
@@ -56,6 +57,50 @@ function updateTemplates(flags) {
56
57
 
57
58
  const plan = [];
58
59
 
60
+ // AGENTS.md command-surface block: the one CLI-OWNED span of the file
61
+ // (marker-delimited, generated from the command catalog). Three cases:
62
+ // markers present but stale -> regenerate the span; a legacy hand-written
63
+ // "## Doctrina command surface" section without markers -> replace that
64
+ // section with the managed block (the section was CLI-scaffolded content
65
+ // to begin with — this is the one sanctioned exception to additive-only,
66
+ // see ADR 0015); neither -> append the block. This is what makes
67
+ // `doctrina upgrade --write` actually refresh AGENTS.md when the CLI
68
+ // gains commands, instead of only bumping the stamp.
69
+ const agentsMdPath = path.join(projectRoot, "AGENTS.md");
70
+ if (isFile(agentsMdPath)) {
71
+ const text = read(agentsMdPath);
72
+ const block = findSurfaceBlock(text);
73
+ const fresh = surfaceBlock();
74
+ if (block) {
75
+ if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
76
+ plan.push({
77
+ describe: ["AGENTS.md: regenerate the doctrina:surface block (stale vs installed CLI)"],
78
+ apply() {
79
+ const cur = read(agentsMdPath);
80
+ const b = findSurfaceBlock(cur);
81
+ if (b) write(agentsMdPath, cur.slice(0, b.start) + fresh + cur.slice(b.end), { force: true });
82
+ },
83
+ });
84
+ }
85
+ } else {
86
+ const legacy = findLegacySurfaceSection(text);
87
+ plan.push({
88
+ describe: [legacy
89
+ ? "AGENTS.md: replace the hand-written \"## Doctrina command surface\" section with the generated doctrina:surface block"
90
+ : "AGENTS.md: append the generated doctrina:surface command block (agents discover commands through this file)"],
91
+ apply() {
92
+ const cur = read(agentsMdPath);
93
+ const l = findLegacySurfaceSection(cur);
94
+ if (l) {
95
+ write(agentsMdPath, cur.slice(0, l.start) + fresh + "\n\n" + cur.slice(l.end), { force: true });
96
+ } else {
97
+ write(agentsMdPath, cur + (cur.endsWith("\n") ? "" : "\n") + "\n" + fresh + "\n", { force: true });
98
+ }
99
+ },
100
+ });
101
+ }
102
+ }
103
+
59
104
  // Markdown files: append stub sections for missing recommended headings.
60
105
  for (const [rel, sections] of [
61
106
  ["AGENTS.md", AGENTS_SECTIONS],
@@ -178,6 +223,15 @@ function checkTemplates() {
178
223
  if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
179
224
  else findings.push(`AGENTS.md missing recommended section "${heading}"`);
180
225
  }
226
+ // Command-surface block: present and current vs the installed catalog.
227
+ const block = findSurfaceBlock(text);
228
+ if (!block) {
229
+ findings.push("AGENTS.md has no doctrina:surface block — agents discover commands through this file (`templates update --write` adds it)");
230
+ } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
231
+ findings.push("AGENTS.md doctrina:surface block is stale vs the installed CLI (`templates update --write` or `doctrina upgrade --write` regenerates it)");
232
+ } else {
233
+ ok.push("AGENTS.md: doctrina:surface block current");
234
+ }
181
235
  } else {
182
236
  findings.push("AGENTS.md missing at project root");
183
237
  }
@@ -194,6 +248,32 @@ function checkTemplates() {
194
248
  findings.push(".doctrina/product.md missing");
195
249
  }
196
250
 
251
+ // Installed agent adapters: every adapter is a thin POINTER at AGENTS.md
252
+ // (that is why one `upgrade --write` refresh of the hub reaches every
253
+ // installed agent). Verify the contract holds for each adapter file the
254
+ // project actually has: it must still reference AGENTS.md. The shipped
255
+ // adapter template tree is the inventory, so a new adapter is covered
256
+ // automatically.
257
+ try {
258
+ const templatesDir = locateTemplatesDir();
259
+ const adaptersRoot = path.join(templatesDir, "adapters");
260
+ if (isDir(adaptersRoot)) {
261
+ for (const agent of walk(adaptersRoot).map((f) => relPath(adaptersRoot, f))) {
262
+ const posix = agent.replace(/\\/g, "/");
263
+ if (posix.endsWith("README.md") || posix.endsWith(".gitkeep")) continue;
264
+ const rel = posix.split("/").slice(1).join("/").replace(/\.template$/, "");
265
+ if (!rel) continue;
266
+ const installed = path.join(projectRoot, rel);
267
+ if (!isFile(installed)) continue; // not installed — nothing to check
268
+ if (read(installed).includes("AGENTS.md")) {
269
+ ok.push(`adapter ${rel}: points at AGENTS.md`);
270
+ } else {
271
+ findings.push(`adapter ${rel} no longer references AGENTS.md — agents loading it will miss the hub (restore the pointer, e.g. re-run \`doctrina init --agent <name> --force\`)`);
272
+ }
273
+ }
274
+ }
275
+ } catch { /* no templates dir (unusual install) — skip the adapter pass */ }
276
+
197
277
  // index.json schema fields
198
278
  const indexPath = path.join(projectRoot, ".doctrina", "index.json");
199
279
  if (isFile(indexPath)) {
@@ -227,6 +307,24 @@ function checkTemplates() {
227
307
  return 1;
228
308
  }
229
309
 
310
+ // Whitespace/EOL-insensitive comparison for the surface block, so a CRLF
311
+ // checkout is not eternally "stale".
312
+ function normalizeBlock(s) {
313
+ return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
314
+ }
315
+
316
+ // A pre-marker AGENTS.md's hand-written surface section: from the
317
+ // "## Doctrina command surface" heading up to (excluding) the next "## "
318
+ // heading. The trailing "Continuous: ..." paragraph the old template put
319
+ // after the bullets belongs to the section body, so it is replaced too.
320
+ function findLegacySurfaceSection(text) {
321
+ const head = text.match(/^##\s+Doctrina command surface.*$/m);
322
+ if (!head) return null;
323
+ const after = text.slice(head.index + head[0].length);
324
+ const next = after.match(/^##\s+/m);
325
+ return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
326
+ }
327
+
230
328
  function hasHeading(text, heading) {
231
329
  // Match an exact h2 heading at line start (case-insensitive for the body
232
330
  // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
@@ -245,15 +343,20 @@ Subcommands:
245
343
  their relative paths and line counts.
246
344
  check Compare the current project's AGENTS.md, product.md,
247
345
  and index.json against the recommended sections and
248
- schema fields shipped in this CLI version. Reports any
249
- recommended section that is missing. Read-only; never
250
- modifies any files. Exits 1 when recommendations exist,
251
- 0 when the project follows the current template shape.
252
- update Additive-only fixer for what check reports: appends
253
- missing recommended sections as stubs and adds missing
254
- index.json fields. Preview by default (exits 1 while
255
- updates are pending); pass --write to apply. Never
256
- rewrites or removes existing content.
346
+ schema fields shipped in this CLI version — including
347
+ whether the AGENTS.md doctrina:surface block (the
348
+ CLI-owned, generated command catalog) is present and
349
+ current. Read-only; never modifies any files. Exits 1
350
+ when recommendations exist, 0 otherwise.
351
+ update Fixer for what check reports: appends missing
352
+ recommended sections as stubs, adds missing index.json
353
+ fields, and regenerates the AGENTS.md doctrina:surface
354
+ block from the installed command catalog. Preview by
355
+ default (exits 1 while updates are pending); pass
356
+ --write to apply. Additive-only outside the surface
357
+ block: the marker-delimited span is the one CLI-owned
358
+ region it rewrites (ADR 0015); your content is never
359
+ touched.
257
360
 
258
361
  Distinct from \`doctrina validate\`: validate answers "is this a
259
362
  well-formed Doctrina tree?"; templates check answers "does this
@@ -82,10 +82,12 @@ export async function run(_positional, flags) {
82
82
  }
83
83
  }
84
84
 
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.
85
+ // 4. Full structural check — surfaces what the upgrade cannot fix by
86
+ // itself (new validate checks the old scaffold fails, hand-authored
87
+ // drift). The command surface in AGENTS.md is NOT in this bucket any
88
+ // more: step 1 regenerates the doctrina:surface block from the
89
+ // installed catalog (ADR 0015). --fix under --write so the mechanical
90
+ // part self-heals.
89
91
  console.log("");
90
92
  console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
91
93
  const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
@@ -102,7 +104,7 @@ export async function run(_positional, flags) {
102
104
  }
103
105
  if (vCode === 0) {
104
106
  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."));
107
+ 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
108
  } else {
107
109
  console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
108
110
  }
@@ -116,14 +118,16 @@ Bring an existing project up to the installed CLI after an npm update.
116
118
  The project keeps the scaffold of the version that init-ed it; this is
117
119
  the one command that closes the gap, orchestrating the existing pieces:
118
120
 
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)
121
+ 1. templates update — regenerate the AGENTS.md doctrina:surface block
122
+ from the installed command catalog (the block is
123
+ CLI-owned, ADR 0015 — this is how agents reading
124
+ the hub discover commands added since init), and
125
+ append missing recommended sections / index.json
126
+ fields (additive-only outside the block)
122
127
  2. index rebuild — regenerate index.json from the tree and migrate
123
128
  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)
129
+ 3. validate (--fix) — surface anything the upgrade cannot fix
130
+ (hand-authored drift, new validate checks)
127
131
 
128
132
  Preview by default (exits 1 when steps are pending); --write applies.
129
133
  `;
@@ -254,7 +254,12 @@ export async function run(_positional, flags) {
254
254
  }
255
255
  }
256
256
 
257
- // 7. Every open change has a proposal
257
+ // 7. Every open change has a proposal, and its delta files carry a
258
+ // well-formed **Operation:** header. The delta used to be the one
259
+ // 100% hand-authored file with no early check: a missing header passed
260
+ // silently at creation and exploded days later in the closing analyze
261
+ // (operator review 2026-07-19 §3.2). Warning-level — the change is
262
+ // still a draft — but now every `validate` run names it.
258
263
  const changesDir = path.join(projectRoot, ".doctrina", "changes");
259
264
  if (isDir(changesDir)) {
260
265
  const { readdirSync } = await import("node:fs");
@@ -267,6 +272,35 @@ export async function run(_positional, flags) {
267
272
  `(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
268
273
  );
269
274
  }
275
+ // A change whose tasks are still the scaffold placeholders was opened
276
+ // but never planned — the agent is (or will be) implementing with no
277
+ // recorded plan. Early, every-run signal; analyze/close hard-fail it.
278
+ const tasksPath = path.join(changesDir, entry, "tasks.md");
279
+ if (isFile(tasksPath)) {
280
+ const ph = (read(tasksPath).match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
281
+ if (ph > 0) {
282
+ warnings.push(
283
+ `open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
284
+ `the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
285
+ );
286
+ }
287
+ }
288
+ for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
289
+ if (!deltaPath.endsWith("delta.md")) continue;
290
+ const text = read(deltaPath);
291
+ const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
292
+ if (!m) {
293
+ warnings.push(
294
+ `${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
295
+ `analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
296
+ );
297
+ } else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
298
+ warnings.push(
299
+ `${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
300
+ `analyze/apply will refuse it at close time`,
301
+ );
302
+ }
303
+ }
270
304
  }
271
305
  }
272
306
 
@@ -2,11 +2,12 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { spawnSync } from "node:child_process";
5
- import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
6
6
  import { flagString, flagBool } from "../lib/args.js";
7
7
  import * as idx from "../lib/index-json.js";
8
8
  import { c } from "../lib/colors.js";
9
9
  import { assessBrief } from "../lib/clarity.js";
10
+ import { locateTemplatesDir, substitute } from "../lib/templates.js";
10
11
  import { changeNew } from "./change.js";
11
12
 
12
13
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -106,6 +107,26 @@ export async function run(positional, flags) {
106
107
  if (updated !== txt) write(proposalPath, updated, { force: true });
107
108
  }
108
109
 
110
+ // A pinned capability is an explicit statement of intent, so the delta —
111
+ // historically the only 100% hand-authored file in the flow, and the one
112
+ // whose missing **Operation:** header exploded days later at analyze
113
+ // (operator review 2026-07-19 §3.2) — is scaffolded from the template with
114
+ // the Operation prefilled: MODIFIED when the spec exists, ADDED when it
115
+ // does not. Never scaffolded from a ranked GUESS (that would put a wrong
116
+ // capability's delta in the change); only from --capability.
117
+ if (pinned && !chore) {
118
+ const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", pinned, "delta.md");
119
+ if (!exists(deltaPath)) {
120
+ const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
121
+ const op = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md")) ? "MODIFIED" : "ADDED";
122
+ const body = substitute(tpl, { CAPABILITY: pinned })
123
+ .replace(/^\*\*Operation:\*\*.*$/m, `**Operation:** ${op}`);
124
+ mkdirp(path.dirname(deltaPath));
125
+ write(deltaPath, body);
126
+ console.log(c.green("created") + ` ${relPath(projectRoot, deltaPath)}` + c.gray(` (Operation: ${op} prefilled)`));
127
+ }
128
+ }
129
+
109
130
  // Capability hint: term overlap for a prompt, changed-file overlap for a diff
110
131
  // (review F10 — rank by what the working tree touched, not just prompt words).
111
132
  const allChanged = fromDiff ? files : changedFiles(projectRoot);
@@ -114,6 +135,16 @@ export async function run(positional, flags) {
114
135
  const capability = pinned ?? matches[0]?.id ?? null;
115
136
  const clarity = assessBrief(effPrompt, { kind: "prompt" });
116
137
 
138
+ // --quiet: registering backlog, not starting now (operator review §3.7 — 19
139
+ // works printed 19 identical 50-line playbooks). One line per change; the
140
+ // playbook is reprintable on demand with --resume.
141
+ if (flagBool(flags, "quiet", false)) {
142
+ console.log("");
143
+ console.log(c.green("opened ") + c.cyan(id) +
144
+ c.gray(` — playbook on demand: doctrina work --resume ${id}`));
145
+ return 0;
146
+ }
147
+
117
148
  console.log("");
118
149
  if (chore) {
119
150
  printChorePlaybook(projectRoot, { id, prompt: effPrompt });
@@ -339,6 +370,8 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
339
370
  console.log("");
340
371
  console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
341
372
  console.log(" with small, checkable steps; implement them, checking each box.");
373
+ console.log(c.gray(" (Do it before implementing — analyze/close refuse leftover scaffold"));
374
+ console.log(c.gray(" placeholders, and `change tick` will not tick an empty box.)"));
342
375
  console.log("2. Prove it (archive refuses unchecked boxes):");
343
376
  console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
344
377
  console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
@@ -406,8 +439,14 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
406
439
  console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
407
440
  console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
408
441
  console.log("");
409
- console.log("3. Write one delta per affected capability at");
410
- console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
442
+ if (pinned) {
443
+ console.log(`3. A delta is already scaffolded (Operation prefilled) at`);
444
+ console.log(` .doctrina/changes/${id}/specs/${pinned}/delta.md — fill its body.`);
445
+ console.log(" Add one more delta per additional affected capability:");
446
+ } else {
447
+ console.log("3. Write one delta per affected capability at");
448
+ console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
449
+ }
411
450
  console.log(c.gray(" # Spec Delta — capability: <capability>"));
412
451
  console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
413
452
  console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
@@ -423,8 +462,12 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
423
462
  console.log(c.gray(" bump-version minor"));
424
463
  console.log(c.gray(" set-criterion 1: verified"));
425
464
  console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
465
+ console.log(c.gray(" append-requirement event: When <trigger>, the system shall <action>."));
466
+ console.log(c.gray(" replace-requirement ubiquitous 2: The system shall <action>."));
426
467
  console.log(c.gray(" ```"));
427
- console.log(c.gray(" Only prose rewrites stay a by-hand merge."));
468
+ console.log(c.gray(" append-* ops number/position at APPLY time, so concurrent changes"));
469
+ console.log(c.gray(" appending to the same spec never collide. Only free-prose rewrites"));
470
+ console.log(c.gray(" (Purpose, Maturity, ...) stay a by-hand merge."));
428
471
  if (fromDiff) {
429
472
  console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
430
473
  console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
@@ -432,7 +475,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
432
475
  }
433
476
  console.log("");
434
477
  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).");
478
+ console.log(" with small, checkable implementation tasks (a few hours each, max),");
479
+ console.log(" and record the change's What/Scope in its proposal.md. Do this BEFORE");
480
+ console.log(" implementing — analyze and close refuse a change whose scaffold");
481
+ console.log(" placeholders were never replaced, and `change tick` will not tick them.");
436
482
  console.log("");
437
483
  console.log("5. Implement task by task, checking each box as it lands. Advance the");
438
484
  console.log(" spec's Implementation: planned → partial → implemented as code lands.");
@@ -442,6 +488,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
442
488
  console.log(" (an architecture, a boundary, a trade-off a future session must");
443
489
  console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
444
490
  console.log(" before closing — the definition of done requires it recorded.");
491
+ console.log(c.gray(" (close re-checks this: it warns when the change touches capabilities"));
492
+ console.log(c.gray(" cited by an accepted ADR — amend via decision supersede, not silence.)"));
493
+ console.log(" Touching an integration surface (ports, env vars, public endpoints)?");
494
+ console.log(` Own it in a contract: ${c.cyan("doctrina contract new <id>")} · ${c.cyan("doctrina contract check")}.`);
445
495
  console.log("");
446
496
  console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
447
497
  console.log(" at the first failure with the exact rerun command):");
@@ -476,7 +526,12 @@ named after that word (review G1). Use --resume to do so directly.
476
526
  Options:
477
527
  --title "<short>" Short display title: drives the slug and the proposal
478
528
  H1; the full prompt still lands under ## Why
479
- --capability <cap> Pin the capability instead of ranking matches
529
+ --capability <cap> Pin the capability instead of ranking matches. Also
530
+ scaffolds specs/<cap>/delta.md in the change with the
531
+ Operation header prefilled (MODIFIED when the spec
532
+ exists, ADDED when it does not)
533
+ --quiet Register the change and print one line — no playbook
534
+ (backlog entry; reprint later with --resume <id>)
480
535
  --id <id> Override the derived change id
481
536
  --resume <id> Reprint the playbook for an open change; create nothing
482
537
  --from-diff Backfill: scaffold from the working-tree changes (no
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+ import { isDir, read, walk } from "./fs-ops.js";
3
+ import { listHeader } from "./scan.js";
4
+
5
+ // Deterministic ADR checkpoint (operator review 2026-07-19 §4.6). The work
6
+ // playbook's "record an ADR" step was ignorable in silence: a change that
7
+ // flexibilised an accepted ADR closed with no amendment and nothing barked.
8
+ // This helper names the accepted ADRs whose text cites any of the change's
9
+ // touched capabilities, so `change check` and `close` can print a loud,
10
+ // advisory reminder: if the change alters what the ADR decided, record it
11
+ // (`decision supersede` / `decision new`) instead of drifting past it.
12
+ // A word match on capability names — a hint, never a decision (ADR 0005),
13
+ // and never a blocker (an ADR merely MENTIONING a capability is normal).
14
+ export function adrAdvisories(projectRoot, capabilities) {
15
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
16
+ if (!isDir(adrDir) || capabilities.length === 0) return [];
17
+ const out = [];
18
+ for (const f of walk(adrDir)) {
19
+ const base = path.basename(f);
20
+ const m = base.match(/^(\d{4})-.*\.md$/);
21
+ if (!m) continue;
22
+ const text = read(f);
23
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
24
+ const caps = capabilities.filter((cap) =>
25
+ new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text),
26
+ );
27
+ if (caps.length === 0) continue;
28
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
29
+ out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : base, caps });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // Print the advisory block shared by `change check` and `close`. Returns the
35
+ // number of ADRs flagged (informational; callers never gate on it).
36
+ export function printAdrCheckpoint(projectRoot, capabilities, { c }) {
37
+ const hits = adrAdvisories(projectRoot, capabilities);
38
+ if (hits.length === 0) return 0;
39
+ console.log(c.yellow("ADR checkpoint") + c.gray(" — accepted ADRs cite the touched capabilities:"));
40
+ for (const h of hits) {
41
+ console.log(` ADR ${c.cyan(h.id)} ${h.title} ${c.gray(`(cites: ${h.caps.join(", ")})`)}`);
42
+ }
43
+ console.log(c.gray(" If this change alters what an ADR decided, record it now:"));
44
+ console.log(c.gray(" ") + c.cyan("doctrina decision supersede <n>") + c.gray(" · ") + c.cyan("doctrina decision new \"<title>\""));
45
+ return hits.length;
46
+ }
@@ -40,8 +40,10 @@ export const OPERATIONS = [
40
40
  ["spec list", "List specs with version, status, and size"],
41
41
  ["spec set", "Edit spec headers / a criterion mark and resync the index"],
42
42
  ["change new", "Open a change proposal"],
43
- ["change apply", "Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)"],
44
- ["change archive", "Archive an applied change"],
43
+ ["change apply", "Apply spec deltas (ops blocks mechanically; batch ids ok)"],
44
+ ["change archive", "Archive an applied change (batch ids ok)"],
45
+ ["change check", "Pre-close dry-run: everything close would refuse, listed first"],
46
+ ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
45
47
  ["change diff", "Preview spec deltas (line diff for MODIFIED)"],
46
48
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
47
49
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
@@ -94,6 +96,102 @@ export function surfaceHelp() {
94
96
  return OPERATIONS.map(([op, summary]) => ` ${op.padEnd(21)}${summary}`).join("\n");
95
97
  }
96
98
 
99
+ // ---------------------------------------------------------------------------
100
+ // The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
101
+ // meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
102
+ // never runs `--help` spontaneously — so every command absent from the hub
103
+ // was invisible for ~35 changes (`prime`, `handoff`, `doctor`, `show`, ...).
104
+ // The fix is a marker-delimited section GENERATED from OPERATIONS, so the
105
+ // hub's catalog is the CLI's catalog by construction: `init` scaffolds it,
106
+ // `templates check` flags it stale, and `templates update --write` (hence
107
+ // `doctrina upgrade --write`) regenerates exactly the marked span and
108
+ // nothing else. Content between the markers is CLI-owned; everything
109
+ // outside remains the adopter's, under the additive-only guarantee.
110
+
111
+ export const SURFACE_BEGIN =
112
+ "<!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->";
113
+ export const SURFACE_END = "<!-- doctrina:surface:end -->";
114
+
115
+ // Workflow grouping for the generated block. A test asserts the flattened
116
+ // names equal COMMAND_NAMES, so a new command cannot ship ungrouped (and
117
+ // therefore cannot ship invisible to AGENTS.md).
118
+ export const SURFACE_GROUPS = [
119
+ ["Start", ["init", "intake", "work"]],
120
+ ["Author", ["spec", "change", "contract", "decision", "skill", "intent"]],
121
+ ["Read / orient", ["prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution"]],
122
+ ["Gates", ["analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor"]],
123
+ ["Maintain", ["templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade"]],
124
+ ];
125
+
126
+ // Per-command usage hints appended to the generated reference — arguments and
127
+ // the flags an agent reaches for daily. Cosmetic only; the operation list
128
+ // itself always comes from OPERATIONS.
129
+ const SURFACE_HINTS = {
130
+ work: '"<prompt>" (--capability · --chore · --from-diff · --quiet)',
131
+ context: "[<cap>] --concat",
132
+ validate: "(--fix)",
133
+ coverage: "--strict",
134
+ trace: "--strict",
135
+ clarify: "--all (--lang pt|en)",
136
+ close: "<id...>",
137
+ upgrade: "--write",
138
+ prime: "(session start)",
139
+ handoff: "(before compaction/handover)",
140
+ };
141
+
142
+ // The full managed section, heading included, between the markers.
143
+ export function surfaceMarkdown() {
144
+ const subsByCmd = new Map();
145
+ for (const [op] of OPERATIONS) {
146
+ const [cmd, sub] = op.split(" ");
147
+ if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
148
+ if (sub) subsByCmd.get(cmd).push(sub);
149
+ }
150
+ const renderCmd = (cmd) => {
151
+ const subs = subsByCmd.get(cmd) ?? [];
152
+ const hint = SURFACE_HINTS[cmd] ? ` ${SURFACE_HINTS[cmd]}` : "";
153
+ return subs.length > 0 ? `\`doctrina ${cmd} ${subs.join("|")}\`` : `\`doctrina ${cmd}${hint}\``;
154
+ };
155
+ const lines = [
156
+ "## Doctrina command surface (generated — reach for these, don't hand-author)",
157
+ "",
158
+ "Every operation of the installed CLI, grouped by workflow. The CLI",
159
+ "scaffolds from canonical templates and keeps `index.json` in sync, so",
160
+ "prefer it over writing artifacts by hand. Details: `doctrina --help`.",
161
+ "",
162
+ ];
163
+ for (const [label, cmds] of SURFACE_GROUPS) {
164
+ lines.push(`- **${label}:** ${cmds.map(renderCmd).join(" · ")}`);
165
+ }
166
+ lines.push("");
167
+ lines.push("Session bookends: `doctrina prime` to orient at session start;");
168
+ lines.push("`doctrina handoff` before compaction or handover. Continuous:");
169
+ lines.push("`doctrina watch`. Capture lessons: `doctrina skill suggest`.");
170
+ return lines.join("\n");
171
+ }
172
+
173
+ // The block as written to AGENTS.md: markers wrapping the generated section.
174
+ export function surfaceBlock() {
175
+ return `${SURFACE_BEGIN}\n${surfaceMarkdown()}\n${SURFACE_END}`;
176
+ }
177
+
178
+ // Locate the managed block in an AGENTS.md text. Returns
179
+ // { start, end, inner } (string offsets; inner excludes the marker lines)
180
+ // or null when either marker is absent.
181
+ export function findSurfaceBlock(text) {
182
+ const begin = text.match(/<!--\s*doctrina:surface:begin[\s\S]*?-->/);
183
+ if (!begin) return null;
184
+ const endRe = /<!--\s*doctrina:surface:end\s*-->/;
185
+ const end = endRe.exec(text.slice(begin.index + begin[0].length));
186
+ if (!end) return null;
187
+ const innerStart = begin.index + begin[0].length;
188
+ return {
189
+ start: begin.index,
190
+ end: innerStart + end.index + end[0].length,
191
+ inner: text.slice(innerStart, innerStart + end.index).replace(/^\r?\n|\r?\n$/g, ""),
192
+ };
193
+ }
194
+
97
195
  // Extract the set of top-level `doctrina <command>` references that appear in
98
196
  // CODE context (fenced blocks or inline backtick spans) of a Markdown file —
99
197
  // AGENTS.md, typically. Restricting to code context avoids matching prose like