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