doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -2,15 +2,17 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
6
5
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
7
- import { listHeader, parseDependsOn, parseAdrScope, adrSummary } from "../lib/scan.js";
8
- import { getTitle, getSectionParagraph } from "../lib/doc-model.js";
9
- import { parseFrontmatter } from "./skill.js";
10
- import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
6
+ import { listHeader, parseDependsOn, parseAdrScope, adrSummary, knownCapabilities } from "../lib/scan.js";
7
+ import { suggest } from "../lib/suggest.js";
8
+ import { checklistProgress, getTitle, getSectionParagraph } from "../lib/doc-model.js";
9
+ import { parseFrontmatter } from "../lib/doc-model.js";
10
+ import { flagBool, flagString, flagGivenWithoutValue, parsePositiveInt } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
- import { GIT_STATE, historyState } from "../lib/git.js";
13
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { loadConfig, DEFAULTS } from "../lib/config.js";
13
+ import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
14
+ import { terms as queryTerms, relevance } from "../lib/lexicon.js";
15
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
14
16
  import * as idx from "../lib/index-json.js";
15
17
 
16
18
  // Materialise the AGENTS.md read order as a command: print the exact
@@ -39,15 +41,33 @@ import * as idx from "../lib/index-json.js";
39
41
  const TOKEN_DIVISOR = 4;
40
42
 
41
43
  // 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;
44
+ // Override per project with `"context_budget": <n>` in .doctrina/config.json
45
+ // (or the legacy `config` block of index.json), or per call with `--budget`.
46
+ // The number itself is DEFAULTS.context_budget in lib/config.js — declared
47
+ // once, so the reader and this renderer cannot disagree about it.
48
+ export const DEFAULT_BUDGET = DEFAULTS.context_budget;
45
49
 
46
50
  // Tiers, worst-first in the degradation ladder. The CORE is the pack's
47
51
  // 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 };
52
+ // spec, and the change this pack is FOR. It is never degraded and never
53
+ // dropped — if it alone exceeds the budget, that is a finding, not
54
+ // something to hide.
55
+ //
56
+ // CHANGE is its own tier since change 0035. Every open change used to sit
57
+ // in CORE, so a backlog — trabalho legítimo, planned and parked — was an
58
+ // irreducible subtraction from every pack: 21 open changes took this
59
+ // repository's own packs from 95% of the ceiling to 230% of it, `context`
60
+ // exited 1, and the CI budget gate went red. The size of the queue decided
61
+ // whether the read path worked at all, which is the one thing a context
62
+ // budget must never be hostage to. Work in flight is still the last thing
63
+ // dropped — it is what a resuming agent cannot reconstruct — but it
64
+ // degrades like everything else.
65
+ // Exported so the ladder's own tests name the tiers instead of repeating
66
+ // their numbers: the fixture hard-coded `3` for a decision, so inserting
67
+ // CHANGE at 2 silently reclassified every ADR in it as a dependency and
68
+ // the ladder tests failed for a reason that had nothing to do with the
69
+ // ladder.
70
+ export const TIER = { CORE: 0, SPEC: 1, CHANGE: 2, DEPENDENCY: 3, DECISION: 4 };
51
71
 
52
72
  // Flags this command accepts. Declared HERE, with the command, so
53
73
  // adding a command never requires editing the entrypoint — the gap that
