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
@@ -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,14 +197,39 @@ 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
@@ -138,30 +248,34 @@ export async function run(positional, flags) {
138
248
  });
139
249
  }
140
250
 
141
- const totalTokens = pack.reduce((sum, item) => sum + item.tokens, 0);
142
- const totalLines = pack.reduce((sum, item) => sum + item.lines, 0);
251
+ // Fit the pack to the budget. This mutates items in place (degrading) and
252
+ // returns what it had to give up, so the report can name it.
253
+ const fit = fitToBudget(pack, budget, capability);
254
+ const kept = pack.filter((item) => !item.dropped);
255
+ const totalTokens = kept.reduce((sum, item) => sum + item.tokens, 0);
256
+ const totalLines = kept.reduce((sum, item) => sum + item.lines, 0);
143
257
 
144
258
  if (concat) {
145
- for (const item of pack) {
259
+ for (const item of kept) {
146
260
  console.log(`===== ${item.rel} (${item.note}) =====`);
147
261
  console.log("");
148
- console.log(read(path.join(projectRoot, item.rel)).trimEnd());
262
+ console.log(item.degraded ? degradedBody(item) : read(path.join(projectRoot, item.rel)).trimEnd());
149
263
  console.log("");
150
264
  }
151
265
  // Keep the pack pipeable: the budget verdict goes to stderr.
152
- if (budget !== null) {
153
- reportBudget(totalTokens, budget, (msg) => console.error(msg));
154
- }
266
+ reportBudget(totalTokens, budget, fit, (msg) => console.error(msg));
155
267
  } else {
156
268
  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:"));
269
+ const forNote = terms.length > 0 ? c.gray(` (for: ${terms.join(" ")})`) : "";
270
+ console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + forNote + scope + c.gray(" — read in this order:"));
158
271
  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)}`);
272
+ kept.forEach((item, i) => {
273
+ const mark = item.degraded ? c.yellow(" summary") : " ";
274
+ 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
275
  });
162
276
  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));
277
+ console.log(c.gray(`${kept.length} files, ${totalLines} lines, ~${totalTokens} tokens total (chars/4) · archive excluded by design`));
278
+ reportBudget(totalTokens, budget, fit, (msg) => console.log(msg));
165
279
  if (onDemand.length > 0) {
166
280
  console.log("");
167
281
  console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):"));
@@ -176,27 +290,210 @@ export async function run(positional, flags) {
176
290
  console.error("");
177
291
  console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
178
292
  }
179
- if (budget !== null && totalTokens > budget) return 1;
180
- return 0;
293
+ // The pack fits, or the command says so. It never silently exceeds.
294
+ return fit.overflowed ? 1 : 0;
181
295
  }
182
296
 
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)}%)`));
297
+ function estimateTokens(text) {
298
+ return Math.round(text.length / TOKEN_DIVISOR);
299
+ }
300
+
301
+ // Flag beats project config beats the built-in default. Returns null for a
302
+ // malformed --budget so the caller can report a usage error.
303
+ function resolveBudget(projectRoot, budgetRaw) {
304
+ if (budgetRaw !== undefined) {
305
+ const n = Number.parseInt(budgetRaw, 10);
306
+ return Number.isFinite(n) && n > 0 ? n : null;
307
+ }
308
+ try {
309
+ const configured = idx.load(projectRoot)?.config?.context_budget;
310
+ if (Number.isFinite(configured) && configured > 0) return configured;
311
+ } catch {
312
+ // An unreadable index is `validate`'s problem, not a reason to refuse
313
+ // to assemble a pack.
314
+ }
315
+ return DEFAULT_BUDGET;
316
+ }
317
+
318
+ // What an artifact reduces to when the budget cannot hold its body: the
319
+ // title, the gist in a sentence or two, and the path to read when that is
320
+ // not enough. A decision in one sentence still carries the decision; an
321
+ // omitted one carries nothing.
322
+ function degradedBody(item) {
323
+ const lines = [`# ${item.title ?? item.rel}`, ""];
324
+ if (item.summary) lines.push(item.summary, "");
325
+ lines.push(`(summarised to fit the context budget — full text: ${item.rel})`);
326
+ return lines.join("\n");
327
+ }
328
+
329
+ // The opening paragraph of a named section, flattened to one line. This is
330
+ // a spec's Purpose: enough to know whether this capability is the one the
331
+ // task is about, which is all an orientation read owes you.
332
+ function sectionSummary(text, section, cap = 400) {
333
+ const body = getSectionParagraph(text, section);
334
+ if (!body) return null;
335
+ return body.length > cap ? body.slice(0, cap - 3).trimEnd() + "..." : body;
336
+ }
337
+
338
+ // Assemble the pack down to the budget. Deterministic and tested: ADRs
339
+ // degrade to title + summary before anything is dropped, dropped before any
340
+ // spec is, and the core is never touched. Best-first throughout — the
341
+ // least relevant artifact is always the next one to give something up.
342
+ export function fitToBudget(pack, budget, capability = null) {
343
+ const summarised = [];
344
+ const dropped = [];
345
+ const total = () => pack.reduce((sum, i) => sum + (i.dropped ? 0 : i.tokens), 0);
346
+
347
+ // Worst-first within a tier: lowest relevance goes first, path breaking
348
+ // ties so two runs over the same tree always give the same pack.
349
+ const worstFirst = (tier) => pack
350
+ .filter((i) => i.tier === tier && !i.dropped)
351
+ .sort((a, b) => compareRank(a.rank ?? [], b.rank ?? []) || (a.rel < b.rel ? -1 : 1));
352
+
353
+ const degrade = (item) => {
354
+ if (item.degraded || !item.summary) return;
355
+ const body = degradedBody(item);
356
+ item.degraded = true;
357
+ item.tokens = estimateTokens(body);
358
+ item.lines = body.split("\n").length;
359
+ summarised.push(item);
360
+ };
361
+ const drop = (item) => {
362
+ item.dropped = true;
363
+ dropped.push(item);
364
+ };
365
+
366
+ // The ladder, rung by rung. EVERY degradation precedes ANY drop: a
367
+ // decision reduced to one sentence still carries the decision, so
368
+ // summarising the whole corpus costs less than deleting any of it. The
369
+ // first ordering here dropped all twenty ADRs to keep seven specs whole,
370
+ // then had to summarise the specs anyway — the worst of both.
371
+ /** @type {Array<[number, (item: PackItem) => void]>} */
372
+ const ladder = [
373
+ [TIER.DECISION, degrade], // 1. every decision to title + summary
374
+ [TIER.SPEC, degrade], // 2. every unnamed capability to title + purpose
375
+ [TIER.DECISION, drop], // 3. only now, let decisions go
376
+ [TIER.DEPENDENCY, drop], // 4. then dependency specs (never the named one)
377
+ ];
378
+ for (const [tier, apply] of ladder) {
379
+ for (const item of worstFirst(tier)) {
380
+ if (total() <= budget) break;
381
+ apply(item);
382
+ }
383
+ }
384
+
385
+ return { summarised, dropped, overflowed: total() > budget, budget, used: total(), capability };
386
+ }
387
+
388
+ function reportBudget(totalTokens, budget, fit, log) {
389
+ const pct = Math.round((totalTokens / budget) * 100);
390
+ if (fit.overflowed) {
391
+ log(c.red("over budget") + ` ~${totalTokens} tokens > ${budget}, and nothing further can be given up`);
392
+ log(c.gray("The pack's core — root rules, product truth, the capability spec, open changes —"));
393
+ log(c.gray("exceeds the budget on its own. Split an oversized spec, close a stale change, or"));
394
+ log(c.gray("raise the ceiling: ") + c.cyan(`doctrina context --budget ${Math.ceil(totalTokens / 1000) * 1000}`));
395
+ return;
396
+ }
397
+ if (fit.summarised.length === 0 && fit.dropped.length === 0) {
398
+ log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${pct}%)`));
399
+ return;
400
+ }
401
+ log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${pct}%) after assembly:`));
402
+ const adrs = fit.summarised.filter((i) => i.adrId);
403
+ const specs = fit.summarised.filter((i) => !i.adrId);
404
+ if (adrs.length > 0) {
405
+ log(c.gray(` ${adrs.length} ADR${adrs.length === 1 ? "" : "s"} reduced to title + summary (least relevant first)`));
406
+ }
407
+ if (specs.length > 0) {
408
+ log(c.gray(` ${specs.length} spec${specs.length === 1 ? "" : "s"} reduced to title + purpose — name one to read it in full`));
409
+ }
410
+ if (fit.dropped.length > 0) {
411
+ log(c.gray(` ${fit.dropped.length} omitted: `) + fit.dropped.map((i) => i.adrId ?? path.basename(path.dirname(i.rel))).join(", "));
412
+ }
413
+ // Only worth saying where scoping can act: an unnamed pack carries every
414
+ // ADR by definition, so there is nothing for a scope to exclude.
415
+ if (fit.capability && adrs.length > 0) {
416
+ log(c.gray(" scope an ADR to shrink this permanently: ") + c.cyan("doctrina decision scope --write"));
188
417
  }
