@sleepy_panda_srl/portulan 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- package/spec/workspace.schema.json +67 -11
package/cli/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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
307
|
-
|
|
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 =
|
|
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
|
|
323
|
-
|
|
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
|
-
|
|
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 ??
|
|
576
|
-
const
|
|
577
|
-
|
|
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:
|
|
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
|
}
|