doctrina-cli 0.11.0 → 0.12.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.
@@ -134,9 +134,10 @@ function specSet(args, flags) {
134
134
  const impl = flagString(flags, "implementation");
135
135
  const status = flagString(flags, "status");
136
136
  const bump = flagString(flags, "bump");
137
+ const versionExplicit = flagString(flags, "version");
137
138
  const criterion = flagString(flags, "criterion");
138
- if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
139
- console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
139
+ if (impl === undefined && status === undefined && bump === undefined && versionExplicit === undefined && criterion === undefined) {
140
+ console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --version, --criterion");
140
141
  console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
141
142
  return 2;
142
143
  }
@@ -156,6 +157,10 @@ function specSet(args, flags) {
156
157
  if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
157
158
  else apply(bumpVersion(text, bump));
158
159
  }
160
+ if (versionExplicit !== undefined) {
161
+ if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) errors.push(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
162
+ else apply(setHeader(text, "Version", versionExplicit));
163
+ }
159
164
  if (criterion !== undefined) {
160
165
  const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
161
166
  if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
@@ -183,7 +188,10 @@ function specSet(args, flags) {
183
188
  const derived = deriveIndex(projectRoot, index);
184
189
  derived.last_updated = date;
185
190
  idx.save(projectRoot, derived);
186
- console.log(c.green("indexed") + ` spec "${capability}" synced`);
191
+ // Echo the SPEC's resulting version, not the framework's — the two look
192
+ // identical in output and the ambiguity was a field-review papercut.
193
+ const specVersion = specHeader(text, "Version") ?? "—";
194
+ console.log(c.green("indexed") + ` spec "${capability}" synced (spec version ${specVersion})`);
187
195
  return 0;
188
196
  }
189
197
 
@@ -214,6 +222,8 @@ Options:
214
222
  --implementation "..." With \`set\`: set the Implementation header.
215
223
  --status "..." With \`set\`: set the Status header.
216
224
  --bump major|minor|patch With \`set\`: bump the spec Version.
225
+ --version X.Y.Z With \`set\`: set the spec Version explicitly
226
+ (the output echoes the SPEC version, not the CLI's).
217
227
  --criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
218
228
  (e.g. --criterion "2:verified").
219
229
  `;
@@ -0,0 +1,129 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists } from "../lib/fs-ops.js";
4
+ import * as idx from "../lib/index-json.js";
5
+ import { deriveIndex, indexesMatch } from "../lib/scan.js";
6
+ import { cliVersion } from "../lib/version.js";
7
+ import { flagBool } from "../lib/args.js";
8
+ import { c } from "../lib/colors.js";
9
+ import { today } from "../lib/dates.js";
10
+ import * as templates from "./templates.js";
11
+ import * as validate from "./validate.js";
12
+
13
+ // Bring an existing project up to the installed CLI (user-reported gap: you
14
+ // `npm update doctrina-cli`, but the project you once `doctrina init`-ed
15
+ // keeps its old scaffold — stale framework stamp, an AGENTS.md that predates
16
+ // new commands, missing recommended sections). `upgrade` is the one command
17
+ // that closes that window, and it is an ORCHESTRATOR over the pieces that
18
+ // already exist — templates update (additive-only), index rebuild (stamp
19
+ // migration), validate --fix — so there is exactly one definition of each
20
+ // step. Preview by default; --write applies. Never rewrites user content
21
+ // (the same additive-only bound as `templates update`).
22
+
23
+ export async function run(_positional, flags) {
24
+ const projectRoot = process.cwd();
25
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
26
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
27
+ }
28
+ const writeMode = flagBool(flags, "write", false);
29
+ const running = cliVersion();
30
+
31
+ // 1. Version gap: what the tree was last managed by vs what is installed.
32
+ let index = null;
33
+ let stamped = null;
34
+ try {
35
+ index = idx.load(projectRoot);
36
+ stamped = index.framework_version ?? null;
37
+ } catch {
38
+ index = null;
39
+ }
40
+ console.log(c.bold("doctrina upgrade") + c.gray(` — project ${stamped ?? "unstamped"} → CLI ${running}${writeMode ? "" : " (preview; --write applies)"}`));
41
+ console.log("");
42
+ if (stamped === running) {
43
+ console.log(c.gray("The framework stamp already matches the installed CLI — checking the scaffold anyway."));
44
+ console.log("");
45
+ }
46
+
47
+ let pending = 0;
48
+
49
+ // 2. Additive template updates (missing recommended sections in AGENTS.md /
50
+ // product.md, missing index.json fields). Reuses `templates update`
51
+ // verbatim: preview without --write, apply with it.
52
+ console.log(c.gray("──── 1/3 scaffold shape (templates update)"));
53
+ const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
54
+ const tplCode = await templates.run(["update"], tplFlags);
55
+ if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
56
+
57
+ // 3. Index: rebuild from the tree, which also migrates the framework stamp.
58
+ console.log("");
59
+ console.log(c.gray("──── 2/3 index + framework stamp"));
60
+ try {
61
+ const current = idx.load(projectRoot);
62
+ const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
63
+ const staleStamp = (current.framework_version ?? null) !== running;
64
+ if (!drifted && !staleStamp) {
65
+ console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
66
+ } else if (writeMode) {
67
+ const derived = deriveIndex(projectRoot, current);
68
+ derived.last_updated = today();
69
+ idx.save(projectRoot, derived); // save() stamps framework_version
70
+ console.log(c.green("rebuilt") + ` index.json${staleStamp ? ` — stamp ${current.framework_version ?? "unstamped"} → ${running}` : ""}`);
71
+ } else {
72
+ if (drifted) console.log(c.yellow("would ") + "rebuild index.json from the tree (drifted)");
73
+ if (staleStamp) console.log(c.yellow("would ") + `migrate framework stamp ${current.framework_version ?? "unstamped"} → ${running}`);
74
+ pending += 1;
75
+ }
76
+ } catch {
77
+ if (writeMode) {
78
+ console.log(c.red("error: ") + "index.json is missing or unreadable — run `doctrina index rebuild` manually");
79
+ } else {
80
+ console.log(c.yellow("would ") + "rebuild index.json (missing or unreadable)");
81
+ pending += 1;
82
+ }
83
+ }
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.
89
+ console.log("");
90
+ console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
91
+ const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
92
+ const vCode = await validate.run([], vFlags);
93
+
94
+ console.log("");
95
+ if (!writeMode) {
96
+ if (pending > 0) {
97
+ console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
98
+ return 1;
99
+ }
100
+ console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
101
+ return vCode;
102
+ }
103
+ if (vCode === 0) {
104
+ 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."));
106
+ } else {
107
+ console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
108
+ }
109
+ return vCode;
110
+ }
111
+
112
+ export const help = `
113
+ Usage: doctrina upgrade [--write]
114
+
115
+ Bring an existing project up to the installed CLI after an npm update.
116
+ The project keeps the scaffold of the version that init-ed it; this is
117
+ the one command that closes the gap, orchestrating the existing pieces:
118
+
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)
122
+ 2. index rebuild — regenerate index.json from the tree and migrate
123
+ 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)
127
+
128
+ Preview by default (exits 1 when steps are pending); --write applies.
129
+ `;
@@ -451,8 +451,15 @@ export async function run(_positional, flags) {
451
451
  if (index?.artifacts && isFile(ledgerPath)) {
452
452
  const ledgerIds = new Set();
453
453
  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]);