189
418
  }
190
419
 
420
+ // Content words in a --for query. Deliberately tiny: the stop list covers
421
+ // the connective tissue of an English task description, nothing domain-
422
+ // specific, so retrieval never quietly discards a real term.
423
+ const STOPWORDS = new Set([
424
+ "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for",
425
+ "from", "has", "have", "how", "i", "if", "in", "into", "is", "it", "its",
426
+ "of", "on", "or", "should", "that", "the", "then", "this", "to", "up",
427
+ "was", "we", "what", "when", "where", "which", "why", "will", "with",
428
+ ]);
429
+
430
+ export function queryTerms(query) {
431
+ if (query === undefined || query === null) return [];
432
+ return [...new Set(
433
+ String(query).toLowerCase().match(/[a-z][a-z0-9-]{1,}/g)?.filter((w) => !STOPWORDS.has(w)) ?? [],
434
+ )];
435
+ }
436
+
437
+ // How strongly a document answers the query, as a comparable tuple rather
438
+ // than one blended number — so the tiebreak order is readable and no
439
+ // weighting constant has to be guessed:
440
+ //
441
+ // [ terms in the title/id, terms in the body, hits per 1000 chars ]
442
+ //
443
+ // Density, not raw hit count, breaks the final tie. Raw hits reward a
444
+ // document for being long: the 473-line `cli` spec out-scored `skills` on
445
+ // the query "write a skill from git history" purely on volume, which is
446
+ // the length bias that makes naive retrieval useless on a mature tree.
447
+ export function relevance(text, terms, emphasis = "") {
448
+ if (terms.length === 0) return [0, 0, 0];
449
+ const hay = text.toLowerCase();
450
+ const head = emphasis.toLowerCase();
451
+ let inTitle = 0;
452
+ let inBody = 0;
453
+ let hits = 0;
454
+ for (const term of terms) {
455
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
456
+ const boundary = `(^|[^a-z0-9-])${escaped}`;
457
+ const n = (hay.match(new RegExp(boundary, "g")) ?? []).length;
458
+ if (n > 0) {
459
+ inBody += 1;
460
+ hits += n;
461
+ }
462
+ if (new RegExp(boundary).test(head)) inTitle += 1;
463
+ }
464
+ return [inTitle, inBody, Math.round((hits * 1000) / Math.max(text.length, 1))];
465
+ }
466
+
467
+ // Ascending comparison of two rank tuples, shorter-is-smaller on a prefix.
468
+ // Ties are broken by the caller (on path), so two runs over the same tree
469
+ // always produce the same pack — the determinism the ladder promises.
470
+ export function compareRank(a, b) {
471
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
472
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
473
+ if (d !== 0) return d;
474
+ }
475
+ return 0;
476
+ }
477
+
191
478
  // Repo-relative POSIX paths changed since <ref> (worktree vs ref, plus
