doctrina-cli 0.12.0 → 0.14.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.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +76 -19
  5. package/src/commands/change.js +341 -44
  6. package/src/commands/clarify.js +40 -19
  7. package/src/commands/close.js +119 -11
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +152 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +14 -2
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +16 -6
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +277 -40
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +23 -12
  34. package/src/commands/validate.js +93 -3
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +69 -7
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +47 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +314 -3
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +228 -3
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/.gitkeep +0 -0
  67. package/templates/AGENTS.md.template +71 -75
  68. package/templates/change/spec-delta.md.template +13 -7
@@ -0,0 +1,140 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
+ import { locateTemplatesDir, substitute } from "./templates.js";
6
+
7
+ // Agent adapters: the thin per-agent files that point at AGENTS.md.
8
+ //
9
+ // Installing one used to be reachable ONLY through a full `doctrina init`
10
+ // run, and `init` refuses to run twice — so `init --force` was the only
11
+ // path, and it regenerated AGENTS.md and product.md from blank templates,
12
+ // destroying every hand-authored rule and the entire product definition
13
+ // with no warning (audit item C1, data loss). This module owns adapters as
14
+ // their own concern so adding one can be strictly additive.
15
+ //
16
+ // Three states, which the old surface could not tell apart:
17
+ // installed — the adapter's files are present in the project
18
+ // available — the adapter ships files and none are installed
19
+ // native — the agent reads AGENTS.md directly and needs no file
20
+ // (its template dir carries only a README)
21
+
22
+ export const ADAPTER_STATES = Object.freeze({
23
+ INSTALLED: "installed",
24
+ AVAILABLE: "available",
25
+ NATIVE: "native",
26
+ });
27
+
28
+ // Where a named adapter's templates live. A project-local adapter under
29
+ // `.doctrina/templates/adapters/<name>/` wins over the bundled one, so a
30
+ // team can ship an adapter for an agent Doctrina does not know about.
31
+ export function resolveAdapterDir(projectRoot, name) {
32
+ const local = path.join(projectRoot, ".doctrina", "templates", "adapters", name);
33
+ if (isDir(local) && walk(local).length > 0) return { dir: local, source: "project" };
34
+ const bundledRoot = path.join(locateTemplatesDir(), "adapters");
35
+ const bundled = path.join(bundledRoot, name);
36
+ if (isDir(bundled)) return { dir: bundled, source: "bundled" };
37
+ return null;
38
+ }
39
+
40
+ // Every adapter name known to this project: bundled plus project-local.
41
+ export function listAdapterNames(projectRoot) {
42
+ const names = new Set();
43
+ try {
44
+ const bundledRoot = path.join(locateTemplatesDir(), "adapters");
45
+ if (isDir(bundledRoot)) {
46
+ for (const e of readdirSync(bundledRoot)) {
47
+ if (isDir(path.join(bundledRoot, e))) names.add(e);
48
+ }
49
+ }
50
+ } catch { /* unusual install without a templates dir */ }
51
+ const localRoot = path.join(projectRoot, ".doctrina", "templates", "adapters");
52
+ if (isDir(localRoot)) {
53
+ for (const e of readdirSync(localRoot)) {
54
+ if (isDir(path.join(localRoot, e))) names.add(e);
55
+ }
56
+ }
57
+ return [...names].sort();
58
+ }
59
+
60
+ // The files a named adapter would write, as project-relative POSIX paths
61
+ // paired with their template source. A README inside the adapter template
62
+ // dir documents the adapter and is never materialised.
63
+ export function adapterFiles(projectRoot, name) {
64
+ const resolved = resolveAdapterDir(projectRoot, name);
65
+ if (!resolved) return null;
66
+ const out = [];
67
+ for (const templatePath of walk(resolved.dir)) {
68
+ const rel = relPath(resolved.dir, templatePath).replace(/\\/g, "/");
69
+ if (rel === "README.md" || rel.endsWith(".gitkeep")) continue;
70
+ out.push({
71
+ templatePath,
72
+ relativePath: rel.replace(/\.template$/, ""),
73
+ });
74
+ }
75
+ return { source: resolved.source, files: out.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) };
76
+ }
77
+
78
+ // Adapter state plus the files backing it, for `adapter list`.
79
+ export function describeAdapter(projectRoot, name) {
80
+ const spec = adapterFiles(projectRoot, name);
81
+ if (!spec) return { name, state: ADAPTER_STATES.AVAILABLE, source: "unknown", files: [], installed: [] };
82
+ if (spec.files.length === 0) {
83
+ // Reads AGENTS.md natively — "no adapter installed" and "no adapter
84
+ // needed" were indistinguishable before this distinction existed.
85
+ return { name, state: ADAPTER_STATES.NATIVE, source: spec.source, files: [], installed: [] };
86
+ }
87
+ const installed = spec.files.filter((f) => isFile(path.join(projectRoot, f.relativePath)));
88
+ return {
89
+ name,
90
+ source: spec.source,
91
+ files: spec.files,
92
+ installed,
93
+ state: installed.length > 0 ? ADAPTER_STATES.INSTALLED : ADAPTER_STATES.AVAILABLE,
94
+ };
95
+ }
96
+
97
+ // The AGENTS.md path an adapter file should point at, relative to its own
98
+ // location — a nested command file needs "../../AGENTS.md".
99
+ export function agentsMdPathFor(relativePath) {
100
+ const depth = relativePath.split("/").length - 1;
101
+ return depth === 0 ? "AGENTS.md" : "../".repeat(depth) + "AGENTS.md";
102
+ }
103
+
104
+ // Is this adapter file a HUB POINTER — a file whose job is to route the
105
+ // agent at AGENTS.md?
106
+ //
107
+ // Declared by the template itself: a template that uses the
108
+ // {{AGENTS_MD_PATH}} token is saying "I point at the hub". Everything else
109
+ // is a command shim — a Claude/Cursor slash command like `/doctrina-status`
110
+ // whose job is to invoke the CLI, and which reaches the hub through its
111
+ // parent CLAUDE.md rather than by naming it.
112
+ //
113
+ // This distinction is the fix for audit item C2: the pointer check demanded
114
+ // the literal string "AGENTS.md" in EVERY adapter file, so a clean
115
+ // `init --agent claude` failed `templates check` on four command shims that
116
+ // were never pointers, and neither suggested remedy could clear it.
117
+ export function isHubPointer(file) {
118
+ return read(file.templatePath).includes("{{AGENTS_MD_PATH}}");
119
+ }
120
+
121
+ // Render one adapter file's body for this project.
122
+ export function renderAdapterFile(file, tokens) {
123
+ return substitute(read(file.templatePath), {
124
+ ...tokens,
125
+ AGENTS_MD_PATH: agentsMdPathFor(file.relativePath),
126
+ });
127
+ }
128
+
129
+ // Is this project-relative path one that some adapter owns? Used by
130
+ // `adapter remove` so it deletes only what an adapter created, and by
131
+ // `init` so it never counts adapter files as user content.
132
+ export function isAdapterOwnedPath(projectRoot, relPathPosix) {
133
+ for (const name of listAdapterNames(projectRoot)) {
134
+ const spec = adapterFiles(projectRoot, name);
135
+ if (spec?.files.some((f) => f.relativePath === relPathPosix)) return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ export { exists };
@@ -0,0 +1,47 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { isDir, read, walk } from "./fs-ops.js";
4
+ import { listHeader } from "./scan.js";
5
+
6
+ // Deterministic ADR checkpoint (operator review 2026-07-19 §4.6). The work
7
+ // playbook's "record an ADR" step was ignorable in silence: a change that
8
+ // flexibilised an accepted ADR closed with no amendment and nothing barked.
9
+ // This helper names the accepted ADRs whose text cites any of the change's
10
+ // touched capabilities, so `change check` and `close` can print a loud,
11
+ // advisory reminder: if the change alters what the ADR decided, record it
12
+ // (`decision supersede` / `decision new`) instead of drifting past it.
13
+ // A word match on capability names — a hint, never a decision (ADR 0005),
14
+ // and never a blocker (an ADR merely MENTIONING a capability is normal).
15
+ export function adrAdvisories(projectRoot, capabilities) {
16
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
17
+ if (!isDir(adrDir) || capabilities.length === 0) return [];
18
+ const out = [];
19
+ for (const f of walk(adrDir)) {
20
+ const base = path.basename(f);
21
+ const m = base.match(/^(\d{4})-.*\.md$/);
22
+ if (!m) continue;
23
+ const text = read(f);
24
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
25
+ const caps = capabilities.filter((cap) =>
26
+ new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text),
27
+ );
28
+ if (caps.length === 0) continue;
29
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
30
+ out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : base, caps });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // Print the advisory block shared by `change check` and `close`. Returns the
36
+ // number of ADRs flagged (informational; callers never gate on it).
37
+ export function printAdrCheckpoint(projectRoot, capabilities, { c }) {
38
+ const hits = adrAdvisories(projectRoot, capabilities);
39
+ if (hits.length === 0) return 0;
40
+ console.log(c.yellow("ADR checkpoint") + c.gray(" — accepted ADRs cite the touched capabilities:"));
41
+ for (const h of hits) {
42
+ console.log(` ADR ${c.cyan(h.id)} ${h.title} ${c.gray(`(cites: ${h.caps.join(", ")})`)}`);
43
+ }
44
+ console.log(c.gray(" If this change alters what an ADR decided, record it now:"));
45
+ console.log(c.gray(" ") + c.cyan("doctrina decision supersede <n>") + c.gray(" · ") + c.cyan("doctrina decision new \"<title>\""));
46
+ return hits.length;
47
+ }
package/src/lib/args.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Tiny argv parser. Returns { positional: string[], flags: Map<string, string|boolean> }.
2
3
  // Supports:
3
4
  // --flag -> flags.set("flag", true)
@@ -82,6 +83,15 @@ export function flagString(flags, name, fallback = undefined) {
82
83
  return String(v);
83
84
  }
84
85
 
86
+ // True when a value-taking flag was written WITHOUT one — `--budget` at the
87
+ // end of the line, or `--budget -100`, where the parser cannot tell a value
88
+ // from the next flag. `flagString` reports both as absent, so without this
89
+ // a mistyped number silently becomes "use the default", which is the one
90
+ // outcome the author definitely did not ask for.
91
+ export function flagGivenWithoutValue(flags, name) {
92
+ return flags.get(name) === true;
93
+ }
94
+
85
95
  export function flagBool(flags, name, fallback = false) {
86
96
  const v = flags.get(name);
87
97
  if (v === undefined) return fallback;
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Deterministic under-specification detector for free-text briefs and
2
3
  // intakes — the clarification gate (review Topic A). It does NOT judge
3
4
  // meaning (that stays the agent's job, ADR 0005); it flags input that is
package/src/lib/colors.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // ANSI colour helpers. Falls back to plain text when:
2
3
  // - stdout is not a TTY, OR
3
4
  // - the NO_COLOR environment variable is set (any value), OR
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Single canonical list of top-level `doctrina` commands.
2
3
  //
3
4
  // This is the one place the command surface is named. `src/index.js` builds
@@ -13,7 +14,7 @@ export const COMMAND_NAMES = [
13
14
  // bootstrap / day-to-day
14
15
  "init", "intake", "work",
15
16
  // authoring
16
- "spec", "change", "contract", "decision", "skill", "intent",
17
+ "spec", "change", "contract", "decision", "skill", "intent", "adapter",
17
18
  // read / orient
18
19
  "prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution",
19
20
  // gates
@@ -40,8 +41,10 @@ export const OPERATIONS = [
40
41
  ["spec list", "List specs with version, status, and size"],
41
42
  ["spec set", "Edit spec headers / a criterion mark and resync the index"],
42
43
  ["change new", "Open a change proposal"],
43
- ["change apply", "Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)"],
44
- ["change archive", "Archive an applied change"],
44
+ ["change apply", "Apply spec deltas (ops blocks mechanically; batch ids ok)"],
45
+ ["change archive", "Archive an applied change (batch ids ok)"],
46
+ ["change check", "Pre-close dry-run: everything close would refuse, listed first"],
47
+ ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
45
48
  ["change diff", "Preview spec deltas (line diff for MODIFIED)"],
46
49
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
47
50
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
@@ -52,10 +55,14 @@ export const OPERATIONS = [
52
55
  ["decision land", "Record that an accepted ADR is now implemented (non-mutating)"],
53
56
  ["decision supersede", "Create a new ADR that supersedes an existing one"],
54
57
  ["decision list", "List ADRs with status, date, and title"],
58
+ ["decision scope", "Show and propose the capabilities each ADR governs"],
55
59
  ["skill new", "Scaffold an on-demand procedural memory skill"],
56
60
  ["skill list", "List skills with their descriptions"],
57
61
  ["skill sync", "Mirror skill frontmatter descriptions into index.json"],
58
62
  ["skill suggest", "Surface fix-shaped lessons worth a skill (--write scaffolds)"],
63
+ ["adapter list", "Inventory agent adapters: installed / available / native"],
64
+ ["adapter add", "Install one agent adapter (additive; never touches AGENTS.md)"],
65
+ ["adapter remove", "Delete the files an adapter installed"],
59
66
  ["intent add", "Append a new product intent anchor post-intake (- [SC5] ...)"],
60
67
  ["intent list", "List product.md intent anchors in document order"],
61
68
  ["analyze", "Inspect a change folder before applying"],
@@ -88,12 +95,263 @@ export const OPERATIONS = [
88
95
  ["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
89
96
  ];
90
97
 
98
+ // ---------------------------------------------------------------------------
99
+ // Per-command PURPOSE and WHEN (audit item M2).
100
+ //
101
+ // The generated surface block was a name index. An agent reading
102
+ // "`doctrina trace --strict`" learns that the command exists and nothing
103
+ // about what triggers it, what it costs, or what it returns — so learning
104
+ // the surface meant running `--help` 35 times, roughly the whole context
105
+ // budget spent on a lookup.
106
+ //
107
+ // Doctrina already solved this for SKILLS: `context` prints a description
108
+ // and a `when:` trigger per skill and tells the agent to load the body only
109
+ // when the trigger fires. Commands get the same treatment.
110
+ //
111
+ // `when` is the moment you reach for it, in the agent's own terms.
112
+ // `purpose` is one line on what it does. Both are required: a command that
113
+ // cannot state its trigger has not earned a place on the surface, and
114
+ // `templates check` fails when either is missing.
115
+ export const COMMAND_META = {
116
+ init: { moment: "Bootstrap", when: "starting a project that has no AGENTS.md yet", purpose: "scaffold AGENTS.md and .doctrina/" },
117
+ intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
118
+ adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
119
+
120
+ prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, standing rules, and open work in one read" },
121
+ status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
122
+ next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
123
+ context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
124
+ show: { moment: "Orient", when: "you need one requirement, criterion, or ADR, not a file", purpose: "point-read a single artifact by reference" },
125
+ search: { moment: "Orient", when: "you do not know which artifact mentions a term", purpose: "search the artifact tree, grouped by category" },
126
+ why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
127
+ constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
128
+ handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
129
+
130
+ work: { moment: "Change", when: "any request arrives that changes behaviour", purpose: "scaffold a change and print the playbook to execute" },
131
+ spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
132
+ change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick / diff / abandon" },
133
+ decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
134
+ contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
135
+ intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
136
+ skill: { moment: "Change", when: "a lesson is worth not relearning", purpose: "capture on-demand procedural memory" },
137
+
138
+ analyze: { moment: "Gate", when: "before applying a change", purpose: "structural pre-flight on a change folder" },
139
+ clarify: { moment: "Gate", when: "before applying, or before opening a PR", purpose: "smell-test Markdown for ambiguity" },
140
+ validate: { moment: "Gate", when: "after any artifact edit, and before considering work done", purpose: "schema, structure, EARS, and index drift" },
141
+ coverage: { moment: "Gate", when: "before claiming a capability is proven", purpose: "acceptance criteria against cited evidence" },
142
+ trace: { moment: "Gate", when: "checking that product intent still maps to capabilities", purpose: "intent provenance across the tree" },
143
+ review: { moment: "Gate", when: "before handing work back, to self-review it", purpose: "conformance of your changes vs specs/ADRs/contracts" },
144
+ verify: { moment: "Gate", when: "the real build gate must run", purpose: "the project's declared typecheck/test/build checks" },
145
+ close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
146
+ doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
147
+
148
+ templates: { moment: "Maintain", when: "after upgrading the CLI, or to customise a scaffold", purpose: "inspect, check, and refresh the template shape" },
149
+ upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
150
+ index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
151
+ hooks: { moment: "Maintain", when: "setting up a repo so drift cannot be committed", purpose: "install the pre-commit gate" },
152
+ watch: { moment: "Maintain", when: "you want the tree re-synced on every save", purpose: "re-run validate --fix and next continuously" },
153
+ metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
154
+ report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
155
+ completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
156
+ };
157
+
158
+ // The order moments appear in the generated block.
159
+ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
160
+
91
161
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
92
162
  // printed surface can never omit an operation the catalog knows about.
93
163
  export function surfaceHelp() {
94
164
  return OPERATIONS.map(([op, summary]) => ` ${op.padEnd(21)}${summary}`).join("\n");
95
165
  }
96
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
169
+ // meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
170
+ // never runs `--help` spontaneously — so every command absent from the hub
171
+ // was invisible for ~35 changes (`prime`, `handoff`, `doctor`, `show`, ...).
172
+ // The fix is a marker-delimited section GENERATED from OPERATIONS, so the
173
+ // hub's catalog is the CLI's catalog by construction: `init` scaffolds it,
174
+ // `templates check` flags it stale, and `templates update --write` (hence
175
+ // `doctrina upgrade --write`) regenerates exactly the marked span and
176
+ // nothing else. Content between the markers is CLI-owned; everything
177
+ // outside remains the adopter's, under the additive-only guarantee.
178
+
179
+ export const SURFACE_BEGIN =
180
+ "<!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->";
181
+ export const SURFACE_END = "<!-- doctrina:surface:end -->";
182
+
183
+
184
+ // Per-command usage hints appended to the generated reference — arguments and
185
+ // the flags an agent reaches for daily. Cosmetic only; the operation list
186
+ // itself always comes from OPERATIONS.
187
+ const SURFACE_HINTS = {
188
+ work: '"<prompt>" (--capability · --chore · --from-diff · --quiet)',
189
+ context: "[<cap>] --for \"<task>\" --concat",
190
+ validate: "(--fix)",
191
+ coverage: "--strict",
192
+ trace: "--strict",
193
+ clarify: "--all (--lang pt|en)",
194
+ close: "<id...>",
195
+ upgrade: "--write",
196
+ prime: "(session start)",
197
+ handoff: "(before compaction/handover)",
198
+ };
199
+
200
+ // The declared size budget for the generated block. A surface that cannot
201
+ // describe itself in this many lines is too large — the answer is to cut
202
+ // commands (M8), never to raise the number.
203
+ export const SURFACE_LINE_BUDGET = 40;
204
+
205
+ // Moments an agent works IN get a trigger per command. "Maintain" is
206
+ // compressed to one line: those commands are reached for by a human after
207
+ // an upgrade or when wiring a repo, not by an agent mid-loop, and giving
208
+ // each its own line pushed the block past its budget. The budget is the
209
+ // forcing function — a surface that cannot describe itself in 40 lines is
210
+ // too large, and the answer is to cut commands (M8), not to raise it.
211
+ const COMPACT_MOMENTS = new Set(["Maintain"]);
212
+
213
+ // The full managed section, heading included, between the markers.
214
+ //
215
+ // Organised by MOMENT rather than by category, and each line carries the
216
+ // trigger: an agent scanning it learns WHEN to reach for a command, which
217
+ // a name index never told it. Same shape `context` prints for skills —
218
+ // description plus `when:`, body on demand.
219
+ export function surfaceMarkdown() {
220
+ const subsByCmd = new Map();
221
+ for (const [op] of OPERATIONS) {
222
+ const [cmd, sub] = op.split(" ");
223
+ if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
224
+ if (sub) subsByCmd.get(cmd).push(sub);
225
+ }
226
+ const invocation = (cmd) => {
227
+ const subs = subsByCmd.get(cmd) ?? [];
228
+ const hint = SURFACE_HINTS[cmd] ? ` ${SURFACE_HINTS[cmd]}` : "";
229
+ return subs.length > 0 ? `${cmd} ${subs.join("|")}` : `${cmd}${hint}`;
230
+ };
231
+
232
+ const lines = [
233
+ "## Doctrina command surface (generated — reach for these, don't hand-author)",
234
+ "",
235
+ "Every command, with the moment you reach for it. The CLI scaffolds from",
236
+ "canonical templates and keeps `index.json` in sync — prefer it over",
237
+ "hand-authoring. Flags and detail: `doctrina <command> --help`.",
238
+ ];
239
+ for (const moment of MOMENTS) {
240
+ const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
241
+ if (cmds.length === 0) continue;
242
+ if (COMPACT_MOMENTS.has(moment)) {
243
+ // Each name still carries the `doctrina ` prefix: the compact line is
244
+ // read by validate's drift gate too, which only recognises a command
245
+ // reference in that form.
246
+ lines.push(`**${moment}** (triggers: \`doctrina <command> --help\`) — ` +
247
+ cmds.map((n) => `\`doctrina ${invocation(n)}\``).join(" · "));
248
+ continue;
249
+ }
250
+ lines.push(`**${moment}**`);
251
+ for (const cmd of cmds) {
252
+ const meta = COMMAND_META[cmd];
253
+ lines.push(`- \`doctrina ${invocation(cmd)}\` — ${meta.purpose}. *When:* ${meta.when}.`);
254
+ }
255
+ }
256
+ return lines.join("\n");
257
+ }
258
+
259
+ // The block as written to AGENTS.md: markers wrapping the generated section.
260
+ export function surfaceBlock() {
261
+ return `${SURFACE_BEGIN}\n${surfaceMarkdown()}\n${SURFACE_END}`;
262
+ }
263
+
264
+ // The block's CANONICAL POSITION, defined once — by the shipped template,
265
+ // which is the only place the intended layout exists.
266
+ //
267
+ // `templates update` used to APPEND the block, so the same CLI produced two
268
+ // different AGENTS.md layouts: third section on a fresh `init`, dead last
269
+ // after an `upgrade` — behind 117 lines the agent reads first, which is the
270
+ // attention problem design principle #2 exists to prevent, and with no
271
+ // heading of its own before it, so a hierarchical parse read the command
272
+ // surface as content of "## What never goes in this file" (audit item C4).
273
+ //
274
+ // Returns { after, before }: the heading text the block follows in the
275
+ // template, and the heading it precedes. Either may be null.
276
+ export function surfaceAnchors(templateText) {
277
+ const block = findSurfaceBlock(templateText);
278
+ if (!block) return { after: null, before: null };
279
+ const headingsBefore = [...templateText.slice(0, block.start).matchAll(/^##\s+.*$/gm)];
280
+ const headingAfter = templateText.slice(block.end).match(/^##\s+.*$/m);
281
+ return {
282
+ after: headingsBefore.length > 0 ? headingsBefore[headingsBefore.length - 1][0].trim() : null,
283
+ before: headingAfter ? headingAfter[0].trim() : null,
284
+ };
285
+ }
286
+
287
+ // Place `block` into `text` at the canonical position, returning the new
288
+ // text. Used by both `init` (via the template, which already has it there)
289
+ // and `templates update` (on a project that has none), so the two produce
290
+ // the same layout and running it twice is a no-op.
291
+ export function placeSurfaceBlock(text, block, anchors) {
292
+ const existing = findSurfaceBlock(text);
293
+ if (existing) return text.slice(0, existing.start) + block + text.slice(existing.end);
294
+
295
+ const lines = text.split("\n");
296
+ const headingIndex = (heading) =>
297
+ heading ? lines.findIndex((l) => l.trim() === heading) : -1;
298
+
299
+ // A section ends at the next heading OR at the start of another CLI-owned
300
+ // block. The marker comment precedes that block's heading, so scanning for
301
+ // headings alone would place this block BETWEEN a marker and its own
302
+ // heading — nesting the two, after which the next regeneration treats the
303
+ // outer span as stale and deletes everything inside it. That corrupted a
304
+ // real file before this guard existed.
305
+ const isBoundary = (line) => /^##\s+/.test(line) || /^\s*<!--\s*doctrina:\w+:begin/.test(line);
306
+
307
+ // Preferred: immediately after the section the template puts it after.
308
+ const afterIdx = headingIndex(anchors.after);
309
+ if (afterIdx >= 0) {
310
+ let end = afterIdx + 1;
311
+ while (end < lines.length && !isBoundary(lines[end])) end += 1;
312
+ const head = lines.slice(0, end).join("\n").replace(/\s+$/, "");
313
+ const tail = lines.slice(end).join("\n");
314
+ return `${head}\n\n${block}\n\n${tail}`.replace(/\n{3,}/g, "\n\n");
315
+ }
316
+
317
+ // Next: immediately before the section the template puts it before.
318
+ const beforeIdx = headingIndex(anchors.before);
319
+ if (beforeIdx >= 0) {
320
+ const head = lines.slice(0, beforeIdx).join("\n").replace(/\s+$/, "");
321
+ const tail = lines.slice(beforeIdx).join("\n");
322
+ return `${head}\n\n${block}\n\n${tail}`.replace(/\n{3,}/g, "\n\n");
323
+ }
324
+
325
+ // Last resort: append, with a trailing newline so the file stays well-formed.
326
+ return `${text.replace(/\s+$/, "")}\n\n${block}\n`;
327
+ }
328
+
329
+ // Locate the managed block in an AGENTS.md text. Returns
330
+ // { start, end, inner } (string offsets; inner excludes the marker lines)
331
+ // or null when either marker is absent.
332
+ export function findSurfaceBlock(text) {
333
+ return findMarkedBlock(text, "surface");
334
+ }
335
+
336
+ // Shared locator for the CLI-owned marker blocks (surface, changed).
337
+ // Returns { start, end, inner } as string offsets, or null when either
338
+ // marker is missing.
339
+ function findMarkedBlock(text, name) {
340
+ // Note the doubled backslashes: these are TEMPLATE literals, so `\s`
341
+ // would collapse to a bare "s" before the RegExp ever sees it.
342
+ const begin = text.match(new RegExp(`<!--\\s*doctrina:${name}:begin[\\s\\S]*?-->`));
343
+ if (!begin) return null;
344
+ const endRe = new RegExp(`<!--\\s*doctrina:${name}:end\\s*-->`);
345
+ const end = endRe.exec(text.slice(begin.index + begin[0].length));
346
+ if (!end) return null;
347
+ const innerStart = begin.index + begin[0].length;
348
+ return {
349
+ start: begin.index,
350
+ end: innerStart + end.index + end[0].length,
351
+ inner: text.slice(innerStart, innerStart + end.index).replace(/^\r?\n|\r?\n$/g, ""),
352
+ };
353
+ }
354
+
97
355
  // Extract the set of top-level `doctrina <command>` references that appear in
98
356
  // CODE context (fenced blocks or inline backtick spans) of a Markdown file —
99
357
  // AGENTS.md, typically. Restricting to code context avoids matching prose like
@@ -116,3 +374,56 @@ export function referencedCommands(markdown) {
116
374
  }
117
375
  return out;
118
376
  }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // The agent-facing changelog (M2, part 3).
380
+ //
381
+ // Reported from real use: when a new command shipped, the LLM driving
382
+ // Doctrina had no idea the capability existed. It only found out by running
383
+ // `doctrina --help` — which it had no reason to run, because nothing told
384
+ // it anything had changed.
385
+ //
386
+ // CHANGELOG.md is 46 KB of human prose and far too large to carry into a
387
+ // context window. This is three to six lines stating only what alters
388
+ // AGENT behaviour: a new capability, a changed trigger, a removed command.
389
+ // `upgrade --write` refreshes it, so an agent reading AGENTS.md after an
390
+ // upgrade learns what is new without being told to look.
391
+
392
+ export const AGENT_CHANGELOG_BEGIN =
393
+ "<!-- doctrina:changed:begin — CLI-owned. Regenerated by `doctrina upgrade --write`. -->";
394
+ export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
395
+
396
+ // What changed for an AGENT, per version. Keyed by the version that
397
+ // introduced it. Only entries that alter what an agent should DO belong
398
+ // here — a bug fix nobody's behaviour depends on does not.
399
+ export const AGENT_CHANGELOG = {
400
+ "0.14.0": [
401
+ "`doctrina adapter add|remove|list` — add an agent without re-scaffolding; `init --force` no longer overwrites authored AGENTS.md/product.md.",
402
+ "`doctrina change apply` now refuses what `analyze` refuses; `change check` previews a close, `change tick` checks boxes in bulk.",
403
+ "Exit codes are a 5-class contract: 1 gate, 2 usage, 3 precondition, 4 environment. Branch on the code, not the prose.",
404
+ "A change that alters a documented surface must carry its docs — `close` refuses otherwise (`--force` records the gap).",
405
+ "Project templates under `.doctrina/templates/` now override the bundled ones, per file.",
406
+ ],
407
+ };
408
+
409
+ // The block written into AGENTS.md for a given version. Empty string when
410
+ // that version has no agent-visible changes.
411
+ export function agentChangelogMarkdown(version) {
412
+ const entries = AGENT_CHANGELOG[version];
413
+ if (!entries || entries.length === 0) return "";
414
+ return [
415
+ `## What changed in ${version}`,
416
+ "",
417
+ ...entries.map((e) => `- ${e}`),
418
+ ].join("\n");
419
+ }
420
+
421
+ export function agentChangelogBlock(version) {
422
+ const md = agentChangelogMarkdown(version);
423
+ if (!md) return "";
424
+ return `${AGENT_CHANGELOG_BEGIN}\n${md}\n${AGENT_CHANGELOG_END}`;
425
+ }
426
+
427
+ export function findAgentChangelogBlock(text) {
428
+ return findMarkedBlock(text, "changed");
429
+ }
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Shared parser for the "## Acceptance criteria" section of a spec.
2
3
  //
3
4
  // A criterion is a numbered item that MAY span several lines (continuation
package/src/lib/dates.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Date helpers. Single source so the CLI emits consistent YYYY-MM-DD strings.
2
3
 
3
4
  export function today(now = new Date()) {
package/src/lib/diff.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Minimal line diff (LCS-based) for `change diff`. Specs are capped at a
2
3
  // few hundred lines, so the O(n*m) table is fine and keeps us at zero deps.
3
4