doctrina-cli 0.15.0 → 0.16.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -199
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +109 -6
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +210 -24
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +238 -11
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/contract.md.template +7 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/context.js
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
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
10
|
import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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 `"
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
|
49
|
-
// it alone exceeds the budget, that is a finding, not
|
|
50
|
-
|
|
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
|
|
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
|
|
186
|
-
//
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
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,14 +443,29 @@ 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
|
+
|
|
324
469
|
function estimateTokens(text) {
|
|
325
470
|
return Math.round(text.length / TOKEN_DIVISOR);
|
|
326
471
|
}
|
|
@@ -332,14 +477,12 @@ function resolveBudget(projectRoot, budgetRaw) {
|
|
|
332
477
|
const n = Number.parseInt(budgetRaw, 10);
|
|
333
478
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
334
479
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
return DEFAULT_BUDGET;
|
|
480
|
+
// One configuration reader (lib/config.js, change 0047): `.doctrina/
|
|
481
|
+
// config.json` is the declared home and index.json's `config` block is
|
|
482
|
+
// still read as the legacy one. A malformed file is `validate`'s problem,
|
|
483
|
+
// not a reason to refuse to assemble a pack, so the reader reports rather
|
|
484
|
+
// than throws.
|
|
485
|
+
return loadConfig(projectRoot).context_budget;
|
|
343
486
|
}
|
|
344
487
|
|
|
345
488
|
// What an artifact reduces to when the budget cannot hold its body: the
|
|
@@ -353,6 +496,73 @@ function degradedBody(item) {
|
|
|
353
496
|
return lines.join("\n");
|
|
354
497
|
}
|
|
355
498
|
|
|
499
|
+
// A parked change as ONE queue line: status, what it is about, how far
|
|
500
|
+
// along, and which specs it will move. Deliberately terse — twenty of
|
|
501
|
+
// these share the pack with the ADRs, and a paragraph each is what pushed
|
|
502
|
+
// the decisions out of it. The reader who wants more names the change.
|
|
503
|
+
function parkedSummary(projectRoot, ch) {
|
|
504
|
+
let status = "proposed";
|
|
505
|
+
let why = null;
|
|
506
|
+
let done = 0;
|
|
507
|
+
let total = 0;
|
|
508
|
+
const caps = new Set();
|
|
509
|
+
|
|
510
|
+
for (const f of ch.files) {
|
|
511
|
+
const base = path.basename(f);
|
|
512
|
+
if (base === "proposal.md") {
|
|
513
|
+
const text = read(f);
|
|
514
|
+
status = listHeader(text, "Status") ?? status;
|
|
515
|
+
why = sectionSummary(text, "Why", 130);
|
|
516
|
+
} else if (base === "tasks.md") {
|
|
517
|
+
// One counter, shared with every other surface (change 0067).
|
|
518
|
+
const progress = checklistProgress(read(f));
|
|
519
|
+
total += progress.total;
|
|
520
|
+
done += progress.done;
|
|
521
|
+
} else if (base === "delta.md") {
|
|
522
|
+
const cap = relPath(projectRoot, f).replaceAll("\\", "/").match(/\/specs\/([^/]+)\/delta\.md$/);
|
|
523
|
+
if (cap) caps.add(cap[1]);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const parts = [`[${status}]`];
|
|
528
|
+
if (why) parts.push(why);
|
|
529
|
+
if (total > 0) parts.push(`${done}/${total} tasks`);
|
|
530
|
+
if (caps.size > 0) parts.push(`specs: ${[...caps].sort().join(", ")}`);
|
|
531
|
+
return parts.join(" · ");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// What one file of an open change reduces to: enough to know it exists,
|
|
535
|
+
// what it is about, and how far along it is — which is what a queue owes a
|
|
536
|
+
// reader. Never null, so a change file can always degrade; an item with no
|
|
537
|
+
// summary is refused by the ladder and would sit at full size forever.
|
|
538
|
+
export function changeSummary(rel, text) {
|
|
539
|
+
const base = path.basename(rel.replaceAll("\\", "/"));
|
|
540
|
+
|
|
541
|
+
if (base === "proposal.md") {
|
|
542
|
+
const status = listHeader(text, "Status") ?? "proposed";
|
|
543
|
+
const why = sectionSummary(text, "Why", 240);
|
|
544
|
+
return why ? `[${status}] ${why}` : `[${status}] no rationale written yet.`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (base === "tasks.md") {
|
|
548
|
+
const progress = checklistProgress(text);
|
|
549
|
+
const { total, done } = progress;
|
|
550
|
+
const open = progress.open.filter((t) => t !== "");
|
|
551
|
+
if (total === 0) return "no checklist.";
|
|
552
|
+
const head = `${done}/${total} tasks checked.`;
|
|
553
|
+
return open.length === 0 ? head : `${head} Next: ${open.slice(0, 2).join("; ")}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (base === "delta.md") {
|
|
557
|
+
const op = listHeader(text, "Operation") ?? "?";
|
|
558
|
+
const target = listHeader(text, "Target spec on apply") ?? "?";
|
|
559
|
+
return `${op} → ${target}`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// design.md and anything else a change carries.
|
|
563
|
+
return sectionSummary(text, "Purpose", 240) ?? `${base} in this change.`;
|
|
564
|
+
}
|
|
565
|
+
|
|
356
566
|
// The opening paragraph of a named section, flattened to one line. This is
|
|
357
567
|
// a spec's Purpose: enough to know whether this capability is the one the
|
|
358
568
|
// task is about, which is all an orientation read owes you.
|
|
@@ -398,9 +608,11 @@ export function fitToBudget(pack, budget, capability = null) {
|
|
|
398
608
|
/** @type {Array<[number, (item: PackItem) => void]>} */
|
|
399
609
|
const ladder = [
|
|
400
610
|
[TIER.DECISION, degrade], // 1. every decision to title + summary
|
|
401
|
-
[TIER.
|
|
402
|
-
[TIER.
|
|
403
|
-
[TIER.
|
|
611
|
+
[TIER.CHANGE, degrade], // 2. every parked change to status + why + progress
|
|
612
|
+
[TIER.SPEC, degrade], // 3. every unnamed capability to title + purpose
|
|
613
|
+
[TIER.DECISION, drop], // 4. only now, let decisions go
|
|
614
|
+
[TIER.DEPENDENCY, drop], // 5. then dependency specs (never the named one)
|
|
615
|
+
[TIER.CHANGE, drop], // 6. last: a parked change, already one line
|
|
404
616
|
];
|
|
405
617
|
for (const [tier, apply] of ladder) {
|
|
406
618
|
for (const item of worstFirst(tier)) {
|
|
@@ -427,13 +639,20 @@ function reportBudget(totalTokens, budget, fit, log) {
|
|
|
427
639
|
}
|
|
428
640
|
log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${pct}%) after assembly:`));
|
|
429
641
|
const adrs = fit.summarised.filter((i) => i.adrId);
|
|
430
|
-
const
|
|
642
|
+
const changes = fit.summarised.filter((i) => i.tier === TIER.CHANGE);
|
|
643
|
+
const specs = fit.summarised.filter((i) => !i.adrId && i.tier !== TIER.CHANGE);
|
|
431
644
|
if (adrs.length > 0) {
|
|
432
645
|
log(c.gray(` ${adrs.length} ADR${adrs.length === 1 ? "" : "s"} reduced to title + summary (least relevant first)`));
|
|
433
646
|
}
|
|
434
647
|
if (specs.length > 0) {
|
|
435
648
|
log(c.gray(` ${specs.length} spec${specs.length === 1 ? "" : "s"} reduced to title + purpose — name one to read it in full`));
|
|
436
649
|
}
|
|
650
|
+
// Parked changes are their own line: reporting them as "specs" is how a
|
|
651
|
+
// pack that had summarised twenty queue entries claimed to have
|
|
652
|
+
// summarised twenty capabilities.
|
|
653
|
+
if (changes.length > 0) {
|
|
654
|
+
log(c.gray(` ${changes.length} parked change${changes.length === 1 ? "" : "s"} reduced to a queue line — name one to read it in full`));
|
|
655
|
+
}
|
|
437
656
|
if (fit.dropped.length > 0) {
|
|
438
657
|
log(c.gray(` ${fit.dropped.length} omitted: `) + fit.dropped.map((i) => i.adrId ?? path.basename(path.dirname(i.rel))).join(", "));
|
|
439
658
|
}
|
|
@@ -444,52 +663,11 @@ function reportBudget(totalTokens, budget, fit, log) {
|
|
|
444
663
|
}
|
|
445
664
|
}
|
|
446
665
|
|
|
447
|
-
//
|
|
448
|
-
// the
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
}
|
|
666
|
+
// Query terms and relevance both come from the SHARED lexicon (change 0040):
|
|
667
|
+
// `work` ranked the same prompt against the same specs with a different stop
|
|
668
|
+
// list, and the work playbook tells the agent to run `context` right after.
|
|
669
|
+
// One vocabulary, so the two cannot disagree about what a prompt is about.
|
|
670
|
+
export { terms as queryTerms, relevance } from "../lib/lexicon.js";
|
|
493
671
|
|
|
494
672
|
// Ascending comparison of two rank tuples, shorter-is-smaller on a prefix.
|
|
495
673
|
// Ties are broken by the caller (on path), so two runs over the same tree
|
|
@@ -517,21 +695,14 @@ function changedPaths(projectRoot, ref) {
|
|
|
517
695
|
}
|
|
518
696
|
return null;
|
|
519
697
|
}
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
698
|
+
const changed = changedFiles(projectRoot, { since: ref });
|
|
699
|
+
if (!changed.ok) {
|
|
700
|
+
// An unresolvable ref is a usage error, not an empty diff — reporting it
|
|
701
|
+
// as "nothing changed" would silently hand back the whole pack.
|
|
702
|
+
console.error(c.red("error:") + ` git diff against "${ref}" failed (${changed.state})`);
|
|
524
703
|
return null;
|
|
525
704
|
}
|
|
526
|
-
|
|
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;
|
|
705
|
+
return new Set(changed.files);
|
|
535
706
|
}
|
|
536
707
|
|
|
537
708
|
export const help = `
|
|
@@ -545,8 +716,8 @@ listed name + description + when-trigger only — they are on-demand by
|
|
|
545
716
|
design. The change archive is excluded.
|
|
546
717
|
|
|
547
718
|
Assembly is retrieval, not a dump. A token budget ALWAYS applies
|
|
548
|
-
(default ${DEFAULT_BUDGET}; set "
|
|
549
|
-
.doctrina/
|
|
719
|
+
(default ${DEFAULT_BUDGET}; set "context_budget": <n> in
|
|
720
|
+
.doctrina/config.json, or pass --budget). Over budget, accepted ADRs
|
|
550
721
|
degrade to title + summary — least relevant first — before anything is
|
|
551
722
|
dropped, and the report names what was given up. Naming a capability
|
|
552
723
|
also drops the ADRs scoped away from it; see \`doctrina decision scope\`.
|