@@ -67,7 +87,31 @@ export async function run(positional, cmdFlags) {
67
87
 
68
88
  if (capability && !/^[a-z][a-z0-9-]*$/.test(capability)) {
69
89
  console.error(c.red("error:") + ` invalid capability "${capability}" (lowercase letters, digits, hyphens)`);
70
- return 2;
90
+ return EXIT.USAGE;
91
+ }
92
+
93
+ // A named capability with no spec on disk is a mistyped invocation, not a
94
+ // pack to assemble. It used to print the whole pack, headed "capability:
95
+ // <typo>", with the warning about the missing spec buried under it and
96
+ // exit 0 — so the agent read a GLOBAL pack believing it was scoped, and
97
+ // nothing in the exit code said otherwise. Same defect as `coverage --only`
98
+ // naming a capability that no longer exists (change 0090): a filter that
99
+ // matches nothing must refuse, wherever it appears.
100
+ if (capability) {
101
+ // A capability the tree has never heard of — no spec, and no open change
102
+ // staging one. A change that CREATES a capability is the reason the
103
+ // second half of that test exists: its delta folder names the capability
104
+ // before any spec does, and `context <newcap>` is exactly the read an
105
+ // agent needs while writing it.
106
+ const known = capabilitiesInPlay(projectRoot);
107
+ if (!known.includes(capability)) {
108
+ console.error(c.red("error:") + ` no spec for capability "${capability}"`);
109
+ const guess = suggest(capability, known);
110
+ if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
111
+ if (known.length > 0) console.error(c.gray("known: ") + known.sort().join(", "));
112
+ console.error(c.gray("hint: ") + `create it with \`doctrina spec new ${capability}\`, or run \`doctrina context\` unscoped`);
113
+ return EXIT.USAGE;
114
+ }
71
115
  }
72
116
 
73
117
  // A value-taking flag written without a value is a usage error, not a
@@ -138,7 +182,6 @@ export async function run(positional, cmdFlags) {
138
182
  // With --for and no named capability, the specs are RANKED by the query
139
183
  // and become degradable: retrieval is what decides which truths this
140
184
  // task needs, and the budget is what enforces the decision.
141
- let specMissing = false;
142
185
  const specsRoot = path.join(projectRoot, ".doctrina", "specs");
143
186
  const dependencies = new Set();
144
187
  if (capability) {
@@ -157,8 +200,6 @@ export async function run(positional, cmdFlags) {
157
200
  pushFile(depRel, `dependency of ${capability}`, { tier: TIER.DEPENDENCY });
158
201
  }
159
202
  }
160
- } else {
161
- specMissing = true;
162
203
  }
163
204
  } else if (isDir(specsRoot)) {
164
205
  // No capability named: this is the orientation read, and it cannot
@@ -168,32 +209,110 @@ export async function run(positional, cmdFlags) {
168
209
  // is how you ask for its truth in full. That is the whole thesis:
169
210
  // without a task there is nothing to retrieve ON, so the honest
170
211
  // answer is an index, not a dump.
212
+ const specItems = [];
171
213
  for (const cap of readdirSync(specsRoot).sort()) {
172
214
  const specRel = `.doctrina/specs/${cap}/spec.md`;
173
215
  const full = path.join(projectRoot, specRel);
174
216
  if (!wanted(specRel) || !isFile(full)) continue;
175
217
  const text = read(full);
176
- pushFile(specRel, `spec: ${cap}`, {
218
+ const item = pushFile(specRel, `spec: ${cap}`, {
177
219
  tier: TIER.SPEC,
178
220
  rank: relevance(text, terms, `${cap} ${getTitle(text) ?? ""}`),
179
221
  title: getTitle(text) ?? cap,
180
222
  summary: sectionSummary(text, "Purpose"),
181
223
  });
224
+ if (item) specItems.push(item);
225
+ }
226
+
227
+ // `--for` IS naming a capability, indirectly — so the spec the query
228
+ // unambiguously points at joins the CORE, exactly as a named one does.
229
+ // Without this the ladder treats it as one summarisable spec among
230
+ // eight, and on a tree where a single spec is half the budget the
231
+ // answer to "what is this task about?" is the one thing that gets
232
+ // summarised away. Same unambiguous-leader rule the change focus uses:
233
+ // a tie means the query did not pick anything.
234
+ if (terms.length > 0 && specItems.length > 0) {
235
+ const ranked = [...specItems].sort((a, b) => compareRank(b.rank, a.rank) || (a.rel < b.rel ? -1 : 1));
236
+ const leader = ranked[0];
237
+ if (leader.rank[1] > 0 && (ranked.length === 1 || compareRank(leader.rank, ranked[1].rank) > 0)) {
238
+ leader.tier = TIER.CORE;
239
+ leader.note = `${leader.note} (the task's capability)`;
240
+ }
182
241
  }
183
242
  }
184
243
 
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.
244
+ // 4. Open changes (their proposal, tasks, and deltas) — never
245
+ // diff-filtered: work in flight is the one thing a resuming agent
246
+ // cannot reconstruct from anywhere else, so it is always present.
247
+ //
248
+ // But present is not the same as WHOLE (change 0035). One change is
249
+ // the one this pack is for; the rest are a queue, and a queue belongs
250
+ // in a pack as a list of what is open, not as every word of it. The
251
+ // focus change stays in CORE; the others degrade to a line each.
188
252
  const changesDir = path.join(projectRoot, ".doctrina", "changes");
189
253
  if (isDir(changesDir)) {
254
+ const open = [];
190
255
  for (const id of readdirSync(changesDir).sort()) {
191
256
  if (id === "archive" || id.startsWith(".")) continue;
192
- if (!isDir(path.join(changesDir, id))) continue;
193
- for (const f of walk(path.join(changesDir, id))) {
194
- if (!f.endsWith(".md")) continue;
195
- pushFile(relPath(projectRoot, f), `open change: ${id}`);
257
+ const changeDir = path.join(changesDir, id);
258
+ if (!isDir(changeDir)) continue;
259
+ const files = walk(changeDir).filter((f) => f.endsWith(".md"));
260
+ if (files.length === 0) continue;
261
+ // Does this change carry a delta for the named capability? Read from
262
+ // the delta's own folder, the same place `close` reads its touched
263
+ // set from.
264
+ const touches = capability !== null && files.some((f) =>
265
+ relPath(projectRoot, f).replaceAll("\\", "/").includes(`/specs/${capability}/`));
266
+ const body = files.map((f) => read(f)).join("\n");
267
+ // Best-first: the capability it touches outranks a query match,
268
+ // which outranks the id — so a tie goes to the newest change.
269
+ open.push({ id, files, rank: [touches ? 1 : 0, ...relevance(body, terms, id)] });
270
+ }
271
+
272
+ // EXACTLY ONE change is in focus, and only when the signals pick it
273
+ // UNAMBIGUOUSLY. Two rules, both learned the hard way here:
274
+ //
275
+ // Being *about* the named capability is not focus. Nine of this
276
+ // repository's own changes carry a `gates` delta; exempting all nine
277
+ // from degradation put the pack straight back over the ceiling.
278
+ //
279
+ // A tie is not a winner. `context cli` matches five changes equally,
280
+ // and picking the lowest-numbered one gave a whole change CORE
281
+ // standing for no reason a reader could see — the pack silently
282
+ // decided what you were working on. When nothing distinguishes the
283
+ // candidates there is no focus, and the queue is the honest answer;
284
+ // `--for "<task>"` is how you say which one you mean.
285
+ //
286
+ // Everything else is a ranked queue the ladder shortens worst-first,
287
+ // so the runners-up still survive whole whenever there is room.
288
+ const ranked = [...open].sort((a, b) => compareRank(b.rank, a.rank) || (a.id < b.id ? -1 : 1));
289
+ const leader = ranked[0];
290
+ const unambiguous = leader
291
+ && leader.rank.some((n) => n > 0)
292
+ && (ranked.length === 1 || compareRank(leader.rank, ranked[1].rank) > 0);
293
+ const focusId = unambiguous ? leader.id : null;
294
+
295
+ for (const ch of open) {
296
+ if (ch.id === focusId) {
297
+ // The change being worked on: every file, whole, in CORE.
298
+ for (const f of ch.files) pushFile(relPath(projectRoot, f), `open change: ${ch.id}`);
299
+ continue;
196
300
  }
301
+ // A PARKED change is ONE entry in a queue, not three documents. It
302
+ // used to contribute its proposal, its tasks and every delta
303
+ // separately — 21 parked changes meant 63 pack entries, and even
304
+ // summarised they crowded out every ADR. What a reader needs from
305
+ // work they are not doing is that it exists, what it is about, and
306
+ // how far along it is; its proposal anchors that, and the summary
307
+ // carries the rest.
308
+ const anchor = ch.files.find((f) => path.basename(f) === "proposal.md") ?? ch.files[0];
309
+ const rel = relPath(projectRoot, anchor);
310
+ pushFile(rel, `open change: ${ch.id} (parked)`, {
311
+ tier: TIER.CHANGE,
312
+ rank: ch.rank,
313
+ title: ch.id,
314
+ summary: parkedSummary(projectRoot, ch),
315
+ });
197
316
  }
198
317
  }
199
318
 
@@ -214,18 +333,29 @@ export async function run(positional, cmdFlags) {
214
333
  // An unscoped ADR is global by definition — it belongs in every pack.
215
334
  // That is what makes scoping opt-in and backward compatible.
216
335
  const global = scope.length === 0;
217
- const governs = capability !== null
218
- && (scope.includes(capability) || scope.some((s) => dependencies.has(s)));
336
+ // NAMED beats INHERITED beats global (change 0075). Both used to collapse
337
+ // to the same 1, so `Scope:` decided candidacy and then said nothing about
338
+ // order — and with no `--for` query every relevance term is 0, leaving the
339
+ // ADR NUMBER as the only tie-break. Worst-first then dropped the oldest
340
+ // decisions, which is how `authoring` lost ADR 0005 (the playbooks for
341
+ // `intake` and `work`) and ADR 0007 (the `ops` verbs it applies) out of its
342
+ // own pack while keeping ADRs that merely reached it through `cli`.
343
+ const named = capability !== null && scope.includes(capability);
344
+ const inherited = capability !== null && scope.some((s) => dependencies.has(s));
345
+ const governs = named || inherited;
219
346
  if (capability !== null && !global && !governs) continue;
220
347
 
221
348
  const id = path.basename(f).match(/^(\d{4})-/)?.[1] ?? "0000";
222
349
  const title = getTitle(text) ?? path.basename(f, ".md");
223
350
  pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
224
351
  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)],
352
+ // Best-first, deterministic: an ADR that NAMES this capability outranks
353
+ // an unscoped one — which is DECLARED to belong in every pack — which in
354
+ // turn outranks one that merely inherits through a dependency. Putting
355
+ // global below inherited broke the guarantee that "unscoped means
356
+ // global", and a test caught it. Then query relevance; then the number,
357
+ // so between equals the newer decision survives the longer.
358
+ rank: [named ? 3 : global ? 2 : 1, ...relevance(text, terms, title), Number.parseInt(id, 10)],
229
359
  adrId: id,
230
360
  title,
231
361
  summary: adrSummary(text),
@@ -313,33 +443,48 @@ export async function run(positional, cmdFlags) {
313
443
  }
314
444
  }
315
445
 
316
- if (specMissing) {
317
- console.error("");
318
- console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
319
- }
320
446
  // The pack fits, or the command says so. It never silently exceeds.
321
447
  return fit.overflowed ? 1 : 0;
322
448
  }
323
449
 
450
+ // Every capability this tree knows about: one with a spec on disk, plus one
451
+ // an open change is staging a delta for. Naming anything else is a typo, and
452
+ // a pack assembled for a typo is a GLOBAL pack wearing a scoped heading.
453
+ function capabilitiesInPlay(projectRoot) {
454
+ const caps = new Set(knownCapabilities(projectRoot));
455
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
456
+ if (isDir(changesDir)) {
457
+ for (const id of readdirSync(changesDir)) {
458
+ if (id === "archive" || id.startsWith(".")) continue;
459
+ const specsDir = path.join(changesDir, id, "specs");
460
+ if (!isDir(specsDir)) continue;
461
+ for (const cap of readdirSync(specsDir)) {
462
+ if (isFile(path.join(specsDir, cap, "delta.md"))) caps.add(cap);
463
+ }
464
+ }
465
+ }
466
+ return [...caps];
467
+ }
468
+
469
+ // Line endings are not content: a Windows checkout stores every line with a
470
+ // CR, which added ~2% to the same pack, so the budget gate — run on every OS
471
+ // in CI — could pass on Linux and fail on Windows for the same tree.
324
472
  function estimateTokens(text) {
325
- return Math.round(text.length / TOKEN_DIVISOR);
473
+ return Math.round(text.replace(/\r\n/g, "\n").length / TOKEN_DIVISOR);
326
474
  }
327
475
 
328
476
  // Flag beats project config beats the built-in default. Returns null for a
329
477
  // malformed --budget so the caller can report a usage error.
330
478
  function resolveBudget(projectRoot, budgetRaw) {
331
479
  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.
480
+ return parsePositiveInt(budgetRaw);
341
481
  }
342
- return DEFAULT_BUDGET;
482
+ // One configuration reader (lib/config.js, change 0047): `.doctrina/
483
+ // config.json` is the declared home and index.json's `config` block is
484
+ // still read as the legacy one. A malformed file is `validate`'s problem,
485
+ // not a reason to refuse to assemble a pack, so the reader reports rather
486
+ // than throws.
487
+ return loadConfig(projectRoot).context_budget;
343
488
  }