454
+ const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
455
+ if (!m) continue;
456
+ // An abandoned change is ledger-only BY DESIGN: `change abandon`
457
+ // records the discard in history but deletes the folder, so there is
458
+ // deliberately no archive entry to cross-check. Requiring one made
459
+ // every abandonment turn validate permanently red (found dogfooding
460
+ // change 0001-review-followups).
461
+ if (/^abandoned\b/i.test(m[2].trim())) continue;
462
+ ledgerIds.add(m[1]);
456
463
  }
457
464
  const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
458
465
  for (const id of archiveIds) {
@@ -523,6 +530,14 @@ export async function run(_positional, flags) {
523
530
  }
524
531
  }
525
532
 
533
+ // Project rules — permanent, lintable constraints (.doctrina/rules.json;
534
+ // 0.11.0 field review item 12: "never reference company X" lived only in
535
+ // agent memory, and the remaining mentions sailed through every gate). Each
536
+ // rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
537
+ // the rule's own message. Deterministic, zero-deps, and the instruction
538
+ // survives sessions because it is an artifact, not a memory.
539
+ for (const line of checkProjectRules(projectRoot)) errors.push(line);
540
+
526
541
  // Output
527
542
  if (flagBool(flags, "json", false)) {
528
543
  console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
@@ -541,6 +556,106 @@ export async function run(_positional, flags) {
541
556
  return errors.length === 0 ? 0 : 1;
542
557
  }
543
558
 
559
+ // Enforce .doctrina/rules.json — permanent project constraints as
560
+ // forbid-regexes over glob-scoped paths:
561
+ // { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
562
+ // "paths": ["src/**", ".doctrina/specs/**"],
563
+ // "message": "white-label product; use a generic placeholder" } ] }
564
+ // Returns error strings (one per offending file+rule, capped per rule so a
565
+ // mass violation stays readable). Missing/invalid file → no rules (an
566
+ // invalid JSON is reported once). Binary-ish and vendored dirs are skipped
567
+ // by the same bounded walk validate already uses elsewhere.
568
+ const RULES_SKIP_DIRS = new Set([
569
+ ".git", "node_modules", "vendor", "dist", "build", "out", "target",
570
+ ".venv", "venv", "__pycache__", ".next", "coverage",
571
+ ]);
572
+ const RULES_MAX_HITS_PER_RULE = 10;
573
+
574
+ function checkProjectRules(projectRoot) {
575
+ const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
576
+ if (!isFile(rulesPath)) return [];
577
+ let cfg;
578
+ try {
579
+ cfg = JSON.parse(read(rulesPath));
580
+ } catch (err) {
581
+ return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
582
+ }
583
+ const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
584
+ const out = [];
585
+ const compiled = [];
586
+ for (const r of rules) {
587
+ if (!r || typeof r.forbid !== "string" || !r.forbid) {
588
+ out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
589
+ continue;
590
+ }
591
+ let re;
592
+ try {
593
+ re = new RegExp(r.forbid, "m");
594
+ } catch (err) {
595
+ out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
596
+ continue;
597
+ }
598
+ const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
599
+ compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
600
+ }
601
+ if (compiled.length === 0) return out;
602
+
603
+ // One bounded walk; each text file is read at most once.
604
+ const stack = [projectRoot];
605
+ while (stack.length) {
606
+ const dir = stack.pop();
607
+ let entries;
608
+ try {
609
+ entries = readdirSync(dir, { withFileTypes: true });
610
+ } catch {
611
+ continue;
612
+ }
613
+ for (const entry of entries) {
614
+ const full = path.join(dir, entry.name);
615
+ if (entry.isDirectory()) {
616
+ if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
617
+ continue;
618
+ }
619
+ const rel = relPath(projectRoot, full).replace(/\\/g, "/");
620
+ if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
621
+ const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
622
+ if (applicable.length === 0) continue;
623
+ let text;
624
+ try {
625
+ text = read(full);
626
+ } catch {
627
+ continue;
628
+ }
629
+ for (const cr of applicable) {
630
+ const m = cr.re.exec(text);
631
+ if (m) {
632
+ cr.hits += 1;
633
+ const lineNo = text.slice(0, m.index).split("\n").length;
634
+ out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
635
+ }
636
+ }
637
+ }
638
+ }
639
+ for (const cr of compiled) {
640
+ if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
641
+ out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
642
+ }
643
+ }
644
+ return out;
645
+ }
646
+
647
+ // Minimal glob → RegExp: ** crosses directories, * stays within a segment.
648
+ // Anchored to the whole repo-relative POSIX path.
649
+ function globToRegExp(glob) {
650
+ const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
651
+ const body = esc
652
+ .replace(/\*\*\//g, "(?:.*/)?")
653
+ .replace(/\*\*/g, ".*")
654
+ .replace(/\*/g, "[^/]*")
655
+ .replace(/\?/g, "[^/]");
656
+ return new RegExp(`^${body}$`);
657
+ }
658
+
544
659
  // Per-entry metadata drift between the on-disk index and the tree-derived
545
660
  // one, for entries present in BOTH. Presence drift (an artifact on disk but
546
661
  // 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
@@ -71,14 +71,18 @@ export async function run(positional, flags) {
71
71
  }
72
72
 
73
73
  const effPrompt = prompt || `backfill specs from ${files.length} changed file${files.length === 1 ? "" : "s"}`;
74
- const slug = prompt ? slugify(prompt) : "backfill";
74
+ // --title separates the short display name from the full brief: the title
75
+ // drives the slug and the proposal H1; the whole prompt still lands, intact,
76
+ // under ## Why. Without it a long prompt used to become a 900-char H1.
77
+ const title = flagString(flags, "title") ?? effPrompt;
78
+ const slug = prompt || flags.has("title") ? slugify(title) : "backfill";
75
79
  const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
76
80
  if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
77
81
  console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
78
82
  return 2;
79
83
  }