192
479
  // untracked files). Returns null (after printing the error) when git or the
193
480
  // ref is unavailable — the caller exits rather than silently showing a full
194
481
  // pack the user asked to narrow.
195
482
  function changedPaths(projectRoot, ref) {
483
+ // Explain the first-run states in the user's terms rather than leaking
484
+ // git plumbing ("fatal: bad revision 'HEAD'") — audit item C8.
485
+ const history = historyState(projectRoot);
486
+ if (!history.usable) {
487
+ console.error(c.red("error:") + ` cannot diff against "${ref}" — ${history.reason}`);
488
+ if (history.state === GIT_STATE.EMPTY) {
489
+ console.error(c.gray("hint: ") + "make a commit first, or drop --diff for the full pack");
490
+ }
491
+ return null;
492
+ }
196
493
  const run = (args) => spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
197
494
  const diff = run(["diff", "--name-only", ref, "--"]);
198
495
  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?)"}`);
496
+ console.error(c.red("error:") + ` git diff against "${ref}" failed${diff.stderr ? `: ${diff.stderr.trim().split("\n")[0]}` : ""}`);
200
497
  return null;
201
498
  }
202
499
  const untracked = run(["ls-files", "--others", "--exclude-standard"]);
@@ -211,24 +508,33 @@ function changedPaths(projectRoot, ref) {
211
508
  }
