@sleepy_panda_srl/portulan 0.3.0 → 0.4.1

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 (90) hide show
  1. package/README.md +18 -6
  2. package/cli/README.md +56 -4
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +9 -67
  12. package/cli/form.mjs +35 -19
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +7 -2
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/ritual-contexts.mjs +103 -0
  34. package/cli/sessions.mjs +2 -1
  35. package/cli/spend-analysis.mjs +111 -0
  36. package/cli/spend-offer.md +53 -0
  37. package/cli/spend-offer.mjs +93 -0
  38. package/cli/stop-diagnostics.mjs +114 -0
  39. package/cli/stop-gate.mjs +133 -90
  40. package/cli/stop-policy.mjs +14 -0
  41. package/cli/symbols.mjs +55 -8
  42. package/cli/telemetry.mjs +19 -12
  43. package/cli/upgrade.mjs +62 -44
  44. package/cli/vendor.mjs +210 -126
  45. package/core/operating/context.md +368 -60
  46. package/core/operating/evolution.md +3 -2
  47. package/core/operating/loop.md +9 -4
  48. package/core/operating/sessions.md +6 -15
  49. package/core/personas/README.md +52 -10
  50. package/core/personas/implementer.md +3 -0
  51. package/core/personas/librarian.md +3 -0
  52. package/core/personas/reviewer.md +3 -0
  53. package/core/templates/handoff.md +6 -2
  54. package/evals/ab/personas/README.md +111 -0
  55. package/evals/ab/personas/acquisition.json +330 -0
  56. package/evals/ab/personas/bindings/implementer.txt +43 -0
  57. package/evals/ab/personas/bindings/librarian.txt +40 -0
  58. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  59. package/evals/ab/personas/corpus.json +69 -0
  60. package/evals/ab/personas/grader.mjs +171 -0
  61. package/evals/ab/personas/implementer.json +774 -0
  62. package/evals/ab/personas/implementer.md +22 -0
  63. package/evals/ab/personas/librarian.json +758 -0
  64. package/evals/ab/personas/librarian.md +22 -0
  65. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  66. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  67. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  68. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  69. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  70. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  71. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  72. package/evals/ab/personas/reviewer.json +758 -0
  73. package/evals/ab/personas/reviewer.md +22 -0
  74. package/evals/releases/0.4.0.json +154 -0
  75. package/evals/releases/0.4.0.md +69 -0
  76. package/evals/releases/0.4.1.json +154 -0
  77. package/evals/releases/0.4.1.md +69 -0
  78. package/package.json +6 -2
  79. package/packs/rituals/checkpoints/README.md +35 -0
  80. package/packs/rituals/checkpoints/pack.json +3 -2
  81. package/packs/tools/local-llm/README.md +46 -6
  82. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  83. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  84. package/spec/README.md +37 -4
  85. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  86. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  87. package/spec/migrations/README.md +6 -0
  88. package/spec/pack.schema.json +12 -3
  89. package/spec/slots.md +262 -117
  90. package/spec/workspace.schema.json +67 -11
package/cli/context.mjs CHANGED
@@ -1,19 +1,22 @@
1
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.
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 (ABSENT.has(error.code)) return null;
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 the id of the host whose exact count calibrated the ratio`);
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 importSpans(text)
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: sizeOf(file, file) });
460
+ entries.push({ label: depth === 0 ? label : imported, file, bytes, ...(includeText ? { text } : {}) });
394
461
  }
395
- for (const { target } of importSpans(readText(file, file))) {
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
- const resolved = path.resolve(path.dirname(file), bare);
404
- if (!isFile(resolved)) {
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
- 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
- );
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 = readText(file, file);
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(readText(file, file));
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; init would offer the larger of " +
571
- `${grouped(OFFER_FLOOR_TOKENS)} tokens and today's load: ${grouped(Math.max(OFFER_FLOOR_TOKENS, tokens))} tokens`,
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(`${MEASURED_HOST}: ${text}`, roots)) });
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 === "--workspace") options.workspace = value;
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` +