344
489
 
345
490
  // What an artifact reduces to when the budget cannot hold its body: the
@@ -353,6 +498,73 @@ function degradedBody(item) {
353
498
  return lines.join("\n");
354
499
  }
355
500
 
501
+ // A parked change as ONE queue line: status, what it is about, how far
502
+ // along, and which specs it will move. Deliberately terse — twenty of
503
+ // these share the pack with the ADRs, and a paragraph each is what pushed
504
+ // the decisions out of it. The reader who wants more names the change.
505
+ function parkedSummary(projectRoot, ch) {
506
+ let status = "proposed";
507
+ let why = null;
508
+ let done = 0;
509
+ let total = 0;
510
+ const caps = new Set();
511
+
512
+ for (const f of ch.files) {
513
+ const base = path.basename(f);
514
+ if (base === "proposal.md") {
515
+ const text = read(f);
516
+ status = listHeader(text, "Status") ?? status;
517
+ why = sectionSummary(text, "Why", 130);
518
+ } else if (base === "tasks.md") {
519
+ // One counter, shared with every other surface (change 0067).
520
+ const progress = checklistProgress(read(f));
521
+ total += progress.total;
522
+ done += progress.done;
523
+ } else if (base === "delta.md") {
524
+ const cap = relPath(projectRoot, f).replaceAll("\\", "/").match(/\/specs\/([^/]+)\/delta\.md$/);
525
+ if (cap) caps.add(cap[1]);
526
+ }
527
+ }
528
+
529
+ const parts = [`[${status}]`];
530
+ if (why) parts.push(why);
531
+ if (total > 0) parts.push(`${done}/${total} tasks`);
532
+ if (caps.size > 0) parts.push(`specs: ${[...caps].sort().join(", ")}`);
533
+ return parts.join(" · ");
534
+ }
535
+
536
+ // What one file of an open change reduces to: enough to know it exists,
537
+ // what it is about, and how far along it is — which is what a queue owes a
538
+ // reader. Never null, so a change file can always degrade; an item with no
539
+ // summary is refused by the ladder and would sit at full size forever.
540
+ export function changeSummary(rel, text) {
541
+ const base = path.basename(rel.replaceAll("\\", "/"));
542
+
543
+ if (base === "proposal.md") {
544
+ const status = listHeader(text, "Status") ?? "proposed";
545
+ const why = sectionSummary(text, "Why", 240);
546
+ return why ? `[${status}] ${why}` : `[${status}] no rationale written yet.`;
547
+ }
548
+
549
+ if (base === "tasks.md") {
550
+ const progress = checklistProgress(text);
551
+ const { total, done } = progress;
552
+ const open = progress.open.filter((t) => t !== "");
553
+ if (total === 0) return "no checklist.";
554
+ const head = `${done}/${total} tasks checked.`;
555
+ return open.length === 0 ? head : `${head} Next: ${open.slice(0, 2).join("; ")}`;
556
+ }
557
+
558
+ if (base === "delta.md") {
559
+ const op = listHeader(text, "Operation") ?? "?";
560
+ const target = listHeader(text, "Target spec on apply") ?? "?";
561
+ return `${op} → ${target}`;
562
+ }
563
+
564
+ // design.md and anything else a change carries.
565
+ return sectionSummary(text, "Purpose", 240) ?? `${base} in this change.`;
566
+ }
567
+
356
568
  // The opening paragraph of a named section, flattened to one line. This is