80
84
 
81
- const code = changeNew([id, effPrompt], flags);
85
+ const code = changeNew([id, title], flags);
82
86
  if (code !== 0) return code;
83
87
 
84
88
  // Record the prompt verbatim as the change's Why — the raw intent has
@@ -228,6 +232,9 @@ function resumeChange(projectRoot, resumeId) {
228
232
  }
229
233
 
230
234
  // ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
235
+ // Over-long slugs are cut at a WORD boundary, not mid-token — a 900-char
236
+ // prompt used to produce folder names ending in a half word ("...-scoped-c"),
237
+ // which then leaks into every path, ledger line, and archive name.
231
238
  export function slugify(text) {
232
239
  let slug = text
233
240
  .normalize("NFD")
@@ -235,7 +242,11 @@ export function slugify(text) {
235
242
  .toLowerCase()
236
243
  .replace(/[^a-z0-9]+/g, "-")
237
244
  .replace(/^-+|-+$/g, "");
238
- if (slug.length > 48) slug = slug.slice(0, 48).replace(/-+$/g, "");
245
+ if (slug.length > 48) {
246
+ const cut = slug.slice(0, 49); // one past the cap: a "-" here means 48 ends a word
247
+ const boundary = cut.lastIndexOf("-");
248
+ slug = (boundary > 0 ? cut.slice(0, boundary) : cut.slice(0, 48)).replace(/-+$/g, "");
249
+ }
239
250
  return slug.length > 0 ? slug : "task";
240
251
  }
241
252
 
@@ -292,7 +303,7 @@ export function rankCapabilities(projectRoot, prompt) {
292
303
  // file scores its capability when the file sits under a path segment named for
293
304
  // it, or when the spec cites the file as evidence. A deterministic overlap
294
305
  // hint for the agent — never a decision (ADR 0005).
295
- export function rankCapabilitiesByDiff(projectRoot, files) {
306
+ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
296
307
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
297
308
  if (!files || files.length === 0 || !isDir(specsDir)) return [];
298
309
  const norm = files.map((f) => f.replace(/\\/g, "/"));
@@ -310,7 +321,10 @@ export function rankCapabilitiesByDiff(projectRoot, files) {
310
321
  }
311
322
  if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
312
323
  }
313
- return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
324
+ // `limit` keeps the work-playbook hint short (top 3); review passes
325
+ // Infinity — truncating there is how it missed 5 of 8 touched capabilities
326
+ // in the 0.11.0 field session.
327
+ return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
314
328
  }
315
329
 
316
330
  // A chore is a spec-less change (review F9): the playbook drops the spec-delta
@@ -401,6 +415,16 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
401
415
  console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
402
416
  console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
403
417
  console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
418
+ console.log(c.gray(" New capability? `spec new` then an ADDED delta with the FULL body — apply"));
419
+ console.log(c.gray(" replaces the untouched scaffold. Bookkeeping edits? MODIFIED with an ops"));
420
+ console.log(c.gray(" block, applied mechanically (all ops or none):"));
421
+ console.log(c.gray(" ```ops"));
422
+ console.log(c.gray(" set-header Implementation: verified — `src/x.js`"));
423
+ console.log(c.gray(" bump-version minor"));
424
+ console.log(c.gray(" set-criterion 1: verified"));
425
+ console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
426
+ console.log(c.gray(" ```"));
427
+ console.log(c.gray(" Only prose rewrites stay a by-hand merge."));
404
428
  if (fromDiff) {
405
429
  console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
406
430
  console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
@@ -414,19 +438,22 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
414
438
  console.log(" spec's Implementation: planned → partial → implemented as code lands.");
415
439
  console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
416
440
  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,");
441
+ console.log(`6. ADR checkpoint — does this change decide something structural`);
442
+ console.log(" (an architecture, a boundary, a trade-off a future session must");
443
+ console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
444
+ console.log(" before closing — the definition of done requires it recorded.");
445
+ console.log("");
446
+ console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
447
+ console.log(" at the first failure with the exact rerun command):");
448
+ console.log(` ${c.cyan(`doctrina close ${id}`)}`);
449
+ console.log(c.gray(" (equivalent, step by step: ") +
450
+ c.gray(`analyze → change apply → verify → coverage → trace → change archive → validate)`));
451
+ console.log(" Before it: check the proposal's ## Verification boxes and every task,");
424
452
  console.log(" closing steps included, and bump Implementation to verified.");
425
453
  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("."));
454
+ console.log(`8. ${c.cyan("doctrina next")} for the follow-up.`);
455
+ console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
456
+ console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
430
457
  }
