@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.
Files changed (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  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 +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  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 +230 -40
  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 +1 -1
  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/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
package/cli/ledger.mjs CHANGED
@@ -38,6 +38,7 @@ export const KEY_LIMIT = 200;
38
38
 
39
39
  /** The classes a request is billed in, in the order a report prints them. */
40
40
  export const CLASSES = ["uncached", "written1h", "written5m", "writtenUnknown", "read", "output"];
41
+ export const RECORDED_ROLES = ["implementer", "reviewer", "librarian", "supervisor", "local-worker"];
41
42
 
42
43
  export class LedgerError extends Error {
43
44
  constructor(message) {
@@ -84,33 +85,55 @@ export function mayHold(name, root) {
84
85
  }
85
86
 
86
87
  /** null for a record that is not an API response with usage; no content field is ever read. */
88
+ export const INPUT_USAGE_MASK = 1 | 4 | 8;
89
+ export const inputUnusable = r => Boolean((r.usageMissing & INPUT_USAGE_MASK) || (r.usageUnsafe & 1));
90
+
91
+ export function usageOf(usage) {
92
+ const lifetimes = usage.cache_creation !== null && typeof usage.cache_creation === "object" ? usage.cache_creation : {};
93
+ const written1h = count(lifetimes.ephemeral_1h_input_tokens);
94
+ const written5m = count(lifetimes.ephemeral_5m_input_tokens);
95
+ const writeParts = (lifetimes.ephemeral_5m_input_tokens === undefined ? 0 : 1) | (lifetimes.ephemeral_1h_input_tokens === undefined ? 0 : 2);
96
+ const invalid = value => value !== undefined && (!Number.isSafeInteger(value) || value < 0);
97
+ const inputUnsafe = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, lifetimes.ephemeral_1h_input_tokens, lifetimes.ephemeral_5m_input_tokens].some(invalid) ||
98
+ !Number.isSafeInteger(count(usage.input_tokens) + Math.max(count(usage.cache_creation_input_tokens), written1h + written5m) + count(usage.cache_read_input_tokens)) ||
99
+ (usage.cache_creation_input_tokens !== undefined && count(usage.cache_creation_input_tokens) < written1h + written5m);
100
+ return {
101
+ usageMissing: (usage.input_tokens === undefined ? 1 : 0) | (usage.output_tokens === undefined ? 2 : 0) |
102
+ (usage.cache_read_input_tokens === undefined ? 4 : 0) | (usage.cache_creation_input_tokens === undefined && writeParts !== 3 ? 8 : 0),
103
+ usageUnsafe: (inputUnsafe ? 1 : 0) | (invalid(usage.output_tokens) ? 2 : 0),
104
+ writeTotal: Number.isSafeInteger(usage.cache_creation_input_tokens) && usage.cache_creation_input_tokens >= 0 ? usage.cache_creation_input_tokens : null,
105
+ writeParts,
106
+ uncached: count(usage.input_tokens),
107
+ written1h,
108
+ written5m,
109
+ writtenUnknown: Math.max(0, count(usage.cache_creation_input_tokens) - written1h - written5m),
110
+ read: count(usage.cache_read_input_tokens),
111
+ output: count(usage.output_tokens),
112
+ };
113
+ }
114
+
87
115
  function requestOf(record) {
88
116
  if (record === null || typeof record !== "object" || record.type !== "assistant") return null;
89
117
  const message = record.message;
90
118
  if (message === null || typeof message !== "object") return null;
91
119
  const usage = message.usage;
92
120
  if (usage === null || typeof usage !== "object") return null;
93
- // The host's own records, an interruption or an API error, with no request behind them.
121
+ // Synthetic host records have no request behind them.
94
122
  if (message.model === "<synthetic>") return { synthetic: true };
95
- const lifetimes = usage.cache_creation !== null && typeof usage.cache_creation === "object" ? usage.cache_creation : {};
96
- const written1h = count(lifetimes.ephemeral_1h_input_tokens);
97
- const written5m = count(lifetimes.ephemeral_5m_input_tokens);
98
123
  const at = Date.parse(record.timestamp);
124
+ const roleParts = text(record.attributionAgent)?.split(":");
125
+ const role = roleParts?.every(part => /^[a-zA-Z0-9_-]+$/.test(part)) && RECORDED_ROLES.includes(roleParts.at(-1)) ? roleParts.at(-1) : null;
99
126
  return {
100
127
  id: text(message.id) ?? text(record.requestId) ?? text(record.uuid),
101
128
  model: text(message.model),
102
129
  effort: text(record.effort),
130
+ role,
131
+ ...usageOf(usage),
103
132
  branch: text(record.gitBranch),
104
133
  cwd: text(record.cwd),
105
134
  at: Number.isFinite(at) ? at : null,
106
135
  sidechain: record.isSidechain === true,
107
136
  agent: text(record.agentId),
108
- uncached: count(usage.input_tokens),
109
- written1h,
110
- written5m,
111
- writtenUnknown: Math.max(0, count(usage.cache_creation_input_tokens) - written1h - written5m),
112
- read: count(usage.cache_read_input_tokens),
113
- output: count(usage.output_tokens),
114
137
  };
115
138
  }
116
139
 
@@ -120,10 +143,63 @@ const writtenOf = (r) => r.written1h + r.written5m + r.writtenUnknown;
120
143
 
121
144
  const chainOf = (r) => (r.sidechain ? `agent:${r.agent ?? ""}` : "session");
122
145
 
146
+ /** A whole-line local receipt is read wherever it appears. */
147
+ const RECEIPT =
148
+ /^OK mode=([a-z]+) model=(\S+)(?: target=.+?)? attempts=([1-9]\d*) in=(?:(0|[1-9]\d*)t|\?) out=(?:(0|[1-9]\d*)t|\?) time=(?:(0|[1-9]\d*)ms|\?) rate=(?:(?:0|[1-9]\d*)(?:\.\d)?tok\/s|\?)(?: reasoning=(?:(0|[1-9]\d*)t|\?) cached-in=(?:(0|[1-9]\d*)t|\?))? spend=\$0\.00$/;
149
+
150
+ /** A figure the runner printed as `?` is null, never 0. */
151
+ export function receiptOf(line) {
152
+ const m = RECEIPT.exec(line);
153
+ if (m === null) return null;
154
+ const [attempts, input, output, time] = [m[3], m[4], m[5], m[6]].map((v) => (v === undefined ? null : Number(v)));
155
+ const [reasoning, cached] = [m[7], m[8]].map((v) => (v === undefined ? null : Number(v)));
156
+ if (![attempts, input, output, time, reasoning, cached].every((v) => v === null || Number.isSafeInteger(v))) return null;
157
+ if (reasoning !== null && (output === null || reasoning > output)) return null;
158
+ if (cached !== null && (input === null || cached > input)) return null;
159
+ return { mode: m[1], model: m[2], attempts, in: input, out: output, time };
160
+ }
161
+
162
+ /** The ids of the Bash calls a record makes: each block's type, name and id, and nothing it says. */
163
+ function bashCallsOf(record) {
164
+ const content = record.message?.content;
165
+ if (!Array.isArray(content)) return [];
166
+ return content.filter((b) => b !== null && typeof b === "object" && b.type === "tool_use" && b.name === "Bash" && text(b.id) !== null).map((b) => b.id);
167
+ }
168
+
169
+ /** A tool result's output as the host writes it: a string, or text blocks and nothing else. */
170
+ function outputOf(content) {
171
+ if (typeof content === "string") return content;
172
+ if (!Array.isArray(content) || !content.length || !content.every((b) => b?.type === "text" && typeof b.text === "string")) return null;
173
+ return content.map((b) => b.text).join("\n");
174
+ }
175
+
176
+ /** The receipts in a user record's tool results, each still to be matched to the Bash call it answers. */
177
+ function resultsOf(record) {
178
+ if (record === null || typeof record !== "object" || record.type !== "user" || !Array.isArray(record.message?.content)) return [];
179
+ const at = Date.parse(record.timestamp);
180
+ const found = [];
181
+ for (const block of record.message.content) {
182
+ if (block === null || typeof block !== "object" || block.type !== "tool_result" || text(block.tool_use_id) === null) continue;
183
+ const receipts = (outputOf(block.content) ?? "").split("\n").map(receiptOf).filter((r) => r !== null);
184
+ if (!receipts.length) continue;
185
+ found.push({
186
+ call: block.tool_use_id,
187
+ receipts,
188
+ branch: text(record.gitBranch),
189
+ cwd: text(record.cwd),
190
+ at: Number.isFinite(at) ? at : null,
191
+ sidechain: record.isSidechain === true,
192
+ agent: text(record.agentId),
193
+ });
194
+ }
195
+ return found;
196
+ }
197
+
123
198
  export function readLine(line) {
124
199
  const usageLike = line.includes('"usage":');
125
200
  const boundaryLike = line.includes('"compact_boundary"');
126
- if (!usageLike && !boundaryLike) return null;
201
+ const resultLike = line.includes('"tool_result"') && line.includes("spend=$0.00");
202
+ if (!usageLike && !boundaryLike && !resultLike) return null;
127
203
  let record;
128
204
  try {
129
205
  record = JSON.parse(line);
@@ -134,16 +210,25 @@ export function readLine(line) {
134
210
  return { boundary: true, sidechain: record.isSidechain === true, agent: text(record.agentId) };
135
211
  }
136
212
  const request = requestOf(record);
137
- return request === null ? null : { request };
213
+ if (request !== null) {
214
+ const calls = bashCallsOf(record);
215
+ return calls.length ? { request, calls } : { request };
216
+ }
217
+ const results = resultLike ? resultsOf(record) : [];
218
+ return results.length ? { results } : null;
138
219
  }
139
220
 
140
221
  /** Claude Code 2.1.280, undocumented: one record per content block, so one request per message id. */
141
- export function readTranscript(file) {
142
- const source = fs.readFileSync(file, "utf8");
222
+ export function readTranscript(file, { end = null } = {}) {
223
+ const bytes = fs.readFileSync(file, end === null ? "utf8" : undefined);
224
+ if (end !== null && (!Number.isSafeInteger(end) || end < 0 || end > bytes.length)) throw new LedgerError("the bounded transcript snapshot changed");
225
+ const source = end === null ? bytes : bytes.subarray(0, end).toString("utf8");
143
226
  const byId = new Map();
144
227
  const requests = [];
145
228
  const tally = { records: 0, duplicates: 0, malformed: 0, synthetic: 0, compactions: 0 };
146
- const figures = sessionFigures();
229
+ let parentCompactions = 0;
230
+ const calls = new Set();
231
+ const receipts = [];
147
232
  // Compacted contexts awaiting their next request, by chain, so a subagent's compaction is never the session's.
148
233
  const awaiting = new Set();
149
234
  for (const line of source.split("\n")) {
@@ -154,13 +239,23 @@ export function readTranscript(file) {
154
239
  tally.malformed += 1;
155
240
  continue;
156
241
  }
157
- foldFigures(figures, read);
242
+ if (read.results) {
243
+ // Only a Bash result is read for the format: an agent's report quoting a receipt is no second one.
244
+ for (const result of read.results) {
245
+ if (!calls.has(result.call)) continue;
246
+ const { call, receipts: lines, ...where } = result;
247
+ lines.forEach((receipt, i) => receipts.push({ key: `${call}#${i}`, ...receipt, ...where }));
248
+ }
249
+ continue;
250
+ }
158
251
  if (read.boundary) {
252
+ if (!read.sidechain) parentCompactions += 1;
159
253
  tally.compactions += 1;
160
254
  awaiting.add(chainOf(read));
161
255
  continue;
162
256
  }
163
257
  const request = read.request;
258
+ for (const id of read.calls ?? []) calls.add(id);
164
259
  tally.records += 1;
165
260
  if (request.synthetic) {
166
261
  tally.synthetic += 1;
@@ -168,16 +263,27 @@ export function readTranscript(file) {
168
263
  }
169
264
  const prior = request.id === null ? undefined : byId.get(request.id);
170
265
  if (prior !== undefined) {
171
- // The host writes a request again per content block, and its output count may grow: keep each largest.
266
+ // Merge growing block counters; unclassified writes remain the residual of the reported total.
172
267
  tally.duplicates += 1;
173
268
  for (const c of CLASSES) prior[c] = Math.max(prior[c], request[c]);
269
+ prior.usageMissing &= request.usageMissing;
270
+ prior.writeParts |= request.writeParts;
271
+ prior.usageUnsafe |= request.usageUnsafe;
272
+ if (request.writeTotal !== null) prior.writeTotal = Math.max(prior.writeTotal ?? 0, request.writeTotal);
273
+ if (prior.writeTotal !== null || prior.writeParts === 3) prior.usageMissing &= ~8;
274
+ if (prior.writeTotal !== null) prior.writtenUnknown = Math.max(0, prior.writeTotal - prior.written1h - prior.written5m);
275
+ if (!Number.isSafeInteger(contextOf(prior)) || (prior.writeTotal !== null && prior.written1h + prior.written5m > prior.writeTotal)) prior.usageUnsafe |= 1;
174
276
  continue;
175
277
  }
176
278
  if (request.id !== null) byId.set(request.id, request);
177
279
  request.compacted = awaiting.delete(chainOf(request));
178
280
  requests.push(request);
179
281
  }
180
- return { requests, figures, ...tally };
282
+ const figures = sessionFigures();
283
+ for (const r of requests) foldCanonical(figures, r);
284
+ figures.compactions = parentCompactions;
285
+ figures.pending = awaiting.has("session");
286
+ return { requests, receipts, figures, ...tally };
181
287
  }
182
288
 
183
289
  /** The lifetime a request's writes used, or null where it wrote nothing it named. */
@@ -212,7 +318,7 @@ export function markRebuilds(requests) {
212
318
  return requests;
213
319
  }
214
320
 
215
- /** Latest request ids remembered to pass over repeats: the host writes a request's blocks back to back. */
321
+ /** Hot IDs only; raw streams need canonical confirmation. */
216
322
  const RECENT = 16;
217
323
 
218
324
  /** Every field JSON-safe: `./advisory.mjs` keeps these between calls and folds in only the lines since. */
@@ -229,8 +335,12 @@ export function foldFigures(figures, read) {
229
335
  }
230
336
  const r = read.request;
231
337
  if (r === undefined || r.synthetic || r.sidechain) return figures;
338
+ if (r.id !== null && figures.recent.includes(r.id)) return figures;
339
+ return foldUniqueRequest(figures, r);
340
+ }
341
+
342
+ function foldUniqueRequest(figures, r) {
232
343
  if (r.id !== null) {
233
- if (figures.recent.includes(r.id)) return figures;
234
344
  figures.recent.push(r.id);
235
345
  if (figures.recent.length > RECENT) figures.recent.shift();
236
346
  }
@@ -247,11 +357,18 @@ export function foldFigures(figures, read) {
247
357
  return figures;
248
358
  }
249
359
 
360
+ export function foldCanonical(figures, r) {
361
+ if (r.synthetic || r.sidechain) return figures;
362
+ if (r.compacted) { figures.compactions += 1; figures.pending = true; }
363
+ return foldUniqueRequest(figures, r);
364
+ }
365
+
250
366
  /** Each test is the one `./doctor.mjs` applies to the same key, so a manifest `doctor` passes is one these take. */
251
367
  export const SPEND_FIGURES = {
252
368
  read: { holds: (v) => Number.isFinite(v) && v > 0 && v <= 1, is: "a number above 0 and at most 1" },
253
369
  write: { holds: (v) => Number.isFinite(v) && v >= 1, is: "a number of at least 1" },
254
370
  requests: { holds: (v) => Number.isInteger(v) && v > 0, is: "a positive integer" },
371
+ resume: { holds: (v) => Number.isFinite(v) && v >= 0, is: "a finite nonnegative number of input-equivalent tokens" },
255
372
  };
256
373
 
257
374
  /** What a crossed restart threshold does: `"advise"` says the line; `"block"` also holds the turn's end once. */
@@ -263,7 +380,7 @@ export function overflowingWrite({ read, write }) {
263
380
 
264
381
  /** Each part null where undeclared; throws at the first fault, since no reader may rely on `doctor` having run. */
265
382
  export function readSpend(value, where) {
266
- if (value === undefined) return { multipliers: null, horizon: null, restart: null };
383
+ if (value === undefined) return { multipliers: null, horizon: null, restart: null, resume: null };
267
384
  const refuse = (what) => new LedgerError(`\`spend\` in ${where} ${what}; ../spec/slots.md gives its shape, and \`doctor\` names every finding`);
268
385
  const code = (key) => `\`${key}\``;
269
386
  const shaped = (v, at, keys, allRequired) => {
@@ -278,7 +395,7 @@ export function readSpend(value, where) {
278
395
  if (!SPEND_FIGURES[range].holds(v)) throw refuse(`${at}sets ${code(key)} to ${JSON.stringify(v)}, which is not ${SPEND_FIGURES[range].is}`);
279
396
  return v;
280
397
  };
281
- shaped(value, "", ["multipliers", "horizon", "restart"], false);
398
+ shaped(value, "", ["multipliers", "horizon", "restart", "resume"], false);
282
399
  let multipliers = null;
283
400
  if (value.multipliers !== undefined) {
284
401
  const m = shaped(value.multipliers, "at `multipliers` ", ["read", "write"], true);
@@ -290,7 +407,8 @@ export function readSpend(value, where) {
290
407
  }
291
408
  const horizon = value.horizon === undefined ? null : figure(shaped(value.horizon, "at `horizon` ", ["requests"], true).requests, "at `horizon` ", "requests", "requests");
292
409
  if (value.restart !== undefined && !RESTARTS.includes(value.restart)) throw refuse(`sets \`restart\` to ${JSON.stringify(value.restart)}, which is neither ${RESTARTS.map((r) => `"${r}"`).join(" nor ")}`);
293
- return { multipliers, horizon, restart: value.restart ?? null };
410
+ const resume = value.resume === undefined ? null : figure(shaped(value.resume, "at `resume` ", ["tokens"], true).tokens, "at `resume` ", "tokens", "resume");
411
+ return { multipliers, horizon, restart: value.restart ?? null, resume };
294
412
  }
295
413
 
296
414
  export function multipliers({ declared = null, lifetime = null } = {}) {
@@ -300,11 +418,14 @@ export function multipliers({ declared = null, lifetime = null } = {}) {
300
418
  return { read: GENERAL_READ, write: WRITE_BY_LIFETIME[at], lifetime: at, recorded, source: "undeclared" };
301
419
  }
302
420
 
303
- /** `C* ≈ F × (1 + m_w / (n × m_r))`, in whole tokens. */
304
- export function restartThreshold({ fresh, write, read, horizon = HORIZON }) {
421
+ /** Break-even context including additional resume expense; display rounding never decides profitability. */
422
+ export function restartThreshold({ fresh, write, read, horizon = HORIZON, resume = 0 }) {
305
423
  if (!(fresh > 0 && write > 0 && read > 0 && horizon > 0)) throw new LedgerError("a restart threshold needs a fresh context, both multipliers and a horizon, each above zero");
306
- const threshold = Math.round(fresh * (1 + write / (horizon * read)));
307
- if (!Number.isFinite(threshold)) throw new LedgerError(`the restart threshold ${fresh} × (1 + ${write} / (${horizon} × ${read})) overflows, so these multipliers give none`);
424
+ if (!SPEND_FIGURES.resume.holds(resume)) throw new LedgerError("resume expense must be finite and nonnegative");
425
+ const reads = horizon * read;
426
+ const expense = fresh * write + resume;
427
+ const threshold = Math.ceil(fresh + expense / reads);
428
+ if (![reads, expense, threshold].every(Number.isFinite)) throw new LedgerError(`the restart threshold ${fresh} × (1 + ${write} / (${horizon} × ${read})) with resume expense ${resume} overflows, so these figures give none`);
308
429
  return threshold;
309
430
  }
310
431
 
@@ -315,16 +436,33 @@ export function describeMultipliers(m) {
315
436
  return `multipliers undeclared: the general read ${m.read}× and the ${m.write}× of ${at}`;
316
437
  }
317
438
 
439
+ export function restartSaving({ fresh, context, horizon, read, write, resume = 0 }) {
440
+ const gross = horizon * read * (context - fresh);
441
+ const written = write * fresh;
442
+ const saving = gross - written - resume;
443
+ if (![gross, written, resume, saving].every(Number.isFinite)) throw new LedgerError("the estimated restart saving overflows, so no comparison is usable");
444
+ // Eight rounding units at the largest operand bound these products and subtractions, without a fixed cost margin.
445
+ const error = Math.max(Math.abs(gross), Math.abs(written), Math.abs(resume)) * Number.EPSILON * 8;
446
+ return Math.abs(saving) <= error ? 0 : saving;
447
+ }
448
+
318
449
  /** null with no request yet, or none since the last compaction: the context held is the one it replaced. */
319
- export function figureOf(figures, { declared = null, horizon = HORIZON } = {}) {
450
+ export function figureOf(figures, { declared = null, horizon = null, resume = null } = {}) {
320
451
  if (figures.fresh === null || figures.pending || figures.fresh === 0) return null;
321
452
  const m = multipliers({ declared, lifetime: figures.freshLifetime ?? figures.lifetime });
322
- const threshold = restartThreshold({ fresh: figures.fresh, write: m.write, read: m.read, horizon });
323
- return { fresh: figures.fresh, context: figures.last, threshold, horizon, multipliers: m, compactions: figures.compactions, requests: figures.requests };
453
+ const n = horizon ?? HORIZON;
454
+ const threshold = restartThreshold({ fresh: figures.fresh, write: m.write, read: m.read, horizon: n, resume: resume ?? 0 });
455
+ const saving = restartSaving({ fresh: figures.fresh, context: figures.last, horizon: n, read: m.read, write: m.write, resume: resume ?? 0 });
456
+ return { fresh: figures.fresh, context: figures.last, threshold, horizon: n, horizonDeclared: horizon !== null, resume, saving, multipliers: m, compactions: figures.compactions, requests: figures.requests };
324
457
  }
325
458
 
326
459
  export function thresholdFor(transcript, options = {}) {
327
- return figureOf(transcript.figures, options);
460
+ const main = transcript.requests?.filter(r => !r.sidechain) ?? [];
461
+ if (!main.length) return figureOf(transcript.figures, options);
462
+ const first = main[Math.max(0, main.findLastIndex(r => r.compacted))], last = main.at(-1);
463
+ if ([first, last].some(inputUnusable)) return { unavailable: "usage" };
464
+ const lifetime = main.findLast(r => lifetimeOf(r) !== null);
465
+ return figureOf({ ...transcript.figures, fresh: contextOf(first), last: contextOf(last), freshLifetime: lifetimeOf(first), lifetime: lifetime ? lifetimeOf(lifetime) : null }, options);
328
466
  }
329
467
 
330
468
  function listDir(dir) {
@@ -375,6 +513,8 @@ export function collect({ projects, roots }) {
375
513
  const contexts = [];
376
514
  const tally = { files: 0, records: 0, duplicates: 0, malformed: 0, synthetic: 0 };
377
515
  const seen = new Set();
516
+ const receipts = [];
517
+ const seenReceipts = new Set();
378
518
  for (const entry of transcriptFiles(projects, roots)) {
379
519
  let transcript;
380
520
  try {
@@ -400,8 +540,13 @@ export function collect({ projects, roots }) {
400
540
  tally.duplicates += inside.length - kept.length;
401
541
  if (kept.length) contexts.push({ file: entry.file, session: entry.session, agent, requests: kept, transcript: agent === null ? transcript : null });
402
542
  }
543
+ for (const r of transcript.receipts) {
544
+ if (!insideAny(r.cwd, roots) || seenReceipts.has(r.key)) continue;
545
+ seenReceipts.add(r.key);
546
+ receipts.push({ ...r, session: entry.session, agent: entry.agent ?? (r.sidechain ? `inline:${r.agent ?? "unnamed"}` : null) });
547
+ }
403
548
  }
404
- return { contexts, ...tally };
549
+ return { contexts, receipts, ...tally };
405
550
  }
406
551
 
407
552
  const zero = () => ({ requests: 0, uncached: 0, written1h: 0, written5m: 0, writtenUnknown: 0, read: 0, output: 0 });
@@ -451,6 +596,20 @@ export function tally(contexts, branch) {
451
596
  };
452
597
  }
453
598
 
599
+ /** What the lines in the receipt format on `branch` say, apart from every host figure: each null where none reported it. */
600
+ export function tallyLocal(receipts, branch) {
601
+ const own = receipts.filter((r) => r.branch === branch);
602
+ const figure = (k) => (own.some((r) => r[k] !== null) ? own.reduce((sum, r) => sum + (r[k] ?? 0), 0) : null);
603
+ return {
604
+ receipts: own.length,
605
+ sessions: new Set(own.map((r) => r.session)).size,
606
+ in: figure("in"),
607
+ out: figure("out"),
608
+ time: figure("time"),
609
+ unreported: Object.fromEntries(["in", "out", "time"].map((k) => [k, own.filter((r) => r[k] === null).length])),
610
+ };
611
+ }
612
+
454
613
  function sessionTotals(contexts) {
455
614
  const by = new Map();
456
615
  for (const c of contexts) {
@@ -572,35 +731,54 @@ export function ledger({ projects, config, roots, branch, lines = null, spend =
572
731
  const figures = tally(collected.contexts, branch);
573
732
  const host = config === null ? [] : compareHost(collected.contexts, hostTotals(config, roots));
574
733
  const latest = latestSession(collected.contexts, branch);
575
- const priced = { declared: spend.multipliers, horizon: spend.horizon ?? HORIZON };
576
- const restart = latest === null ? null : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
577
- return { projects, roots, branch, collected, figures, host, restart, lines, priced };
734
+ const priced = { declared: spend.multipliers, horizon: spend.horizon ?? null, resume: spend.resume ?? null };
735
+ const last = latest?.transcript.requests.findLast(r => !r.sidechain);
736
+ const restart = latest === null ? null : last?.branch !== branch || !latest.requests.includes(last)
737
+ ? { session: latest.session, unavailable: "branch-scope" }
738
+ : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
739
+ const local = tallyLocal(collected.receipts, branch);
740
+ return { projects, roots, branch, collected, figures, local, host, restart, lines, priced };
578
741
  }
579
742
 
580
743
  const signed = (n) => (n > 0 ? `+${grouped(n)}` : n < 0 ? `−${grouped(-n)}` : "0");
581
744
 
582
745
  const short = (id) => id.slice(0, 8);
583
746
 
584
- function unjudged({ declared, horizon } = { declared: null, horizon: HORIZON }) {
747
+ function unjudged({ declared, horizon, resume = null } = { declared: null, horizon: null }) {
585
748
  const m = declared ?? { read: GENERAL_READ, write: WRITE_BY_LIFETIME };
586
749
  return (
587
750
  " restart: no session on this branch has a request with a time to judge; a threshold would take a horizon of " +
588
- `${horizon} requests and ${declared === null ? "the general multipliers, undeclared" : "the declared multipliers"}: ` +
589
- `read ${m.read}×, writes ${m.write["5m"]}× for five minutes and ${m.write["1h"]}× for an hour`
751
+ `${horizon ?? HORIZON} requests (${horizon === null ? "default" : "declared"}) and ${declared === null ? "the general multipliers, undeclared" : "the declared multipliers"}: ` +
752
+ `read ${m.read}×, writes ${m.write["5m"]}× for five minutes and ${m.write["1h"]}× for an hour; ${resume === null ? "resume cost unknown" : `resume expense ${resume} input-equivalent tokens`}`
753
+ );
754
+ }
755
+
756
+ function localLine({ local: l, branch }) {
757
+ if (l.receipts === 0) return ` local worker: no line in the runner's receipt format on ${branch} is recorded here`;
758
+ const figure = (k, unit) => {
759
+ if (l[k] === null) return `${k} unreported`;
760
+ return `${k} ${grouped(l[k])} ${unit}${l.unreported[k] ? ` (${grouped(l.unreported[k])} receipt(s) did not report it)` : ""}`;
761
+ };
762
+ return (
763
+ ` local worker: ${grouped(l.receipts)} line(s) in the runner's receipt format, from Bash results in ${grouped(l.sessions)} session(s), at $0.00, none of it in the host's tokens or the restart threshold: ` +
764
+ `${figure("in", "tokens")}, ${figure("out", "tokens")}, ${figure("time", "ms")}`
590
765
  );
591
766
  }
592
767
 
593
768
  export function print(report, say) {
594
769
  const { collected, figures: f, host, restart, lines, priced } = report;
595
- say(`ledger: branch ${report.branch} — what this change spent, from Claude Code's own usage records (numbers only: nothing a context said is read)`);
770
+ say(`ledger: branch ${report.branch} — what this change spent, from Claude Code's own usage records (numbers only: nothing a context said is read but a line in the runner's receipt format in a Bash result)`);
596
771
  say(` worktrees: ${report.roots.join(", ")}`);
597
772
  say(
598
773
  ` read: ${grouped(collected.files)} transcript(s) under ${report.projects}, ${grouped(collected.records)} usage record(s): ` +
599
774
  `${grouped(collected.duplicates)} per-block duplicate(s) counted once, ${grouped(collected.synthetic)} host-written record(s) with no request behind them, ` +
600
775
  `${grouped(collected.malformed)} line(s) that did not parse`,
601
776
  );
777
+ const uncertain = (collected.contexts ?? []).reduce((n, c) => n + c.requests.filter(r => r.usageMissing || r.usageUnsafe).length, 0);
778
+ if (uncertain) say(` usage: ${grouped(uncertain)} owned request(s) have missing or invalid counters; totals are partial, sanitized zeros are not known observations`);
602
779
  if (f.main.requests + f.subagents.requests === 0) {
603
780
  say(` no request on ${report.branch} is recorded here`);
781
+ say(localLine(report));
604
782
  say(unjudged(priced));
605
783
  return;
606
784
  }
@@ -613,6 +791,7 @@ export function print(report, say) {
613
791
  row("written, unstated", "writtenUnknown");
614
792
  row("read", "read");
615
793
  row("output", "output");
794
+ say(localLine(report));
616
795
  say(` contexts opened: ${grouped(f.sessions + f.subagentContexts)} (${grouped(f.sessions)} session(s), ${grouped(f.subagentContexts)} subagent(s)); compactions: ${grouped(f.compactions)}`);
617
796
  say(` largest context: ${grouped(f.largest)} tokens`);
618
797
  if (f.hitRate === null) say(" hit rate: none — no input was recorded, so none could be read from cache");
@@ -629,6 +808,14 @@ export function print(report, say) {
629
808
  const parts = ["uncached", "written", "read", "output"].map((k) => `${k} ${grouped(h.ledger[k])} of ${grouped(h.host[k])} (${signed(h.difference[k])})`);
630
809
  say(` host totals, session ${short(h.session)} (the last the host saved for ${h.cwd}, every branch): ${parts.join(", ")}`);
631
810
  }
811
+ if (restart?.unavailable === "branch-scope") {
812
+ say(" restart: comparison unavailable — the latest session's main request is outside this branch's owned scope");
813
+ return;
814
+ }
815
+ if (restart?.unavailable === "usage") {
816
+ say(" restart: comparison unavailable — input usage counters are invalid or incomplete");
817
+ return;
818
+ }
632
819
  if (restart === null || restart.threshold === undefined) {
633
820
  say(unjudged(priced));
634
821
  return;
@@ -637,7 +824,9 @@ export function print(report, say) {
637
824
  say(
638
825
  ` restart: session ${short(restart.session)} is at ${grouped(restart.context)} tokens and ` +
639
826
  `${restart.context >= restart.threshold ? "has reached" : "is below"} its threshold of ${grouped(restart.threshold)} = ` +
640
- `${grouped(restart.fresh)} × (1 + ${m.write} / (${restart.horizon} × ${m.read})); ${describeMultipliers(m)}`,
827
+ `${grouped(restart.fresh)} × (1 + ${m.write} / (${restart.horizon} × ${m.read})) + ${restart.resume ?? 0} / (${restart.horizon} × ${m.read}); ${describeMultipliers(m)}; ` +
828
+ `horizon ${restart.horizon} requests remaining (${restart.horizonDeclared ? "declared" : "default"}); ` +
829
+ `${restart.resume === null ? "resume cost unknown: threshold is a lower bound" : `resume expense ${restart.resume} input-equivalent tokens; estimated saving ${restart.saving}`}`,
641
830
  );
642
831
  }
643
832
 
@@ -665,6 +854,7 @@ export function pinned(report) {
665
854
  rebuilt: f.rebuilt,
666
855
  causes: f.causes,
667
856
  host: report.host.map((h) => ({ session: h.session, difference: h.difference })),
857
+ local: report.local,
668
858
  threshold: report.restart?.threshold ?? null,
669
859
  };
670
860
  }