doctrina-cli 0.13.0 → 0.15.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 (71) 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 +120 -17
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  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 +139 -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 +38 -3
  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 +201 -8
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. package/templates/spec.md.template +22 -0
@@ -1,34 +1,68 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { spawnSync } from "node:child_process";
5
6
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
6
- import { listHeader, parseDependsOn } from "../lib/scan.js";
7
+ import { listHeader, parseDependsOn, parseAdrScope, adrSummary } from "../lib/scan.js";
8
+ import { getTitle, getSectionParagraph } from "../lib/doc-model.js";
7
9
  import { parseFrontmatter } from "./skill.js";
8
- import { flagBool, flagString } from "../lib/args.js";
10
+ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
9
11
  import { c } from "../lib/colors.js";
12
+ import { GIT_STATE, historyState } from "../lib/git.js";
13
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
14
+ import * as idx from "../lib/index-json.js";
10
15
 
11
16
  // Materialise the AGENTS.md read order as a command: print the exact
12
17
  // context pack for a task, in the order an agent should read it. This is
13
18
  // the context-engineering thesis turned into tooling — selection over
14
19
  // dumping, ranked by the documented read order.
15
20
  //
16
- // The pack now carries a token estimate per file (chars/4 — the standard
17
- // rough heuristic; the thesis the product is built on is that token spend
18
- // predicts performance, so the pack must be measurable in tokens, not only
19
- // lines). `--budget <n>` turns the estimate into a gate, and `--diff <ref>`
20
- // scopes the pack to what changed since a git ref — the resume-session read.
21
+ // Context assembly is RETRIEVAL, not a dump (ADR 0022). A pack that grows
22
+ // with the project's age rather than with the task at hand stops being a
23
+ // context pack and becomes the corpus. Three mechanisms keep it bounded:
24
+ //
25
+ // 1. SCOPE — an ADR declaring `- **Scope:** <cap>` joins only the packs
26
+ // for those capabilities. Unscoped means global, which is
27
+ // also the backward-compatible default.
28
+ // 2. BUDGET — a token ceiling always applies (flag > index.json config >
29
+ // DEFAULT_BUDGET). The pack is assembled to FIT it.
30
+ // 3. DEGRADE — over budget, ADRs fall back to title + summary before
31
+ // anything is dropped, least-relevant first. A decision
32
+ // reduced to one sentence still carries the decision; an
33
+ // omitted one carries nothing.
34
+ //
35
+ // `--for "<task>"` makes the ordering query-driven, so what survives the
36
+ // budget is what the task is about. `--diff <ref>` scopes the pack to what
37
+ // changed since a git ref — the resume-session read.
21
38
 
22
39
  const TOKEN_DIVISOR = 4;
23
40
 
