@sleepy_panda_srl/portulan 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
@@ -0,0 +1,883 @@
1
+ #!/usr/bin/env node
2
+ // What a context loads, measured: what a boot reads in full, and what the host loads into every context.
3
+ //
4
+ // node cli/context.mjs --workspace <dir> [--repo <card>] [--rail <name>=<bytes>]...
5
+ // node cli/context.mjs --workspace <dir> --brief
6
+ //
7
+ // Exit 0 within every rail and declared budget · 1 a rail or budget exceeded · 2 could not run or judge a declared budget.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ import { BOOT_CARD_LINE, IMPORT_DEPTH, importPath, importSpans } from "./compile.mjs";
14
+ import { offerText, splitOffers } from "./instructions.mjs";
15
+ import { AGENT_DIR, parseFrontmatter } from "./plugin-lint.mjs";
16
+ import { HOST_SKILL_DEPTH, manifestPath } from "./skills-set.mjs";
17
+
18
+ export { IMPORT_DEPTH };
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ export const BUNDLE_ROOT = path.resolve(HERE, "..");
23
+
24
+ export const BOOT_SKILL = { label: "boot skill", rel: "plugin/skills/portulan/SKILL.md" };
25
+
26
+ /** What a boot with no card reads from the bundle. */
27
+ export const ENGINE = [BOOT_SKILL, { label: "boot steps", rel: "plugin/skills/portulan/steps.md" }, { label: "kernel", rel: "core/engine.md" }];
28
+
29
+ /** The kernel's first line, by which the router tells whether a session's context already holds it. */
30
+ export const KERNEL_LINE = "# Portulan engine";
31
+
32
+ export const POINTER_STEP = { label: "pointer step", rel: "plugin/skills/portulan/pointer-manifest.md", where: "the manifest is a pointer" };
33
+ export const PACKS_STEP = { label: "packs step", rel: "plugin/skills/portulan/packs.md", where: "the manifest names a pack" };
34
+ export const STEPS = [POINTER_STEP, PACKS_STEP];
35
+
36
+ export const WORKSPACE_KINDS = ["repository", "demo", "portfolio"];
37
+
38
+ /** The schema's required slots, copied: a test holds this list to it. */
39
+ export const REQUIRED_SLOTS = ["identity", "principles", "gates"];
40
+
41
+ export const BOOT_SLOTS = ["identity", "principles", "constitution", "gates", "dod"];
42
+
43
+ export const ESTIMATED_BYTES_PER_TOKEN = 2.99;
44
+
45
+ export const OFFER_FLOOR_TOKENS = 8000;
46
+
47
+ export const HEADROOM_PERCENT = 2;
48
+
49
+ /** Past this share of headroom, the report says the rail can come down. */
50
+ export const NOTE_PERCENT = 5;
51
+
52
+ export const MEASURED_HOST = "Claude Code";
53
+
54
+ export const TOP_CONTRIBUTORS = 3;
55
+
56
+ export const RAILS = ["boot", "engine", "steps", "descriptions"];
57
+
58
+ export class ContextError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = "ContextError";
62
+ }
63
+ }
64
+
65
+ /** Rounded up in integer arithmetic, so no float decides a byte. */
66
+ export function railFor(bytes) {
67
+ return Math.floor((bytes * (100 + HEADROOM_PERCENT) + 99) / 100);
68
+ }
69
+
70
+ const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
71
+
72
+ export const tokensOf = (bytes, ratio) => Math.round(bytes / ratio);
73
+
74
+ function sizeOf(file, what) {
75
+ let stat;
76
+ try {
77
+ stat = fs.statSync(file);
78
+ } catch (error) {
79
+ throw new ContextError(`${what} is not on disk (${error.code ?? error.message}) — a boot would miss it, and a measure without it would understate the load`);
80
+ }
81
+ if (!stat.isFile()) throw new ContextError(`${what} is not a file`);
82
+ return stat.size;
83
+ }
84
+
85
+ function readText(file, what) {
86
+ try {
87
+ return fs.readFileSync(file, "utf8");
88
+ } catch (error) {
89
+ throw new ContextError(`${what} could not be read (${error.code ?? error.message})`);
90
+ }
91
+ }
92
+
93
+ // Only these are absent: a denied read or a loop of links, read as absent, would be a green over less than the host loads.
94
+ const ABSENT = new Set(["ENOENT", "ENOTDIR", "ENAMETOOLONG", "ERR_INVALID_ARG_VALUE"]);
95
+
96
+ function statOf(file) {
97
+ try {
98
+ return fs.statSync(file);
99
+ } catch (error) {
100
+ if (ABSENT.has(error.code)) return null;
101
+ throw new ContextError(`${file} could not be read (${error.code ?? error.message})`);
102
+ }
103
+ }
104
+
105
+ const isDir = (dir) => statOf(dir)?.isDirectory() ?? false;
106
+
107
+ const isFile = (file) => statOf(file)?.isFile() ?? false;
108
+
109
+ function realOf(file) {
110
+ try {
111
+ return fs.realpathSync(file);
112
+ } catch (error) {
113
+ throw new ContextError(`${file} could not be resolved (${error.code ?? error.message})`);
114
+ }
115
+ }
116
+
117
+ // ===========================================================================================
118
+ // The manifest key
119
+ // ===========================================================================================
120
+
121
+ /** Malformed is refused, never read as absent, so a misspelt key cannot switch the budget off. */
122
+ export function declaredContext(manifest) {
123
+ if (manifest?.context === undefined) return { budget: null, ratio: null, calibratedBy: null };
124
+ const shown = (value) => (value === undefined ? "absent" : JSON.stringify(value));
125
+ const shaped = (value, where, keys) => {
126
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
127
+ throw new ContextError(`${where} is ${shown(value)}, not an object`);
128
+ }
129
+ const unknown = Object.keys(value).filter((key) => !keys.includes(key));
130
+ if (unknown.length) {
131
+ throw new ContextError(`${where} has ${unknown.map((key) => `\`${key}\``).join(", ")}, which spec 2.9 does not define — only ${keys.map((key) => `\`${key}\``).join(" and ")}`);
132
+ }
133
+ return value;
134
+ };
135
+ const context = shaped(manifest.context, "the manifest's `context`", ["always", "ratio"]);
136
+ if (context.ratio === undefined) {
137
+ throw new ContextError("context declares no ratio — spec 2.9 requires context.ratio, because a token budget with nothing to count it by is not a budget");
138
+ }
139
+ const ratio = shaped(context.ratio, "context.ratio", ["bytes_per_token", "calibrated_by"]);
140
+ const bytesPerToken = ratio.bytes_per_token;
141
+ if (!(typeof bytesPerToken === "number" && Number.isFinite(bytesPerToken) && bytesPerToken >= 1)) {
142
+ throw new ContextError(`context.ratio.bytes_per_token is ${shown(bytesPerToken)}, not a finite number of at least 1`);
143
+ }
144
+ if (typeof ratio.calibrated_by !== "string" || ratio.calibrated_by === "") {
145
+ throw new ContextError(`context.ratio.calibrated_by is ${shown(ratio.calibrated_by)}, not the id of the host whose exact count calibrated the ratio`);
146
+ }
147
+ let budget = null;
148
+ if (context.always !== undefined) {
149
+ const always = shaped(context.always, "context.always", ["budget"]);
150
+ budget = shaped(always.budget, "context.always.budget", ["tokens"]).tokens;
151
+ if (!(Number.isInteger(budget) && budget > 0)) {
152
+ throw new ContextError(`context.always.budget.tokens is ${shown(budget)}, not a positive whole number of tokens`);
153
+ }
154
+ }
155
+ return { budget, ratio: bytesPerToken, calibratedBy: ratio.calibrated_by };
156
+ }
157
+
158
+ // ===========================================================================================
159
+ // The boot read-set
160
+ // ===========================================================================================
161
+
162
+ function repoCard(workspaceDir, slots, repo) {
163
+ const card = { selected: null, why: null, others: 0, file: null };
164
+ if (slots.repos === undefined) {
165
+ if (repo !== null) throw new ContextError(`--repo ${repo} was given, and this workspace declares no repos slot`);
166
+ return card;
167
+ }
168
+ const dir = path.resolve(workspaceDir, slots.repos);
169
+ let cards = [];
170
+ try {
171
+ cards = fs
172
+ .readdirSync(dir, { withFileTypes: true })
173
+ .filter((e) => e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
174
+ .map((e) => e.name.slice(0, -3))
175
+ .sort();
176
+ } catch (error) {
177
+ throw new ContextError(`slot \`repos\` (${slots.repos}) could not be listed (${error.code ?? error.message})`);
178
+ }
179
+ if (repo !== null) {
180
+ if (!cards.includes(repo)) {
181
+ throw new ContextError(`--repo ${repo} names no card in ${slots.repos} — the cards there are ${cards.join(", ") || "none"}`);
182
+ }
183
+ card.selected = repo;
184
+ } else if (cards.length === 1) {
185
+ card.selected = cards[0];
186
+ } else if (cards.length > 1) {
187
+ card.why = `${cards.length} cards and none named with --repo, so none is counted — a boot reads the one naming its repository`;
188
+ } else {
189
+ card.why = "the repos slot holds no card";
190
+ }
191
+ if (card.selected !== null) card.file = path.join(dir, `${card.selected}.md`);
192
+ card.others = cards.length - (card.selected === null ? 0 : 1);
193
+ return card;
194
+ }
195
+
196
+ export function bootReadSet(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT, repo = null, always = null } = {}) {
197
+ if (manifest.kind === "pointer") {
198
+ throw new ContextError(
199
+ "the manifest is a pointer, and a pointer's workspace is resolved from the host's install records, which a recipe must not read — " +
200
+ `measure the workspace \`node cli/discover.mjs --json\` resolves it to; a boot through the pointer also reads this manifest and the pointer step, ${POINTER_STEP.rel}`,
201
+ );
202
+ }
203
+ if (!WORKSPACE_KINDS.includes(manifest.kind)) {
204
+ throw new ContextError(
205
+ `the manifest's kind is ${manifest.kind === undefined ? "absent" : JSON.stringify(manifest.kind)}, none of ${WORKSPACE_KINDS.join(", ")} or pointer — a defect \`doctor\` reports, and a boot reads no slot of it`,
206
+ );
207
+ }
208
+
209
+ const entries = [];
210
+ const engineMissing = [];
211
+ const fromBundle = ({ label, rel }, engine) => {
212
+ const file = path.join(bundleRoot, rel);
213
+ // Reported, not refused: the npm package carries the kernel but not the plugin's skill.
214
+ if (!isFile(file)) {
215
+ engineMissing.push(rel);
216
+ return;
217
+ }
218
+ entries.push({ label, file, bytes: sizeOf(file, label), bundle: true, ...(engine ? { engine: true } : {}) });
219
+ };
220
+ const slots = manifest.slots;
221
+ if (slots === null || typeof slots !== "object" || Array.isArray(slots)) {
222
+ throw new ContextError(`slots is ${slots === undefined ? "absent" : JSON.stringify(slots)}, and a ${manifest.kind} workspace declares its slots as an object`);
223
+ }
224
+ const undeclared = REQUIRED_SLOTS.filter((name) => slots[name] === undefined);
225
+ if (undeclared.length) {
226
+ throw new ContextError(
227
+ `slot${undeclared.length === 1 ? "" : "s"} ${undeclared.map((name) => `\`${name}\``).join(", ")} ${undeclared.length === 1 ? "is" : "are"} not declared — the Workspace Definition requires ${REQUIRED_SLOTS.join(", ")} of every ${manifest.kind} workspace`,
228
+ );
229
+ }
230
+ for (const [name, value] of Object.entries(slots)) {
231
+ if (typeof value !== "string") throw new ContextError(`slot \`${name}\` is ${JSON.stringify(value)}, not a path`);
232
+ }
233
+
234
+ // A carded boot reads the skill alone: the host loaded the card, and the rest of the always tier, before it began.
235
+ if (always?.card) {
236
+ fromBundle(BOOT_SKILL, true);
237
+ for (const e of always.entries) entries.push({ ...e, always: true });
238
+ // An adopter's card cannot import a file outside the project, so its boot reads the plugin's kernel.
239
+ if (!always.entries.some((e) => readText(e.file, e.file).split(/\r?\n/)[0] === KERNEL_LINE)) fromBundle(ENGINE[2], true);
240
+ const real = (file) => {
241
+ try {
242
+ return fs.realpathSync(file);
243
+ } catch {
244
+ return path.resolve(file);
245
+ }
246
+ };
247
+ const loaded = new Set(always.entries.map((e) => real(e.file)));
248
+ const waits = (rel) => !loaded.has(real(path.resolve(workspaceDir, rel)));
249
+ const replaced = [];
250
+ if (waits("workspace.json")) replaced.push("the manifest");
251
+ for (const slot of BOOT_SLOTS) if (slots[slot] !== undefined && waits(slots[slot])) replaced.push(`\`${slot}\``);
252
+ if (slots.repos !== undefined) {
253
+ const { file: cardFile } = repoCard(workspaceDir, slots, repo);
254
+ if (cardFile === null || !loaded.has(real(cardFile))) replaced.push("this repository's card");
255
+ } else if (repo !== null) {
256
+ throw new ContextError(`--repo ${repo} was given, and this workspace declares no repos slot`);
257
+ }
258
+ if (typeof manifest.memory?.index?.path === "string" && waits(manifest.memory.index.path)) replaced.push("the memory index");
259
+ if (Array.isArray(manifest.packs) && manifest.packs.length) replaced.push("the packs step");
260
+ const notCounted = [`what the card replaces, each opened when the card or an on-path rule sends a session to it: ${replaced.join(", ")}`];
261
+ if (slots.memory !== undefined) notCounted.push("memory records (the index points at each)");
262
+ for (const [name, value] of Object.entries(slots)) {
263
+ if (BOOT_SLOTS.includes(name) || name === "memory" || name === "repos") continue;
264
+ notCounted.push(`${name} (${value})`);
265
+ }
266
+ notCounted.push("the on-read files anything above links to");
267
+ return { entries, engineMissing, card: { selected: null, why: null, others: 0 }, notCounted, carded: always.card };
268
+ }
269
+
270
+ for (const e of ENGINE) fromBundle(e, true);
271
+ const manifestFile = path.join(workspaceDir, "workspace.json");
272
+ entries.push({ label: "manifest", file: manifestFile, bytes: sizeOf(manifestFile, "the manifest"), manifest: true });
273
+ for (const slot of BOOT_SLOTS) {
274
+ if (slots[slot] === undefined) continue;
275
+ const file = path.resolve(workspaceDir, slots[slot]);
276
+ entries.push({ label: slot, file, bytes: sizeOf(file, `slot \`${slot}\` (${slots[slot]})`) });
277
+ }
278
+
279
+ const { file: cardFile, ...card } = repoCard(workspaceDir, slots, repo);
280
+ if (cardFile !== null) entries.push({ label: "repo card", file: cardFile, bytes: sizeOf(cardFile, `the ${card.selected} card`) });
281
+
282
+ const indexPath = manifest.memory?.index?.path;
283
+ if (indexPath !== undefined) {
284
+ if (typeof indexPath !== "string") throw new ContextError(`memory.index.path is ${JSON.stringify(indexPath)}, not a path`);
285
+ const file = path.resolve(workspaceDir, indexPath);
286
+ entries.push({ label: "memory index", file, bytes: sizeOf(file, `the memory index (${indexPath})`) });
287
+ }
288
+
289
+ const packs = Array.isArray(manifest.packs) ? manifest.packs.length : 0;
290
+ if (packs > 0) fromBundle(PACKS_STEP, false);
291
+
292
+ const notCounted = [];
293
+ if (slots.memory !== undefined) notCounted.push("memory records (the index points at each)");
294
+ if (card.others > 0) notCounted.push(card.others === 1 ? "1 other repo card" : `${card.others} other repo cards`);
295
+ for (const [name, value] of Object.entries(slots)) {
296
+ if (BOOT_SLOTS.includes(name) || name === "memory" || name === "repos") continue;
297
+ notCounted.push(`${name} (${value})`);
298
+ }
299
+ for (const key of ["handoffs", "personas"]) {
300
+ if (manifest[key]?.index?.path !== undefined) notCounted.push(`the ${key} index`);
301
+ }
302
+ if (packs > 0) notCounted.push(`${packs === 1 ? "1 pack's" : `${packs} packs'`} files (the boot reads the key)`);
303
+ notCounted.push("the on-read files anything above links to");
304
+
305
+ return { entries, engineMissing, card, notCounted, carded: null };
306
+ }
307
+
308
+ // ===========================================================================================
309
+ // The always tier, as Claude Code loads it from a repository
310
+ // ===========================================================================================
311
+
312
+ export function importsOf(text) {
313
+ return importSpans(text)
314
+ .map(({ target }) => importPath(target))
315
+ .filter((bare) => bare !== null);
316
+ }
317
+
318
+ const inside = (root, file) => {
319
+ const rel = path.relative(root, file);
320
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
321
+ };
322
+
323
+ function descriptionOf(text) {
324
+ const { fields } = parseFrontmatter(text);
325
+ if (fields?.["disable-model-invocation"] === "true") return null;
326
+ if (fields?.description !== undefined && fields.description !== "") return fields.description;
327
+ const lines = text.split(/\r?\n/);
328
+ let start = 0;
329
+ if (lines[0]?.trim() === "---") {
330
+ const close = lines.indexOf("---", 1);
331
+ start = close === -1 ? lines.length : close + 1;
332
+ }
333
+ const paragraph = [];
334
+ for (const line of lines.slice(start)) {
335
+ if (line.trim() === "") {
336
+ if (paragraph.length) break;
337
+ continue;
338
+ }
339
+ paragraph.push(line.trim());
340
+ }
341
+ return paragraph.join(" ");
342
+ }
343
+
344
+ // Code-unit order, never the locale's, so a recipe's output does not move with the machine that printed it.
345
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
346
+
347
+ function listed(dir) {
348
+ try {
349
+ return fs.readdirSync(dir, { withFileTypes: true }).sort(byName);
350
+ } catch (error) {
351
+ throw new ContextError(`${dir} could not be listed (${error.code ?? error.message})`);
352
+ }
353
+ }
354
+
355
+ /** Linked directories are not walked. */
356
+ function walkMarkdown(dir, kept) {
357
+ const out = [];
358
+ if (!isDir(dir) || !kept(dir)) return out;
359
+ for (const entry of listed(dir)) {
360
+ const full = path.join(dir, entry.name);
361
+ if (entry.isDirectory()) out.push(...walkMarkdown(full, kept));
362
+ else if (entry.name.endsWith(".md") && isFile(full) && kept(full)) out.push(full);
363
+ }
364
+ return out;
365
+ }
366
+
367
+ export function alwaysTier(repoRoot) {
368
+ const entries = [];
369
+ const missing = [];
370
+ const outside = [];
371
+ const tooDeep = [];
372
+ const seen = new Set();
373
+
374
+ // Judged on real paths: a link out of the repository is named, never opened, since it is not this repository's to measure.
375
+ const realRoot = realOf(repoRoot);
376
+ const kept = (file) => {
377
+ const real = realOf(file);
378
+ if (real === realRoot || inside(realRoot, real)) return true;
379
+ outside.push(`${path.relative(repoRoot, file)} (a link out of the repository)`);
380
+ return false;
381
+ };
382
+
383
+ // A path-scoped rule is on-path, but the files it imports carry no `paths:` and load into every context.
384
+ const follow = (seeds) => {
385
+ const queue = seeds.map(({ file, label, scoped = false }) => ({ file, label, depth: 0, scoped }));
386
+ while (queue.length) {
387
+ const { file, label, depth, scoped } = queue.shift();
388
+ const real = realOf(file);
389
+ if (!(scoped && depth === 0)) {
390
+ if (seen.has(real)) continue;
391
+ seen.add(real);
392
+ const imported = `import, depth ${depth}${scoped ? ", of a path-scoped rule" : ""}`;
393
+ entries.push({ label: depth === 0 ? label : imported, file, bytes: sizeOf(file, file) });
394
+ }
395
+ for (const { target } of importSpans(readText(file, file))) {
396
+ const bare = importPath(target);
397
+ if (bare === null) continue;
398
+ const spelled = `@${target} (in ${path.relative(repoRoot, file)})`;
399
+ if (bare.startsWith("~")) {
400
+ outside.push(spelled);
401
+ continue;
402
+ }
403
+ const resolved = path.resolve(path.dirname(file), bare);
404
+ if (!isFile(resolved)) {
405
+ missing.push(spelled);
406
+ continue;
407
+ }
408
+ const real = realOf(resolved);
409
+ if (real !== realRoot && !inside(realRoot, real)) {
410
+ outside.push(spelled);
411
+ continue;
412
+ }
413
+ if (depth + 1 >= IMPORT_DEPTH) {
414
+ tooDeep.push(spelled);
415
+ continue;
416
+ }
417
+ queue.push({ file: resolved, depth: depth + 1, scoped });
418
+ }
419
+ }
420
+ };
421
+ follow(
422
+ ["CLAUDE.md", path.join(".claude", "CLAUDE.md")]
423
+ .map((rel) => path.join(repoRoot, rel))
424
+ .filter((file) => isFile(file) && kept(file))
425
+ .map((file) => ({ file, label: "instructions" })),
426
+ );
427
+
428
+ let scoped = 0;
429
+ let card = null;
430
+ const unscoped = [];
431
+ const pathScoped = [];
432
+ for (const file of walkMarkdown(path.join(repoRoot, ".claude", "rules"), kept)) {
433
+ const text = readText(file, file);
434
+ const { fields } = parseFrontmatter(text);
435
+ if (fields && Object.hasOwn(fields, "paths")) {
436
+ scoped += 1;
437
+ pathScoped.push({ file, label: "path-scoped rule", scoped: true });
438
+ continue;
439
+ }
440
+ if (card === null && text.split(/\r?\n/)[0] === BOOT_CARD_LINE) card = file;
441
+ unscoped.push({ file, label: "rule" });
442
+ }
443
+ follow([...unscoped, ...pathScoped]);
444
+
445
+ // The host lists a skill's, command's or agent's description in every context, and loads the body when it is invoked.
446
+ let unlisted = 0;
447
+ const describe = (file, label) => {
448
+ const description = descriptionOf(readText(file, file));
449
+ if (description === null) {
450
+ unlisted += 1;
451
+ return;
452
+ }
453
+ entries.push({ label, file, bytes: Buffer.byteLength(description, "utf8") });
454
+ };
455
+ const skills = path.join(repoRoot, ".claude", "skills");
456
+ if (isDir(skills) && kept(skills)) {
457
+ for (const entry of listed(skills)) {
458
+ const file = path.join(skills, entry.name, "SKILL.md");
459
+ if (isFile(file) && kept(file)) describe(file, "skill description");
460
+ }
461
+ }
462
+ for (const file of walkMarkdown(path.join(repoRoot, ".claude", "commands"), kept)) describe(file, "command description");
463
+ const agents = path.join(repoRoot, ".claude", "agents");
464
+ if (isDir(agents) && kept(agents)) {
465
+ for (const entry of listed(agents)) {
466
+ const file = path.join(agents, entry.name);
467
+ if (entry.name.endsWith(".md") && isFile(file) && kept(file)) describe(file, "agent description");
468
+ }
469
+ }
470
+
471
+ return { entries, scoped, unlisted, missing, outside, tooDeep, card };
472
+ }
473
+
474
+ export function pluginDescriptions(bundleRoot = BUNDLE_ROOT) {
475
+ const file = manifestPath(bundleRoot);
476
+ if (!isFile(file)) return { unavailable: "this bundle carries no .claude-plugin/plugin.json, so it is not the plugin" };
477
+ let manifest;
478
+ try {
479
+ manifest = JSON.parse(readText(file, "the plugin manifest"));
480
+ } catch (error) {
481
+ if (error instanceof ContextError) throw error;
482
+ throw new ContextError(`the plugin manifest does not parse (${error.message})`);
483
+ }
484
+ const realBundle = realOf(bundleRoot);
485
+ const within = (file) => {
486
+ const real = realOf(file);
487
+ if (real !== realBundle && !inside(realBundle, real)) {
488
+ throw new ContextError(`${path.relative(bundleRoot, file)} resolves out of the bundle — the plugin's skills and agents are read from inside it, never from elsewhere`);
489
+ }
490
+ };
491
+ const declared = manifest.skills === undefined ? [] : [manifest.skills].flat();
492
+ const roots = [path.join(bundleRoot, "skills"), ...declared.map((p) => path.resolve(bundleRoot, String(p)))];
493
+ const skillDirs = new Set();
494
+ const expand = (dir, depth) => {
495
+ if (!isDir(dir)) return;
496
+ within(dir);
497
+ if (isFile(path.join(dir, "SKILL.md"))) {
498
+ skillDirs.add(dir);
499
+ return;
500
+ }
501
+ if (depth >= HOST_SKILL_DEPTH) return;
502
+ for (const entry of listed(dir)) {
503
+ if (entry.isDirectory()) expand(path.join(dir, entry.name), depth + 1);
504
+ }
505
+ };
506
+ for (const root of roots) expand(root, 0);
507
+
508
+ const entries = [];
509
+ let skills = 0;
510
+ let agents = 0;
511
+ for (const dir of [...skillDirs].sort()) {
512
+ const skill = path.join(dir, "SKILL.md");
513
+ within(skill);
514
+ const description = descriptionOf(readText(skill, skill));
515
+ if (description === null) continue;
516
+ skills += 1;
517
+ entries.push({ label: "skill description", file: skill, bytes: Buffer.byteLength(description, "utf8") });
518
+ }
519
+ const agentDir = path.join(bundleRoot, AGENT_DIR);
520
+ if (isDir(agentDir)) {
521
+ within(agentDir);
522
+ for (const { name } of listed(agentDir)) {
523
+ const agent = path.join(agentDir, name);
524
+ if (!name.endsWith(".md") || !isFile(agent)) continue;
525
+ within(agent);
526
+ const description = descriptionOf(readText(agent, agent));
527
+ if (description === null) continue;
528
+ agents += 1;
529
+ entries.push({ label: "agent description", file: agent, bytes: Buffer.byteLength(description, "utf8") });
530
+ }
531
+ }
532
+ return { entries, skills, agents };
533
+ }
534
+
535
+ // ===========================================================================================
536
+ // The whole measure
537
+ // ===========================================================================================
538
+
539
+ const sum = (entries) => entries.reduce((total, e) => total + e.bytes, 0);
540
+
541
+ export function readManifest(workspaceDir) {
542
+ let manifest;
543
+ try {
544
+ manifest = JSON.parse(readText(path.join(workspaceDir, "workspace.json"), "the manifest"));
545
+ } catch (error) {
546
+ if (error instanceof ContextError) throw error;
547
+ throw new ContextError(`the manifest does not parse (${error.message})`);
548
+ }
549
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
550
+ throw new ContextError("the manifest is not a JSON object");
551
+ }
552
+ return manifest;
553
+ }
554
+
555
+ export function treeOf(workspaceDir, manifest) {
556
+ if (manifest.tree === undefined) return null;
557
+ if (typeof manifest.tree !== "string") throw new ContextError(`tree is ${JSON.stringify(manifest.tree)}, not a path`);
558
+ const repoRoot = path.resolve(workspaceDir, manifest.tree);
559
+ if (!isDir(repoRoot)) throw new ContextError(`tree (${manifest.tree}) names no directory, so there is no repository here whose always tier could be measured`);
560
+ return repoRoot;
561
+ }
562
+
563
+ export function judgeBudget(declared, bytes) {
564
+ const tokens = tokensOf(bytes, declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN);
565
+ if (declared.budget === null) {
566
+ return {
567
+ verdict: "undeclared",
568
+ tokens,
569
+ text:
570
+ "budget: undeclared (context.always.budget.tokens) — a report, not a rail; init would offer the larger of " +
571
+ `${grouped(OFFER_FLOOR_TOKENS)} tokens and today's load: ${grouped(Math.max(OFFER_FLOOR_TOKENS, tokens))} tokens`,
572
+ };
573
+ }
574
+ if (tokens > declared.budget) {
575
+ return {
576
+ verdict: "over",
577
+ tokens,
578
+ text:
579
+ `budget: the always tier is ~${grouped(tokens)} tokens, over the ${grouped(declared.budget)} declared by ${grouped(tokens - declared.budget)} — ` +
580
+ "repair by demotion to a later tier, by a merge or by a retirement, never by raising the budget in this change",
581
+ };
582
+ }
583
+ return { verdict: "within", tokens, text: `budget: the always tier is ~${grouped(tokens)} tokens of the ${grouped(declared.budget)} declared` };
584
+ }
585
+
586
+ export function measure(workspaceDir, { bundleRoot = BUNDLE_ROOT, repo = null } = {}) {
587
+ const manifest = readManifest(workspaceDir);
588
+ const declared = declaredContext(manifest);
589
+ const repoRoot = treeOf(workspaceDir, manifest);
590
+ const always = repoRoot === null ? null : alwaysTier(repoRoot);
591
+ const boot = bootReadSet(workspaceDir, manifest, { bundleRoot, repo, always });
592
+ const plugin = pluginDescriptions(bundleRoot);
593
+ const steps = STEPS.map(({ rel }) => path.join(bundleRoot, rel));
594
+ const engine = ENGINE.map(({ rel }) => path.join(bundleRoot, rel));
595
+ return {
596
+ declared,
597
+ boot,
598
+ always,
599
+ plugin,
600
+ figures: {
601
+ boot: boot.engineMissing.length ? null : sum(boot.entries),
602
+ records: boot.engineMissing.length || boot.carded ? null : sum(boot.entries.filter((e) => !e.manifest)),
603
+ engine: engine.every(isFile) ? engine.reduce((total, file, i) => total + sizeOf(file, ENGINE[i].label), 0) : null,
604
+ // Every step file, applying here or not: each is some adopter's boot read-set.
605
+ steps: steps.every(isFile) ? steps.reduce((total, file, i) => total + sizeOf(file, STEPS[i].label), 0) : null,
606
+ workspace: sum(boot.entries.filter((e) => !e.bundle)),
607
+ always: always === null ? null : sum(always.entries),
608
+ descriptions: plugin.unavailable === undefined ? sum(plugin.entries) : null,
609
+ },
610
+ };
611
+ }
612
+
613
+ // ===========================================================================================
614
+ // The line `doctor` reports and the boot closes with
615
+ // ===========================================================================================
616
+
617
+ /** `over` and `unjudged` are verdicts; `unmeasured` is a report, said where no budget waits on the figure. */
618
+ export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT } = {}) {
619
+ // Read raw, so a budget whose key is malformed still counts as one somebody meant to declare.
620
+ const budgeted = manifest.context?.always !== undefined;
621
+ // The tree as declared, before it is checked, since checking it is one of the things that can fail on it.
622
+ const roots = [
623
+ [typeof manifest.tree === "string" ? path.resolve(workspaceDir, manifest.tree) : null, "the repository"],
624
+ [path.resolve(bundleRoot), "the plugin"],
625
+ ];
626
+ const said = (verdict, text) => ({ verdict, line: printable(relativeTo(`${MEASURED_HOST}: ${text}`, roots)) });
627
+ const notMeasured = (why) =>
628
+ budgeted ? said("unjudged", `the declared budget cannot be judged — ${why}`) : said("unmeasured", `the always tier is not measured — ${why}`);
629
+ let declared;
630
+ let root;
631
+ let always;
632
+ try {
633
+ declared = declaredContext(manifest);
634
+ if (manifest.kind === "pointer") return notMeasured("the manifest is a pointer, which declares no tree, so the repository it sits in is not measured here");
635
+ root = treeOf(workspaceDir, manifest);
636
+ if (root === null) return notMeasured("this workspace declares no tree, so there is no repository here whose instruction files a host would load");
637
+ always = alwaysTier(root);
638
+ } catch (error) {
639
+ if (!(error instanceof ContextError)) throw error;
640
+ return notMeasured(error.message);
641
+ }
642
+ let plugin;
643
+ try {
644
+ plugin = pluginDescriptions(bundleRoot);
645
+ } catch (error) {
646
+ if (!(error instanceof ContextError)) throw error;
647
+ plugin = { unavailable: error.message };
648
+ }
649
+
650
+ const ratio = declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN;
651
+ const bytes = sum(always.entries);
652
+ const judged = judgeBudget(declared, bytes);
653
+ const parts = [
654
+ `this repository's always tier is ~${grouped(judged.tokens)} tokens, ${grouped(bytes)} B at ${ratio} bytes per token` +
655
+ (declared.ratio === null ? ", proposal 0036's estimate: none is declared" : `, declared, calibrated by ${declared.calibratedBy}`),
656
+ ];
657
+ if (always.entries.length) {
658
+ // Stable, so files of one size keep the host's own order.
659
+ const top = [...always.entries].sort((a, b) => b.bytes - a.bytes).slice(0, TOP_CONTRIBUTORS);
660
+ parts.push(
661
+ `the largest: ${top.map((e) => `${path.relative(root, e.file)} ~${grouped(tokensOf(e.bytes, ratio))} (${e.label})`).join(", ")}` +
662
+ (always.entries.length > top.length ? `, of ${grouped(always.entries.length)} files` : ""),
663
+ );
664
+ } else {
665
+ parts.push("nothing in it: no CLAUDE.md, .claude/CLAUDE.md, unscoped rule, or project skill, command or agent");
666
+ }
667
+ const scoped = always.scoped;
668
+ parts.push(scoped === 0 ? "no path-scoped rule sits on-path" : scoped === 1 ? "1 path-scoped rule sits on-path" : `${grouped(scoped)} path-scoped rules sit on-path`);
669
+ const outside = always.outside.length;
670
+ parts.push(
671
+ outside === 0
672
+ ? "no import or link out of the repository loads"
673
+ : outside === 1
674
+ ? "1 import or link out of the repository loads and is not counted"
675
+ : `${grouped(outside)} imports or links out of the repository load and are not counted`,
676
+ );
677
+ parts.push(
678
+ plugin.unavailable === undefined
679
+ ? `the Portulan plugin's descriptions add ~${grouped(tokensOf(sum(plugin.entries), ratio))} tokens wherever it is enabled, Portulan's to budget, not this workspace's`
680
+ : `the Portulan plugin's descriptions are not measured — ${plugin.unavailable}`,
681
+ );
682
+ parts.push(judged.text);
683
+ // `init` and `upgrade` draft a card importing the identity whole, so over a budget that import is the demotion to name.
684
+ const identity = typeof manifest.slots?.identity === "string" ? path.resolve(workspaceDir, manifest.slots.identity) : null;
685
+ const whole = judged.verdict === "over" && identity !== null ? always.entries.find((e) => e.label.startsWith("import") && path.resolve(e.file) === identity) : undefined;
686
+ if (whole) {
687
+ parts.push(
688
+ `the tier imports ${path.relative(root, identity)} whole, ~${grouped(tokensOf(whole.bytes, ratio))} tokens: one demotion is to make it an on-demand read, ` +
689
+ "as Portulan's own card does, a few lines on the card saying who the team is, naming the file and when to open it",
690
+ );
691
+ }
692
+ const over = judged.verdict === "over";
693
+ const offer = offerText(splitOffers(root, { ratio, floor: OFFER_FLOOR_TOKENS, over }), { over, workspace: path.relative(root, workspaceDir).split(path.sep).join("/") || "." });
694
+ if (offer !== null) parts.push(offer);
695
+ return said(judged.verdict, parts.join("; "));
696
+ }
697
+
698
+ /** Longest root first, so a root inside another gives its own paths; a filesystem root is skipped, as every path is under it. */
699
+ function relativeTo(text, roots) {
700
+ const usable = roots.filter(([root]) => root !== null && path.dirname(root) !== root).sort((a, b) => b[0].length - a[0].length);
701
+ for (const [root, name] of usable) {
702
+ text = text.split(`${root}${path.sep}`).join("").split(`${root} `).join(`${name} `);
703
+ if (text.endsWith(root)) text = `${text.slice(0, -root.length)}${name}`;
704
+ }
705
+ return text;
706
+ }
707
+
708
+ function printable(text) {
709
+ return text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
710
+ }
711
+
712
+ // ===========================================================================================
713
+ // The report
714
+ // ===========================================================================================
715
+
716
+ function parseArgs(argv) {
717
+ const options = { workspace: null, repo: null, rails: new Map(), brief: false };
718
+ for (let i = 0; i < argv.length; i += 1) {
719
+ const flag = argv[i];
720
+ if (flag === "--brief") {
721
+ if (options.brief) throw new ContextError("--brief is given twice");
722
+ options.brief = true;
723
+ } else if (flag === "--workspace" || flag === "--repo" || flag === "--rail") {
724
+ const value = argv[i + 1];
725
+ i += 1;
726
+ if (value === undefined) throw new ContextError(`${flag} needs a value`);
727
+ if (flag === "--workspace") options.workspace = value;
728
+ else if (flag === "--repo") options.repo = value;
729
+ else {
730
+ const match = /^([a-z]+)=([1-9]\d*)$/.exec(value);
731
+ if (!match) throw new ContextError(`--rail ${value} is not <name>=<bytes>, with a whole number of bytes`);
732
+ if (!RAILS.includes(match[1])) throw new ContextError(`--rail ${match[1]} is not a rail — the rails are ${RAILS.join(", ")}`);
733
+ if (options.rails.has(match[1])) throw new ContextError(`--rail ${match[1]} is given twice`);
734
+ if (!Number.isSafeInteger(Number(match[2]))) throw new ContextError(`--rail ${value} is past the largest whole number of bytes this can compare exactly`);
735
+ options.rails.set(match[1], Number(match[2]));
736
+ }
737
+ } else {
738
+ throw new ContextError(`unknown argument ${JSON.stringify(flag)}`);
739
+ }
740
+ }
741
+ if (options.workspace === null) throw new ContextError("--workspace <dir> is required: the directory holding workspace.json");
742
+ if (options.brief && (options.repo !== null || options.rails.size)) {
743
+ throw new ContextError("--brief prints the always tier's line, which judges no rail and reads no card — run without it to use --repo or --rail");
744
+ }
745
+ return options;
746
+ }
747
+
748
+ export function run(argv, say = (line) => process.stdout.write(`${line}\n`), { bundleRoot = BUNDLE_ROOT, cwd = process.cwd() } = {}) {
749
+ let options;
750
+ let result;
751
+ try {
752
+ options = parseArgs(argv);
753
+ if (options.brief) {
754
+ const workspaceDir = path.resolve(cwd, options.workspace);
755
+ const { verdict, line } = alwaysLine(workspaceDir, readManifest(workspaceDir), { bundleRoot });
756
+ say(line);
757
+ return verdict === "over" ? 1 : verdict === "unjudged" ? 2 : 0;
758
+ }
759
+ result = measure(path.resolve(cwd, options.workspace), { bundleRoot, repo: options.repo });
760
+ } catch (error) {
761
+ if (!(error instanceof ContextError)) throw error;
762
+ say(` ✗ ${error.message}`);
763
+ return 2;
764
+ }
765
+ const { declared, boot, always, plugin, figures } = result;
766
+ const ratio = declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN;
767
+ const shown = (file) => {
768
+ const rel = path.relative(cwd, file);
769
+ return rel.startsWith("..") || path.isAbsolute(rel) ? file : rel;
770
+ };
771
+ const line = (bytes, label, what) =>
772
+ say(` ${grouped(bytes).padStart(9)} B ${`~${grouped(tokensOf(bytes, ratio))}`.padStart(8)} tok ${label.padEnd(18)} ${what}`);
773
+
774
+ say(`context: ${options.workspace} — what a session that boots here reads, and what the host loads into every context`);
775
+ say(
776
+ declared.ratio === null
777
+ ? ` ratio: ${ESTIMATED_BYTES_PER_TOKEN} bytes per token, proposal 0036's estimate — this workspace declares none, so every token figure is approximate`
778
+ : ` ratio: ${declared.ratio} bytes per token, declared${declared.calibratedBy === null ? "" : `, calibrated by ${declared.calibratedBy}`}`,
779
+ );
780
+
781
+ if (boot.carded) {
782
+ const fallback = boot.entries.some((e) => e.bundle && e.label === "kernel");
783
+ say(
784
+ ` boot read-set — carded: ${shown(boot.carded)} is this repository's boot card, loaded into every context with the rest of the always tier, ` +
785
+ (fallback
786
+ ? "so a session that boots here reads the skill and the plugin's kernel, which nothing in context carries, and the card stands in for the slots"
787
+ : "so a session that boots here reads the skill and stops: the card carries the kernel and stands in for the slots"),
788
+ );
789
+ } else {
790
+ say(" boot read-set — read in full by every session that boots Portulan here: the skill, its steps and the kernel (step 1), the manifest (step 2, to find the slots), the slots, this repository's card and the memory index (step 3), the packs step where the manifest names a pack (step 3a)");
791
+ }
792
+ for (const e of boot.entries) line(e.bytes, e.label, shown(e.file));
793
+ for (const rel of boot.engineMissing) say(` ${"—".padStart(9)} ${"".padStart(8)} ${"engine".padEnd(18)} ${rel} is not in this bundle, so the engine half is not measured`);
794
+ if (boot.card.why !== null) say(` ${"—".padStart(9)} ${"".padStart(8)} ${"repo card".padEnd(18)} ${boot.card.why}`);
795
+ if (figures.records !== null) line(figures.records, "without manifest", "the figure the 2026-09-23 records measured, which left the manifest out");
796
+ if (figures.boot !== null) {
797
+ line(
798
+ figures.boot,
799
+ "total",
800
+ boot.carded
801
+ ? `the boot read-set; the engine half a boot with no card reads is ${grouped(figures.engine)} B`
802
+ : `the boot read-set; its engine half, which every adopter's boot reads, is ${grouped(figures.engine)} B`,
803
+ );
804
+ }
805
+ else line(figures.workspace, "workspace half", "the boot read-set less the bundle's files, which this bundle does not carry in full");
806
+ if (figures.steps !== null) {
807
+ line(figures.steps, "step files", `the skill's, each read only where it applies: ${STEPS.map((s) => `${shown(path.join(bundleRoot, s.rel))} where ${s.where}`).join(", ")}`);
808
+ }
809
+ say(` not counted, because a boot opens them on demand: ${boot.notCounted.join(", ")}`);
810
+
811
+ say(" always tier — what Claude Code loads into every context in this repository, booted or not (proposal 0036, rule 1)");
812
+ if (always === null) {
813
+ say(" not measured: this workspace declares no tree, so there is no repository whose instruction files a host would load");
814
+ } else {
815
+ for (const e of always.entries) line(e.bytes, e.label, shown(e.file));
816
+ line(figures.always, "repository's own", always.entries.length ? "what a declared context.always.budget.tokens budgets" : "none: no CLAUDE.md, .claude/CLAUDE.md, unscoped rule, or project skill, command or agent");
817
+ const aside = [];
818
+ if (always.scoped) aside.push(`${always.scoped} path-scoped rule(s), on-path`);
819
+ if (always.unlisted) aside.push(`${always.unlisted} description(s) the host does not list (disable-model-invocation)`);
820
+ if (always.missing.length) aside.push(`imports naming no file, which load nothing: ${always.missing.join(", ")}`);
821
+ if (always.tooDeep.length) aside.push(`imports ${IMPORT_DEPTH} deep or more, which the host does not load: ${always.tooDeep.join(", ")}`);
822
+ if (always.outside.length) aside.push(`imports and links outside the repository, loaded and not measured: ${always.outside.join(", ")}`);
823
+ for (const a of aside) say(` also: ${a}`);
824
+ }
825
+ if (plugin.unavailable !== undefined) say(` the Portulan plugin's descriptions: not measured — ${plugin.unavailable}`);
826
+ else line(figures.descriptions, "Portulan plugin", `${plugin.skills} skill and ${plugin.agents} agent descriptions, in every session where the plugin is enabled — Portulan's to budget, not this workspace's`);
827
+ say(" not measured, because they are not this repository's: CLAUDE.local.md (personal), CLAUDE.md files above the repository, the host's auto memory, its system prompt, tools and MCP servers");
828
+
829
+ let red = false;
830
+ const couldNot = [];
831
+ if (always === null) {
832
+ if (declared.budget === null) say(" budget: undeclared (context.always.budget.tokens) — a report, not a rail");
833
+ else couldNot.push("context.always.budget.tokens is declared, and this workspace declares no tree whose always tier it could budget");
834
+ } else {
835
+ const judged = judgeBudget(declared, figures.always);
836
+ if (judged.verdict === "over") red = true;
837
+ say(` ${{ undeclared: "", within: "ok ", over: "✗ " }[judged.verdict]}${judged.text}`);
838
+ }
839
+
840
+ for (const [name, rail] of options.rails) {
841
+ const figure = figures[name];
842
+ if (figure === null) {
843
+ const why = { descriptions: "the plugin's descriptions were not measured", steps: "the skill's step files are not in this bundle" };
844
+ couldNot.push(`rail ${name} cannot be judged: ${why[name] ?? `${boot.engineMissing.join(" and ")} ${boot.engineMissing.length === 1 ? "is" : "are"} not in this bundle`}`);
845
+ continue;
846
+ }
847
+ if (name === "boot" && boot.card.why !== null && boot.card.others > 0) {
848
+ couldNot.push(`rail boot cannot be judged: ${boot.card.why}`);
849
+ continue;
850
+ }
851
+ if (figure > rail) {
852
+ red = true;
853
+ say(
854
+ ` ✗ rail ${name}: ${grouped(figure)} B, over its rail of ${grouped(rail)} B by ${grouped(figure - rail)} B — ` +
855
+ "repair by demotion, merge or retirement, never by raising the rail in this change",
856
+ );
857
+ continue;
858
+ }
859
+ say(` ok rail ${name}: ${grouped(figure)} B of ${grouped(rail)} B (${grouped(rail - figure)} B headroom)`);
860
+ if ((rail - figure) * 100 > rail * NOTE_PERCENT) {
861
+ say(` note rail ${name}: its headroom is more than ${NOTE_PERCENT}% of it — lower it to ${grouped(railFor(figure))} B, today's figure plus ${HEADROOM_PERCENT}%`);
862
+ }
863
+ }
864
+
865
+ if (couldNot.length) {
866
+ for (const c of couldNot) say(` ✗ ${c}`);
867
+ return 2;
868
+ }
869
+ return red ? 1 : 0;
870
+ }
871
+
872
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
873
+ // `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
874
+ try {
875
+ process.exitCode = run(process.argv.slice(2));
876
+ } catch (cause) {
877
+ process.stderr.write(
878
+ `context: could not run — ${cause?.stack ?? cause}\n` +
879
+ "This is a defect in the measure, not a verdict about the workspace: nothing was judged.\n",
880
+ );
881
+ process.exitCode = 2;
882
+ }
883
+ }