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