24
- export async function run(positional, flags) {
41
+ // The ceiling a pack is assembled to when the project has not set one.
42
+ // Override per project with `"config": { "context_budget": <n> }` in
43
+ // .doctrina/index.json, or per call with `--budget`.
44
+ export const DEFAULT_BUDGET = 15000;
45
+
46
+ // Tiers, worst-first in the degradation ladder. The CORE is the pack's
47
+ // irreducible minimum: the rules, the product truth, the named capability's
48
+ // spec, and the work in flight. It is never degraded and never dropped — if
49
+ // it alone exceeds the budget, that is a finding, not something to hide.
50
+ const TIER = { CORE: 0, SPEC: 1, DEPENDENCY: 2, DECISION: 3 };
51
+
52
+ // Flags this command accepts. Declared HERE, with the command, so
53
+ // adding a command never requires editing the entrypoint — the gap that
54
+ // let six flags ship undeclared and silently swallow a positional (C3).
55
+ export const flags = { boolean: ["json", "concat"], string: ["budget", "diff", "for"] };
56
+
57
+ export async function run(positional, cmdFlags) {
25
58
  const capability = positional[0] ?? null;
26
- const concat = flagBool(flags, "concat", false);
27
- const budgetRaw = flagString(flags, "budget");
28
- const diffRef = flagString(flags, "diff");
59
+ const concat = flagBool(cmdFlags, "concat", false);
60
+ const budgetRaw = flagString(cmdFlags, "budget");
61
+ const diffRef = flagString(cmdFlags, "diff");
62
+ const query = flagString(cmdFlags, "for");
29
63
  const projectRoot = process.cwd();
30
64
  if (!exists(path.join(projectRoot, ".doctrina"))) {
31
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
65
+ throw notADoctrinaProject();
32
66
  }
33
67
 
34
68
  if (capability && !/^[a-z][a-z0-9-]*$/.test(capability)) {
@@ -36,15 +70,29 @@ export async function run(positional, flags) {
36
70
  return 2;
37
71
  }
38
72
 
39
- let budget = null;
40
- if (budgetRaw !== undefined) {
41
- budget = Number.parseInt(budgetRaw, 10);
42
- if (!Number.isFinite(budget) || budget <= 0) {
43
- console.error(c.red("error:") + ` --budget expects a positive token count, got "${budgetRaw}"`);
73
+ // A value-taking flag written without a value is a usage error, not a
74
+ // silent fall-back to the default — `--budget -100` reaches here as
75
+ // "budget given, value unreadable", and quietly using the default would
76
+ // hide the typo behind a pack that looks fine.
77
+ for (const name of ["budget", "diff", "for"]) {
78
+ if (flagGivenWithoutValue(cmdFlags, name)) {
79
+ console.error(c.red("error:") + ` --${name} needs a value`);
44
80
  return 2;
45
81
  }
46
82
  }
47
83
 
84
+ const budget = resolveBudget(projectRoot, budgetRaw);
85
+ if (budget === null) {
86
+ console.error(c.red("error:") + ` --budget expects a positive token count, got "${budgetRaw}"`);
87
+ return 2;
88
+ }
89
+
90
+ const terms = queryTerms(query);
91
+ if (query !== undefined && terms.length === 0) {
92
+ console.error(c.red("error:") + " --for needs at least one word to retrieve on");
93
+ return 2;
94
+ }
95
+
48
96
  // --diff <ref>: restrict the stable artifacts to those changed since the
49
97
  // ref (a resuming session already carries the rest); open changes are
50
98
  // always in — they ARE the work being resumed.
@@ -57,11 +105,23 @@ export async function run(positional, flags) {
57
105
  const wanted = (rel) => changed === null || changed.has(rel.replaceAll("\\", "/"));
58
106
 
59
107
  const pack = [];
60
- const pushFile = (rel, note) => {
108
+ const pushFile = (rel, note, extra = {}) => {
61
109
  const full = path.join(projectRoot, rel);
62
- if (!isFile(full)) return;
110
+ if (!isFile(full)) return null;
63
111
  const text = read(full);
64
- pack.push({ rel, lines: text.split(/\r?\n/).length, tokens: Math.round(text.length / TOKEN_DIVISOR), note });
112
+ const item = {
113
+ rel,
114
+ lines: text.split(/\r?\n/).length,
115
+ tokens: estimateTokens(text),
116
+ fullTokens: estimateTokens(text),
117
+ note,
118
+ tier: TIER.CORE,
119
+ rank: [],
120
+ degraded: false,
121
+ ...extra,
122
+ };
123
+ pack.push(item);
124
+ return item;
65
125
  };
66
126
 
67
127
  // 1-2. Root rules and product truth — always first. Displayed paths are
@@ -74,8 +134,13 @@ export async function run(positional, flags) {
74
134
  // read previously jumped from product.md straight to the ADRs, leaving
75
135
  // the single source of truth out of the pack entirely. Specs come before
76
136
  // open changes and ADRs, matching the documented read order.
137
+ //
138
+ // With --for and no named capability, the specs are RANKED by the query
139
+ // and become degradable: retrieval is what decides which truths this
140
+ // task needs, and the budget is what enforces the decision.
77
141
  let specMissing = false;
78
142
  const specsRoot = path.join(projectRoot, ".doctrina", "specs");
143
+ const dependencies = new Set();
79
144
  if (capability) {
80
145
  const specRel = `.doctrina/specs/${capability}/spec.md`;
81
146
  if (isFile(path.join(projectRoot, specRel))) {
@@ -87,19 +152,39 @@ export async function run(positional, flags) {
87
152
  for (const dep of parseDependsOn(read(path.join(projectRoot, specRel)))) {
88
153
  if (dep === capability) continue;
89
154
  const depRel = `.doctrina/specs/${dep}/spec.md`;
90
- if (isFile(path.join(projectRoot, depRel))) pushFile(depRel, `dependency of ${capability}`);
155
+ if (isFile(path.join(projectRoot, depRel))) {
156
+ dependencies.add(dep);
157
+ pushFile(depRel, `dependency of ${capability}`, { tier: TIER.DEPENDENCY });
158
+ }
91
159
  }
92
160
  } else {
93
161
  specMissing = true;
94
162
  }
95
163
  } else if (isDir(specsRoot)) {
164
+ // No capability named: this is the orientation read, and it cannot
165
+ // hold every spec in full on a project of any size. Specs are
166
+ // SUMMARISABLE here — title + purpose — so the pack degrades into a
167
+ // map of what the capabilities are and what each is for. Naming one
168
+ // is how you ask for its truth in full. That is the whole thesis:
169
+ // without a task there is nothing to retrieve ON, so the honest
170
+ // answer is an index, not a dump.
96
171
  for (const cap of readdirSync(specsRoot).sort()) {
97
172
  const specRel = `.doctrina/specs/${cap}/spec.md`;
98
- if (wanted(specRel) && isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
173
+ const full = path.join(projectRoot, specRel);
174
+ if (!wanted(specRel) || !isFile(full)) continue;
175
+ const text = read(full);
176
+ pushFile(specRel, `spec: ${cap}`, {
177
+ tier: TIER.SPEC,
178
+ rank: relevance(text, terms, `${cap} ${getTitle(text) ?? ""}`),
179
+ title: getTitle(text) ?? cap,
180
+ summary: sectionSummary(text, "Purpose"),
181
+ });
99
182
  }
100
183
  }
101
184
 
102
- // 4. Open changes (their proposal, tasks, and deltas) — never diff-filtered.
185
+ // 4. Open changes (their proposal, tasks, and deltas) — never diff-filtered,
186
+ // never degraded. Work in flight is the one thing a resuming agent
187
+ // cannot reconstruct from anywhere else.
103
188
  const changesDir = path.join(projectRoot, ".doctrina", "changes");
104
189
  if (isDir(changesDir)) {
105
190
  for (const id of readdirSync(changesDir).sort()) {
@@ -112,63 +197,119 @@ export async function run(positional, flags) {
112
197
  }
113
198
  }
114
199
 
115
- // 5. Accepted ADRs only — superseded/withdrawn stay out of the pack.
200
+ // 5. Accepted ADRs — superseded and withdrawn stay out of the pack, and
201
+ // a SCOPED ADR joins only the packs of the capabilities it governs.
202
+ // Without scoping, every accepted decision loads into every pack
203
+ // forever: ADRs are immutable and never retire, so the pack grows
204
+ // with the project's age and nothing ever decays out of it.
116
205
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
117
206
  for (const f of walk(adrDir)) {
118
207
  if (!f.endsWith(".md")) continue;
119
208
  const rel = relPath(projectRoot, f);
120
209
  if (!wanted(rel)) continue;
121
- const status = (listHeader(read(f), "Status") ?? "").toLowerCase();
122
- if (status === "accepted") pushFile(rel, "accepted ADR");
210
+ const text = read(f);
211
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
212
+
213
+ const scope = parseAdrScope(text);
214
+ // An unscoped ADR is global by definition — it belongs in every pack.
215
+ // That is what makes scoping opt-in and backward compatible.
216
+ const global = scope.length === 0;
217
+ const governs = capability !== null
218
+ && (scope.includes(capability) || scope.some((s) => dependencies.has(s)));
219
+ if (capability !== null && !global && !governs) continue;
220
+
221
+ const id = path.basename(f).match(/^(\d{4})-/)?.[1] ?? "0000";
222
+ const title = getTitle(text) ?? path.basename(f, ".md");
223
+ pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
224
+ tier: TIER.DECISION,
225
+ // Best-first, deterministic: an ADR that explicitly governs this
226
+ // capability outranks a merely global one; then query relevance;
227
+ // then the number, so the newer decision survives the longer.
228
+ rank: [governs ? 1 : 0, ...relevance(text, terms, title), Number.parseInt(id, 10)],
229
+ adrId: id,
230
+ title,
231
+ summary: adrSummary(text),
232
+ });
123
233
  }
124
234
 
125
235
  // 6. Skills are on-demand: list name + description + trigger, never the
126
236
  // body. The `when:` line is what lets an agent fire the right skill
127
237
  // without loading any of them.
238
+ // RANKED by the query (change 0029). Alphabetical order is the wrong
239
+ // order for a list whose whole job is "fire the right one": on a
240
+ // diagnostic task the agent would read four specs and still not learn
241
+ // that CI injects an empty string, because the skill that says so sat
242
+ // at the bottom under `z`. The TRIGGER is what a task matches against,
243
+ // so it is weighted above the description — that is what `when:` is
244
+ // for. A skill with no query to rank on keeps alphabetical order.
128
245
  const onDemand = [];
129
246
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
130
247
  for (const f of walk(skillsDir)) {
131
248
  if (!f.endsWith(".md")) continue;
132
249
  const text = read(f);
250
+ const name = parseFrontmatter(text, "name") ?? path.basename(f, ".md");
251
+ const description = parseFrontmatter(text, "description") ?? "<missing description>";
252
+ const when = parseFrontmatter(text, "when") ?? null;
133
253
  onDemand.push({
134
254
  rel: relPath(projectRoot, f),
135
- name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
136
- description: parseFrontmatter(text, "description") ?? "<missing description>",
137
- when: parseFrontmatter(text, "when") ?? null,
255
+ name,
256
+ description,
257
+ when,
258
+ // The trigger is the emphasis field, so a term matching `when:`
259
+ // outranks the same term buried in the body.
260
+ rank: relevance(`${when ?? ""} ${description} ${name}`, terms, `${when ?? ""} ${name}`),
138
261
  });
139
262
  }
263
+ if (terms.length > 0) {
264
+ onDemand.sort((a, b) => compareRank(b.rank, a.rank) || a.name.localeCompare(b.name));
265
+ }
266
+ const skillsMatched = onDemand.filter((s) => s.rank[1] > 0).length;
140
267
 
141
- const totalTokens = pack.reduce((sum, item) => sum + item.tokens, 0);
142
- const totalLines = pack.reduce((sum, item) => sum + item.lines, 0);
268
+ // Fit the pack to the budget. This mutates items in place (degrading) and
269
+ // returns what it had to give up, so the report can name it.
270
+ const fit = fitToBudget(pack, budget, capability);
271
+ const kept = pack.filter((item) => !item.dropped);
272
+ const totalTokens = kept.reduce((sum, item) => sum + item.tokens, 0);
273
+ const totalLines = kept.reduce((sum, item) => sum + item.lines, 0);
143
274
 
144
275
  if (concat) {
145
- for (const item of pack) {
276
+ for (const item of kept) {
146
277
  console.log(`===== ${item.rel} (${item.note}) =====`);
147
278
  console.log("");
148
- console.log(read(path.join(projectRoot, item.rel)).trimEnd());
279
+ console.log(item.degraded ? degradedBody(item) : read(path.join(projectRoot, item.rel)).trimEnd());
149
280
  console.log("");
150
281
  }
151
282
  // Keep the pack pipeable: the budget verdict goes to stderr.
152
- if (budget !== null) {
153
- reportBudget(totalTokens, budget, (msg) => console.error(msg));
154
- }
283
+ reportBudget(totalTokens, budget, fit, (msg) => console.error(msg));
155
284
  } else {
156
285
  const scope = diffRef !== undefined ? c.gray(` (diff vs ${diffRef})`) : "";
157
- console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + scope + c.gray(" — read in this order:"));
286
+ const forNote = terms.length > 0 ? c.gray(` (for: ${terms.join(" ")})`) : "";
287
+ console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + forNote + scope + c.gray(" — read in this order:"));
158
288
  console.log("");
159
- pack.forEach((item, i) => {
160
- console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)} ${c.gray(item.note)}`);
289
+ kept.forEach((item, i) => {
290
+ const mark = item.degraded ? c.yellow(" summary") : " ";
291
+ console.log(`${String(i + 1).padStart(2)}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)}${mark} ${c.gray(item.note)}`);
161
292
  });
162
293
  console.log("");
163
- console.log(c.gray(`${pack.length} files, ${totalLines} lines, ~${totalTokens} tokens total (chars/4) · archive excluded by design`));
164
- if (budget !== null) reportBudget(totalTokens, budget, (msg) => console.log(msg));
294
+ console.log(c.gray(`${kept.length} files, ${totalLines} lines, ~${totalTokens} tokens total (chars/4) · archive excluded by design`));
295
+ reportBudget(totalTokens, budget, fit, (msg) => console.log(msg));
165
296
  if (onDemand.length > 0) {
166
297
  console.log("");
167
- console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):"));
298
+ const heading = skillsMatched > 0
299
+ ? c.bold("On-demand skills") + c.gray(` — ${skillsMatched} match this task; READ THESE FIRST:`)
300
+ : c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):");
301
+ console.log(heading);
168
302
  for (const s of onDemand) {
169
- console.log(` ${c.cyan(s.name.padEnd(26))} ${s.description}`);
303
+ // A skill whose trigger the task actually matches is marked, so the
304
+ // list stops being a uniform inventory the reader has to scan.
305
+ const hit = s.rank[1] > 0 ? c.green("→ ") : " ";
306
+ console.log(` ${hit}${c.cyan(s.name.padEnd(26))} ${s.description}`);
170
307
  if (s.when) console.log(` ${" ".repeat(26)} ${c.gray(`when: ${s.when}`)}`);
171
308
  }
309
+ if (skillsMatched > 0) {
310
+ console.log("");
311
+ console.log(c.gray(" A matching skill is procedural memory for THIS task — read it before the specs above."));
312
+ }
172
313
  }
173
314
  }
174
315
 
@@ -176,16 +317,189 @@ export async function run(positional, flags) {
176
317
  console.error("");
177
318
  console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
178
319
  }
179
- if (budget !== null && totalTokens > budget) return 1;
180
- return 0;
320
+ // The pack fits, or the command says so. It never silently exceeds.
321
+ return fit.overflowed ? 1 : 0;
181
322
  }
182
323
 
183
- function reportBudget(totalTokens, budget, log) {
184
- if (totalTokens > budget) {
185
- log(c.red("over budget") + ` ~${totalTokens} tokens > ${budget} — narrow the pack: name a capability, use --diff <ref>, split oversized specs`);
186
- } else {
187
- log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${Math.round((totalTokens / budget) * 100)}%)`));
324
+ function estimateTokens(text) {
325
+ return Math.round(text.length / TOKEN_DIVISOR);
326
+ }
327
+
328
+ // Flag beats project config beats the built-in default. Returns null for a
329
+ // malformed --budget so the caller can report a usage error.
330
+ function resolveBudget(projectRoot, budgetRaw) {
331
+ if (budgetRaw !== undefined) {
332
+ const n = Number.parseInt(budgetRaw, 10);
333
+ return Number.isFinite(n) && n > 0 ? n : null;
334
+ }
335
+ try {
336
+ const configured = idx.load(projectRoot)?.config?.context_budget;
337
+ if (Number.isFinite(configured) && configured > 0) return configured;
338
+ } catch {
339
+ // An unreadable index is `validate`'s problem, not a reason to refuse
340
+ // to assemble a pack.
341
+ }
342
+ return DEFAULT_BUDGET;
343
+ }
344
+
345
+ // What an artifact reduces to when the budget cannot hold its body: the
346
+ // title, the gist in a sentence or two, and the path to read when that is
347
+ // not enough. A decision in one sentence still carries the decision; an
348
+ // omitted one carries nothing.
349
+ function degradedBody(item) {
350
+ const lines = [`# ${item.title ?? item.rel}`, ""];
351
+ if (item.summary) lines.push(item.summary, "");
352
+ lines.push(`(summarised to fit the context budget — full text: ${item.rel})`);
353
+ return lines.join("\n");
354
+ }
355
+
356
+ // The opening paragraph of a named section, flattened to one line. This is
357
+ // a spec's Purpose: enough to know whether this capability is the one the
358
+ // task is about, which is all an orientation read owes you.
359
+ function sectionSummary(text, section, cap = 400) {
360
+ const body = getSectionParagraph(text, section);
361
+ if (!body) return null;
362
+ return body.length > cap ? body.slice(0, cap - 3).trimEnd() + "..." : body;
363
+ }
364
+
365
+ // Assemble the pack down to the budget. Deterministic and tested: ADRs
366
+ // degrade to title + summary before anything is dropped, dropped before any
367
+ // spec is, and the core is never touched. Best-first throughout — the
368
+ // least relevant artifact is always the next one to give something up.
369
+ export function fitToBudget(pack, budget, capability = null) {
370
+ const summarised = [];
371
+ const dropped = [];
372
+ const total = () => pack.reduce((sum, i) => sum + (i.dropped ? 0 : i.tokens), 0);
373
+
374
+ // Worst-first within a tier: lowest relevance goes first, path breaking
375
+ // ties so two runs over the same tree always give the same pack.
376
+ const worstFirst = (tier) => pack
377
+ .filter((i) => i.tier === tier && !i.dropped)
378
+ .sort((a, b) => compareRank(a.rank ?? [], b.rank ?? []) || (a.rel < b.rel ? -1 : 1));
379
+
380
+ const degrade = (item) => {
381
+ if (item.degraded || !item.summary) return;
382
+ const body = degradedBody(item);
383
+ item.degraded = true;
384
+ item.tokens = estimateTokens(body);
385
+ item.lines = body.split("\n").length;
386
+ summarised.push(item);
387
+ };
388
+ const drop = (item) => {
389
+ item.dropped = true;
390
+ dropped.push(item);
391
+ };
392
+
393
+ // The ladder, rung by rung. EVERY degradation precedes ANY drop: a
394
+ // decision reduced to one sentence still carries the decision, so
395
+ // summarising the whole corpus costs less than deleting any of it. The
396
+ // first ordering here dropped all twenty ADRs to keep seven specs whole,
397
+ // then had to summarise the specs anyway — the worst of both.
398
+ /** @type {Array<[number, (item: PackItem) => void]>} */
399
+ const ladder = [
400
+ [TIER.DECISION, degrade], // 1. every decision to title + summary
401
+ [TIER.SPEC, degrade], // 2. every unnamed capability to title + purpose
402
+ [TIER.DECISION, drop], // 3. only now, let decisions go
403
+ [TIER.DEPENDENCY, drop], // 4. then dependency specs (never the named one)
404
+ ];
405
+ for (const [tier, apply] of ladder) {
406
+ for (const item of worstFirst(tier)) {
407
+ if (total() <= budget) break;
408
+ apply(item);
409
+ }
410
+ }
411
+
412
+ return { summarised, dropped, overflowed: total() > budget, budget, used: total(), capability };
413
+ }
414
+
415
+ function reportBudget(totalTokens, budget, fit, log) {
416
+ const pct = Math.round((totalTokens / budget) * 100);
417
+ if (fit.overflowed) {
418
+ log(c.red("over budget") + ` ~${totalTokens} tokens > ${budget}, and nothing further can be given up`);
419
+ log(c.gray("The pack's core — root rules, product truth, the capability spec, open changes —"));
420
+ log(c.gray("exceeds the budget on its own. Split an oversized spec, close a stale change, or"));
421
+ log(c.gray("raise the ceiling: ") + c.cyan(`doctrina context --budget ${Math.ceil(totalTokens / 1000) * 1000}`));
422
+ return;
423
+ }
424
+ if (fit.summarised.length === 0 && fit.dropped.length === 0) {
425
+ log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${pct}%)`));
426
+ return;
427
+ }
428
+ log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${pct}%) after assembly:`));
429
+ const adrs = fit.summarised.filter((i) => i.adrId);
430
+ const specs = fit.summarised.filter((i) => !i.adrId);
431
+ if (adrs.length > 0) {
432
+ log(c.gray(` ${adrs.length} ADR${adrs.length === 1 ? "" : "s"} reduced to title + summary (least relevant first)`));
433
+ }
434
+ if (specs.length > 0) {
435
+ log(c.gray(` ${specs.length} spec${specs.length === 1 ? "" : "s"} reduced to title + purpose — name one to read it in full`));
436
+ }
437
+ if (fit.dropped.length > 0) {
438
+ log(c.gray(` ${fit.dropped.length} omitted: `) + fit.dropped.map((i) => i.adrId ?? path.basename(path.dirname(i.rel))).join(", "));
439
+ }
440
+ // Only worth saying where scoping can act: an unnamed pack carries every
441
+ // ADR by definition, so there is nothing for a scope to exclude.
442
+ if (fit.capability && adrs.length > 0) {
443
+ log(c.gray(" scope an ADR to shrink this permanently: ") + c.cyan("doctrina decision scope --write"));
444
+ }
445
+ }
446
+
447
+ // Content words in a --for query. Deliberately tiny: the stop list covers
448
+ // the connective tissue of an English task description, nothing domain-
449
+ // specific, so retrieval never quietly discards a real term.
450
+ const STOPWORDS = new Set([
451
+ "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for",
452
+ "from", "has", "have", "how", "i", "if", "in", "into", "is", "it", "its",
453
+ "of", "on", "or", "should", "that", "the", "then", "this", "to", "up",
454
+ "was", "we", "what", "when", "where", "which", "why", "will", "with",
455
+ ]);
456
+
457
+ export function queryTerms(query) {
458
+ if (query === undefined || query === null) return [];
459
+ return [...new Set(
460
+ String(query).toLowerCase().match(/[a-z][a-z0-9-]{1,}/g)?.filter((w) => !STOPWORDS.has(w)) ?? [],
461
+ )];
462
+ }
463
+
464
+ // How strongly a document answers the query, as a comparable tuple rather
465
+ // than one blended number — so the tiebreak order is readable and no
466
+ // weighting constant has to be guessed:
467
+ //
468
+ // [ terms in the title/id, terms in the body, hits per 1000 chars ]
469
+ //
470
+ // Density, not raw hit count, breaks the final tie. Raw hits reward a
471
+ // document for being long: the 473-line `cli` spec out-scored `skills` on
472
+ // the query "write a skill from git history" purely on volume, which is
473
+ // the length bias that makes naive retrieval useless on a mature tree.
474
+ export function relevance(text, terms, emphasis = "") {
475
+ if (terms.length === 0) return [0, 0, 0];
476
+ const hay = text.toLowerCase();
477
+ const head = emphasis.toLowerCase();
478
+ let inTitle = 0;
479
+ let inBody = 0;
480
+ let hits = 0;
481
+ for (const term of terms) {
482
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
483
+ const boundary = `(^|[^a-z0-9-])${escaped}`;
484
+ const n = (hay.match(new RegExp(boundary, "g")) ?? []).length;
485
+ if (n > 0) {
486
+ inBody += 1;
487
+ hits += n;
488
+ }
489
+ if (new RegExp(boundary).test(head)) inTitle += 1;
188
490
  }
491
+ return [inTitle, inBody, Math.round((hits * 1000) / Math.max(text.length, 1))];
492
+ }
493
+
494
+ // Ascending comparison of two rank tuples, shorter-is-smaller on a prefix.
495
+ // Ties are broken by the caller (on path), so two runs over the same tree
496
+ // always produce the same pack — the determinism the ladder promises.
497
+ export function compareRank(a, b) {
498
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
499
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
500
+ if (d !== 0) return d;
501
+ }
502
+ return 0;
189
503
  }
190
504
 
191
505
  // Repo-relative POSIX paths changed since <ref> (worktree vs ref, plus
@@ -193,10 +507,20 @@ function reportBudget(totalTokens, budget, log) {
193
507
  // ref is unavailable — the caller exits rather than silently showing a full
194
508
  // pack the user asked to narrow.
195
509
  function changedPaths(projectRoot, ref) {
510
+ // Explain the first-run states in the user's terms rather than leaking
511
+ // git plumbing ("fatal: bad revision 'HEAD'") — audit item C8.
512
+ const history = historyState(projectRoot);
513
+ if (!history.usable) {
514
+ console.error(c.red("error:") + ` cannot diff against "${ref}" — ${history.reason}`);
515
+ if (history.state === GIT_STATE.EMPTY) {
516
+ console.error(c.gray("hint: ") + "make a commit first, or drop --diff for the full pack");
517
+ }
518
+ return null;
519
+ }
196
520
  const run = (args) => spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
197
521
  const diff = run(["diff", "--name-only", ref, "--"]);
198
522
  if (diff.status !== 0) {
199
- console.error(c.red("error:") + ` git diff against "${ref}" failed${diff.stderr ? `: ${diff.stderr.trim().split("\n")[0]}` : " (not a git repository?)"}`);
523
+ console.error(c.red("error:") + ` git diff against "${ref}" failed${diff.stderr ? `: ${diff.stderr.trim().split("\n")[0]}` : ""}`);
200
524
  return null;
201
525
  }
202
526
  const untracked = run(["ls-files", "--others", "--exclude-standard"]);
@@ -211,24 +535,33 @@ function changedPaths(projectRoot, ref) {
211
535
  }
212
536
 
213
537
  export const help = `
214
- Usage: doctrina context [<capability>] [--concat] [--budget <tokens>] [--diff <ref>]
538
+ Usage: doctrina context [<capability>] [--for "<task>"] [--concat]
539
+ [--budget <tokens>] [--diff <ref>]
215
540
 
216
541
  Print the exact context pack for a task, in the documented read
217
542
  order: AGENTS.md, product.md, the capability spec (when given —
218
543
  otherwise every active spec), open changes, accepted ADRs. Skills are
219
544
  listed name + description + when-trigger only — they are on-demand by
220
- design. The change archive is excluded. Every file carries a token
221
- estimate (chars/4) and the pack reports its total.
545
+ design. The change archive is excluded.
546
+
547
+ Assembly is retrieval, not a dump. A token budget ALWAYS applies
548
+ (default ${DEFAULT_BUDGET}; set "config": { "context_budget": <n> } in
549
+ .doctrina/index.json, or pass --budget). Over budget, accepted ADRs
550
+ degrade to title + summary — least relevant first — before anything is
551
+ dropped, and the report names what was given up. Naming a capability
552
+ also drops the ADRs scoped away from it; see \`doctrina decision scope\`.
222
553
 
223
554
  Flags:
555
+ --for "<task>" Rank the pack by relevance to a task description, so
556
+ what survives the budget is what the task is about.
224
557
  --concat Print the file contents (with separators) instead of
225
558
  the list — ready to pipe into an agent. The budget
226
- verdict (if any) goes to stderr, keeping stdout pure.
227
- --budget <n> Token budget for the pack: prints over/under and
228
- exits 1 when the estimate exceeds it (a context gate).
559
+ verdict goes to stderr, keeping stdout pure.
560
+ --budget <n> Token ceiling for this call, overriding the project's.
229
561
  --diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
230
562
  specs, ADRs) to those changed since the git ref; open
231
563
  changes are always included. The resume-session read.
232
564
 
233
- Read-only; exits 0 (or 1 when over --budget).
565
+ Read-only. Exits 1 only when the pack's core alone exceeds the budget —
566
+ which the report explains rather than silently exceeding.
234
567
  `;