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.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- 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 +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -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/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -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 +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
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 {
|
|
10
|
-
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
|
+
import { flagBool, flagString, flagGivenWithoutValue, parsePositiveInt } 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,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
|
-
|
|
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
|
-
|
|
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.
|
|
402
|
-
[TIER.
|
|
403
|
-
[TIER.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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 "
|
|
549
|
-
.doctrina/
|
|
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\`.
|