212
509
 
213
510
  export const help = `
214
- Usage: doctrina context [<capability>] [--concat] [--budget <tokens>] [--diff <ref>]
511
+ Usage: doctrina context [<capability>] [--for "<task>"] [--concat]
512
+ [--budget <tokens>] [--diff <ref>]
215
513
 
216
514
  Print the exact context pack for a task, in the documented read
217
515
  order: AGENTS.md, product.md, the capability spec (when given —
218
516
  otherwise every active spec), open changes, accepted ADRs. Skills are
219
517
  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.
518
+ design. The change archive is excluded.
519
+
520
+ Assembly is retrieval, not a dump. A token budget ALWAYS applies
521
+ (default ${DEFAULT_BUDGET}; set "config": { "context_budget": <n> } in
522
+ .doctrina/index.json, or pass --budget). Over budget, accepted ADRs
523
+ degrade to title + summary — least relevant first — before anything is
524
+ dropped, and the report names what was given up. Naming a capability
525
+ also drops the ADRs scoped away from it; see \`doctrina decision scope\`.
222
526
 
223
527
  Flags:
528
+ --for "<task>" Rank the pack by relevance to a task description, so
529
+ what survives the budget is what the task is about.
224
530
  --concat Print the file contents (with separators) instead of
225
531
  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).
532
+ verdict goes to stderr, keeping stdout pure.
533
+ --budget <n> Token ceiling for this call, overriding the project's.
229
534
  --diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
230
535
  specs, ADRs) to those changed since the git ref; open
231
536
  changes are always included. The resume-session read.
232
537
 
233
- Read-only; exits 0 (or 1 when over --budget).
538
+ Read-only. Exits 1 only when the pack's core alone exceeds the budget —
539
+ which the report explains rather than silently exceeding.
234
540
  `;
@@ -1,14 +1,17 @@
1
+ // @ts-check
2
+ import { getSection } from "../lib/doc-model.js";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { readdirSync } from "node:fs";
4
6
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
7
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
6
8
  import { specHeader } from "../lib/scan.js";
7
9
  import * as idx from "../lib/index-json.js";
8
10
  import { today } from "../lib/dates.js";
9
11
  import { flagBool } from "../lib/args.js";
10
12
  import { c } from "../lib/colors.js";
11
13
  import { suggest } from "../lib/suggest.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
15
 
13
16
  // Contracts are the first-class home for the integration/runtime surface
14
17
  // no single capability owns: the port map, the env/dependency contract,
