@sleepy_panda_srl/portulan 0.2.0 → 0.4.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 +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- package/spec/workspace.schema.json +67 -11
package/cli/context.mjs
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Measure boot reads and the project's always tier.
|
|
3
3
|
//
|
|
4
4
|
// node cli/context.mjs --workspace <dir> [--repo <card>] [--rail <name>=<bytes>]...
|
|
5
|
-
// node cli/context.mjs --workspace <dir> --brief
|
|
5
|
+
// node cli/context.mjs --workspace <dir> --brief | --count-tokens <model>
|
|
6
6
|
//
|
|
7
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
8
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
+
import { isAbsentImportError } from "./import-files.mjs";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
13
|
|
|
13
|
-
import { BOOT_CARD_LINE, IMPORT_DEPTH, importPath, importSpans } from "./compile.mjs";
|
|
14
|
+
import { BOOT_CARD_LINE, CompileError, GUIDANCE_HOSTS, IMPORT_DEPTH, importPath, importSpans } from "./compile.mjs";
|
|
14
15
|
import { offerText, splitOffers } from "./instructions.mjs";
|
|
15
16
|
import { AGENT_DIR, parseFrontmatter } from "./plugin-lint.mjs";
|
|
16
17
|
import { HOST_SKILL_DEPTH, manifestPath } from "./skills-set.mjs";
|
|
18
|
+
import { readHandoffs, renderHandoffIndex } from "./index.mjs";
|
|
19
|
+
import { handoffPolicy, handoffOverages, handoffTokens } from "./handoff-policy.mjs";
|
|
17
20
|
|
|
18
21
|
export { IMPORT_DEPTH };
|
|
19
22
|
|
|
@@ -71,6 +74,28 @@ const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
|
71
74
|
|
|
72
75
|
export const tokensOf = (bytes, ratio) => Math.round(bytes / ratio);
|
|
73
76
|
|
|
77
|
+
export function handoffReport(dir, manifest) {
|
|
78
|
+
const policy = handoffPolicy(manifest);
|
|
79
|
+
if (manifest.slots?.handoffs === undefined) return { lines: [], over: false };
|
|
80
|
+
const declared = declaredContext(manifest);
|
|
81
|
+
const ratio = declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN;
|
|
82
|
+
const series = readHandoffs(dir, manifest);
|
|
83
|
+
const sizes = series.records.map(r => r.bytes).sort((a, b) => a - b);
|
|
84
|
+
const middle = Math.floor(sizes.length / 2);
|
|
85
|
+
const median = sizes.length ? sizes.length % 2 ? sizes[middle] : (sizes[middle - 1] + sizes[middle]) / 2 : 0;
|
|
86
|
+
const size = bytes => `${bytes} B (~${handoffTokens(bytes, ratio)} tokens)`;
|
|
87
|
+
const lines = [`handoffs: ${sizes.length} record(s), total ${size(series.bytes)}, median ${size(median)}; ${declared.ratio === null ? "estimate" : "declared ratio"} ${ratio} bytes per token; on-read, excluded from boot and always tier`];
|
|
88
|
+
for (const record of [...series.records].sort((a, b) => b.bytes - a.bytes || a.file.localeCompare(b.file)).slice(0, 3)) lines.push(`handoffs largest: ${record.file} — ${size(record.bytes)}`);
|
|
89
|
+
if (manifest.handoffs?.index?.path) {
|
|
90
|
+
if (series.records.some(r => r.date === null || r.heading === null)) throw new ContextError("handoff index cannot render: repair the filename dates and H1 headings with portulan index");
|
|
91
|
+
const text = renderHandoffIndex(manifest, series);
|
|
92
|
+
lines.push(`handoffs index: ${manifest.handoffs.index.path}, rendered ${size(Buffer.byteLength(text))}; ${policy.window === null ? "budget undeclared" : `budget ${policy.window} tokens`}`);
|
|
93
|
+
} else lines.push("handoffs index: undeclared — no index measured");
|
|
94
|
+
const overages = handoffOverages(series, policy);
|
|
95
|
+
lines.push(policy.cap === null ? "handoffs cap: undeclared — report only" : `handoffs cap: ${policy.cap} tokens, cutoff ${policy.cutoff}; ${overages.bound.length} bound over, ${overages.historical.length} on/before cutoff over (reported, never railed)`);
|
|
96
|
+
return { lines, over: overages.bound.length > 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
function sizeOf(file, what) {
|
|
75
100
|
let stat;
|
|
76
101
|
try {
|
|
@@ -90,14 +115,11 @@ function readText(file, what) {
|
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
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
118
|
function statOf(file) {
|
|
97
119
|
try {
|
|
98
120
|
return fs.statSync(file);
|
|
99
121
|
} catch (error) {
|
|
100
|
-
if (
|
|
122
|
+
if (isAbsentImportError(error)) return null;
|
|
101
123
|
throw new ContextError(`${file} could not be read (${error.code ?? error.message})`);
|
|
102
124
|
}
|
|
103
125
|
}
|
|
@@ -142,7 +164,7 @@ export function declaredContext(manifest) {
|
|
|
142
164
|
throw new ContextError(`context.ratio.bytes_per_token is ${shown(bytesPerToken)}, not a finite number of at least 1`);
|
|
143
165
|
}
|
|
144
166
|
if (typeof ratio.calibrated_by !== "string" || ratio.calibrated_by === "") {
|
|
145
|
-
throw new ContextError(`context.ratio.calibrated_by is ${shown(ratio.calibrated_by)}, not
|
|
167
|
+
throw new ContextError(`context.ratio.calibrated_by is ${shown(ratio.calibrated_by)}, not a non-empty compile-host calibration ID`);
|
|
146
168
|
}
|
|
147
169
|
let budget = null;
|
|
148
170
|
if (context.always !== undefined) {
|
|
@@ -310,11 +332,19 @@ export function bootReadSet(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT,
|
|
|
310
332
|
// ===========================================================================================
|
|
311
333
|
|
|
312
334
|
export function importsOf(text) {
|
|
313
|
-
return
|
|
335
|
+
return measuredImports(text, "import text")
|
|
314
336
|
.map(({ target }) => importPath(target))
|
|
315
337
|
.filter((bare) => bare !== null);
|
|
316
338
|
}
|
|
317
339
|
|
|
340
|
+
function measuredImports(text, file) {
|
|
341
|
+
try { return importSpans(text); }
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (error instanceof CompileError) throw new ContextError(`${file}: ${error.message}`);
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
318
348
|
const inside = (root, file) => {
|
|
319
349
|
const rel = path.relative(root, file);
|
|
320
350
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
@@ -364,15 +394,51 @@ function walkMarkdown(dir, kept) {
|
|
|
364
394
|
return out;
|
|
365
395
|
}
|
|
366
396
|
|
|
367
|
-
export function alwaysTier(repoRoot) {
|
|
397
|
+
export function alwaysTier(repoRoot, { includeText = false, host = "claude-code", materializeImport } = {}) {
|
|
398
|
+
if (!Object.hasOwn(GUIDANCE_HOSTS, host)) throw new ContextError("unknown project guidance host");
|
|
368
399
|
const entries = [];
|
|
369
400
|
const missing = [];
|
|
370
401
|
const outside = [];
|
|
371
402
|
const tooDeep = [];
|
|
372
403
|
const seen = new Set();
|
|
404
|
+
const captured = new Map();
|
|
405
|
+
const source = (file) => {
|
|
406
|
+
const real = realOf(file);
|
|
407
|
+
if (!includeText || !captured.has(real)) {
|
|
408
|
+
let raw;
|
|
409
|
+
try { raw = fs.readFileSync(file); }
|
|
410
|
+
catch { throw new ContextError("an always-tier source could not be read"); }
|
|
411
|
+
const text = raw.toString("utf8");
|
|
412
|
+
if (!Buffer.from(text, "utf8").equals(raw)) throw new ContextError("an always-tier source is not valid UTF-8");
|
|
413
|
+
const value = { text, bytes: raw.length };
|
|
414
|
+
if (!includeText) return value;
|
|
415
|
+
captured.set(real, value);
|
|
416
|
+
}
|
|
417
|
+
return captured.get(real);
|
|
418
|
+
};
|
|
373
419
|
|
|
374
420
|
// Judged on real paths: a link out of the repository is named, never opened, since it is not this repository's to measure.
|
|
375
421
|
const realRoot = realOf(repoRoot);
|
|
422
|
+
if (host === "agents-md") {
|
|
423
|
+
const file = path.join(repoRoot, "AGENTS.md");
|
|
424
|
+
let entry;
|
|
425
|
+
try { entry = fs.lstatSync(file); }
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (!isAbsentImportError(error)) throw new ContextError(`AGENTS.md could not be inspected (${error.code ?? "read error"})`);
|
|
428
|
+
}
|
|
429
|
+
if (entry) {
|
|
430
|
+
let real, raw;
|
|
431
|
+
try {
|
|
432
|
+
real = fs.realpathSync(file);
|
|
433
|
+
if (!inside(realRoot, real) || !fs.statSync(file).isFile()) throw new Error("source boundary");
|
|
434
|
+
raw = fs.readFileSync(file);
|
|
435
|
+
} catch (error) { throw new ContextError(`AGENTS.md is present but cannot be measured within the repository (${error.code ?? "nonregular or outside source"})`); }
|
|
436
|
+
const text = raw.toString("utf8");
|
|
437
|
+
if (!Buffer.from(text, "utf8").equals(raw)) throw new ContextError("AGENTS.md is not valid UTF-8");
|
|
438
|
+
entries.push({ label: "vendored instructions", file, bytes: raw.length, ...(includeText ? { text } : {}) });
|
|
439
|
+
}
|
|
440
|
+
return { entries, scoped: 0, unlisted: 0, missing, outside, tooDeep, card: null };
|
|
441
|
+
}
|
|
376
442
|
const kept = (file) => {
|
|
377
443
|
const real = realOf(file);
|
|
378
444
|
if (real === realRoot || inside(realRoot, real)) return true;
|
|
@@ -386,13 +452,14 @@ export function alwaysTier(repoRoot) {
|
|
|
386
452
|
while (queue.length) {
|
|
387
453
|
const { file, label, depth, scoped } = queue.shift();
|
|
388
454
|
const real = realOf(file);
|
|
455
|
+
const { text, bytes } = source(file);
|
|
389
456
|
if (!(scoped && depth === 0)) {
|
|
390
457
|
if (seen.has(real)) continue;
|
|
391
458
|
seen.add(real);
|
|
392
459
|
const imported = `import, depth ${depth}${scoped ? ", of a path-scoped rule" : ""}`;
|
|
393
|
-
entries.push({ label: depth === 0 ? label : imported, file, bytes
|
|
460
|
+
entries.push({ label: depth === 0 ? label : imported, file, bytes, ...(includeText ? { text } : {}) });
|
|
394
461
|
}
|
|
395
|
-
for (const { target } of
|
|
462
|
+
for (const { target } of measuredImports(text, file)) {
|
|
396
463
|
const bare = importPath(target);
|
|
397
464
|
if (bare === null) continue;
|
|
398
465
|
const spelled = `@${target} (in ${path.relative(repoRoot, file)})`;
|
|
@@ -400,8 +467,12 @@ export function alwaysTier(repoRoot) {
|
|
|
400
467
|
outside.push(spelled);
|
|
401
468
|
continue;
|
|
402
469
|
}
|
|
403
|
-
|
|
404
|
-
if (
|
|
470
|
+
let resolved = path.resolve(path.dirname(file), bare);
|
|
471
|
+
if (depth + 1 < IMPORT_DEPTH) {
|
|
472
|
+
const projected = materializeImport?.({ file: resolved, source: file, bare, depth });
|
|
473
|
+
if (projected !== undefined) resolved = projected;
|
|
474
|
+
}
|
|
475
|
+
if (resolved === null || !isFile(resolved)) {
|
|
405
476
|
missing.push(spelled);
|
|
406
477
|
continue;
|
|
407
478
|
}
|
|
@@ -418,19 +489,24 @@ export function alwaysTier(repoRoot) {
|
|
|
418
489
|
}
|
|
419
490
|
}
|
|
420
491
|
};
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
492
|
+
const instructions = [];
|
|
493
|
+
for (const rel of ["CLAUDE.md", path.join(".claude", "CLAUDE.md")]) {
|
|
494
|
+
const file = path.join(repoRoot, rel);
|
|
495
|
+
let entry;
|
|
496
|
+
try { entry = fs.lstatSync(file); }
|
|
497
|
+
catch (error) { if (!isAbsentImportError(error)) throw new ContextError(`${rel} could not be inspected (${error.code ?? "read error"})`); }
|
|
498
|
+
if (!entry) continue;
|
|
499
|
+
if (!isFile(file)) throw new ContextError(`${rel} is present but not a readable regular file`);
|
|
500
|
+
if (kept(file)) instructions.push({ file, label: "instructions" });
|
|
501
|
+
}
|
|
502
|
+
follow(instructions);
|
|
427
503
|
|
|
428
504
|
let scoped = 0;
|
|
429
505
|
let card = null;
|
|
430
506
|
const unscoped = [];
|
|
431
507
|
const pathScoped = [];
|
|
432
508
|
for (const file of walkMarkdown(path.join(repoRoot, ".claude", "rules"), kept)) {
|
|
433
|
-
const text =
|
|
509
|
+
const { text } = source(file);
|
|
434
510
|
const { fields } = parseFrontmatter(text);
|
|
435
511
|
if (fields && Object.hasOwn(fields, "paths")) {
|
|
436
512
|
scoped += 1;
|
|
@@ -445,12 +521,12 @@ export function alwaysTier(repoRoot) {
|
|
|
445
521
|
// The host lists a skill's, command's or agent's description in every context, and loads the body when it is invoked.
|
|
446
522
|
let unlisted = 0;
|
|
447
523
|
const describe = (file, label) => {
|
|
448
|
-
const description = descriptionOf(
|
|
524
|
+
const description = descriptionOf(source(file).text);
|
|
449
525
|
if (description === null) {
|
|
450
526
|
unlisted += 1;
|
|
451
527
|
return;
|
|
452
528
|
}
|
|
453
|
-
entries.push({ label, file, bytes: Buffer.byteLength(description, "utf8") });
|
|
529
|
+
entries.push({ label, file, bytes: Buffer.byteLength(description, "utf8"), ...(includeText ? { text: description } : {}) });
|
|
454
530
|
};
|
|
455
531
|
const skills = path.join(repoRoot, ".claude", "skills");
|
|
456
532
|
if (isDir(skills) && kept(skills)) {
|
|
@@ -471,6 +547,72 @@ export function alwaysTier(repoRoot) {
|
|
|
471
547
|
return { entries, scoped, unlisted, missing, outside, tooDeep, card };
|
|
472
548
|
}
|
|
473
549
|
|
|
550
|
+
export function countRoot(workspaceDir) {
|
|
551
|
+
let manifest;
|
|
552
|
+
try { manifest = readManifest(workspaceDir); }
|
|
553
|
+
catch { throw new ContextError("provider counting needs a readable, valid JSON workspace manifest"); }
|
|
554
|
+
if (!WORKSPACE_KINDS.includes(manifest.kind)) throw new ContextError("provider counting needs a supported governing workspace kind");
|
|
555
|
+
try { declaredContext(manifest); }
|
|
556
|
+
catch { throw new ContextError("provider counting needs a valid workspace context declaration"); }
|
|
557
|
+
let root;
|
|
558
|
+
try { root = treeOf(workspaceDir, manifest); }
|
|
559
|
+
catch { throw new ContextError("provider counting needs a readable repository tree"); }
|
|
560
|
+
if (root === null) throw new ContextError("provider counting needs a measurable repository tree");
|
|
561
|
+
return root;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function countAlways(workspaceDir, { model, env = process.env, fetch = globalThis.fetch, timeoutMs = 30_000 } = {}) {
|
|
565
|
+
if (typeof model !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(model)) throw new ContextError("--count-tokens needs an explicit provider model ID");
|
|
566
|
+
if (env.CI) throw new ContextError("provider token counting is unavailable in CI; verify recipes stay offline");
|
|
567
|
+
const root = countRoot(workspaceDir);
|
|
568
|
+
let always;
|
|
569
|
+
try { always = alwaysTier(root, { includeText: true }); }
|
|
570
|
+
catch (error) { throw new ContextError(error instanceof ContextError && error.message.includes("UTF-8") ? "an always-tier source is not valid UTF-8" : "always-tier sources or imports could not be measured"); }
|
|
571
|
+
if (always.missing.length || always.outside.length || always.tooDeep.length) throw new ContextError("always-tier imports are missing, outside the measured tree or too deep; no provider request made");
|
|
572
|
+
const bytes = always.entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
573
|
+
const result = { provider: "Anthropic", model, bytes, tokens: null, ratio: null, files: always.entries.map((entry) => path.relative(root, entry.file)) };
|
|
574
|
+
if (!bytes) return result;
|
|
575
|
+
const key = env.ANTHROPIC_API_KEY;
|
|
576
|
+
if (typeof key !== "string" || !key || /\s/.test(key)) throw new ContextError("ANTHROPIC_API_KEY is required for provider counting");
|
|
577
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new ContextError("provider timeout must be a positive whole number of milliseconds");
|
|
578
|
+
const controller = new AbortController();
|
|
579
|
+
let timer;
|
|
580
|
+
const expired = new Promise((_, reject) => {
|
|
581
|
+
timer = setTimeout(() => { controller.abort(); reject(new ContextError("provider token count timed out")); }, timeoutMs);
|
|
582
|
+
});
|
|
583
|
+
try {
|
|
584
|
+
const load = async () => {
|
|
585
|
+
let response;
|
|
586
|
+
try {
|
|
587
|
+
response = await fetch("https://api.anthropic.com/v1/messages/count_tokens", {
|
|
588
|
+
method: "POST", redirect: "error", signal: controller.signal,
|
|
589
|
+
headers: { "Content-Type": "application/json", "anthropic-version": "2026-10-21", Authorization: `Bearer ${key}` },
|
|
590
|
+
body: JSON.stringify({ model, messages: [{ role: "user", content: always.entries.filter((entry) => entry.bytes).map((entry) => ({ type: "text", text: entry.text })) }] }),
|
|
591
|
+
});
|
|
592
|
+
} catch { throw new ContextError("provider token count request failed"); }
|
|
593
|
+
if (response.redirected) throw new ContextError("provider token count redirect refused");
|
|
594
|
+
if (!response.ok) throw new ContextError(`provider token count failed (HTTP ${Number.isInteger(response.status) ? response.status : "unavailable"})`);
|
|
595
|
+
let body;
|
|
596
|
+
try { body = await response.json(); }
|
|
597
|
+
catch { throw new ContextError("provider returned an invalid token count response"); }
|
|
598
|
+
if (!Number.isSafeInteger(body?.input_tokens) || body.input_tokens <= 0) throw new ContextError("provider input_tokens is not a positive safe integer token count");
|
|
599
|
+
return body.input_tokens;
|
|
600
|
+
};
|
|
601
|
+
const tokens = await Promise.race([load(), expired]);
|
|
602
|
+
return { ...result, tokens, ratio: bytes / tokens };
|
|
603
|
+
} finally {
|
|
604
|
+
clearTimeout(timer);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function countLine(result) {
|
|
609
|
+
if (!result.bytes) return "project always corpus: empty (0 B); no request made and no ratio available";
|
|
610
|
+
return `${result.provider} provider estimate (${result.model}): ${result.tokens} input tokens for ${result.bytes} B of measured project source; ` +
|
|
611
|
+
`effective ratio ${result.ratio.toFixed(6)} B/token${result.ratio < 1 ? " (below the manifest's minimum; not declarable)" : " (approximate; assess manually before declaring a host ratio)"}. ` +
|
|
612
|
+
"API framing/system-added tokens may be included; actual host usage may differ. No manifest written. " +
|
|
613
|
+
"Excludes personal/global instructions, auto-memory, system/tools/MCP, plugin descriptions, scoped-rule bodies and on-invoke bodies.";
|
|
614
|
+
}
|
|
615
|
+
|
|
474
616
|
export function pluginDescriptions(bundleRoot = BUNDLE_ROOT) {
|
|
475
617
|
const file = manifestPath(bundleRoot);
|
|
476
618
|
if (!isFile(file)) return { unavailable: "this bundle carries no .claude-plugin/plugin.json, so it is not the plugin" };
|
|
@@ -560,15 +702,15 @@ export function treeOf(workspaceDir, manifest) {
|
|
|
560
702
|
return repoRoot;
|
|
561
703
|
}
|
|
562
704
|
|
|
563
|
-
export function judgeBudget(declared, bytes) {
|
|
705
|
+
export function judgeBudget(declared, bytes, { offer = true } = {}) {
|
|
564
706
|
const tokens = tokensOf(bytes, declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN);
|
|
565
707
|
if (declared.budget === null) {
|
|
566
708
|
return {
|
|
567
709
|
verdict: "undeclared",
|
|
568
710
|
tokens,
|
|
569
711
|
text:
|
|
570
|
-
"budget: undeclared (context.always.budget.tokens) — a report, not a rail
|
|
571
|
-
|
|
712
|
+
"budget: undeclared (context.always.budget.tokens) — a report, not a rail" +
|
|
713
|
+
(offer ? "; init offers a projected budget for both guidance formats at your accepted ratio, with an 8,000-token floor" : ""),
|
|
572
714
|
};
|
|
573
715
|
}
|
|
574
716
|
if (tokens > declared.budget) {
|
|
@@ -615,7 +757,9 @@ export function measure(workspaceDir, { bundleRoot = BUNDLE_ROOT, repo = null }
|
|
|
615
757
|
// ===========================================================================================
|
|
616
758
|
|
|
617
759
|
/** `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 } = {}) {
|
|
760
|
+
export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT, host = "claude-code" } = {}) {
|
|
761
|
+
if (!Object.hasOwn(GUIDANCE_HOSTS, host)) throw new ContextError("unknown project guidance host");
|
|
762
|
+
const claude = host === "claude-code";
|
|
619
763
|
// Read raw, so a budget whose key is malformed still counts as one somebody meant to declare.
|
|
620
764
|
const budgeted = manifest.context?.always !== undefined;
|
|
621
765
|
// The tree as declared, before it is checked, since checking it is one of the things that can fail on it.
|
|
@@ -623,7 +767,7 @@ export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT }
|
|
|
623
767
|
[typeof manifest.tree === "string" ? path.resolve(workspaceDir, manifest.tree) : null, "the repository"],
|
|
624
768
|
[path.resolve(bundleRoot), "the plugin"],
|
|
625
769
|
];
|
|
626
|
-
const said = (verdict, text) => ({ verdict, line: printable(relativeTo(`${
|
|
770
|
+
const said = (verdict, text) => ({ verdict, line: printable(relativeTo(`${GUIDANCE_HOSTS[host].label}: ${text}`, roots)) });
|
|
627
771
|
const notMeasured = (why) =>
|
|
628
772
|
budgeted ? said("unjudged", `the declared budget cannot be judged — ${why}`) : said("unmeasured", `the always tier is not measured — ${why}`);
|
|
629
773
|
let declared;
|
|
@@ -634,14 +778,14 @@ export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT }
|
|
|
634
778
|
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
779
|
root = treeOf(workspaceDir, manifest);
|
|
636
780
|
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);
|
|
781
|
+
always = alwaysTier(root, { host });
|
|
638
782
|
} catch (error) {
|
|
639
783
|
if (!(error instanceof ContextError)) throw error;
|
|
640
784
|
return notMeasured(error.message);
|
|
641
785
|
}
|
|
642
786
|
let plugin;
|
|
643
787
|
try {
|
|
644
|
-
plugin = pluginDescriptions(bundleRoot);
|
|
788
|
+
plugin = claude ? pluginDescriptions(bundleRoot) : null;
|
|
645
789
|
} catch (error) {
|
|
646
790
|
if (!(error instanceof ContextError)) throw error;
|
|
647
791
|
plugin = { unavailable: error.message };
|
|
@@ -649,10 +793,10 @@ export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT }
|
|
|
649
793
|
|
|
650
794
|
const ratio = declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN;
|
|
651
795
|
const bytes = sum(always.entries);
|
|
652
|
-
const judged = judgeBudget(declared, bytes);
|
|
796
|
+
const judged = judgeBudget(declared, bytes, { offer: claude });
|
|
653
797
|
const parts = [
|
|
654
798
|
`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}`),
|
|
799
|
+
(declared.ratio === null ? ", proposal 0036's estimate: none is declared" : host === declared.calibratedBy ? `, declared, calibrated by ${declared.calibratedBy}` : `, shared declared ratio extrapolated to this host; calibration reference ${declared.calibratedBy}`),
|
|
656
800
|
];
|
|
657
801
|
if (always.entries.length) {
|
|
658
802
|
// Stable, so files of one size keep the host's own order.
|
|
@@ -662,27 +806,29 @@ export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT }
|
|
|
662
806
|
(always.entries.length > top.length ? `, of ${grouped(always.entries.length)} files` : ""),
|
|
663
807
|
);
|
|
664
808
|
} else {
|
|
665
|
-
parts.push("nothing in it: no CLAUDE.md, .claude/CLAUDE.md, unscoped rule, or project skill, command or agent");
|
|
809
|
+
parts.push(claude ? "nothing in it: no CLAUDE.md, .claude/CLAUDE.md, unscoped rule, or project skill, command or agent" : "nothing in it: no root AGENTS.md");
|
|
666
810
|
}
|
|
667
811
|
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`);
|
|
812
|
+
if (claude) 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
813
|
const outside = always.outside.length;
|
|
670
|
-
parts.push(
|
|
814
|
+
if (claude) parts.push(
|
|
671
815
|
outside === 0
|
|
672
816
|
? "no import or link out of the repository loads"
|
|
673
817
|
: outside === 1
|
|
674
818
|
? "1 import or link out of the repository loads and is not counted"
|
|
675
819
|
: `${grouped(outside)} imports or links out of the repository load and are not counted`,
|
|
676
820
|
);
|
|
677
|
-
parts.push(
|
|
821
|
+
if (claude) parts.push(
|
|
678
822
|
plugin.unavailable === undefined
|
|
679
823
|
? `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
824
|
: `the Portulan plugin's descriptions are not measured — ${plugin.unavailable}`,
|
|
681
825
|
);
|
|
826
|
+
if (!claude) parts.push("root AGENTS.md only: pointer targets and at-sign text are not expanded; personal, above-tree, nested files and plugins are excluded");
|
|
827
|
+
if (claude && (always.missing.length || always.tooDeep.length)) parts.push(`${always.missing.length} missing imports and ${always.tooDeep.length} beyond the host's import depth are not loaded or counted`);
|
|
682
828
|
parts.push(judged.text);
|
|
683
829
|
// `init` and `upgrade` draft a card importing the identity whole, so over a budget that import is the demotion to name.
|
|
684
830
|
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;
|
|
831
|
+
const whole = claude && judged.verdict === "over" && identity !== null ? always.entries.find((e) => e.label.startsWith("import") && path.resolve(e.file) === identity) : undefined;
|
|
686
832
|
if (whole) {
|
|
687
833
|
parts.push(
|
|
688
834
|
`the tier imports ${path.relative(root, identity)} whole, ~${grouped(tokensOf(whole.bytes, ratio))} tokens: one demotion is to make it an on-demand read, ` +
|
|
@@ -690,7 +836,7 @@ export function alwaysLine(workspaceDir, manifest, { bundleRoot = BUNDLE_ROOT }
|
|
|
690
836
|
);
|
|
691
837
|
}
|
|
692
838
|
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("/") || "." });
|
|
839
|
+
const offer = claude ? offerText(splitOffers(root, { ratio, floor: OFFER_FLOOR_TOKENS, over }), { over, workspace: path.relative(root, workspaceDir).split(path.sep).join("/") || "." }) : null;
|
|
694
840
|
if (offer !== null) parts.push(offer);
|
|
695
841
|
return said(judged.verdict, parts.join("; "));
|
|
696
842
|
}
|
|
@@ -714,17 +860,20 @@ function printable(text) {
|
|
|
714
860
|
// ===========================================================================================
|
|
715
861
|
|
|
716
862
|
function parseArgs(argv) {
|
|
717
|
-
const options = { workspace: null, repo: null, rails: new Map(), brief: false };
|
|
863
|
+
const options = { workspace: null, repo: null, rails: new Map(), brief: false, countModel: null };
|
|
718
864
|
for (let i = 0; i < argv.length; i += 1) {
|
|
719
865
|
const flag = argv[i];
|
|
720
866
|
if (flag === "--brief") {
|
|
721
867
|
if (options.brief) throw new ContextError("--brief is given twice");
|
|
722
868
|
options.brief = true;
|
|
723
|
-
} else if (flag === "--workspace" || flag === "--repo" || flag === "--rail") {
|
|
869
|
+
} else if (flag === "--workspace" || flag === "--repo" || flag === "--rail" || flag === "--count-tokens") {
|
|
724
870
|
const value = argv[i + 1];
|
|
725
871
|
i += 1;
|
|
726
872
|
if (value === undefined) throw new ContextError(`${flag} needs a value`);
|
|
727
|
-
if (flag === "--
|
|
873
|
+
if (flag === "--count-tokens") {
|
|
874
|
+
if (options.countModel !== null) throw new ContextError("--count-tokens is given twice");
|
|
875
|
+
options.countModel = value;
|
|
876
|
+
} else if (flag === "--workspace") options.workspace = value;
|
|
728
877
|
else if (flag === "--repo") options.repo = value;
|
|
729
878
|
else {
|
|
730
879
|
const match = /^([a-z]+)=([1-9]\d*)$/.exec(value);
|
|
@@ -739,17 +888,23 @@ function parseArgs(argv) {
|
|
|
739
888
|
}
|
|
740
889
|
}
|
|
741
890
|
if (options.workspace === null) throw new ContextError("--workspace <dir> is required: the directory holding workspace.json");
|
|
891
|
+
if (options.countModel !== null && (options.brief || options.repo !== null || options.rails.size)) throw new ContextError("--count-tokens is a separate provider report; omit --brief, --repo and --rail");
|
|
742
892
|
if (options.brief && (options.repo !== null || options.rails.size)) {
|
|
743
893
|
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
894
|
}
|
|
745
895
|
return options;
|
|
746
896
|
}
|
|
747
897
|
|
|
748
|
-
export function run(argv, say = (line) => process.stdout.write(`${line}\n`), { bundleRoot = BUNDLE_ROOT, cwd = process.cwd() } = {}) {
|
|
898
|
+
export function run(argv, say = (line) => process.stdout.write(`${line}\n`), { bundleRoot = BUNDLE_ROOT, cwd = process.cwd(), countOptions = {} } = {}) {
|
|
749
899
|
let options;
|
|
750
900
|
let result;
|
|
751
901
|
try {
|
|
752
902
|
options = parseArgs(argv);
|
|
903
|
+
if (options.countModel !== null) {
|
|
904
|
+
return countAlways(path.resolve(cwd, options.workspace), { ...countOptions, model: options.countModel })
|
|
905
|
+
.then((result) => { say(countLine(result)); return 0; })
|
|
906
|
+
.catch((error) => { say(` ✗ ${error instanceof ContextError ? error.message : "provider token count could not run"}`); return 2; });
|
|
907
|
+
}
|
|
753
908
|
if (options.brief) {
|
|
754
909
|
const workspaceDir = path.resolve(cwd, options.workspace);
|
|
755
910
|
const { verdict, line } = alwaysLine(workspaceDir, readManifest(workspaceDir), { bundleRoot });
|
|
@@ -862,6 +1017,12 @@ export function run(argv, say = (line) => process.stdout.write(`${line}\n`), { b
|
|
|
862
1017
|
}
|
|
863
1018
|
}
|
|
864
1019
|
|
|
1020
|
+
try {
|
|
1021
|
+
const handoffs = handoffReport(path.resolve(cwd, options.workspace), readManifest(path.resolve(cwd, options.workspace)));
|
|
1022
|
+
for (const text of handoffs.lines) say(` ${text}`);
|
|
1023
|
+
red ||= handoffs.over;
|
|
1024
|
+
} catch (error) { couldNot.push(`handoffs: ${error.message}`); }
|
|
1025
|
+
|
|
865
1026
|
if (couldNot.length) {
|
|
866
1027
|
for (const c of couldNot) say(` ✗ ${c}`);
|
|
867
1028
|
return 2;
|
|
@@ -872,7 +1033,7 @@ export function run(argv, say = (line) => process.stdout.write(`${line}\n`), { b
|
|
|
872
1033
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
873
1034
|
// `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
|
|
874
1035
|
try {
|
|
875
|
-
process.exitCode = run(process.argv.slice(2));
|
|
1036
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
876
1037
|
} catch (cause) {
|
|
877
1038
|
process.stderr.write(
|
|
878
1039
|
`context: could not run — ${cause?.stack ?? cause}\n` +
|