431
458
 
432
459
  export const help = `
@@ -447,6 +474,8 @@ already open, work suggests resuming it instead of opening a junk change
447
474
  named after that word (review G1). Use --resume to do so directly.
448
475
 
449
476
  Options:
477
+ --title "<short>" Short display title: drives the slug and the proposal
478
+ H1; the full prompt still lands under ## Why
450
479
  --capability <cap> Pin the capability instead of ranking matches
451
480
  --id <id> Override the derived change id
452
481
  --resume <id> Reprint the playbook for an open change; create nothing
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
  }
@@ -13,13 +13,13 @@ export const COMMAND_NAMES = [
13
13
  // bootstrap / day-to-day
14
14
  "init", "intake", "work",
15
15
  // authoring
16
- "spec", "change", "contract", "decision", "skill",
16
+ "spec", "change", "contract", "decision", "skill", "intent",
17
17
  // read / orient
18
18
  "prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution",
19
19
  // gates
20
20
  "analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
21
21
  // maintenance
22
- "templates", "hooks", "index", "watch", "metrics", "report", "completion",
22
+ "templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade",
23
23
  ];
24
24
 
25
25
  // The full operation surface — every `doctrina <command> [<subcommand>]` a
@@ -56,6 +56,8 @@ export const OPERATIONS = [
56
56
  ["skill list", "List skills with their descriptions"],
57
57
  ["skill sync", "Mirror skill frontmatter descriptions into index.json"],
58
58
  ["skill suggest", "Surface fix-shaped lessons worth a skill (--write scaffolds)"],
59
+ ["intent add", "Append a new product intent anchor post-intake (- [SC5] ...)"],
60
+ ["intent list", "List product.md intent anchors in document order"],
59
61
  ["analyze", "Inspect a change folder before applying"],
60
62
  ["clarify", "Smell-test a Markdown file for ambiguity (--all for the tree)"],
61
63
  ["prime", "Session primer: gates, rules, open work, next steps in one read"],
@@ -83,6 +85,7 @@ export const OPERATIONS = [
83
85
  ["metrics", "Local git-derived adoption metrics (no network)"],
84
86
  ["report", "Markdown digest for a period: changes, gates, git summary"],
85
87
  ["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
88
+ ["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
86
89
  ];
87
90
 
88
91
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
package/src/lib/scan.js CHANGED
@@ -25,6 +25,15 @@ export function listHeader(text, name) {
25
25
  return m ? m[1].trim() : null;
26
26
  }
27
27
 
28
+ // The machine-readable "**Depends on:** defects, decisions" spec header: the
29
+ // sibling capabilities this spec builds on. Returns [] when absent, "n/a", or
30
+ // empty. One parser so index, why, context, and review agree.
31
+ export function parseDependsOn(text) {
32
+ const raw = specHeader(text, "Depends on");
33
+ if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
34
+ return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
35
+ }
36
+
28
37
  function dirEntries(dir) {
29
38
  if (!isDir(dir)) return [];
30
39
  return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
@@ -87,6 +96,13 @@ export function deriveIndex(projectRoot, current) {
87
96
  const realizesRaw = specHeader(text, "Realizes");
88
97
  const realizes = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : prev?.realizes;
89
98
  if (realizes && realizes.length) entry.realizes = realizes;
99
+ // Depends on: sibling capabilities this spec builds on (0.11.0 field
100
+ // review — specs cited each other only in prose, unreadable by machine).
101
+ // Stored as a capability-id list so `why` can show the graph, `context`
102
+ // can pull dependencies into the read pack, and `review` can flag
103
+ // dependents of a touched capability.
104
+ const depends = specHeader(text, "Depends on") !== null ? parseDependsOn(text) : prev?.depends_on;
105
+ if (depends && depends.length) entry.depends_on = depends;
90
106
  out.artifacts.specs.push(entry);
91
107
  }
92
108
 
@@ -42,17 +42,21 @@ so prefer it over writing artifacts by hand. Full list: `doctrina --help`.
42
42
  existing code) · `--chore` (no-spec infra/docs/build)
43
43
  - **Scaffold (manual creation stays available):** `doctrina spec new <cap>`
44
44
  (`--bug`) · `doctrina decision new "<title>"` · `doctrina change new <id>`
45
- · `doctrina contract new <id>` · `doctrina skill new <slug>`
45
+ · `doctrina contract new <id>` · `doctrina skill new <slug>` · `doctrina
46
+ intent add "<text>"` (new product anchor post-intake)
46
47
  - **Advance / close:** `doctrina change apply|archive|diff <id>` · `doctrina
