doctrina-cli 0.11.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.
@@ -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
 
@@ -451,8 +485,15 @@ export async function run(_positional, flags) {
451
485
  if (index?.artifacts && isFile(ledgerPath)) {
452
486
  const ledgerIds = new Set();
453
487
  for (const line of read(ledgerPath).split(/\r?\n/)) {
454
- const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+/);
455
- if (m) ledgerIds.add(m[1]);
488
+ const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
489
+ if (!m) continue;
490
+ // An abandoned change is ledger-only BY DESIGN: `change abandon`
491
+ // records the discard in history but deletes the folder, so there is
492
+ // deliberately no archive entry to cross-check. Requiring one made
493
+ // every abandonment turn validate permanently red (found dogfooding
494
+ // change 0001-review-followups).
495
+ if (/^abandoned\b/i.test(m[2].trim())) continue;
496
+ ledgerIds.add(m[1]);
456
497
  }
457
498
  const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
458
499
  for (const id of archiveIds) {
@@ -523,6 +564,14 @@ export async function run(_positional, flags) {
523
564
  }
524
565
  }
525
566
 
567
+ // Project rules — permanent, lintable constraints (.doctrina/rules.json;
568
+ // 0.11.0 field review item 12: "never reference company X" lived only in
569
+ // agent memory, and the remaining mentions sailed through every gate). Each
570
+ // rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
571
+ // the rule's own message. Deterministic, zero-deps, and the instruction
572
+ // survives sessions because it is an artifact, not a memory.
573
+ for (const line of checkProjectRules(projectRoot)) errors.push(line);
574
+
526
575
  // Output
527
576
  if (flagBool(flags, "json", false)) {
528
577
  console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
@@ -541,6 +590,106 @@ export async function run(_positional, flags) {
541
590
  return errors.length === 0 ? 0 : 1;
542
591
  }
543
592
 
593
+ // Enforce .doctrina/rules.json — permanent project constraints as
594
+ // forbid-regexes over glob-scoped paths:
595
+ // { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
596
+ // "paths": ["src/**", ".doctrina/specs/**"],
597
+ // "message": "white-label product; use a generic placeholder" } ] }
598
+ // Returns error strings (one per offending file+rule, capped per rule so a
599
+ // mass violation stays readable). Missing/invalid file → no rules (an
600
+ // invalid JSON is reported once). Binary-ish and vendored dirs are skipped
601
+ // by the same bounded walk validate already uses elsewhere.
602
+ const RULES_SKIP_DIRS = new Set([
603
+ ".git", "node_modules", "vendor", "dist", "build", "out", "target",
604
+ ".venv", "venv", "__pycache__", ".next", "coverage",
605
+ ]);
606
+ const RULES_MAX_HITS_PER_RULE = 10;
607
+
608
+ function checkProjectRules(projectRoot) {
609
+ const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
610
+ if (!isFile(rulesPath)) return [];
611
+ let cfg;
612
+ try {
613
+ cfg = JSON.parse(read(rulesPath));
614
+ } catch (err) {
615
+ return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
616
+ }
617
+ const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
618
+ const out = [];
619
+ const compiled = [];
620
+ for (const r of rules) {
621
+ if (!r || typeof r.forbid !== "string" || !r.forbid) {
622
+ out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
623
+ continue;
624
+ }
625
+ let re;
626
+ try {
627
+ re = new RegExp(r.forbid, "m");
628
+ } catch (err) {
629
+ out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
630
+ continue;
631
+ }
632
+ const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
633
+ compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
634
+ }
635
+ if (compiled.length === 0) return out;
636
+
637
+ // One bounded walk; each text file is read at most once.
638
+ const stack = [projectRoot];
639
+ while (stack.length) {
640
+ const dir = stack.pop();
641
+ let entries;
642
+ try {
643
+ entries = readdirSync(dir, { withFileTypes: true });
644
+ } catch {
645
+ continue;
646
+ }
647
+ for (const entry of entries) {
648
+ const full = path.join(dir, entry.name);
649
+ if (entry.isDirectory()) {
650
+ if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
651
+ continue;
652
+ }
653
+ const rel = relPath(projectRoot, full).replace(/\\/g, "/");
654
+ if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
655
+ const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
656
+ if (applicable.length === 0) continue;
657
+ let text;
658
+ try {
659
+ text = read(full);
660
+ } catch {
661
+ continue;
662
+ }
663
+ for (const cr of applicable) {
664
+ const m = cr.re.exec(text);
665
+ if (m) {
666
+ cr.hits += 1;
667
+ const lineNo = text.slice(0, m.index).split("\n").length;
668
+ out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
669
+ }
670
+ }
671
+ }
672
+ }
673
+ for (const cr of compiled) {
674
+ if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
675
+ out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
676
+ }
677
+ }
678
+ return out;
679
+ }
680
+
681
+ // Minimal glob → RegExp: ** crosses directories, * stays within a segment.
682
+ // Anchored to the whole repo-relative POSIX path.
683
+ function globToRegExp(glob) {
684
+ const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
685
+ const body = esc
686
+ .replace(/\*\*\//g, "(?:.*/)?")
687
+ .replace(/\*\*/g, ".*")
688
+ .replace(/\*/g, "[^/]*")
689
+ .replace(/\?/g, "[^/]");
690
+ return new RegExp(`^${body}$`);
691
+ }
692
+
544
693
  // Per-entry metadata drift between the on-disk index and the tree-derived
545
694
  // one, for entries present in BOTH. Presence drift (an artifact on disk but
546
695
  // not indexed, or indexed but missing) is left to the orphan warning and the
@@ -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 } from "../lib/fs-ops.js";
5
- import { specHeader, listHeader } from "../lib/scan.js";
5
+ import { specHeader, listHeader, parseDependsOn } from "../lib/scan.js";
6
6
  import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
7
7
  import * as idx from "../lib/index-json.js";
8
8
  import { c } from "../lib/colors.js";
@@ -75,6 +75,16 @@ export async function run(positional, _flags) {
75
75
  const purpose = sectionParagraph(text, "Purpose");
76
76
  if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
77
77
 
78
+ // Dependency graph, both directions (the machine-readable **Depends on:**
79
+ // header — specs used to cite each other only in prose, invisible here).
80
+ const deps = parseDependsOn(text);
81
+ const dependents = known
82
+ .filter((other) => other !== cap)
83
+ .filter((other) => parseDependsOn(read(path.join(specsDir, other, "spec.md"))).includes(cap))
84
+ .sort();
85
+ if (deps.length > 0) console.log(` ${c.gray("depends on:")} ${deps.map((d) => c.cyan(d)).join(", ")}`);
86
+ if (dependents.length > 0) console.log(` ${c.gray("depended on by:")} ${dependents.map((d) => c.cyan(d)).join(", ")}`);
87
+
78
88
  // 3. The proof (acceptance criteria with cited evidence). Read through the
79
89
  // shared multi-line parser so the marks here agree with `doctrina
80
90
  // coverage` — a criterion that cites its proof on a continuation line is
@@ -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
@@ -71,14 +72,18 @@ export async function run(positional, flags) {
71
72
  }
72
73
 
73
74
  const effPrompt = prompt || `backfill specs from ${files.length} changed file${files.length === 1 ? "" : "s"}`;
74
- const slug = prompt ? slugify(prompt) : "backfill";
75
+ // --title separates the short display name from the full brief: the title
76
+ // drives the slug and the proposal H1; the whole prompt still lands, intact,
77
+ // under ## Why. Without it a long prompt used to become a 900-char H1.
78
+ const title = flagString(flags, "title") ?? effPrompt;
79
+ const slug = prompt || flags.has("title") ? slugify(title) : "backfill";
75
80
  const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
76
81
  if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
77
82
  console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
78
83
  return 2;
79
84
  }
80
85
 
81
- const code = changeNew([id, effPrompt], flags);
86
+ const code = changeNew([id, title], flags);
82
87
  if (code !== 0) return code;
83
88
 
84
89
  // Record the prompt verbatim as the change's Why — the raw intent has
@@ -102,6 +107,26 @@ export async function run(positional, flags) {
102
107
  if (updated !== txt) write(proposalPath, updated, { force: true });
103
108
  }
104
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
+
105
130
  // Capability hint: term overlap for a prompt, changed-file overlap for a diff
106
131
  // (review F10 — rank by what the working tree touched, not just prompt words).
107
132
  const allChanged = fromDiff ? files : changedFiles(projectRoot);
@@ -110,6 +135,16 @@ export async function run(positional, flags) {
110
135
  const capability = pinned ?? matches[0]?.id ?? null;
111
136
  const clarity = assessBrief(effPrompt, { kind: "prompt" });
112
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
+
113
148
  console.log("");
114
149
  if (chore) {
115
150
  printChorePlaybook(projectRoot, { id, prompt: effPrompt });
@@ -228,6 +263,9 @@ function resumeChange(projectRoot, resumeId) {
228
263
  }
229
264
 
230
265
  // ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
266
+ // Over-long slugs are cut at a WORD boundary, not mid-token — a 900-char
267
+ // prompt used to produce folder names ending in a half word ("...-scoped-c"),
268
+ // which then leaks into every path, ledger line, and archive name.
231
269
  export function slugify(text) {
232
270
  let slug = text
233
271
  .normalize("NFD")
@@ -235,7 +273,11 @@ export function slugify(text) {
235
273
  .toLowerCase()
236
274
  .replace(/[^a-z0-9]+/g, "-")
237
275
  .replace(/^-+|-+$/g, "");
238
- if (slug.length > 48) slug = slug.slice(0, 48).replace(/-+$/g, "");
276
+ if (slug.length > 48) {
277
+ const cut = slug.slice(0, 49); // one past the cap: a "-" here means 48 ends a word
278
+ const boundary = cut.lastIndexOf("-");
279
+ slug = (boundary > 0 ? cut.slice(0, boundary) : cut.slice(0, 48)).replace(/-+$/g, "");
280
+ }
239
281
  return slug.length > 0 ? slug : "task";
240
282
  }
241
283
 
@@ -292,7 +334,7 @@ export function rankCapabilities(projectRoot, prompt) {
292
334
  // file scores its capability when the file sits under a path segment named for
293
335
  // it, or when the spec cites the file as evidence. A deterministic overlap
294
336
  // hint for the agent — never a decision (ADR 0005).
295
- export function rankCapabilitiesByDiff(projectRoot, files) {
337
+ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
296
338
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
297
339
  if (!files || files.length === 0 || !isDir(specsDir)) return [];
298
340
  const norm = files.map((f) => f.replace(/\\/g, "/"));
@@ -310,7 +352,10 @@ export function rankCapabilitiesByDiff(projectRoot, files) {
310
352
  }
311
353
  if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
312
354
  }
313
- return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
355
+ // `limit` keeps the work-playbook hint short (top 3); review passes
356
+ // Infinity — truncating there is how it missed 5 of 8 touched capabilities
357
+ // in the 0.11.0 field session.
358
+ return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
314
359
  }
315
360
 
316
361
  // A chore is a spec-less change (review F9): the playbook drops the spec-delta
@@ -325,6 +370,8 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
325
370
  console.log("");
326
371
  console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
327
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.)"));
328
375
  console.log("2. Prove it (archive refuses unchecked boxes):");
329
376
  console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
330
377
  console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
@@ -392,8 +439,14 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
392
439
  console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
393
440
  console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
394
441
  console.log("");
395
- console.log("3. Write one delta per affected capability at");
396
- 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
+ }
397
450
  console.log(c.gray(" # Spec Delta — capability: <capability>"));
398
451
  console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
399
452
  console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
@@ -401,6 +454,20 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
401
454
  console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
402
455
  console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
403
456
  console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
457
+ console.log(c.gray(" New capability? `spec new` then an ADDED delta with the FULL body — apply"));
458
+ console.log(c.gray(" replaces the untouched scaffold. Bookkeeping edits? MODIFIED with an ops"));
459
+ console.log(c.gray(" block, applied mechanically (all ops or none):"));
460
+ console.log(c.gray(" ```ops"));
461
+ console.log(c.gray(" set-header Implementation: verified — `src/x.js`"));
462
+ console.log(c.gray(" bump-version minor"));
463
+ console.log(c.gray(" set-criterion 1: verified"));
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>."));
467
+ console.log(c.gray(" ```"));
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."));
404
471
  if (fromDiff) {
405
472
  console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
406
473
  console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
@@ -408,25 +475,35 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
408
475
  }
409
476
  console.log("");
410
477
  console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
411
- 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.");
412
482
  console.log("");
413
483
  console.log("5. Implement task by task, checking each box as it lands. Advance the");
414
484
  console.log(" spec's Implementation: planned → partial → implemented as code lands.");
415
485
  console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
416
486
  console.log("");
417
- console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
418
- console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED with an ops block applies mechanically; prose merges by hand).`);
419
- console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
420
- console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
421
- console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
422
- console.log(` ${c.cyan("doctrina trace")} — the capability traces to product intent`);
423
- console.log(" Then check the proposal's ## Verification boxes and every task,");
487
+ console.log(`6. ADR checkpoint — does this change decide something structural`);
488
+ console.log(" (an architecture, a boundary, a trade-off a future session must");
489
+ console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
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")}.`);
495
+ console.log("");
496
+ console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
497
+ console.log(" at the first failure with the exact rerun command):");
498
+ console.log(` ${c.cyan(`doctrina close ${id}`)}`);
499
+ console.log(c.gray(" (equivalent, step by step: ") +
500
+ c.gray(`analyze → change apply → verify → coverage → trace → change archive → validate)`));
501
+ console.log(" Before it: check the proposal's ## Verification boxes and every task,");
424
502
  console.log(" closing steps included, and bump Implementation to verified.");
