@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
@@ -0,0 +1,131 @@
1
+ // Shared asynchronous recipe execution, bounded output and process-tree cleanup.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn, execFileSync } from "node:child_process";
5
+
6
+ const CANNOT_RUN = new Set([2, 126, 127]);
7
+ export const INTERRUPTIONS = ["SIGINT", "SIGTERM", "SIGHUP"];
8
+
9
+ /** A stream's last `limit` bytes, retaining at most `limit` and one chunk. */
10
+ export function tailKeeper(limit = 64 * 1024) {
11
+ const chunks = [];
12
+ let held = 0;
13
+ return {
14
+ add(chunk) {
15
+ chunks.push(chunk);
16
+ held += chunk.length;
17
+ while (held - chunks[0].length >= limit) held -= chunks.shift().length;
18
+ },
19
+ get held() { return held; },
20
+ text: () => Buffer.concat(chunks).subarray(-limit).toString("utf8"),
21
+ };
22
+ }
23
+
24
+ export function windowsTaskkill(env, access = fs.accessSync) {
25
+ const root = env.SystemRoot || env.SYSTEMROOT || "C:\\Windows";
26
+ if (!path.win32.isAbsolute(root)) throw new Error("taskkill requires an absolute Windows system root");
27
+ const command = path.win32.join(root, "System32", "taskkill.exe");
28
+ access(command, fs.constants.F_OK);
29
+ return command;
30
+ }
31
+
32
+ /** Signal only a recipe-owned group, or force its Windows tree with a bounded system utility. */
33
+ export function terminateTree(pid, signal, { platform = process.platform, kill = process.kill.bind(process), exec = execFileSync, taskkill } = {}) {
34
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("recipe has no valid process id");
35
+ if (platform === "win32") {
36
+ if (!taskkill) throw new Error("taskkill was not preflighted");
37
+ exec(taskkill, ["/PID", String(pid), "/T", "/F"], { timeout: 1000, windowsHide: true, stdio: "pipe" });
38
+ } else {
39
+ try { kill(-pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
40
+ }
41
+ }
42
+
43
+ export function runRecipe(recipe, { root, env = process.env, timeout = 600_000, onOutput = null, mergeOutput = true, inheritGroup = false, groupId = null }) {
44
+ return new Promise((resolve) => {
45
+ const kept = tailKeeper();
46
+ const failures = [];
47
+ let child, taskkill, limit, grace, drain;
48
+ let settled = false, closed = false, stopping = false, escalated = false, late = false;
49
+ let status = null, interrupted = null;
50
+ const handlers = new Map();
51
+ const record = (error) => failures.push(String(error?.message ?? error));
52
+ const terminate = (signal) => terminateTree(inheritGroup && process.platform !== "win32" ? groupId : child.pid, signal, { taskkill });
53
+ const pipes = () => { child?.stdout?.destroy(); child?.stderr?.destroy(); };
54
+ const settle = () => {
55
+ if (settled) return;
56
+ settled = true;
57
+ for (const timer of [limit, grace, drain]) clearTimeout(timer);
58
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
59
+ const code = failures.length || late || interrupted ? null : status;
60
+ if (code === 0) return resolve({ id: recipe.id, outcome: "green" });
61
+ const why = [
62
+ ...failures,
63
+ ...(interrupted ? [`interrupted by ${interrupted}`] : []),
64
+ ...(late ? [`killed at its time limit of ${timeout / 1000} s`] : []),
65
+ ];
66
+ resolve({ id: recipe.id, outcome: code === null || CANNOT_RUN.has(code) ? "could not run" : "red", code,
67
+ output: kept.text() + (why.length ? `\n${why.join("\n")}` : ""),
68
+ ...(onOutput ? { diagnostics: why } : {}),
69
+ ...(late ? { timedOut: true } : {}),
70
+ ...(interrupted ? { interrupted } : {}) });
71
+ };
72
+ const hardStop = () => {
73
+ try { terminate("SIGKILL"); }
74
+ catch (error) {
75
+ record(error);
76
+ try { child.kill("SIGKILL"); } catch (error) { record(error); }
77
+ }
78
+ escalated = true;
79
+ if (closed) return settle();
80
+ drain = setTimeout(() => {
81
+ record(new Error("recipe did not close after forced termination"));
82
+ pipes();
83
+ child.unref();
84
+ settle();
85
+ }, 1000);
86
+ };
87
+ const stop = (signal) => {
88
+ if (settled) return;
89
+ if (signal && interrupted === null) interrupted = signal;
90
+ if (stopping) return;
91
+ stopping = true;
92
+ clearTimeout(limit);
93
+ clearTimeout(drain);
94
+ if (process.platform === "win32") return hardStop();
95
+ try { terminate(signal || "SIGTERM"); } catch (error) { record(error); }
96
+ // The group may outlive its leader and pipes: neither event cancels escalation.
97
+ grace = setTimeout(hardStop, 250);
98
+ };
99
+ try {
100
+ for (const signal of INTERRUPTIONS) {
101
+ const handler = () => stop(signal);
102
+ handlers.set(signal, handler);
103
+ process.on(signal, handler);
104
+ }
105
+ if (process.platform === "win32") taskkill = windowsTaskkill(env);
106
+ if (inheritGroup && process.platform !== "win32" && (!Number.isInteger(groupId) || groupId <= 1)) throw new Error("inherited recipe group is invalid");
107
+ child = spawn("bash", ["-c", `${mergeOutput ? "exec 2>&1\n" : ""}${inheritGroup ? "" : "export PORTULAN_RECIPE_GROUP=$$\n"}${recipe.run}`], {
108
+ cwd: root, env: { ...env, PORTULAN_RECIPE_MANAGED: "1" }, detached: process.platform !== "win32" && !inheritGroup, windowsHide: true, stdio: ["ignore", "pipe", "pipe"],
109
+ });
110
+ } catch (error) { record(error); settle(); return; }
111
+ for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {
112
+ stream.on("data", (chunk) => {
113
+ kept.add(chunk);
114
+ try { onOutput?.(name, chunk); } catch (error) { record(error); stop(); }
115
+ });
116
+ stream.on("error", (error) => { record(error); stop(); });
117
+ }
118
+ child.on("error", (error) => { record(error); if (!child.pid) settle(); else stop(); });
119
+ child.on("exit", (code) => {
120
+ status = code;
121
+ clearTimeout(limit);
122
+ if (!stopping) drain = setTimeout(() => { pipes(); settle(); }, 1000);
123
+ });
124
+ child.on("close", (code) => {
125
+ status = code;
126
+ closed = true;
127
+ if (!stopping || escalated) settle();
128
+ });
129
+ if (!inheritGroup) limit = setTimeout(() => { late = true; stop(); }, timeout);
130
+ });
131
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import { pathToFileURL } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
7
  import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
8
8
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
@@ -23,6 +23,8 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
23
23
 
24
24
  /** The token a composed `run` uses to name its own pack's files. */
25
25
  const PACK_ROOT_TOKEN = "${PACK_ROOT}";
26
+ const quote = (word) => `'${word.replaceAll("'", "'\\''")}'`;
27
+ export const agentCommand = (command) => `${quote(process.execPath)} ${quote(path.join(path.dirname(fileURLToPath(import.meta.url)), "recipe-output.mjs"))} -- ${quote(command)}`;
26
28
 
27
29
  /** Its `:` is outside `$defs/slug`, so a composed id never equals a workspace id or `verify.default`. */
28
30
  export function composedId(packRef, id) {
@@ -115,6 +117,13 @@ export function recipeSet(manifest, options = {}) {
115
117
  if (recipes.length === 0) return refuse("the manifest yields no verify recipes — refusing to report green");
116
118
 
117
119
  const declaredDefault = manifest?.verify?.default;
120
+ if (options.agent) {
121
+ for (const recipe of recipes) {
122
+ recipe.run = agentCommand(recipe.run);
123
+ const bad = invalidRunnable(recipe.id, recipe.run, "agent executor", { requireSlug: false });
124
+ if (bad) return refuse(bad);
125
+ }
126
+ }
118
127
  return { ok: true, recipes, default: declaredDefault == null ? declaredDefault : String(declaredDefault) };
119
128
  }
120
129
 
@@ -228,7 +237,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
228
237
  stderr.write(`${err.message}\n`);
229
238
  return 2;
230
239
  }
231
- const set = recipeSet(manifest, { resolve });
240
+ const set = recipeSet(manifest, { resolve, agent: argv.includes("--agent") });
232
241
  if (!set.ok) {
233
242
  stderr.write(`${set.reason}\n`);
234
243
  return set.exitCode;
@@ -238,5 +247,5 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
238
247
  }
239
248
 
240
249
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
241
- process.exit(run(process.argv.slice(2)));
250
+ process.exitCode = run(process.argv.slice(2));
242
251
  }
@@ -0,0 +1,103 @@
1
+ // Own native settings without inventing a gate policy.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ export const CONTEXT_CAP_ENV = "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS";
6
+ const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
7
+ export function readContextCap(manifest, pack) {
8
+ const c = manifest?.contributes?.contexts;
9
+ if (c === undefined) return null;
10
+ const bad = why => { throw Error(`\`${pack}\` contributes.contexts ${why}`); };
11
+ if (manifest.category !== "rituals" || !pack.startsWith("rituals/")) bad("is reserved for ritual packs");
12
+ if (manifest?.portulan?.pack !== "1.1") bad("requires supported Pack Definition 1.1");
13
+ if (!object(c) || Object.keys(c).length !== 1 || !Object.hasOwn(c, "max_concurrent_subagents")) bad("requires only max_concurrent_subagents");
14
+ const n = c.max_concurrent_subagents;
15
+ if (!Number.isSafeInteger(n) || n < 1) bad("max_concurrent_subagents must be a positive safe integer");
16
+ return n;
17
+ }
18
+ export function contextCaps(contributions, unresolved = []) {
19
+ const sources = contributions.filter(c => c.contextCap != null).map(c => {
20
+ if (!Number.isSafeInteger(c.contextCap) || c.contextCap < 1) throw Error("invalid contributes.contexts count");
21
+ return { pack: c.pack, limit: c.contextCap, origin: c.origin ?? null, version: c.version ?? null };
22
+ }).sort((a, b) => a.pack.localeCompare(b.pack));
23
+ if (sources.length && unresolved.length) throw Error("ritual contexts cap cannot compile while composed packs remain unresolved; their contributions are unknown");
24
+ return { limit: sources.length ? sources.reduce((n, s) => Math.min(n, s.limit), Infinity) : null, sources, unknown: unresolved.length };
25
+ }
26
+ export function contextCapLines(cap) {
27
+ if (cap.limit === null) return cap.unknown ? ["No context cap found in resolved packs; unresolved pack contributions are unknown."] : [];
28
+ return [
29
+ `Claude Code: ${CONTEXT_CAP_ENV}=${cap.limit}, minimum of ${cap.sources.map(s => `${s.pack}=${s.limit}`).join(", ")}; compiled for ordinary fresh native Agent admission within one session.`,
30
+ "Parent excluded; project setting applies across rituals. Total children, requests, time and tokens are unbounded. Resumes and /subtask forks can exceed the admission limit; workflows/team agents have independent limits. Higher-precedence settings and native experimental/ultracode bypasses can defeat it. Measured on Claude Code 2.1.321; not a universal hard ceiling.",
31
+ "GitHub repository ruleset and AGENTS guidance: context cap not compiled by these adapters.",
32
+ ];
33
+ }
34
+ const keys = (v, required, optional = []) => object(v) && required.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => [...required, ...optional].includes(k));
35
+ const canonical = v => Array.isArray(v) ? v.map(canonical) : object(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
36
+ const fingerprint = v => {
37
+ const payload = structuredClone(v); delete payload.$portulan.digest;
38
+ return createHash("sha256").update(JSON.stringify(canonical(payload))).digest("hex");
39
+ };
40
+ const sourcesValid = sources => Array.isArray(sources) && sources.length > 0 && sources.every((s, i) =>
41
+ keys(s, ["pack", "limit", "origin", "version"]) && /^rituals\/[a-z0-9][a-z0-9-]*$/.test(s.pack) && Number.isSafeInteger(s.limit) && s.limit > 0 &&
42
+ [null, "tree", "outside-tree", "discovered"].includes(s.origin) && (s.version === null || typeof s.version === "string") &&
43
+ (i === 0 || sources[i - 1].pack.localeCompare(s.pack) < 0));
44
+ const capValid = (env, sources) => keys(env, [CONTEXT_CAP_ENV]) && sourcesValid(sources) && env[CONTEXT_CAP_ENV] === String(Math.min(...sources.map(s => s.limit)));
45
+ const commandValid = (v, action) => keys(v, ["type", "command"]) && v.type === "command" && typeof v.command === "string" && new RegExp(String.raw`^node "[^"\r\n]+[/\\]advisory\.mjs" ${action}(?: --(?:read|write-5m|write-1h|horizon|resume-tokens) [0-9.eE+-]+)*$`).test(v.command);
46
+ const hookValid = (v, action) => Array.isArray(v) && v.length === 1 && keys(v[0], ["hooks"]) && Array.isArray(v[0].hooks) && v[0].hooks.length === 1 && commandValid(v[0].hooks[0], action);
47
+ function adviceOwned(v) {
48
+ const m = v?.$portulan;
49
+ return keys(v, ["$portulan", "hooks", "statusLine"], ["includeGitInstructions", "promptCacheTtl", "env"]) &&
50
+ keys(m, ["generated", "kind", "digest", "source", "warning"], ["sessions", "spend", "packs", "contexts"]) &&
51
+ m.generated === "cli/compile.mjs" && m.kind === "advisory" && /^[a-f0-9]{64}$/.test(m.digest) && m.digest === fingerprint(v) &&
52
+ typeof m.source === "string" && !path.isAbsolute(m.source) && !m.source.split("/").includes("..") && /(?:^|\/)workspace\.json$/.test(m.source) && typeof m.warning === "string" &&
53
+ ["sessions", "spend"].every(k => !Object.hasOwn(m, k) || m[k] === m.source) &&
54
+ (!Object.hasOwn(m, "packs") || (Array.isArray(m.packs) && m.packs.length > 0 && m.packs.every((p, i) => keys(p, ["pack", "origin", "version"]) && typeof p.pack === "string" && ["tree", "outside-tree", "discovered"].includes(p.origin) && (p.version === null || typeof p.version === "string") && (i === 0 || m.packs[i - 1].pack.localeCompare(p.pack) < 0)))) &&
55
+ (!Object.hasOwn(v, "includeGitInstructions") || typeof v.includeGitInstructions === "boolean") && (!Object.hasOwn(v, "promptCacheTtl") || ["5m", "1h"].includes(v.promptCacheTtl)) &&
56
+ (Object.hasOwn(v, "env") ? capValid(v.env, m.contexts) : !Object.hasOwn(m, "contexts")) &&
57
+ keys(v.hooks, ["PostToolUse", "UserPromptSubmit"], ["Stop"]) && hookValid(v.hooks.PostToolUse, "tool") && hookValid(v.hooks.UserPromptSubmit, "prompt") &&
58
+ (!Object.hasOwn(v.hooks, "Stop") || hookValid(v.hooks.Stop, "stop")) && commandValid(v.statusLine, "status");
59
+ }
60
+ export function capOnlyPlan(root, cap, advice = null) {
61
+ const file = path.join(root, ".claude/settings.json");
62
+ const parent = path.dirname(file), desired = advice !== null || cap.limit !== null;
63
+ let parentStat;
64
+ try { parentStat = fs.lstatSync(parent); } catch (e) { if (e.code !== "ENOENT") throw e; }
65
+ if (parentStat && (!parentStat.isDirectory() || parentStat.isSymbolicLink())) {
66
+ if (!desired) return null;
67
+ throw Error("native settings parent is not an owned directory");
68
+ }
69
+ let current = null, old = null, links = 1;
70
+ try {
71
+ const stat = fs.lstatSync(file);
72
+ if (!stat.isFile() || stat.isSymbolicLink()) {
73
+ if (!desired) return null;
74
+ throw Error("native settings is not a regular owned file");
75
+ }
76
+ links = stat.nlink;
77
+ current = fs.readFileSync(file, "utf8");
78
+ try { old = JSON.parse(current); } catch { /* foreign */ }
79
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
80
+ const generated = old?.$portulan?.generated === "cli/compile.mjs";
81
+ const marked = generated && old.$portulan.kind === "ritual-contexts";
82
+ const advisory = generated && old.$portulan.kind === "advisory";
83
+ if (advisory && advice === null) return null;
84
+ if (links !== 1 && (desired || marked)) throw Error("native settings is multiply linked; refusing replacement or removal");
85
+ if (cap.limit === null && !marked && !advisory && generated && Object.hasOwn(old?.env ?? {}, CONTEXT_CAP_ENV)) throw Error("previous policy-generated settings retain a stale context cap after policy removal; preserve and resolve those settings explicitly, or restore the policy and recompile");
86
+ const legacy = marked && keys(old, ["$portulan", "env"]) && keys(old.$portulan, ["generated", "kind", "sources"]) && capValid(old.env, old.$portulan.sources);
87
+ const owned = legacy || (advisory && adviceOwned(old));
88
+ if (current !== null && !owned && (desired || marked)) throw Error("native compile refuses settings with edited, unrelated or unowned content; preserve it and resolve ownership explicitly");
89
+ if (!desired && !owned) return null;
90
+ const value = advice !== null ? structuredClone(advice) : cap.limit === null ? null : {
91
+ $portulan: { generated: "cli/compile.mjs", kind: "ritual-contexts", sources: cap.sources },
92
+ env: { [CONTEXT_CAP_ENV]: String(cap.limit) },
93
+ };
94
+ if (advice !== null) { value.$portulan.kind = "advisory"; value.$portulan.digest = fingerprint(value); }
95
+ return { file, current, text: value === null ? null : JSON.stringify(value, null, 2) + "\n" };
96
+ }
97
+ export function emitCapOnly(plan, { check, say }) {
98
+ if (!plan || plan.current === plan.text) return 0;
99
+ if (check) { say("RED — native Claude settings drifted; recompile"); return 1; }
100
+ if (plan.text === null) { fs.rmSync(plan.file); say(`removed ${plan.file} — no ritual cap remains`); }
101
+ else { fs.mkdirSync(path.dirname(plan.file), { recursive: true }); fs.writeFileSync(plan.file, plan.text); say(`wrote ${plan.file}`); }
102
+ return 0;
103
+ }
package/cli/sessions.mjs CHANGED
@@ -74,7 +74,8 @@ export function sessionsLine(manifest) {
74
74
  parts.push(multipliersWords(spend.multipliers) ?? "multipliers the general ones");
75
75
  const horizon = horizonWords(spend.horizon);
76
76
  if (horizon !== null) parts.push(horizon);
77
- if (spend.restart === "block") parts.push("a turn's end held once at the restart threshold");
77
+ if (spend.resume?.tokens !== undefined) parts.push(`resume expense ${spend.resume.tokens} input-equivalent tokens`);
78
+ if (spend.restart === "block") parts.push("a turn's end held once only with explicit horizon, resume expense and positive estimated savings");
78
79
  if (lifetime === null && workspace.kind === "repository") {
79
80
  parts.push("`portulan upgrade` prints the five-minute lifetime's offer and its trade-off");
80
81
  }
@@ -0,0 +1,111 @@
1
+ // Class indices compare stated assumptions, never bills or route quality.
2
+ import { CLASSES, GENERAL_READ, WRITE_BY_LIFETIME, RECORDED_ROLES, SPEND_FIGURES, restartSaving } from "./ledger.mjs";
3
+
4
+ const classes = ["uncached", "written", "read", "output"];
5
+ const efforts = ["low", "medium", "high", "max"];
6
+ const families = ["opus", "sonnet", "haiku", "fable", "mythos"];
7
+ const causes = ["compaction", "model change", "effort change", "lifetime lapsed", "unexplained"];
8
+ const grouped = n => n.toLocaleString("en-US", { maximumFractionDigits: 0 });
9
+ const estimate = n => n.toLocaleString("en-US", { maximumSignificantDigits: 8 });
10
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
11
+ const count = n => Number.isSafeInteger(n) && n >= 0;
12
+ const leaders = scores => {
13
+ const max = Math.max(...Object.values(scores));
14
+ return max <= 0 ? [] : classes.filter(k => Math.abs(scores[k] - max) <= max * Number.EPSILON * 8);
15
+ };
16
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
17
+
18
+ function scopeLines(label, contexts, rates) {
19
+ const total = Object.fromEntries(CLASSES.map(k => [k, 0]));
20
+ const roles = new Map(), inferred = Object.fromEntries(causes.map(k => [k, 0]));
21
+ let contributing = 0, requests = 0;
22
+ try {
23
+ for (const c of contexts) {
24
+ if (c.requests.length) contributing++;
25
+ for (const r of c.requests) {
26
+ if (r.usageMissing || r.usageUnsafe) return [`${label}: class analysis unassessed — usage counters are missing or invalid; totals are partial.`];
27
+ requests++;
28
+ for (const k of CLASSES) {
29
+ if (!count(r[k]) || !count(total[k] + r[k])) throw Error();
30
+ total[k] += r[k];
31
+ }
32
+ if (r.rebuild) inferred[causes.includes(r.rebuild.cause) ? r.rebuild.cause : "unexplained"]++;
33
+ const role = RECORDED_ROLES.includes(r.role) ? r.role : c.agent === null ? "main" : "unattributed subagent";
34
+ if (!roles.has(role)) roles.set(role, { output: 0, efforts: {}, models: {} });
35
+ const into = roles.get(role), effort = efforts.includes(r.effort) ? r.effort : "unknown";
36
+ const family = families.find(f => typeof r.model === "string" && new RegExp(`^(?:claude-)?${f}(?:-|$)`).test(r.model)) ?? "unknown";
37
+ into.output += r.output;
38
+ into.efforts[effort] = (into.efforts[effort] ?? 0) + r.output;
39
+ into.models[family] = (into.models[family] ?? 0) + r.output;
40
+ }
41
+ }
42
+ if (!requests) return [`${label}: no host requests in this scope; local receipts excluded.`];
43
+ const written = total.written1h + total.written5m + total.writtenUnknown;
44
+ if (!count(written)) throw Error();
45
+ const knownWrites = total.written1h * rates.write["1h"] + total.written5m * rates.write["5m"];
46
+ const low = { uncached: total.uncached, written: knownWrites + total.writtenUnknown * Math.min(...Object.values(rates.write)), read: total.read * rates.read, output: total.output * 5 };
47
+ const high = { ...low, written: knownWrites + total.writtenUnknown * Math.max(...Object.values(rates.write)) };
48
+ if (![...Object.values(low), ...Object.values(high)].every(Number.isFinite)) throw Error();
49
+ const first = leaders(low), last = leaders(high), certain = same(first, last);
50
+ const index = classes.map(k => `${k} ~${estimate(low[k])}${low[k] === high[k] ? "" : `..${estimate(high[k])}`}`).join(", ");
51
+ const verdict = certain ? first.length ? `dominant ${first.join(" + ")}${first.length > 1 ? " (tie)" : ""}` : "no nonzero dominant class"
52
+ : `uncertain dominance (lower-bound leaders ${first.join(" + ")}; upper-bound leaders ${last.join(" + ")})`;
53
+ const lines = [
54
+ `${label} tokens: ${grouped(requests)} requests; uncached ${grouped(total.uncached)}, written ${grouped(written)}, read ${grouped(total.read)}, output ${grouped(total.output)}.`,
55
+ `${label} class index: ${index}; ${verdict}.`,
56
+ ];
57
+ if (total.writtenUnknown) lines.push(`${label}: ${grouped(total.writtenUnknown)} write tokens have unstated TTL; index spans min/max of both write rates.`);
58
+ for (const key of new Set([...first, ...last])) {
59
+ const lever = {
60
+ read: "session length and tool output; compare a restart only with supported estimates",
61
+ written: `fresh contexts, TTL gaps and cache busts; ${grouped(contributing)} contributing contexts; inferred rebuilds: ${causes.map(k => `${k} ${inferred[k]}`).join(", ")}`,
62
+ output: "effort and model by recorded role; alternate-route savings unpriced without quality evidence",
63
+ uncached: "caching may not be engaged; inspect uncached tails and cache controls",
64
+ }[key];
65
+ lines.push(`${label} ${key} lever: ${lever}.`);
66
+ }
67
+ if (first.includes("output") || last.includes("output")) {
68
+ for (const [role, r] of [...roles].sort(([a], [b]) => compare(a, b))) {
69
+ const histogram = values => Object.entries(values).filter(([, n]) => n > 0).sort(([a], [b]) => compare(a, b)).map(([k, n]) => `${k} ${grouped(n)}`).join(", ") || "none";
70
+ lines.push(`${label} output role ${role}: ${grouped(r.output)} tokens; recorded effort ${histogram(r.efforts)}; model family ${histogram(r.models)}.`);
71
+ }
72
+ }
73
+ return lines;
74
+ } catch { return [`${label}: class analysis unassessed — token counts or estimated indices are unsafe or overflow.`]; }
75
+ }
76
+
77
+ function counterfactual(figure) {
78
+ if (figure?.unavailable === "branch-scope") return "restart counterfactual unpriced: latest session tail is outside branch-owned scope.";
79
+ if (figure?.unavailable === "usage") return "restart counterfactual unpriced: input usage counters are invalid or incomplete.";
80
+ const unavailable = "restart counterfactual unpriced: explicit horizon, resume expense and usable latest-branch figures required; model/effort and TTL alternatives are not priced here.";
81
+ if (!figure?.horizonDeclared || figure.resume == null) return unavailable;
82
+ const { fresh, context, horizon, resume } = figure, read = figure.multipliers?.read, write = figure.multipliers?.write;
83
+ if (![fresh, context, horizon, resume, read, write].every(Number.isFinite) || fresh <= 0 || context < 0 || horizon <= 0 || resume < 0 || read <= 0 || write <= 0) return unavailable;
84
+ try {
85
+ const continuing = horizon * read * context, restarting = horizon * read * fresh + write * fresh + resume;
86
+ const saving = restartSaving({ fresh, context, horizon, resume, read, write });
87
+ if (![continuing, restarting].every(Number.isFinite)) return unavailable;
88
+ return `restart counterfactual: continue reads ~${estimate(continuing)} vs restart write + reads + resume ~${estimate(restarting)} input-equivalents; estimated saving ~${estimate(saving)}${saving <= 0 ? " (no positive saving)" : ""}; fixed-context comparison using caller-supplied horizon/resume, not a forecast.`;
89
+ } catch { return unavailable; }
90
+ }
91
+
92
+ export function spendAnalysis(report) {
93
+ const contexts = report.collected.contexts;
94
+ if (!contexts.some(c => c.requests.length)) return [];
95
+ const rates = report.priced?.declared ?? { read: GENERAL_READ, write: WRITE_BY_LIFETIME };
96
+ if (!SPEND_FIGURES.read.holds(rates.read) || ["5m", "1h"].some(k => !SPEND_FIGURES.write.holds(rates.write?.[k]))) return ["class analysis unassessed — invalid rate assumptions."];
97
+ const sessions = new Map();
98
+ for (const c of contexts) for (const r of c.requests) {
99
+ if (Number.isFinite(r.at)) sessions.set(c.session, Math.max(sessions.get(c.session) ?? -Infinity, r.at));
100
+ }
101
+ const chosen = [...sessions].sort(([a, x], [b, y]) => y - x || compare(String(a), String(b))).slice(0, 5).map(([id]) => id);
102
+ const undated = new Set(contexts.map(c => c.session).filter(id => !sessions.has(id))).size;
103
+ const branch = contexts.map(c => ({ ...c, requests: c.requests.filter(r => r.branch === report.branch) }));
104
+ return [
105
+ `class index assumptions: uniform input rate across models; uncached 1×, read ${rates.read}×, writes 5m ${rates.write["5m"]}× / 1h ${rates.write["1h"]}× (${report.priced?.declared ? "declared" : "general, undeclared"}); output 5× assumed. Estimated normalized input-equivalents, not monetary dominance or a bill; local receipts excluded${report.collected.malformed ? "; partial usage data" : ""}.`,
106
+ ...scopeLines("branch", branch, rates),
107
+ `recent selection: latest ${chosen.length} of at most 5 timed owned parent sessions across branches/worktrees; latest main/child timestamp, session-ID tie break; all owned requests in selected sessions included; ${undated} wholly undated sessions excluded.`,
108
+ ...scopeLines("recent", contexts.filter(c => chosen.includes(c.session)), rates),
109
+ counterfactual(report.restart),
110
+ ];
111
+ }
@@ -0,0 +1,53 @@
1
+ # Init's spend offer
2
+
3
+ `init` offers `spend.multipliers` and `spend.horizon.requests` independently. Each is
4
+ declared only after accepting its interview answer or providing `--multipliers
5
+ '<JSON>'` / `--horizon <positive integer>`. The answers file takes the same keys as
6
+ strings; flags win. A declined offer leaves the key absent. A pointer has no spend
7
+ policy and refuses these options. Either declaration requires spec 2.12.
8
+
9
+ For Claude Code, the helper reads model IDs, timestamps, repository paths and usage
10
+ presence from local assistant records. It selects the latest main-context record
11
+ for this repository, ignoring synthetic records, sidechains, separate subagents
12
+ and foreign paths. Conflicting latest models, missing models or unreadable records
13
+ give a general offer with a reason. Malformed lines are counted and disclosed.
14
+ Conversation content is not inspected. A configured model does not replace a
15
+ recorded one. The last recorded model need not be the next session's model.
16
+
17
+ The offer tries these sources in order:
18
+
19
+ 1. A matching `modelPricing.overrides` row in the system managed settings file:
20
+ `/Library/Application Support/ClaudeCode/managed-settings.json` on macOS,
21
+ `/etc/claude-code/managed-settings.json` on Linux, or
22
+ `C:\Program Files\ClaudeCode\managed-settings.json` on Windows. Exact model IDs
23
+ precede supported canonical IDs with an eight-digit snapshot suffix removed.
24
+ All four rates must be finite numbers in 0–10000. `input` and `cacheRead` must
25
+ also be positive, the read/input ratio at most one, and write/input at least
26
+ one, for `spend` to represent them. One `cacheWrite` rate supplies both
27
+ lifetimes; a global multiplier cancels in ratios. Unusable rows fall through
28
+ with a reason. User/project/local `modelPricing` settings are ignored, as the
29
+ host ignores them. See the [host contract](https://code.claude.com/docs/en/settings-reference#modelpricing).
30
+ 2. The table embedded in `spend-offer.mjs`, checked **2026-11-09** against the
31
+ [vendor's price page](https://platform.claude.com/docs/en/about-claude/pricing).
32
+ The explicit Claude API model IDs use cache reads at 0.025 for Fable/Mythos
33
+ 5.1/5.2, 0.05 for Opus/Sonnet 5.5, and 0.1 for the other listed IDs; writes are
34
+ 1.25 at five minutes and 2 at one hour. This is a dated offer, updated with the
35
+ product, without a runtime fetch. Unknown IDs and provider-specific aliases
36
+ are not guessed. Only known canonical IDs and their eight-digit snapshots
37
+ normalize; custom gateway overrides require an exact match.
38
+ 3. General figures: read 0.1, writes 1.25/2. The horizon offered is 20 requests
39
+ still to go, which the task or workspace should replace with its own estimate.
40
+
41
+ The file is **not an effective-settings resolver**. A nonempty JSON drop-in set
42
+ at `managed-settings.d` makes the file source unavailable rather than pretending
43
+ to merge it. Remote managed settings, MDM/plist/registry, policy helpers and
44
+ embedding-host sources are not inspected; they can supersede the file. The offer
45
+ names that condition. Native Windows discovery is not demonstrated here. See
46
+ [managed source precedence](https://code.claude.com/docs/en/managed-settings).
47
+ Other hosts and unsupported models get the general offer. A declaration is the
48
+ workspace's value, not a verified billing rate.
49
+
50
+ `spend-offer.test.mjs` uses injected local transcript and managed-file paths. All
51
+ verification is offline; no recipe reads the operator's usage records or managed
52
+ pricing. Accepted drafts are validated by `doctor`, and `compile` carries their
53
+ figures onto advisory commands.
@@ -0,0 +1,93 @@
1
+ // Local evidence for init's optional spend declaration.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { GENERAL_READ, WRITE_BY_LIFETIME, HORIZON, hostPaths, transcriptFiles, readSpend } from "./ledger.mjs";
5
+ import { isInside } from "./inside.mjs";
6
+
7
+ export const PRICE_DATE = "2026-11-09";
8
+ export const PRICE_SOURCE = "https://platform.claude.com/docs/en/about-claude/pricing";
9
+ export const PRICE_READS = {
10
+ "claude-fable-5-2": 0.025, "claude-mythos-5-2": 0.025,
11
+ "claude-fable-5-1": 0.025, "claude-mythos-5-1": 0.025,
12
+ "claude-opus-5-5": 0.05, "claude-sonnet-5-5": 0.05,
13
+ "claude-haiku-5-5": 0.1, "claude-fable-5": 0.1, "claude-mythos-5": 0.1,
14
+ "claude-opus-5": 0.1, "claude-sonnet-5": 0.1,
15
+ "claude-opus-4-8": 0.1, "claude-opus-4-7": 0.1, "claude-opus-4-6": 0.1,
16
+ "claude-opus-4-5": 0.1, "claude-opus-4-1": 0.1, "claude-opus-4": 0.1,
17
+ "claude-sonnet-4-6": 0.1, "claude-sonnet-4-5": 0.1, "claude-sonnet-4": 0.1,
18
+ "claude-haiku-4-5": 0.1,
19
+ };
20
+
21
+ export function recordedModel(target, projects) {
22
+ let latest = -Infinity;
23
+ const models = new Set();
24
+ let malformed = 0;
25
+ try {
26
+ for (const { file, agent } of transcriptFiles(projects, [target])) {
27
+ if (agent !== null) continue;
28
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
29
+ if (!line.trim()) continue;
30
+ let r;
31
+ try { r = JSON.parse(line); } catch { malformed++; continue; }
32
+ if (r?.type !== "assistant" || r.isSidechain || r.agentId || !r.message?.usage || typeof r.message.usage !== "object" || Array.isArray(r.message.usage) || r.message.model === "<synthetic>" || typeof r.timestamp !== "string" || typeof r.cwd !== "string" || !path.isAbsolute(r.cwd) || !isInside(target, r.cwd)) continue;
33
+ const at = Date.parse(r.timestamp);
34
+ if (!Number.isFinite(at) || at < latest) continue;
35
+ if (at > latest) { latest = at; models.clear(); }
36
+ models.add(typeof r.message.model === "string" && r.message.model ? r.message.model : null);
37
+ }
38
+ }
39
+ } catch (error) {
40
+ return { model: null, reason: `recorded model could not be read (${error.code ?? "unreadable"})` };
41
+ }
42
+ const model = models.size === 1 ? [...models][0] : null;
43
+ const reason = model === null ? (models.size > 1 ? "conflicting latest recorded models" : "no recorded model") : `last recorded model ${JSON.stringify(model)}`;
44
+ return { model, reason: reason + (malformed ? `; ignored ${malformed} malformed record(s)` : "") };
45
+ }
46
+
47
+ export function managedPricingFile(platform = process.platform) {
48
+ if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
49
+ if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
50
+ return "/etc/claude-code/managed-settings.json";
51
+ }
52
+
53
+ function hostMultipliers(model, canonical, file) {
54
+ if (file === null) return { reason: "file-based managed pricing not supplied" };
55
+ try {
56
+ const dropins = path.join(path.dirname(file), "managed-settings.d");
57
+ if (fs.existsSync(dropins) && fs.readdirSync(dropins).some(name => name.endsWith(".json"))) return { reason: "managed-settings.d may supersede file pricing; drop-ins are not resolved" };
58
+ if (!fs.statSync(file).isFile()) return { reason: "managed pricing is not a regular file" };
59
+ const pricing = JSON.parse(fs.readFileSync(file, "utf8"))?.modelPricing;
60
+ const overrides = pricing?.overrides;
61
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return { reason: "no file-based modelPricing override" };
62
+ const row = Object.hasOwn(overrides, model) ? overrides[model] : canonical !== null && Object.hasOwn(overrides, canonical) ? overrides[canonical] : null;
63
+ if (row === null) return { reason: "no matching file-based modelPricing override" };
64
+ if (!row || ["input", "output", "cacheRead", "cacheWrite"].some(key => typeof row[key] !== "number" || !Number.isFinite(row[key]) || row[key] < 0 || row[key] > 10000)) return { reason: "invalid file-based modelPricing override" };
65
+ try {
66
+ const multipliers = { read: row.cacheRead / row.input, write: { "5m": row.cacheWrite / row.input, "1h": row.cacheWrite / row.input } };
67
+ readSpend({ multipliers }, "host offer");
68
+ return { multipliers };
69
+ } catch { return { reason: "file-based modelPricing override is unrepresentable by spend" }; }
70
+ } catch (error) {
71
+ return { reason: error.code === "ENOENT" ? "no file-based managed pricing" : `file-based managed pricing unreadable (${error.code ?? "invalid JSON"})` };
72
+ }
73
+ }
74
+
75
+ export function spendOffer(target, options = {}) {
76
+ const paths = hostPaths(options.env, options.home);
77
+ const recorded = options.projects !== undefined ? recordedModel(target, options.projects) : paths.why ? { model: null, reason: "host record path is not absolute" } : recordedModel(target, paths.projects);
78
+ const { model } = recorded;
79
+ const normalized = model?.replace(/-\d{8}$/, "");
80
+ const canonical = normalized && Object.hasOwn(PRICE_READS, normalized) ? normalized : null;
81
+ const host = model === null ? { reason: "host pricing needs a recorded model" } : hostMultipliers(model, canonical, options.managedFile === undefined ? managedPricingFile(options.platform) : options.managedFile);
82
+ const source = host.multipliers ? "file-based managed modelPricing (conditional on this file being active)" : canonical !== null ? `Claude Code table ${PRICE_DATE}, ${PRICE_SOURCE}` : "general multipliers";
83
+ const multipliers = host.multipliers ?? { read: canonical === null ? GENERAL_READ : PRICE_READS[canonical], write: { ...WRITE_BY_LIFETIME } };
84
+ return { multipliers, horizon: HORIZON, source, reason: `${recorded.reason}; ${host.reason ?? "one cacheWrite rate supplies both lifetimes; a global multiplier cancels"}` };
85
+ }
86
+
87
+ export function spendOfferLines(offer) {
88
+ return [
89
+ `spend.multipliers offer ${JSON.stringify(offer.multipliers)} — ${offer.source}; ${offer.reason}`,
90
+ `spend.horizon offer {"requests":${offer.horizon}} — requests still to go, not requests already used; choose a task's expected remainder`,
91
+ "Only accepted answers or explicit --multipliers '<JSON>' / --horizon <requests> declare these at spec 2.12. Remote, MDM/registry, helper and embedding-host pricing are not inspected; other providers and unsupported model IDs use general figures. Confirm against your rates and next model.",
92
+ ];
93
+ }