@sleepy_panda_srl/portulan 0.1.3 → 0.3.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 +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +882 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +82 -505
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +258 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -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 +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +343 -27
- package/spec/workspace.schema.json +145 -4
package/cli/ledger.mjs
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// What a change spends, measured from the host's own usage records.
|
|
3
|
+
//
|
|
4
|
+
// node cli/ledger.mjs [--branch <name>] [--repo <dir>] [--base <ref>] [--projects <dir>] [--config <file>]
|
|
5
|
+
// [--workspace <dir>]
|
|
6
|
+
// node cli/ledger.mjs --fixture <dir>
|
|
7
|
+
//
|
|
8
|
+
// Exit 0 a report · 1 `--fixture` no longer reproduces its known totals · 2 could not run.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
|
|
16
|
+
import { isInside } from "./inside.mjs";
|
|
17
|
+
|
|
18
|
+
/** Cache reads cost between a fortieth and a tenth of input; the tenth errs toward an earlier restart. */
|
|
19
|
+
export const GENERAL_READ = 0.1;
|
|
20
|
+
|
|
21
|
+
export const WRITE_BY_LIFETIME = { "5m": 1.25, "1h": 2 };
|
|
22
|
+
|
|
23
|
+
/** The API's default, for a lifetime the records do not state. */
|
|
24
|
+
export const DEFAULT_LIFETIME = "5m";
|
|
25
|
+
|
|
26
|
+
export const LIFETIME_MS = { "5m": 5 * 60 * 1000, "1h": 60 * 60 * 1000 };
|
|
27
|
+
|
|
28
|
+
/** Requests still to go when the threshold is judged, where `spend.horizon` declares none. */
|
|
29
|
+
export const HORIZON = 20;
|
|
30
|
+
|
|
31
|
+
/** With `MISS_PERCENT`, the host's own `/usage` definition of a cache miss. */
|
|
32
|
+
export const MISS_TOKENS = 2000;
|
|
33
|
+
|
|
34
|
+
export const MISS_PERCENT = 5;
|
|
35
|
+
|
|
36
|
+
/** Where the host cuts a project key and appends a hash of the whole path. */
|
|
37
|
+
export const KEY_LIMIT = 200;
|
|
38
|
+
|
|
39
|
+
/** The classes a request is billed in, in the order a report prints them. */
|
|
40
|
+
export const CLASSES = ["uncached", "written1h", "written5m", "writtenUnknown", "read", "output"];
|
|
41
|
+
|
|
42
|
+
export class LedgerError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "LedgerError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
50
|
+
|
|
51
|
+
const count = (v) => (Number.isSafeInteger(v) && v > 0 ? v : 0);
|
|
52
|
+
|
|
53
|
+
const text = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
54
|
+
|
|
55
|
+
export function hostPaths(env = process.env, home = os.homedir()) {
|
|
56
|
+
const configDir = text(env.CLAUDE_CONFIG_DIR);
|
|
57
|
+
if (configDir !== null && !path.isAbsolute(configDir)) {
|
|
58
|
+
return { why: `CLAUDE_CONFIG_DIR is the relative path ${JSON.stringify(configDir)}, which the host read from the directory it was started in; name --projects and --config` };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
projects: path.join(configDir ?? path.join(home, ".claude"), "projects"),
|
|
62
|
+
config: path.join(configDir ?? home, ".claude.json"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The host's 32-bit string hash, as its key function spells it. */
|
|
67
|
+
function hostHash(value) {
|
|
68
|
+
let h = 0;
|
|
69
|
+
for (let i = 0; i < value.length; i += 1) h = ((h << 5) - h + value.charCodeAt(i)) | 0;
|
|
70
|
+
return h;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The directory name the host keeps a working directory's transcripts under. */
|
|
74
|
+
export function projectKey(dir) {
|
|
75
|
+
const key = dir.replace(/[^a-zA-Z0-9]/g, "-");
|
|
76
|
+
return key.length <= KEY_LIMIT ? key : `${key.slice(0, KEY_LIMIT)}-${Math.abs(hostHash(dir)).toString(36)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A pre-filter only: `/work/demo`'s key prefixes `/work/demo-old`'s, so each record's `cwd` decides. */
|
|
80
|
+
export function mayHold(name, root) {
|
|
81
|
+
const key = root.replace(/[^a-zA-Z0-9]/g, "-");
|
|
82
|
+
const stem = key.length <= KEY_LIMIT ? key : key.slice(0, KEY_LIMIT);
|
|
83
|
+
return name === projectKey(root) || name.startsWith(`${stem}-`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** null for a record that is not an API response with usage; no content field is ever read. */
|
|
87
|
+
function requestOf(record) {
|
|
88
|
+
if (record === null || typeof record !== "object" || record.type !== "assistant") return null;
|
|
89
|
+
const message = record.message;
|
|
90
|
+
if (message === null || typeof message !== "object") return null;
|
|
91
|
+
const usage = message.usage;
|
|
92
|
+
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.
|
|
94
|
+
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
|
+
const at = Date.parse(record.timestamp);
|
|
99
|
+
return {
|
|
100
|
+
id: text(message.id) ?? text(record.requestId) ?? text(record.uuid),
|
|
101
|
+
model: text(message.model),
|
|
102
|
+
effort: text(record.effort),
|
|
103
|
+
branch: text(record.gitBranch),
|
|
104
|
+
cwd: text(record.cwd),
|
|
105
|
+
at: Number.isFinite(at) ? at : null,
|
|
106
|
+
sidechain: record.isSidechain === true,
|
|
107
|
+
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
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const contextOf = (r) => r.uncached + r.written1h + r.written5m + r.writtenUnknown + r.read;
|
|
118
|
+
|
|
119
|
+
const writtenOf = (r) => r.written1h + r.written5m + r.writtenUnknown;
|
|
120
|
+
|
|
121
|
+
const chainOf = (r) => (r.sidechain ? `agent:${r.agent ?? ""}` : "session");
|
|
122
|
+
|
|
123
|
+
/** The local runner's receipt format, a whole line or nothing: a format, so any line that matches it is read as one. */
|
|
124
|
+
const RECEIPT =
|
|
125
|
+
/^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|\?) spend=\$0\.00$/;
|
|
126
|
+
|
|
127
|
+
/** A figure the runner printed as `?` is null, never 0. */
|
|
128
|
+
export function receiptOf(line) {
|
|
129
|
+
const m = RECEIPT.exec(line);
|
|
130
|
+
if (m === null) return null;
|
|
131
|
+
const [attempts, input, output, time] = [m[3], m[4], m[5], m[6]].map((v) => (v === undefined ? null : Number(v)));
|
|
132
|
+
if (![attempts, input, output, time].every((v) => v === null || Number.isSafeInteger(v))) return null;
|
|
133
|
+
return { mode: m[1], model: m[2], attempts, in: input, out: output, time };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The ids of the Bash calls a record makes: each block's type, name and id, and nothing it says. */
|
|
137
|
+
function bashCallsOf(record) {
|
|
138
|
+
const content = record.message?.content;
|
|
139
|
+
if (!Array.isArray(content)) return [];
|
|
140
|
+
return content.filter((b) => b !== null && typeof b === "object" && b.type === "tool_use" && b.name === "Bash" && text(b.id) !== null).map((b) => b.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A tool result's output as the host writes it: a string, or text blocks and nothing else. */
|
|
144
|
+
function outputOf(content) {
|
|
145
|
+
if (typeof content === "string") return content;
|
|
146
|
+
if (!Array.isArray(content) || !content.length || !content.every((b) => b?.type === "text" && typeof b.text === "string")) return null;
|
|
147
|
+
return content.map((b) => b.text).join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The receipts in a user record's tool results, each still to be matched to the Bash call it answers. */
|
|
151
|
+
function resultsOf(record) {
|
|
152
|
+
if (record === null || typeof record !== "object" || record.type !== "user" || !Array.isArray(record.message?.content)) return [];
|
|
153
|
+
const at = Date.parse(record.timestamp);
|
|
154
|
+
const found = [];
|
|
155
|
+
for (const block of record.message.content) {
|
|
156
|
+
if (block === null || typeof block !== "object" || block.type !== "tool_result" || text(block.tool_use_id) === null) continue;
|
|
157
|
+
const receipts = (outputOf(block.content) ?? "").split("\n").map(receiptOf).filter((r) => r !== null);
|
|
158
|
+
if (!receipts.length) continue;
|
|
159
|
+
found.push({
|
|
160
|
+
call: block.tool_use_id,
|
|
161
|
+
receipts,
|
|
162
|
+
branch: text(record.gitBranch),
|
|
163
|
+
cwd: text(record.cwd),
|
|
164
|
+
at: Number.isFinite(at) ? at : null,
|
|
165
|
+
sidechain: record.isSidechain === true,
|
|
166
|
+
agent: text(record.agentId),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readLine(line) {
|
|
173
|
+
const usageLike = line.includes('"usage":');
|
|
174
|
+
const boundaryLike = line.includes('"compact_boundary"');
|
|
175
|
+
const resultLike = line.includes('"tool_result"') && line.includes("spend=$0.00");
|
|
176
|
+
if (!usageLike && !boundaryLike && !resultLike) return null;
|
|
177
|
+
let record;
|
|
178
|
+
try {
|
|
179
|
+
record = JSON.parse(line);
|
|
180
|
+
} catch {
|
|
181
|
+
return { malformed: true };
|
|
182
|
+
}
|
|
183
|
+
if (boundaryLike && record?.type === "system" && record.subtype === "compact_boundary") {
|
|
184
|
+
return { boundary: true, sidechain: record.isSidechain === true, agent: text(record.agentId) };
|
|
185
|
+
}
|
|
186
|
+
const request = requestOf(record);
|
|
187
|
+
if (request !== null) {
|
|
188
|
+
const calls = bashCallsOf(record);
|
|
189
|
+
return calls.length ? { request, calls } : { request };
|
|
190
|
+
}
|
|
191
|
+
const results = resultLike ? resultsOf(record) : [];
|
|
192
|
+
return results.length ? { results } : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Claude Code 2.1.280, undocumented: one record per content block, so one request per message id. */
|
|
196
|
+
export function readTranscript(file) {
|
|
197
|
+
const source = fs.readFileSync(file, "utf8");
|
|
198
|
+
const byId = new Map();
|
|
199
|
+
const requests = [];
|
|
200
|
+
const tally = { records: 0, duplicates: 0, malformed: 0, synthetic: 0, compactions: 0 };
|
|
201
|
+
const figures = sessionFigures();
|
|
202
|
+
const calls = new Set();
|
|
203
|
+
const receipts = [];
|
|
204
|
+
// Compacted contexts awaiting their next request, by chain, so a subagent's compaction is never the session's.
|
|
205
|
+
const awaiting = new Set();
|
|
206
|
+
for (const line of source.split("\n")) {
|
|
207
|
+
const read = readLine(line);
|
|
208
|
+
if (read === null) continue;
|
|
209
|
+
if (read.malformed) {
|
|
210
|
+
// A torn last line is what a live transcript looks like mid-write; counted, never fatal.
|
|
211
|
+
tally.malformed += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (read.results) {
|
|
215
|
+
// Only a Bash result is read for the format: an agent's report quoting a receipt is no second one.
|
|
216
|
+
for (const result of read.results) {
|
|
217
|
+
if (!calls.has(result.call)) continue;
|
|
218
|
+
const { call, receipts: lines, ...where } = result;
|
|
219
|
+
lines.forEach((receipt, i) => receipts.push({ key: `${call}#${i}`, ...receipt, ...where }));
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
foldFigures(figures, read);
|
|
224
|
+
if (read.boundary) {
|
|
225
|
+
tally.compactions += 1;
|
|
226
|
+
awaiting.add(chainOf(read));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const request = read.request;
|
|
230
|
+
for (const id of read.calls ?? []) calls.add(id);
|
|
231
|
+
tally.records += 1;
|
|
232
|
+
if (request.synthetic) {
|
|
233
|
+
tally.synthetic += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const prior = request.id === null ? undefined : byId.get(request.id);
|
|
237
|
+
if (prior !== undefined) {
|
|
238
|
+
// The host writes a request again per content block, and its output count may grow: keep each largest.
|
|
239
|
+
tally.duplicates += 1;
|
|
240
|
+
for (const c of CLASSES) prior[c] = Math.max(prior[c], request[c]);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (request.id !== null) byId.set(request.id, request);
|
|
244
|
+
request.compacted = awaiting.delete(chainOf(request));
|
|
245
|
+
requests.push(request);
|
|
246
|
+
}
|
|
247
|
+
return { requests, receipts, figures, ...tally };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The lifetime a request's writes used, or null where it wrote nothing it named. */
|
|
251
|
+
function lifetimeOf(r) {
|
|
252
|
+
if (r.written1h === 0 && r.written5m === 0) return null;
|
|
253
|
+
return r.written1h >= r.written5m ? "1h" : "5m";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function markRebuilds(requests) {
|
|
257
|
+
let lifetime = DEFAULT_LIFETIME;
|
|
258
|
+
for (let i = 0; i < requests.length; i += 1) {
|
|
259
|
+
const r = requests[i];
|
|
260
|
+
r.rebuild = null;
|
|
261
|
+
if (i > 0) {
|
|
262
|
+
const before = requests[i - 1];
|
|
263
|
+
const context = contextOf(r);
|
|
264
|
+
// What it could have read: the prefix left in cache, as far as a context that shrank still carries it.
|
|
265
|
+
const missed = Math.min(contextOf(before), context) - r.read;
|
|
266
|
+
if (missed >= MISS_TOKENS && missed * 100 > context * MISS_PERCENT) {
|
|
267
|
+
const gap = r.at !== null && before.at !== null ? r.at - before.at : null;
|
|
268
|
+
// Unexplained: pruned history, a changed tool list or an eviction, none of which the records name.
|
|
269
|
+
let cause = "unexplained";
|
|
270
|
+
if (r.compacted) cause = "compaction";
|
|
271
|
+
else if (before.model !== null && r.model !== null && before.model !== r.model) cause = "model change";
|
|
272
|
+
else if (before.effort !== null && r.effort !== null && before.effort !== r.effort) cause = "effort change";
|
|
273
|
+
else if (gap !== null && gap > LIFETIME_MS[lifetime]) cause = "lifetime lapsed";
|
|
274
|
+
r.rebuild = { tokens: missed, cause };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
lifetime = lifetimeOf(r) ?? lifetime;
|
|
278
|
+
}
|
|
279
|
+
return requests;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Latest request ids remembered to pass over repeats: the host writes a request's blocks back to back. */
|
|
283
|
+
const RECENT = 16;
|
|
284
|
+
|
|
285
|
+
/** Every field JSON-safe: `./advisory.mjs` keeps these between calls and folds in only the lines since. */
|
|
286
|
+
export function sessionFigures() {
|
|
287
|
+
return { compactions: 0, pending: false, fresh: null, freshLifetime: null, lifetime: null, last: null, requests: 0, recent: [] };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function foldFigures(figures, read) {
|
|
291
|
+
if (read.boundary) {
|
|
292
|
+
if (read.sidechain) return figures;
|
|
293
|
+
figures.compactions += 1;
|
|
294
|
+
figures.pending = true;
|
|
295
|
+
return figures;
|
|
296
|
+
}
|
|
297
|
+
const r = read.request;
|
|
298
|
+
if (r === undefined || r.synthetic || r.sidechain) return figures;
|
|
299
|
+
if (r.id !== null) {
|
|
300
|
+
if (figures.recent.includes(r.id)) return figures;
|
|
301
|
+
figures.recent.push(r.id);
|
|
302
|
+
if (figures.recent.length > RECENT) figures.recent.shift();
|
|
303
|
+
}
|
|
304
|
+
figures.requests += 1;
|
|
305
|
+
const context = contextOf(r);
|
|
306
|
+
const lifetime = lifetimeOf(r);
|
|
307
|
+
if (figures.fresh === null || figures.pending) {
|
|
308
|
+
figures.fresh = context;
|
|
309
|
+
figures.freshLifetime = lifetime;
|
|
310
|
+
figures.pending = false;
|
|
311
|
+
}
|
|
312
|
+
figures.last = context;
|
|
313
|
+
if (lifetime !== null) figures.lifetime = lifetime;
|
|
314
|
+
return figures;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Each test is the one `./doctor.mjs` applies to the same key, so a manifest `doctor` passes is one these take. */
|
|
318
|
+
export const SPEND_FIGURES = {
|
|
319
|
+
read: { holds: (v) => Number.isFinite(v) && v > 0 && v <= 1, is: "a number above 0 and at most 1" },
|
|
320
|
+
write: { holds: (v) => Number.isFinite(v) && v >= 1, is: "a number of at least 1" },
|
|
321
|
+
requests: { holds: (v) => Number.isInteger(v) && v > 0, is: "a positive integer" },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** What a crossed restart threshold does: `"advise"` says the line; `"block"` also holds the turn's end once. */
|
|
325
|
+
export const RESTARTS = ["advise", "block"];
|
|
326
|
+
|
|
327
|
+
export function overflowingWrite({ read, write }) {
|
|
328
|
+
return ["5m", "1h"].find((at) => !Number.isFinite(write[at] / read)) ?? null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Each part null where undeclared; throws at the first fault, since no reader may rely on `doctor` having run. */
|
|
332
|
+
export function readSpend(value, where) {
|
|
333
|
+
if (value === undefined) return { multipliers: null, horizon: null, restart: null };
|
|
334
|
+
const refuse = (what) => new LedgerError(`\`spend\` in ${where} ${what}; ../spec/slots.md gives its shape, and \`doctor\` names every finding`);
|
|
335
|
+
const code = (key) => `\`${key}\``;
|
|
336
|
+
const shaped = (v, at, keys, allRequired) => {
|
|
337
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw refuse(`${at}is not an object`);
|
|
338
|
+
const stray = Object.keys(v).find((key) => !keys.includes(key));
|
|
339
|
+
if (stray !== undefined) throw refuse(`${at}names ${code(stray)}, which is ${keys.length === 1 ? `not ${code(keys[0])}` : `neither ${keys.map(code).join(" nor ")}`}`);
|
|
340
|
+
const missing = allRequired ? keys.find((key) => !Object.hasOwn(v, key)) : undefined;
|
|
341
|
+
if (missing !== undefined) throw refuse(`${at}has no ${code(missing)}, which it needs`);
|
|
342
|
+
return v;
|
|
343
|
+
};
|
|
344
|
+
const figure = (v, at, key, range) => {
|
|
345
|
+
if (!SPEND_FIGURES[range].holds(v)) throw refuse(`${at}sets ${code(key)} to ${JSON.stringify(v)}, which is not ${SPEND_FIGURES[range].is}`);
|
|
346
|
+
return v;
|
|
347
|
+
};
|
|
348
|
+
shaped(value, "", ["multipliers", "horizon", "restart"], false);
|
|
349
|
+
let multipliers = null;
|
|
350
|
+
if (value.multipliers !== undefined) {
|
|
351
|
+
const m = shaped(value.multipliers, "at `multipliers` ", ["read", "write"], true);
|
|
352
|
+
const read = figure(m.read, "at `multipliers` ", "read", "read");
|
|
353
|
+
const write = shaped(m.write, "at `multipliers.write` ", ["5m", "1h"], true);
|
|
354
|
+
multipliers = { read, write: { "5m": figure(write["5m"], "at `multipliers.write` ", "5m", "write"), "1h": figure(write["1h"], "at `multipliers.write` ", "1h", "write") } };
|
|
355
|
+
const over = overflowingWrite(multipliers);
|
|
356
|
+
if (over !== null) throw refuse(`at \`multipliers\` gives no finite restart threshold, since \`write["${over}"]\` divided by \`read\` overflows`);
|
|
357
|
+
}
|
|
358
|
+
const horizon = value.horizon === undefined ? null : figure(shaped(value.horizon, "at `horizon` ", ["requests"], true).requests, "at `horizon` ", "requests", "requests");
|
|
359
|
+
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 ")}`);
|
|
360
|
+
return { multipliers, horizon, restart: value.restart ?? null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function multipliers({ declared = null, lifetime = null } = {}) {
|
|
364
|
+
const recorded = lifetime !== null && lifetime in WRITE_BY_LIFETIME;
|
|
365
|
+
const at = recorded ? lifetime : DEFAULT_LIFETIME;
|
|
366
|
+
if (declared !== null) return { read: declared.read, write: declared.write[at], lifetime: at, recorded, source: "declared" };
|
|
367
|
+
return { read: GENERAL_READ, write: WRITE_BY_LIFETIME[at], lifetime: at, recorded, source: "undeclared" };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** `C* ≈ F × (1 + m_w / (n × m_r))`, in whole tokens. */
|
|
371
|
+
export function restartThreshold({ fresh, write, read, horizon = HORIZON }) {
|
|
372
|
+
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");
|
|
373
|
+
const threshold = Math.round(fresh * (1 + write / (horizon * read)));
|
|
374
|
+
if (!Number.isFinite(threshold)) throw new LedgerError(`the restart threshold ${fresh} × (1 + ${write} / (${horizon} × ${read})) overflows, so these multipliers give none`);
|
|
375
|
+
return threshold;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function describeMultipliers(m) {
|
|
379
|
+
const lifetime = m.lifetime === "1h" ? "one-hour" : "five-minute";
|
|
380
|
+
const at = m.recorded ? `the ${lifetime} writes the host recorded` : `a ${lifetime} write, the lifetime the records did not state`;
|
|
381
|
+
if (m.source === "declared") return `multipliers declared: read ${m.read}×, write ${m.write}× (${at})`;
|
|
382
|
+
return `multipliers undeclared: the general read ${m.read}× and the ${m.write}× of ${at}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** null with no request yet, or none since the last compaction: the context held is the one it replaced. */
|
|
386
|
+
export function figureOf(figures, { declared = null, horizon = HORIZON } = {}) {
|
|
387
|
+
if (figures.fresh === null || figures.pending || figures.fresh === 0) return null;
|
|
388
|
+
const m = multipliers({ declared, lifetime: figures.freshLifetime ?? figures.lifetime });
|
|
389
|
+
const threshold = restartThreshold({ fresh: figures.fresh, write: m.write, read: m.read, horizon });
|
|
390
|
+
return { fresh: figures.fresh, context: figures.last, threshold, horizon, multipliers: m, compactions: figures.compactions, requests: figures.requests };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function thresholdFor(transcript, options = {}) {
|
|
394
|
+
return figureOf(transcript.figures, options);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function listDir(dir) {
|
|
398
|
+
try {
|
|
399
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
|
|
402
|
+
throw new LedgerError(`${dir} could not be read — ${error.code ?? error.message}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Every `agent-*.jsonl` under a session's `subagents/`, at any depth, as the host nests them. */
|
|
407
|
+
function subagentFiles(dir) {
|
|
408
|
+
const found = [];
|
|
409
|
+
for (const entry of listDir(dir)) {
|
|
410
|
+
const full = path.join(dir, entry.name);
|
|
411
|
+
if (entry.isDirectory()) found.push(...subagentFiles(full));
|
|
412
|
+
else if (entry.isFile() && /^agent-.+\.jsonl$/.test(entry.name)) found.push(full);
|
|
413
|
+
}
|
|
414
|
+
return found.sort();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function transcriptFiles(projects, roots) {
|
|
418
|
+
const files = [];
|
|
419
|
+
if (fs.existsSync(projects) && !fs.statSync(projects).isDirectory()) throw new LedgerError(`${projects} is not a directory, so no transcript could be read from it`);
|
|
420
|
+
const dirs = listDir(projects)
|
|
421
|
+
.filter((e) => e.isDirectory() && roots.some((root) => mayHold(e.name, root)))
|
|
422
|
+
.map((e) => e.name)
|
|
423
|
+
.sort();
|
|
424
|
+
for (const name of dirs) {
|
|
425
|
+
const dir = path.join(projects, name);
|
|
426
|
+
for (const entry of listDir(dir).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
427
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
428
|
+
const session = entry.name.slice(0, -".jsonl".length);
|
|
429
|
+
files.push({ file: path.join(dir, entry.name), session, agent: null });
|
|
430
|
+
for (const sub of subagentFiles(path.join(dir, session, "subagents"))) {
|
|
431
|
+
files.push({ file: sub, session, agent: path.basename(sub, ".jsonl").slice("agent-".length) });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return files;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const insideAny = (cwd, roots) => cwd !== null && roots.some((root) => isInside(root, cwd));
|
|
439
|
+
|
|
440
|
+
/** Rebuilds are marked over each context whole, before a branch is chosen or a copied request passed over. */
|
|
441
|
+
export function collect({ projects, roots }) {
|
|
442
|
+
const contexts = [];
|
|
443
|
+
const tally = { files: 0, records: 0, duplicates: 0, malformed: 0, synthetic: 0 };
|
|
444
|
+
const seen = new Set();
|
|
445
|
+
const receipts = [];
|
|
446
|
+
const seenReceipts = new Set();
|
|
447
|
+
for (const entry of transcriptFiles(projects, roots)) {
|
|
448
|
+
let transcript;
|
|
449
|
+
try {
|
|
450
|
+
transcript = readTranscript(entry.file);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
throw new LedgerError(`${entry.file} could not be read — ${error.code ?? error.message}`);
|
|
453
|
+
}
|
|
454
|
+
tally.files += 1;
|
|
455
|
+
for (const k of ["records", "duplicates", "malformed", "synthetic"]) tally[k] += transcript[k];
|
|
456
|
+
// Earlier hosts wrote a subagent's requests inline, as sidechains: each agent id is a context of its own.
|
|
457
|
+
const parts = new Map();
|
|
458
|
+
for (const r of transcript.requests) {
|
|
459
|
+
const agent = entry.agent ?? (r.sidechain ? `inline:${r.agent ?? "unnamed"}` : null);
|
|
460
|
+
if (!parts.has(agent)) parts.set(agent, []);
|
|
461
|
+
parts.get(agent).push(r);
|
|
462
|
+
}
|
|
463
|
+
for (const [agent, requests] of parts) {
|
|
464
|
+
markRebuilds(requests);
|
|
465
|
+
// A request copied into a second transcript counts once, in the first that keeps it.
|
|
466
|
+
const inside = requests.filter((r) => insideAny(r.cwd, roots));
|
|
467
|
+
const kept = inside.filter((r) => r.id === null || !seen.has(r.id));
|
|
468
|
+
for (const r of kept) if (r.id !== null) seen.add(r.id);
|
|
469
|
+
tally.duplicates += inside.length - kept.length;
|
|
470
|
+
if (kept.length) contexts.push({ file: entry.file, session: entry.session, agent, requests: kept, transcript: agent === null ? transcript : null });
|
|
471
|
+
}
|
|
472
|
+
for (const r of transcript.receipts) {
|
|
473
|
+
if (!insideAny(r.cwd, roots) || seenReceipts.has(r.key)) continue;
|
|
474
|
+
seenReceipts.add(r.key);
|
|
475
|
+
receipts.push({ ...r, session: entry.session, agent: entry.agent ?? (r.sidechain ? `inline:${r.agent ?? "unnamed"}` : null) });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { contexts, receipts, ...tally };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const zero = () => ({ requests: 0, uncached: 0, written1h: 0, written5m: 0, writtenUnknown: 0, read: 0, output: 0 });
|
|
482
|
+
|
|
483
|
+
export function tally(contexts, branch) {
|
|
484
|
+
const main = zero();
|
|
485
|
+
const subagents = zero();
|
|
486
|
+
const opened = { sessions: new Set(), subagents: 0 };
|
|
487
|
+
const causes = {};
|
|
488
|
+
let compactions = 0;
|
|
489
|
+
let largest = 0;
|
|
490
|
+
let rebuilds = 0;
|
|
491
|
+
let rebuilt = 0;
|
|
492
|
+
for (const c of contexts) {
|
|
493
|
+
const own = c.requests.filter((r) => r.branch === branch);
|
|
494
|
+
if (!own.length) continue;
|
|
495
|
+
const into = c.agent === null ? main : subagents;
|
|
496
|
+
if (c.agent === null) opened.sessions.add(c.session);
|
|
497
|
+
else opened.subagents += 1;
|
|
498
|
+
for (const r of own) {
|
|
499
|
+
into.requests += 1;
|
|
500
|
+
for (const k of CLASSES) into[k] += r[k];
|
|
501
|
+
largest = Math.max(largest, contextOf(r));
|
|
502
|
+
if (r.rebuild) {
|
|
503
|
+
rebuilds += 1;
|
|
504
|
+
rebuilt += r.rebuild.tokens;
|
|
505
|
+
causes[r.rebuild.cause] = (causes[r.rebuild.cause] ?? 0) + 1;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (c.agent === null) compactions += own.filter((r) => r.compacted).length;
|
|
509
|
+
}
|
|
510
|
+
const total = zero();
|
|
511
|
+
for (const k of Object.keys(total)) total[k] = main[k] + subagents[k];
|
|
512
|
+
const input = total.uncached + writtenOf(total) + total.read;
|
|
513
|
+
return {
|
|
514
|
+
main,
|
|
515
|
+
subagents,
|
|
516
|
+
total,
|
|
517
|
+
sessions: opened.sessions.size,
|
|
518
|
+
subagentContexts: opened.subagents,
|
|
519
|
+
compactions,
|
|
520
|
+
largest,
|
|
521
|
+
hitRate: input === 0 ? null : total.read / input,
|
|
522
|
+
rebuilds,
|
|
523
|
+
rebuilt,
|
|
524
|
+
causes: Object.fromEntries(Object.entries(causes).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** What the lines in the receipt format on `branch` say, apart from every host figure: each null where none reported it. */
|
|
529
|
+
export function tallyLocal(receipts, branch) {
|
|
530
|
+
const own = receipts.filter((r) => r.branch === branch);
|
|
531
|
+
const figure = (k) => (own.some((r) => r[k] !== null) ? own.reduce((sum, r) => sum + (r[k] ?? 0), 0) : null);
|
|
532
|
+
return {
|
|
533
|
+
receipts: own.length,
|
|
534
|
+
sessions: new Set(own.map((r) => r.session)).size,
|
|
535
|
+
in: figure("in"),
|
|
536
|
+
out: figure("out"),
|
|
537
|
+
time: figure("time"),
|
|
538
|
+
unreported: Object.fromEntries(["in", "out", "time"].map((k) => [k, own.filter((r) => r[k] === null).length])),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function sessionTotals(contexts) {
|
|
543
|
+
const by = new Map();
|
|
544
|
+
for (const c of contexts) {
|
|
545
|
+
const t = by.get(c.session) ?? zero();
|
|
546
|
+
for (const r of c.requests) {
|
|
547
|
+
t.requests += 1;
|
|
548
|
+
for (const k of CLASSES) t[k] += r[k];
|
|
549
|
+
}
|
|
550
|
+
by.set(c.session, t);
|
|
551
|
+
}
|
|
552
|
+
return by;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The `lastTotal*` counters the host saves per project for its last session; nothing else there is read. */
|
|
556
|
+
export function hostTotals(config, roots) {
|
|
557
|
+
let parsed;
|
|
558
|
+
try {
|
|
559
|
+
parsed = JSON.parse(fs.readFileSync(config, "utf8"));
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (error.code === "ENOENT") return [];
|
|
562
|
+
throw new LedgerError(`${config} could not be read as the host's configuration — ${error.code ?? error.message}`);
|
|
563
|
+
}
|
|
564
|
+
const projects = parsed !== null && typeof parsed === "object" && parsed.projects !== null && typeof parsed.projects === "object" ? parsed.projects : {};
|
|
565
|
+
const found = [];
|
|
566
|
+
for (const [cwd, entry] of Object.entries(projects).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
567
|
+
if (!insideAny(cwd, roots) || entry === null || typeof entry !== "object") continue;
|
|
568
|
+
const session = text(entry.lastSessionId);
|
|
569
|
+
const fields = ["lastTotalInputTokens", "lastTotalCacheCreationInputTokens", "lastTotalCacheReadInputTokens", "lastTotalOutputTokens"];
|
|
570
|
+
if (session === null || !fields.every((f) => Number.isSafeInteger(entry[f]))) continue;
|
|
571
|
+
found.push({
|
|
572
|
+
cwd,
|
|
573
|
+
session,
|
|
574
|
+
uncached: entry.lastTotalInputTokens,
|
|
575
|
+
written: entry.lastTotalCacheCreationInputTokens,
|
|
576
|
+
read: entry.lastTotalCacheReadInputTokens,
|
|
577
|
+
output: entry.lastTotalOutputTokens,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return found;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Each host total beside the ledger's own for the same session, and the difference, ledger less host. */
|
|
584
|
+
export function compareHost(contexts, totals) {
|
|
585
|
+
const ours = sessionTotals(contexts);
|
|
586
|
+
return totals
|
|
587
|
+
.filter((h) => ours.has(h.session))
|
|
588
|
+
.map((h) => {
|
|
589
|
+
const t = ours.get(h.session);
|
|
590
|
+
const ledger = { uncached: t.uncached, written: writtenOf(t), read: t.read, output: t.output };
|
|
591
|
+
const difference = Object.fromEntries(Object.keys(ledger).map((k) => [k, ledger[k] - h[k]]));
|
|
592
|
+
return { cwd: h.cwd, session: h.session, ledger, host: { uncached: h.uncached, written: h.written, read: h.read, output: h.output }, difference };
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function git(repo, args) {
|
|
597
|
+
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The main one first; null outside a git repository. */
|
|
601
|
+
export function worktrees(repo) {
|
|
602
|
+
let listing;
|
|
603
|
+
try {
|
|
604
|
+
listing = git(repo, ["worktree", "list", "--porcelain"]);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (!underGit(repo)) return null;
|
|
607
|
+
throw new LedgerError(`the worktrees of ${repo} could not be listed — ${String(error.stderr ?? "").trim().split("\n")[0] || error.code || error.message}`);
|
|
608
|
+
}
|
|
609
|
+
return listing
|
|
610
|
+
.split("\n")
|
|
611
|
+
.filter((l) => l.startsWith("worktree "))
|
|
612
|
+
.map((l) => l.slice("worktree ".length));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Whether `dir` or a directory above it holds a `.git`: the directory a repository keeps, or a worktree's file. */
|
|
616
|
+
function underGit(dir) {
|
|
617
|
+
for (let at = path.resolve(dir); ; at = path.dirname(at)) {
|
|
618
|
+
if (fs.existsSync(path.join(at, ".git"))) return true;
|
|
619
|
+
if (path.dirname(at) === at) return false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Lines added and removed on `branch` since its merge base with `base`, or the reason there is no figure. */
|
|
624
|
+
export function linesChanged(repo, branch, base = null) {
|
|
625
|
+
const candidates = base !== null ? [base] : ["origin/HEAD", "origin/main", "main", "origin/master", "master"];
|
|
626
|
+
const resolved = candidates.find((ref) => {
|
|
627
|
+
try {
|
|
628
|
+
git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
629
|
+
return true;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
if (resolved === undefined) return { why: base !== null ? `${base} does not name a commit here` : `no base found among ${candidates.join(", ")}; name one with --base` };
|
|
635
|
+
try {
|
|
636
|
+
const since = git(repo, ["merge-base", resolved, branch]);
|
|
637
|
+
const stat = git(repo, ["diff", "--shortstat", since, branch]);
|
|
638
|
+
const added = Number(/(\d+) insertion/.exec(stat)?.[1] ?? 0);
|
|
639
|
+
const removed = Number(/(\d+) deletion/.exec(stat)?.[1] ?? 0);
|
|
640
|
+
return { lines: added + removed, base: resolved };
|
|
641
|
+
} catch {
|
|
642
|
+
return { why: `${branch} has no merge base with ${resolved} here` };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function latestSession(contexts, branch) {
|
|
647
|
+
let latest = null;
|
|
648
|
+
for (const c of contexts) {
|
|
649
|
+
if (c.agent !== null || c.transcript === null) continue;
|
|
650
|
+
const own = c.requests.filter((r) => r.branch === branch && r.at !== null);
|
|
651
|
+
if (!own.length) continue;
|
|
652
|
+
const at = own.at(-1).at;
|
|
653
|
+
if (latest === null || at > latest.at) latest = { at, context: c };
|
|
654
|
+
}
|
|
655
|
+
return latest?.context ?? null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function ledger({ projects, config, roots, branch, lines = null, spend = { multipliers: null, horizon: null } }) {
|
|
659
|
+
const collected = collect({ projects, roots });
|
|
660
|
+
const figures = tally(collected.contexts, branch);
|
|
661
|
+
const host = config === null ? [] : compareHost(collected.contexts, hostTotals(config, roots));
|
|
662
|
+
const latest = latestSession(collected.contexts, branch);
|
|
663
|
+
const priced = { declared: spend.multipliers, horizon: spend.horizon ?? HORIZON };
|
|
664
|
+
const restart = latest === null ? null : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
|
|
665
|
+
const local = tallyLocal(collected.receipts, branch);
|
|
666
|
+
return { projects, roots, branch, collected, figures, local, host, restart, lines, priced };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const signed = (n) => (n > 0 ? `+${grouped(n)}` : n < 0 ? `−${grouped(-n)}` : "0");
|
|
670
|
+
|
|
671
|
+
const short = (id) => id.slice(0, 8);
|
|
672
|
+
|
|
673
|
+
function unjudged({ declared, horizon } = { declared: null, horizon: HORIZON }) {
|
|
674
|
+
const m = declared ?? { read: GENERAL_READ, write: WRITE_BY_LIFETIME };
|
|
675
|
+
return (
|
|
676
|
+
" restart: no session on this branch has a request with a time to judge; a threshold would take a horizon of " +
|
|
677
|
+
`${horizon} requests and ${declared === null ? "the general multipliers, undeclared" : "the declared multipliers"}: ` +
|
|
678
|
+
`read ${m.read}×, writes ${m.write["5m"]}× for five minutes and ${m.write["1h"]}× for an hour`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function localLine({ local: l, branch }) {
|
|
683
|
+
if (l.receipts === 0) return ` local worker: no line in the runner's receipt format on ${branch} is recorded here`;
|
|
684
|
+
const figure = (k, unit) => {
|
|
685
|
+
if (l[k] === null) return `${k} unreported`;
|
|
686
|
+
return `${k} ${grouped(l[k])} ${unit}${l.unreported[k] ? ` (${grouped(l.unreported[k])} receipt(s) did not report it)` : ""}`;
|
|
687
|
+
};
|
|
688
|
+
return (
|
|
689
|
+
` 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: ` +
|
|
690
|
+
`${figure("in", "tokens")}, ${figure("out", "tokens")}, ${figure("time", "ms")}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function print(report, say) {
|
|
695
|
+
const { collected, figures: f, host, restart, lines, priced } = report;
|
|
696
|
+
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)`);
|
|
697
|
+
say(` worktrees: ${report.roots.join(", ")}`);
|
|
698
|
+
say(
|
|
699
|
+
` read: ${grouped(collected.files)} transcript(s) under ${report.projects}, ${grouped(collected.records)} usage record(s): ` +
|
|
700
|
+
`${grouped(collected.duplicates)} per-block duplicate(s) counted once, ${grouped(collected.synthetic)} host-written record(s) with no request behind them, ` +
|
|
701
|
+
`${grouped(collected.malformed)} line(s) that did not parse`,
|
|
702
|
+
);
|
|
703
|
+
if (f.main.requests + f.subagents.requests === 0) {
|
|
704
|
+
say(` no request on ${report.branch} is recorded here`);
|
|
705
|
+
say(localLine(report));
|
|
706
|
+
say(unjudged(priced));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const row = (label, k) => say(` ${label.padEnd(18)}${grouped(f.main[k]).padStart(14)}${grouped(f.subagents[k]).padStart(14)}${grouped(f.total[k]).padStart(14)}`);
|
|
710
|
+
say(` ${"".padEnd(18)}${"main".padStart(14)}${"subagents".padStart(14)}${"total".padStart(14)}`);
|
|
711
|
+
row("requests", "requests");
|
|
712
|
+
row("uncached", "uncached");
|
|
713
|
+
row("written, 1h", "written1h");
|
|
714
|
+
row("written, 5m", "written5m");
|
|
715
|
+
row("written, unstated", "writtenUnknown");
|
|
716
|
+
row("read", "read");
|
|
717
|
+
row("output", "output");
|
|
718
|
+
say(localLine(report));
|
|
719
|
+
say(` contexts opened: ${grouped(f.sessions + f.subagentContexts)} (${grouped(f.sessions)} session(s), ${grouped(f.subagentContexts)} subagent(s)); compactions: ${grouped(f.compactions)}`);
|
|
720
|
+
say(` largest context: ${grouped(f.largest)} tokens`);
|
|
721
|
+
if (f.hitRate === null) say(" hit rate: none — no input was recorded, so none could be read from cache");
|
|
722
|
+
else say(` hit rate: ${(f.hitRate * 100).toFixed(1)}% of input read from cache — a hit rate does not bound spend; requests do`);
|
|
723
|
+
const causes = Object.entries(f.causes).map(([cause, n]) => `${grouped(n)} ${cause}`).join(", ");
|
|
724
|
+
say(` rebuilds: ${grouped(f.rebuilds)}${f.rebuilds ? `, ${grouped(f.rebuilt)} tokens written again (${causes})` : ""}`);
|
|
725
|
+
const processed = f.total.uncached + writtenOf(f.total) + f.total.read + f.total.output;
|
|
726
|
+
if (lines === null) say(" per changed line: not computed for this run");
|
|
727
|
+
else if (lines.why !== undefined) say(` per changed line: not computed — ${lines.why}`);
|
|
728
|
+
else if (lines.lines === 0) say(` per changed line: not computed — no line changed since the merge base with ${lines.base}`);
|
|
729
|
+
else say(` per changed line: ${grouped(Math.round(processed / lines.lines))} tokens processed per line (${grouped(lines.lines)} changed since the merge base with ${lines.base})`);
|
|
730
|
+
if (!host.length) say(" host totals: none recorded for a session read here");
|
|
731
|
+
for (const h of host) {
|
|
732
|
+
const parts = ["uncached", "written", "read", "output"].map((k) => `${k} ${grouped(h.ledger[k])} of ${grouped(h.host[k])} (${signed(h.difference[k])})`);
|
|
733
|
+
say(` host totals, session ${short(h.session)} (the last the host saved for ${h.cwd}, every branch): ${parts.join(", ")}`);
|
|
734
|
+
}
|
|
735
|
+
if (restart === null || restart.threshold === undefined) {
|
|
736
|
+
say(unjudged(priced));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const m = restart.multipliers;
|
|
740
|
+
say(
|
|
741
|
+
` restart: session ${short(restart.session)} is at ${grouped(restart.context)} tokens and ` +
|
|
742
|
+
`${restart.context >= restart.threshold ? "has reached" : "is below"} its threshold of ${grouped(restart.threshold)} = ` +
|
|
743
|
+
`${grouped(restart.fresh)} × (1 + ${m.write} / (${restart.horizon} × ${m.read})); ${describeMultipliers(m)}`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** The figures a fixture pins, flat, in the names `fixture.json` spells them. */
|
|
748
|
+
export function pinned(report) {
|
|
749
|
+
const f = report.figures;
|
|
750
|
+
const pair = (k) => [f.main[k], f.subagents[k]];
|
|
751
|
+
return {
|
|
752
|
+
records: report.collected.records,
|
|
753
|
+
duplicates: report.collected.duplicates,
|
|
754
|
+
synthetic: report.collected.synthetic,
|
|
755
|
+
malformed: report.collected.malformed,
|
|
756
|
+
requests: pair("requests"),
|
|
757
|
+
uncached: pair("uncached"),
|
|
758
|
+
written1h: pair("written1h"),
|
|
759
|
+
written5m: pair("written5m"),
|
|
760
|
+
writtenUnknown: pair("writtenUnknown"),
|
|
761
|
+
read: pair("read"),
|
|
762
|
+
output: pair("output"),
|
|
763
|
+
sessions: f.sessions,
|
|
764
|
+
subagentContexts: f.subagentContexts,
|
|
765
|
+
compactions: f.compactions,
|
|
766
|
+
largest: f.largest,
|
|
767
|
+
rebuilds: f.rebuilds,
|
|
768
|
+
rebuilt: f.rebuilt,
|
|
769
|
+
causes: f.causes,
|
|
770
|
+
host: report.host.map((h) => ({ session: h.session, difference: h.difference })),
|
|
771
|
+
local: report.local,
|
|
772
|
+
threshold: report.restart?.threshold ?? null,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function named(cwd, value, flag, kind) {
|
|
777
|
+
const full = path.resolve(cwd, value);
|
|
778
|
+
let stat;
|
|
779
|
+
try {
|
|
780
|
+
stat = fs.statSync(full);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
throw new LedgerError(`${flag} ${full} could not be read — ${error.code ?? error.message}`);
|
|
783
|
+
}
|
|
784
|
+
if (kind === "directory" ? !stat.isDirectory() : !stat.isFile()) throw new LedgerError(`${flag} ${full} is not a ${kind}, so nothing could be read from it`);
|
|
785
|
+
return full;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function workspaceSpend(dir) {
|
|
789
|
+
const file = path.join(dir, "workspace.json");
|
|
790
|
+
let manifest;
|
|
791
|
+
try {
|
|
792
|
+
manifest = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
793
|
+
} catch (error) {
|
|
794
|
+
throw new LedgerError(`--workspace ${file} could not be read as a manifest — ${error.code ?? error.message}`);
|
|
795
|
+
}
|
|
796
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) throw new LedgerError(`--workspace ${file} is not a JSON object, so no \`spend\` could be read from it`);
|
|
797
|
+
return readSpend(manifest.spend, file);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function parseArgs(argv) {
|
|
801
|
+
const options = { branch: null, repo: null, base: null, projects: null, config: null, workspace: null, fixture: null };
|
|
802
|
+
const flags = { "--branch": "branch", "--repo": "repo", "--base": "base", "--projects": "projects", "--config": "config", "--workspace": "workspace", "--fixture": "fixture" };
|
|
803
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
804
|
+
const key = flags[argv[i]];
|
|
805
|
+
if (key === undefined) throw new LedgerError(`unknown argument ${JSON.stringify(argv[i])}`);
|
|
806
|
+
const value = argv[i + 1];
|
|
807
|
+
i += 1;
|
|
808
|
+
if (value === undefined || value === "") throw new LedgerError(`${argv[i - 1]} needs a value`);
|
|
809
|
+
if (options[key] !== null) throw new LedgerError(`${argv[i - 1]} is given twice`);
|
|
810
|
+
options[key] = value;
|
|
811
|
+
}
|
|
812
|
+
if (options.fixture !== null && Object.entries(options).some(([k, v]) => k !== "fixture" && v !== null)) {
|
|
813
|
+
throw new LedgerError("--fixture reads the fixture's own records and nothing else, so it takes no other argument");
|
|
814
|
+
}
|
|
815
|
+
return options;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function runFixture(dir, say) {
|
|
819
|
+
let spec;
|
|
820
|
+
try {
|
|
821
|
+
spec = JSON.parse(fs.readFileSync(path.join(dir, "fixture.json"), "utf8"));
|
|
822
|
+
} catch (error) {
|
|
823
|
+
throw new LedgerError(`${path.join(dir, "fixture.json")} could not be read — ${error.code ?? error.message}`);
|
|
824
|
+
}
|
|
825
|
+
const roots = Array.isArray(spec?.roots) && spec.roots.length > 0 && spec.roots.every((r) => typeof r === "string" && path.isAbsolute(r));
|
|
826
|
+
const expect = spec?.expect !== null && typeof spec?.expect === "object" && !Array.isArray(spec.expect);
|
|
827
|
+
if (!roots || typeof spec.branch !== "string" || spec.branch === "" || !expect) {
|
|
828
|
+
throw new LedgerError(`${path.join(dir, "fixture.json")} does not carry roots as absolute paths, a branch and the known totals to expect`);
|
|
829
|
+
}
|
|
830
|
+
const report = ledger({ projects: named(dir, "projects", "--fixture", "directory"), config: named(dir, "claude.json", "--fixture", "file"), roots: spec.roots, branch: spec.branch });
|
|
831
|
+
print(report, say);
|
|
832
|
+
const got = pinned(report);
|
|
833
|
+
const wrong = Object.keys({ ...spec.expect, ...got }).filter((k) => JSON.stringify(got[k]) !== JSON.stringify(spec.expect[k]));
|
|
834
|
+
for (const k of wrong) say(` ✗ fixture: ${k} is ${JSON.stringify(got[k])}, and its known total is ${JSON.stringify(spec.expect[k])}`);
|
|
835
|
+
if (wrong.length) {
|
|
836
|
+
say(` ✗ fixture: the ledger does not reproduce ${wrong.length} of the fixture's known totals — the reader changed, or the fixture did; a host format change is recorded by a new fixture, dated, never by editing these totals to match`);
|
|
837
|
+
return 1;
|
|
838
|
+
}
|
|
839
|
+
say(` ok fixture: ${Object.keys(got).length} figures reproduce their known totals, per-block duplicates included`);
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function run(argv, say = (line) => process.stdout.write(`${line}\n`), { env = process.env, home = os.homedir(), cwd = process.cwd() } = {}) {
|
|
844
|
+
try {
|
|
845
|
+
const options = parseArgs(argv);
|
|
846
|
+
if (options.fixture !== null) return runFixture(path.resolve(cwd, options.fixture), say);
|
|
847
|
+
const spend = options.workspace === null ? undefined : workspaceSpend(named(cwd, options.workspace, "--workspace", "directory"));
|
|
848
|
+
const host = hostPaths(env, home);
|
|
849
|
+
if (host.why !== undefined && (options.projects === null || options.config === null)) throw new LedgerError(host.why);
|
|
850
|
+
const repo = path.resolve(cwd, options.repo ?? ".");
|
|
851
|
+
const roots = worktrees(repo);
|
|
852
|
+
let branch = options.branch;
|
|
853
|
+
if (branch === null) {
|
|
854
|
+
if (roots === null) throw new LedgerError(`${repo} is not inside a git repository, so name the branch with --branch`);
|
|
855
|
+
branch = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
856
|
+
}
|
|
857
|
+
const report = ledger({
|
|
858
|
+
projects: options.projects === null ? host.projects : named(cwd, options.projects, "--projects", "directory"),
|
|
859
|
+
config: options.config === null ? host.config : named(cwd, options.config, "--config", "file"),
|
|
860
|
+
roots: roots ?? [repo],
|
|
861
|
+
branch,
|
|
862
|
+
lines: roots === null ? { why: `${repo} is not inside a git repository` } : linesChanged(repo, branch, options.base),
|
|
863
|
+
spend,
|
|
864
|
+
});
|
|
865
|
+
print(report, say);
|
|
866
|
+
return 0;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (!(error instanceof LedgerError)) throw error;
|
|
869
|
+
say(` ✗ ${error.message}`);
|
|
870
|
+
return 2;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
875
|
+
// `process.exitCode`, not `process.exit`, so a pipe that has not drained is not cut.
|
|
876
|
+
try {
|
|
877
|
+
process.exitCode = run(process.argv.slice(2));
|
|
878
|
+
} catch (cause) {
|
|
879
|
+
process.stderr.write(`ledger: could not run — ${cause?.stack ?? cause}\nThis is a defect in the ledger, not a figure: nothing was measured.\n`);
|
|
880
|
+
process.exitCode = 2;
|
|
881
|
+
}
|
|
882
|
+
}
|