47
48
  decision accept|land|supersede <n>` · `doctrina spec set <cap> [...]` ·
48
- `doctrina close <id>` (whole close in one pass)
49
+ `doctrina close <id>` (whole close in one pass, coverage scoped to the
50
+ change)
49
51
  - **Gates (ground truth, not guesses):** `doctrina validate` (`--fix`) ·
50
52
  `doctrina verify` · `doctrina review` (conformance vs specs/ADRs) ·
51
- `doctrina coverage --strict` · `doctrina trace --strict` · `doctrina
52
- contract check` · `doctrina clarify --all` · `doctrina analyze <id>`
53
+ `doctrina coverage --strict` (`--run` executes cited proof) ·
54
+ `doctrina trace --strict` · `doctrina contract check` ·
55
+ `doctrina clarify --all` · `doctrina analyze <id>`
53
56
 
54
57
  Continuous: `doctrina watch` re-syncs and re-orients on every save.
55
58
  Capture lessons: `doctrina skill suggest` surfaces skills worth writing.
59
+ After a CLI update: `doctrina upgrade --write` brings this project up.
56
60
 
57
61
  ## Stack and tooling
58
62
 
@@ -24,7 +24,15 @@ ADR 0006). Tag product.md success-criteria bullets with an anchor —
24
24
  "- [SC1] ..." — and list the ones this spec delivers above. An "active"
25
25
  spec with no Realizes: header warns under `doctrina validate`; if the
26
26
  capability genuinely maps to no product anchor (internal tooling), keep
27
- the header and write "n/a — <why>" to record that on purpose.
27
+ the header and write "n/a — <why>" to record that on purpose. Need a NEW
28
+ anchor for intent born after the intake? `doctrina intent add "<text>"`.
29
+
30
+ Optional: declare the sibling capabilities this spec builds on with a
31
+ machine-readable header — "**Depends on:** defects, decisions". It feeds
32
+ `doctrina why` (the dependency graph), `doctrina context <cap>` (the
33
+ dependencies join the read pack), and `doctrina review` (dependents of a
34
+ touched capability are flagged). Citing them only in prose keeps every
35
+ tool blind to the link.
28
36
  -->
29
37
 
30
38
  ## Purpose