357
569
  // a spec's Purpose: enough to know whether this capability is the one the
358
570
  // task is about, which is all an orientation read owes you.
@@ -398,9 +610,11 @@ export function fitToBudget(pack, budget, capability = null) {
398
610
  /** @type {Array<[number, (item: PackItem) => void]>} */
399
611
  const ladder = [
400
612
  [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)
613
+ [TIER.CHANGE, degrade], // 2. every parked change to status + why + progress
614
+ [TIER.SPEC, degrade], // 3. every unnamed capability to title + purpose
615
+ [TIER.DECISION, drop], // 4. only now, let decisions go
616
+ [TIER.DEPENDENCY, drop], // 5. then dependency specs (never the named one)
617
+ [TIER.CHANGE, drop], // 6. last: a parked change, already one line
404
618
  ];
405
619
  for (const [tier, apply] of ladder) {
406
620
  for (const item of worstFirst(tier)) {
@@ -427,13 +641,20 @@ function reportBudget(totalTokens, budget, fit, log) {
427
641
  }
428
642
  log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${pct}%) after assembly:`));
429
643
  const adrs = fit.summarised.filter((i) => i.adrId);
430
- const specs = fit.summarised.filter((i) => !i.adrId);
644
+ const changes = fit.summarised.filter((i) => i.tier === TIER.CHANGE);
645
+ const specs = fit.summarised.filter((i) => !i.adrId && i.tier !== TIER.CHANGE);
431
646
  if (adrs.length > 0) {
432
647
  log(c.gray(` ${adrs.length} ADR${adrs.length === 1 ? "" : "s"} reduced to title + summary (least relevant first)`));
433
648
  }
434
649
  if (specs.length > 0) {
435
650
  log(c.gray(` ${specs.length} spec${specs.length === 1 ? "" : "s"} reduced to title + purpose — name one to read it in full`));
436
651
  }
652
+ // Parked changes are their own line: reporting them as "specs" is how a
653
+ // pack that had summarised twenty queue entries claimed to have
654
+ // summarised twenty capabilities.
655
+ if (changes.length > 0) {
656
+ log(c.gray(` ${changes.length} parked change${changes.length === 1 ? "" : "s"} reduced to a queue line — name one to read it in full`));
657
+ }
437
658
  if (fit.dropped.length > 0) {
438
659
  log(c.gray(` ${fit.dropped.length} omitted: `) + fit.dropped.map((i) => i.adrId ?? path.basename(path.dirname(i.rel))).join(", "));
439
660
  }
@@ -444,52 +665,11 @@ function reportBudget(totalTokens, budget, fit, log) {
444
665
  }
445
666
  }
446
667
 
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;
490
- }
491
- return [inTitle, inBody, Math.round((hits * 1000) / Math.max(text.length, 1))];
492
- }
668
+ // Query terms and relevance both come from the SHARED lexicon (change 0040):
669
+ // `work` ranked the same prompt against the same specs with a different stop
670
+ // list, and the work playbook tells the agent to run `context` right after.
671
+ // One vocabulary, so the two cannot disagree about what a prompt is about.
672
+ export { terms as queryTerms, relevance } from "../lib/lexicon.js";
493
673
 
494
674
  // Ascending comparison of two rank tuples, shorter-is-smaller on a prefix.
495
675
  // Ties are broken by the caller (on path), so two runs over the same tree
@@ -517,21 +697,14 @@ function changedPaths(projectRoot, ref) {
517
697
  }
518
698
  return null;
519
699
  }
520
- const run = (args) => spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
521
- const diff = run(["diff", "--name-only", ref, "--"]);
522
- if (diff.status !== 0) {
523
- console.error(c.red("error:") + ` git diff against "${ref}" failed${diff.stderr ? `: ${diff.stderr.trim().split("\n")[0]}` : ""}`);
700
+ const changed = changedFiles(projectRoot, { since: ref });
701
+ if (!changed.ok) {
702
+ // An unresolvable ref is a usage error, not an empty diff — reporting it
703
+ // as "nothing changed" would silently hand back the whole pack.
704
+ console.error(c.red("error:") + ` git diff against "${ref}" failed (${changed.state})`);
524
705
  return null;
525
706
  }
526
- const untracked = run(["ls-files", "--others", "--exclude-standard"]);
527
- const out = new Set();
528
- for (const chunk of [diff.stdout, untracked.status === 0 ? untracked.stdout : ""]) {
529
- for (const line of chunk.split(/\r?\n/)) {
530
- const p = line.trim();
531
- if (p) out.add(p);
532
- }
533
- }
534
- return out;
707
+ return new Set(changed.files);
535
708
  }
536
709
 
537
710
  export const help = `
@@ -545,8 +718,8 @@ listed name + description + when-trigger only — they are on-demand by
545
718
  design. The change archive is excluded.
546
719
 
547
720
  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
721
+ (default ${DEFAULT_BUDGET}; set "context_budget": <n> in
722
+ .doctrina/config.json, or pass --budget). Over budget, accepted ADRs
550
723
  degrade to title + summary — least relevant first — before anything is
551
724
  dropped, and the report names what was given up. Naming a capability
552
725
  also drops the ADRs scoped away from it; see \`doctrina decision scope\`.