425
503
  console.log("");
426
- console.log(`9. ${c.cyan(`doctrina change archive ${id}`)}`);
427
- console.log(`10. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
428
- console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
429
- console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
504
+ console.log(`8. ${c.cyan("doctrina next")} for the follow-up.`);
505
+ console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
506
+ console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
430
507
  }
431
508
 
432
509
  export const help = `
@@ -447,7 +524,14 @@ already open, work suggests resuming it instead of opening a junk change
447
524
  named after that word (review G1). Use --resume to do so directly.
448
525
 
449
526
  Options:
450
- --capability <cap> Pin the capability instead of ranking matches
527
+ --title "<short>" Short display title: drives the slug and the proposal
528
+ H1; the full prompt still lands under ## Why
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>)
451
535
  --id <id> Override the derived change id
452
536
  --resume <id> Reprint the playbook for an open change; create nothing
453
537
  --from-diff Backfill: scaffold from the working-tree changes (no
package/src/index.js CHANGED
@@ -39,6 +39,8 @@ import * as show from "./commands/show.js";
39
39
  import * as doctor from "./commands/doctor.js";
40
40
  import * as report from "./commands/report.js";
41
41
  import * as completion from "./commands/completion.js";
42
+ import * as intent from "./commands/intent.js";
43
+ import * as upgrade from "./commands/upgrade.js";
42
44
 
43
45
  const COMMANDS = {
44
46
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -46,6 +48,7 @@ const COMMANDS = {
46
48
  intake, work, coverage, verify, contract, trace,
47
49
  status, close, review, watch, why, constitution,
48
50
  prime, handoff, show, doctor, report, completion,
51
+ intent, upgrade,
49
52
  };
50
53
 
51
54
  const TOP_HELP = `
@@ -60,11 +63,17 @@ Global flags:
60
63
  `;
61
64
 
62
65
  async function main(argv) {
66
+ // `--version` is only "print the CLI version" when NO command is given.
67
+ // With a command it is that command's flag to consume — the old global
68
+ // intercept made `doctrina spec set x --version 0.10.0` print the CLI
69
+ // version and exit, looking exactly like the spec's version had been set
70
+ // (0.11.0 field-review papercut). `-v` stays global either way.
71
+ const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
63
72
  const { positional, flags } = parseArgs(argv, {
64
- boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init", "fix", "once", "clean", "json"],
73
+ boolean: ["help", "h", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init", "fix", "once", "clean", "json"],
65
74
  });
66
75
 
67
- if (flags.get("version") || flags.get("v")) {
76
+ if (versionOnly || flags.get("v") === true) {
68
77
  console.log(cliVersion());
69
78
  return 0;
70
79
  }
@@ -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
+ }