@@ -20,6 +23,11 @@ import { suggest } from "../lib/suggest.js";
20
23
 
21
24
  const SUBCOMMANDS = ["new", "list", "check"];
22
25
 
26
+ // Flags this command accepts. Declared HERE, with the command, so
27
+ // adding a command never requires editing the entrypoint — the gap that
28
+ // let six flags ship undeclared and silently swallow a positional (C3).
29
+ export const flags = { boolean: ["json", "force"], string: [] };
30
+
23
31
  export async function run(positional, flags) {
24
32
  const sub = positional[0];
25
33
  switch (sub) {
@@ -55,11 +63,12 @@ function contractNew(args, flags) {
55
63
  return 1;
56
64
  }
57
65
 
58
- const templatesDir = locateTemplatesDir();
66
+ const tpl = readTemplate(projectRoot, "contract.md.template");
59
67
  const date = today();
60
- const body = substitute(read(path.join(templatesDir, "contract.md.template")), { CONTRACT_NAME: name, DATE: date });
68
+ const body = substitute(tpl.body, { CONTRACT_NAME: name, DATE: date });
61
69
  write(targetPath, body, { force });
62
- console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
70
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}` +
71
+ (tpl.source === "project" ? c.gray(" (project template)") : ""));
63
72
 
64
73
  const index = idx.load(projectRoot);
65
74
  idx.addContract(index, {
@@ -135,7 +144,7 @@ function contractCheck(args, _flags) {
135
144
  console.log(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
136
145
 
137
146
  // 1. Port collisions — two services must not claim the same port.
138
- const ports = parseTable(sectionOf(text, "Ports"));
147
+ const ports = parseTable(getSection(text, "Ports"));
139
148
  if (ports) {
140
149
  const portCol = colIndex(ports.headers, "port");
141
150
  const svcCol = colIndex(ports.headers, "service");
@@ -155,7 +164,7 @@ function contractCheck(args, _flags) {
155
164
 
156
165
  // 2. Environment drift — every declared variable must exist in
157
166
  // .env.example (when present), so code, example, and infra agree.
158
- const env = parseTable(sectionOf(text, "Environment"));
167
+ const env = parseTable(getSection(text, "Environment"));
159
168
  if (env && envExample !== null) {
160
169
  const varCol = colIndex(env.headers, "variable");
161
170
  for (const row of env.rows) {
@@ -170,7 +179,7 @@ function contractCheck(args, _flags) {
170
179
  }
171
180
 
172
181
  // 3. Referenced capability specs must exist.
173
- for (const refCap of referencedCapabilities(sectionOf(text, "References"))) {
182
+ for (const refCap of referencedCapabilities(getSection(text, "References"))) {
174
183
  const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
175
184
  if (!isFile(specPath)) {
176
185
  console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
@@ -189,22 +198,6 @@ function contractCheck(args, _flags) {
189
198
  return errors === 0 ? 0 : 1;
190
199
  }
191
200
 
192
- // Return the body of a "## <name>" section, up to the next "## " heading.
193
- function sectionOf(text, name) {
194
- const lines = text.split(/\r?\n/);
195
- const head = new RegExp(`^##\\s+${name}\\b`, "i");
196
- let inSection = false;
197
- const out = [];
198
- for (const line of lines) {
199
- if (/^##\s+/.test(line)) {
200
- inSection = head.test(line);
201
- continue;
202
- }
203
- if (inSection) out.push(line);
204
- }
205
- return out.join("\n");
206
- }
207
-
208
201
  // Parse a GitHub-flavoured Markdown table into { headers, rows }.
209
202
  function parseTable(text) {
210
203
  const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
@@ -234,7 +227,7 @@ function referencedCapabilities(text) {
234
227
 
235
228
  function ensureDoctrinaProject(projectRoot) {
236
229
  if (!exists(path.join(projectRoot, ".doctrina"))) {
237
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
230
+ throw notADoctrinaProject();
238
231
  }
239
232
  }
240
233