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.
@@ -0,0 +1,173 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
+ import { c } from "../lib/colors.js";
5
+ import { suggest } from "../lib/suggest.js";
6
+
7
+ // Post-intake intent evolution (0.11.0 field review item 8). The intake is
8
+ // converted once; capabilities born later — brainstorms, pivots, new asks —
9
+ // had no way to gain a product anchor, so they inevitably ended at
10
+ // `Realizes: n/a` and `trace` reported "14/14 ok" while blind to 40% of the
11
+ // system. `intent add` appends a new [SC*] anchor bullet to product.md's
12
+ // Success criteria (allocating the next number), so growth keeps provenance:
13
+ // new intent gets an anchor, the new spec Realizes it, `trace` sees it.
14
+ //
15
+ // The CLI allocates and appends; WHAT the intent says is the human/agent's
16
+ // words verbatim (ADR 0005).
17
+
18
+ const SUBCOMMANDS = ["add", "list"];
19
+
20
+ export async function run(positional, _flags) {
21
+ const sub = positional[0];
22
+ if (!SUBCOMMANDS.includes(sub)) {
23
+ console.error(c.red("error:") + ` unknown intent subcommand "${sub ?? ""}"`);
24
+ const guess = suggest(sub, SUBCOMMANDS);
25
+ console.error(c.gray("hint: ") + (guess
26
+ ? `did you mean \`doctrina intent ${guess}\`?`
27
+ : `available: ${SUBCOMMANDS.join(", ")}`));
28
+ return 2;
29
+ }
30
+ const projectRoot = process.cwd();
31
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
32
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
+ }
34
+ if (sub === "list") return intentList(projectRoot);
35
+ return intentAdd(projectRoot, positional.slice(1).join(" ").trim());
36
+ }
37
+
38
+ function productPathOf(projectRoot) {
39
+ return path.join(projectRoot, ".doctrina", "product.md");
40
+ }
41
+
42
+ // Every "[A-Z]+\d+" anchor at the head of a bullet, in document order —
43
+ // the same convention `trace` reads.
44
+ function collectAnchors(text) {
45
+ const out = [];
46
+ const seen = new Set();
47
+ const lines = text.split(/\r?\n/);
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+)(\d+)\]\s+(.*)$/);
50
+ if (m && !seen.has(m[1] + m[2])) {
51
+ seen.add(m[1] + m[2]);
52
+ out.push({ prefix: m[1], num: Number(m[2]), id: m[1] + m[2], text: m[3].trim(), line: i });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function intentList(projectRoot) {
59
+ const p = productPathOf(projectRoot);
60
+ if (!isFile(p)) {
61
+ console.error(c.red("error:") + " no .doctrina/product.md");
62
+ return 1;
63
+ }
64
+ const anchors = collectAnchors(read(p));
65
+ if (anchors.length === 0) {
66
+ console.log(c.gray("no intent anchors in product.md — tag bullets with `- [SC1] ...`, or add one: doctrina intent add \"<text>\""));
67
+ return 0;
68
+ }
69
+ console.log(c.bold("Intent anchors") + c.gray(" — product.md, document order:"));
70
+ console.log("");
71
+ for (const a of anchors) {
72
+ console.log(` ${c.cyan(a.id.padEnd(6))} ${a.text}`);
73
+ }
74
+ console.log("");
75
+ console.log(c.gray(`${anchors.length} anchor${anchors.length === 1 ? "" : "s"}. Provenance: doctrina trace · doctrina why <id>`));
76
+ return 0;
77
+ }
78
+
79
+ function intentAdd(projectRoot, textArg) {
80
+ if (!textArg) {
81
+ console.error(c.red("error:") + " intent add requires the intent text (quote it)");
82
+ console.error(c.gray("hint: ") + `example: doctrina intent add "Risk map answers 'what should we test next?' in one read"`);
83
+ return 2;
84
+ }
85
+ const p = productPathOf(projectRoot);
86
+ if (!isFile(p)) {
87
+ console.error(c.red("error:") + " no .doctrina/product.md — run `doctrina init` / `doctrina intake` first");
88
+ return 1;
89
+ }
90
+ const original = read(p);
91
+ const anchors = collectAnchors(original);
92
+
93
+ // Accept "SC15: text" to pin an explicit id; otherwise allocate the next
94
+ // number for the dominant prefix (SC by default).
95
+ let id = null;
96
+ let text = textArg;
97
+ const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
98
+ if (pinned) {
99
+ id = pinned[1] + pinned[2];
100
+ text = pinned[3].trim();
101
+ if (anchors.some((a) => a.id === id)) {
102
+ console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
103
+ return 1;
104
+ }
105
+ } else {
106
+ const prefix = anchors.length > 0 ? dominantPrefix(anchors) : "SC";
107
+ const maxNum = anchors.filter((a) => a.prefix === prefix).reduce((m, a) => Math.max(m, a.num), 0);
108
+ id = `${prefix}${maxNum + 1}`;
109
+ }
110
+
111
+ const lines = original.split(/\r?\n/);
112
+ const bullet = `- [${id}] ${text}`;
113
+
114
+ // Insertion point: after the LAST existing anchor bullet (keeps them
115
+ // contiguous); else at the end of "## Success criteria"; else appended as a
116
+ // new Success criteria section (a product.md without one gains it).
117
+ const samePrefix = anchors.length > 0;
118
+ let inserted = false;
119
+ if (samePrefix) {
120
+ const last = anchors[anchors.length - 1];
121
+ lines.splice(last.line + 1, 0, bullet);
122
+ inserted = true;
123
+ } else {
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (/^##\s+Success criteria\b/i.test(lines[i])) {
126
+ // Insert before the next "## " heading (or at EOF).
127
+ let j = i + 1;
128
+ while (j < lines.length && !/^##\s+/.test(lines[j])) j += 1;
129
+ // Trim trailing blank lines inside the section for a tidy append.
130
+ let k = j;
131
+ while (k > i + 1 && lines[k - 1].trim() === "") k -= 1;
132
+ lines.splice(k, 0, bullet);
133
+ inserted = true;
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ if (!inserted) {
139
+ lines.push("", "## Success criteria", "", bullet);
140
+ }
141
+
142
+ write(p, lines.join("\n"), { force: true });
143
+ console.log(c.green("added") + ` [${id}] to ${relPath(projectRoot, p)}`);
144
+ console.log("");
145
+ console.log(` ${c.cyan(id.padEnd(6))} ${text}`);
146
+ console.log("");
147
+ console.log(c.gray("Next: the spec that delivers this intent declares it —"));
148
+ console.log(` ${c.cyan(`**Realizes:** ${id}`)} ${c.gray("(in .doctrina/specs/<capability>/spec.md; `doctrina trace` closes the loop)")}`);
149
+ return 0;
150
+ }
151
+
152
+ function dominantPrefix(anchors) {
153
+ const counts = new Map();
154
+ for (const a of anchors) counts.set(a.prefix, (counts.get(a.prefix) ?? 0) + 1);
155
+ return [...counts.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
156
+ }
157
+
158
+ export const help = `
159
+ Usage: doctrina intent add "<text>"
160
+ doctrina intent add "SC15: <text>"
161
+ doctrina intent list
162
+
163
+ Post-intake intent evolution. \`add\` appends a new intent anchor bullet
164
+ (\`- [SC5] <text>\`) to product.md's Success criteria, allocating the next
165
+ number automatically (or pin one with the "SC15: ..." form). New
166
+ capabilities born after the intake — brainstorms, pivots — get an anchor
167
+ to Realize instead of inevitably landing at "Realizes: n/a", so
168
+ \`doctrina trace\` keeps seeing the whole system as it grows.
169
+
170
+ \`list\` prints every anchor in document order (the same convention trace
171
+ reads). The CLI allocates and appends; the intent's wording is yours,
172
+ verbatim (ADR 0005).
173
+ `;
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool, flagString } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "./work.js";
9
+ import { parseDependsOn } from "../lib/scan.js";
9
10
  import { summarize as coverageSummary } from "./coverage.js";
10
11
  import { summarize as traceSummary } from "./trace.js";
11
12
 
@@ -42,18 +43,38 @@ export async function run(_positional, flags) {
42
43
  const notes = []; // advisory observations
43
44
 
44
45
  // 1. Code changed under a capability whose spec was NOT touched — the spec
45
- // may have drifted from the code it describes.
46
+ // may have drifted from the code it describes. Touched = every capability
47
+ // matched by the code diff (UNCAPPED — the top-3 hint truncation is how
48
+ // this missed 5 of 8 in the 0.11.0 field session) UNIONED with every
49
+ // capability whose spec.md itself changed.
46
50
  const specChanged = new Set(
47
51
  all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
48
52
  .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
49
53
  );
50
- const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles);
54
+ const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
55
+ const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
51
56
  for (const m of ranked) {
52
57
  if (!specChanged.has(m.id)) {
53
58
  breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
54
59
  }
55
60
  }
56
61
 
62
+ // 1b. Dependents of touched capabilities (the machine-readable
63
+ // **Depends on:** header): a spec that builds on something you changed
64
+ // may silently no longer hold. Advisory — a pointer, not a verdict.
65
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
66
+ if (isDir(specsDir) && touched.size > 0) {
67
+ for (const cap of readdirSync(specsDir).sort()) {
68
+ if (touched.has(cap)) continue;
69
+ const specPath = path.join(specsDir, cap, "spec.md");
70
+ if (!isFile(specPath)) continue;
71
+ const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
72
+ if (deps.length > 0) {
73
+ notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
74
+ }
75
+ }
76
+ }
77
+
57
78
  // 2. New source files that map to no capability at all — code with no home
58
79
  // in any spec (a likely new, unspecced capability).
59
80
  if (ranked.length === 0 && sourceFiles.length > 0) {
@@ -83,8 +104,8 @@ export async function run(_positional, flags) {
83
104
 
84
105
  // --- Output ---
85
106
  console.log(c.gray(`${all.length} changed path(s); ${sourceFiles.length} source file(s) outside .doctrina/.`));
86
- if (ranked.length > 0) {
87
- console.log(c.gray("Capabilities touched: ") + ranked.map((m) => c.cyan(m.id)).join(", "));
107
+ if (touched.size > 0) {
108
+ console.log(c.gray("Capabilities touched: ") + [...touched].sort().map((id) => c.cyan(id)).join(", "));
88
109
  }
89
110
  console.log("");
90
111
 
@@ -61,9 +61,23 @@ function skillSuggest(args, flags) {
61
61
  const since = flagString(flags, "since");
62
62
 
63
63
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
64
- const existing = new Set(
65
- isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")).map((f) => path.basename(f, ".md")) : [],
66
- );
64
+ const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
65
+ const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
66
+ // A lesson may already be captured under a DIFFERENT slug (0.11.0 field
67
+ // review item 11: the human names the skill after the lesson, not after the
68
+ // change id — "tela-preta-crash-de-render..." for change 0049). Two extra
69
+ // dedup signals: an existing skill whose body cites the candidate's change
70
+ // id / commit hash, and token-overlap similarity between the slugs.
71
+ const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
72
+ const captured = (cand) => {
73
+ if (existing.has(cand.slug)) return true;
74
+ for (const sk of existingBodies) {
75
+ if (cand.id && sk.body.includes(cand.id)) return true;
76
+ if (cand.from && sk.body.includes(cand.from)) return true;
77
+ if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
78
+ }
79
+ return false;
80
+ };
67
81
 
68
82
  // Two deterministic sources, deduped by slug. `seen` guards against a lesson
69
83
  // appearing twice (e.g. a fix committed and later archived as a change).
@@ -78,18 +92,20 @@ function skillSuggest(args, flags) {
78
92
  const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
79
93
  if (!FIX_SHAPED.test(id)) continue;
80
94
  const slug = skillSlug(id);
81
- if (existing.has(slug) || seen.has(slug)) continue; // captured or already queued
95
+ if (seen.has(slug)) continue;
96
+ const cand = { id, slug, source: "change", from: name };
97
+ if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
82
98
  const proposal = path.join(archiveDir, name, "proposal.md");
83
- const why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
99
+ cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
84
100
  seen.add(slug);
85
- candidates.push({ id, slug, why, source: "change", from: name });
101
+ candidates.push(cand);
86
102
  }
87
103
  }
88
104
 
89
105
  // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
90
106
  // slug collision: a `## Why` paragraph is a richer seed than a subject line.
91
107
  for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
92
- if (existing.has(cand.slug) || seen.has(cand.slug)) continue;
108
+ if (seen.has(cand.slug) || captured(cand)) continue;
93
109
  seen.add(cand.slug);
94
110
  candidates.push(cand);
95
111
  }
@@ -216,6 +232,29 @@ function skillSlug(id) {
216
232
  return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
217
233
  }
218
234
 
235
+ // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
236
+ // tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
237
+ // error-boundary" share tela/preta/render → high overlap even though the
238
+ // human renamed the skill after the lesson. Deterministic; never semantic.
239
+ function slugSimilarity(a, b) {
240
+ const toks = (s) => new Set(
241
+ String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
242
+ );
243
+ const A = toks(a);
244
+ const B = toks(b);
245
+ if (A.size === 0 || B.size === 0) return 0;
246
+ let inter = 0;
247
+ for (const t of A) if (B.has(t)) inter += 1;
248
+ return inter / (A.size + B.size - inter);
249
+ }
250
+
251
+ // Fix-shaped verbs and connectives carry no lesson identity — without this,
252
+ // every "fix-..." candidate looks 30% similar to every "fix-..." skill.
253
+ const SLUG_NOISE = new Set([
254
+ "fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
255
+ "que", "nao", "por", "para", "una", "uma", "dos", "das",
256
+ ]);
257
+
219
258
  function firstWhyLine(proposalText) {
220
259
  const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
221
260
  if (!m) return "";
@@ -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
  `;
@@ -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
@@ -0,0 +1,133 @@
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 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.
91
+ console.log("");
92
+ console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
93
+ const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
94
+ const vCode = await validate.run([], vFlags);
95
+
96
+ console.log("");
97
+ if (!writeMode) {
98
+ if (pending > 0) {
99
+ console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
100
+ return 1;
101
+ }
102
+ console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
103
+ return vCode;
104
+ }
105
+ if (vCode === 0) {
106
+ console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
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."));
108
+ } else {
109
+ console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
110
+ }
111
+ return vCode;
112
+ }
113
+
114
+ export const help = `
115
+ Usage: doctrina upgrade [--write]
116
+
117
+ Bring an existing project up to the installed CLI after an npm update.
118
+ The project keeps the scaffold of the version that init-ed it; this is
119
+ the one command that closes the gap, orchestrating the existing pieces:
120
+
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)
127
+ 2. index rebuild — regenerate index.json from the tree and migrate
128
+ the framework_version stamp to the running CLI
129
+ 3. validate (--fix) — surface anything the upgrade cannot fix
130
+ (hand-authored drift, new validate checks)
131
+
132
+ Preview by default (exits 1 when steps are pending); --write applies.
133
+ `;