@sleepy_panda_srl/portulan 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/cli/README.md +56 -4
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +9 -67
- package/cli/form.mjs +35 -19
- 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 +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +7 -2
- 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/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 +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +210 -126
- package/core/operating/context.md +368 -60
- 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 +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- 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.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/evals/releases/0.4.1.json +154 -0
- package/evals/releases/0.4.1.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- 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 +262 -117
- 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,16 +143,19 @@ const writtenOf = (r) => r.written1h + r.written5m + r.writtenUnknown;
|
|
|
120
143
|
|
|
121
144
|
const chainOf = (r) => (r.sidechain ? `agent:${r.agent ?? ""}` : "session");
|
|
122
145
|
|
|
123
|
-
/**
|
|
146
|
+
/** A whole-line local receipt is read wherever it appears. */
|
|
124
147
|
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$/;
|
|
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$/;
|
|
126
149
|
|
|
127
150
|
/** A figure the runner printed as `?` is null, never 0. */
|
|
128
151
|
export function receiptOf(line) {
|
|
129
152
|
const m = RECEIPT.exec(line);
|
|
130
153
|
if (m === null) return null;
|
|
131
154
|
const [attempts, input, output, time] = [m[3], m[4], m[5], m[6]].map((v) => (v === undefined ? null : Number(v)));
|
|
132
|
-
|
|
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;
|
|
133
159
|
return { mode: m[1], model: m[2], attempts, in: input, out: output, time };
|
|
134
160
|
}
|
|
135
161
|
|
|
@@ -193,12 +219,14 @@ export function readLine(line) {
|
|
|
193
219
|
}
|
|
194
220
|
|
|
195
221
|
/** Claude Code 2.1.280, undocumented: one record per content block, so one request per message id. */
|
|
196
|
-
export function readTranscript(file) {
|
|
197
|
-
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");
|
|
198
226
|
const byId = new Map();
|
|
199
227
|
const requests = [];
|
|
200
228
|
const tally = { records: 0, duplicates: 0, malformed: 0, synthetic: 0, compactions: 0 };
|
|
201
|
-
|
|
229
|
+
let parentCompactions = 0;
|
|
202
230
|
const calls = new Set();
|
|
203
231
|
const receipts = [];
|
|
204
232
|
// Compacted contexts awaiting their next request, by chain, so a subagent's compaction is never the session's.
|
|
@@ -220,8 +248,8 @@ export function readTranscript(file) {
|
|
|
220
248
|
}
|
|
221
249
|
continue;
|
|
222
250
|
}
|
|
223
|
-
foldFigures(figures, read);
|
|
224
251
|
if (read.boundary) {
|
|
252
|
+
if (!read.sidechain) parentCompactions += 1;
|
|
225
253
|
tally.compactions += 1;
|
|
226
254
|
awaiting.add(chainOf(read));
|
|
227
255
|
continue;
|
|
@@ -235,15 +263,26 @@ export function readTranscript(file) {
|
|
|
235
263
|
}
|
|
236
264
|
const prior = request.id === null ? undefined : byId.get(request.id);
|
|
237
265
|
if (prior !== undefined) {
|
|
238
|
-
//
|
|
266
|
+
// Merge growing block counters; unclassified writes remain the residual of the reported total.
|
|
239
267
|
tally.duplicates += 1;
|
|
240
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;
|
|
241
276
|
continue;
|
|
242
277
|
}
|
|
243
278
|
if (request.id !== null) byId.set(request.id, request);
|
|
244
279
|
request.compacted = awaiting.delete(chainOf(request));
|
|
245
280
|
requests.push(request);
|
|
246
281
|
}
|
|
282
|
+
const figures = sessionFigures();
|
|
283
|
+
for (const r of requests) foldCanonical(figures, r);
|
|
284
|
+
figures.compactions = parentCompactions;
|
|
285
|
+
figures.pending = awaiting.has("session");
|
|
247
286
|
return { requests, receipts, figures, ...tally };
|
|
248
287
|
}
|
|
249
288
|
|
|
@@ -279,7 +318,7 @@ export function markRebuilds(requests) {
|
|
|
279
318
|
return requests;
|
|
280
319
|
}
|
|
281
320
|
|
|
282
|
-
/**
|
|
321
|
+
/** Hot IDs only; raw streams need canonical confirmation. */
|
|
283
322
|
const RECENT = 16;
|
|
284
323
|
|
|
285
324
|
/** Every field JSON-safe: `./advisory.mjs` keeps these between calls and folds in only the lines since. */
|
|
@@ -296,8 +335,12 @@ export function foldFigures(figures, read) {
|
|
|
296
335
|
}
|
|
297
336
|
const r = read.request;
|
|
298
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) {
|
|
299
343
|
if (r.id !== null) {
|
|
300
|
-
if (figures.recent.includes(r.id)) return figures;
|
|
301
344
|
figures.recent.push(r.id);
|
|
302
345
|
if (figures.recent.length > RECENT) figures.recent.shift();
|
|
303
346
|
}
|
|
@@ -314,11 +357,18 @@ export function foldFigures(figures, read) {
|
|
|
314
357
|
return figures;
|
|
315
358
|
}
|
|
316
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
|
+
|
|
317
366
|
/** Each test is the one `./doctor.mjs` applies to the same key, so a manifest `doctor` passes is one these take. */
|
|
318
367
|
export const SPEND_FIGURES = {
|
|
319
368
|
read: { holds: (v) => Number.isFinite(v) && v > 0 && v <= 1, is: "a number above 0 and at most 1" },
|
|
320
369
|
write: { holds: (v) => Number.isFinite(v) && v >= 1, is: "a number of at least 1" },
|
|
321
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" },
|
|
322
372
|
};
|
|
323
373
|
|
|
324
374
|
/** What a crossed restart threshold does: `"advise"` says the line; `"block"` also holds the turn's end once. */
|
|
@@ -330,7 +380,7 @@ export function overflowingWrite({ read, write }) {
|
|
|
330
380
|
|
|
331
381
|
/** Each part null where undeclared; throws at the first fault, since no reader may rely on `doctor` having run. */
|
|
332
382
|
export function readSpend(value, where) {
|
|
333
|
-
if (value === undefined) return { multipliers: null, horizon: null, restart: null };
|
|
383
|
+
if (value === undefined) return { multipliers: null, horizon: null, restart: null, resume: null };
|
|
334
384
|
const refuse = (what) => new LedgerError(`\`spend\` in ${where} ${what}; ../spec/slots.md gives its shape, and \`doctor\` names every finding`);
|
|
335
385
|
const code = (key) => `\`${key}\``;
|
|
336
386
|
const shaped = (v, at, keys, allRequired) => {
|
|
@@ -345,7 +395,7 @@ export function readSpend(value, where) {
|
|
|
345
395
|
if (!SPEND_FIGURES[range].holds(v)) throw refuse(`${at}sets ${code(key)} to ${JSON.stringify(v)}, which is not ${SPEND_FIGURES[range].is}`);
|
|
346
396
|
return v;
|
|
347
397
|
};
|
|
348
|
-
shaped(value, "", ["multipliers", "horizon", "restart"], false);
|
|
398
|
+
shaped(value, "", ["multipliers", "horizon", "restart", "resume"], false);
|
|
349
399
|
let multipliers = null;
|
|
350
400
|
if (value.multipliers !== undefined) {
|
|
351
401
|
const m = shaped(value.multipliers, "at `multipliers` ", ["read", "write"], true);
|
|
@@ -357,7 +407,8 @@ export function readSpend(value, where) {
|
|
|
357
407
|
}
|
|
358
408
|
const horizon = value.horizon === undefined ? null : figure(shaped(value.horizon, "at `horizon` ", ["requests"], true).requests, "at `horizon` ", "requests", "requests");
|
|
359
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 ")}`);
|
|
360
|
-
|
|
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 };
|
|
361
412
|
}
|
|
362
413
|
|
|
363
414
|
export function multipliers({ declared = null, lifetime = null } = {}) {
|
|
@@ -367,11 +418,14 @@ export function multipliers({ declared = null, lifetime = null } = {}) {
|
|
|
367
418
|
return { read: GENERAL_READ, write: WRITE_BY_LIFETIME[at], lifetime: at, recorded, source: "undeclared" };
|
|
368
419
|
}
|
|
369
420
|
|
|
370
|
-
/**
|
|
371
|
-
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 }) {
|
|
372
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");
|
|
373
|
-
|
|
374
|
-
|
|
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`);
|
|
375
429
|
return threshold;
|
|
376
430
|
}
|
|
377
431
|
|
|
@@ -382,16 +436,33 @@ export function describeMultipliers(m) {
|
|
|
382
436
|
return `multipliers undeclared: the general read ${m.read}× and the ${m.write}× of ${at}`;
|
|
383
437
|
}
|
|
384
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
|
+
|
|
385
449
|
/** 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 =
|
|
450
|
+
export function figureOf(figures, { declared = null, horizon = null, resume = null } = {}) {
|
|
387
451
|
if (figures.fresh === null || figures.pending || figures.fresh === 0) return null;
|
|
388
452
|
const m = multipliers({ declared, lifetime: figures.freshLifetime ?? figures.lifetime });
|
|
389
|
-
const
|
|
390
|
-
|
|
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 };
|
|
391
457
|
}
|
|
392
458
|
|
|
393
459
|
export function thresholdFor(transcript, options = {}) {
|
|
394
|
-
|
|
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);
|
|
395
466
|
}
|
|
396
467
|
|
|
397
468
|
function listDir(dir) {
|
|
@@ -660,8 +731,11 @@ export function ledger({ projects, config, roots, branch, lines = null, spend =
|
|
|
660
731
|
const figures = tally(collected.contexts, branch);
|
|
661
732
|
const host = config === null ? [] : compareHost(collected.contexts, hostTotals(config, roots));
|
|
662
733
|
const latest = latestSession(collected.contexts, branch);
|
|
663
|
-
const priced = { declared: spend.multipliers, horizon: spend.horizon ??
|
|
664
|
-
const
|
|
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) };
|
|
665
739
|
const local = tallyLocal(collected.receipts, branch);
|
|
666
740
|
return { projects, roots, branch, collected, figures, local, host, restart, lines, priced };
|
|
667
741
|
}
|
|
@@ -670,12 +744,12 @@ const signed = (n) => (n > 0 ? `+${grouped(n)}` : n < 0 ? `−${grouped(-n)}` :
|
|
|
670
744
|
|
|
671
745
|
const short = (id) => id.slice(0, 8);
|
|
672
746
|
|
|
673
|
-
function unjudged({ declared, horizon } = { declared: null, horizon:
|
|
747
|
+
function unjudged({ declared, horizon, resume = null } = { declared: null, horizon: null }) {
|
|
674
748
|
const m = declared ?? { read: GENERAL_READ, write: WRITE_BY_LIFETIME };
|
|
675
749
|
return (
|
|
676
750
|
" 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`
|
|
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`}`
|
|
679
753
|
);
|
|
680
754
|
}
|
|
681
755
|
|
|
@@ -700,6 +774,8 @@ export function print(report, say) {
|
|
|
700
774
|
`${grouped(collected.duplicates)} per-block duplicate(s) counted once, ${grouped(collected.synthetic)} host-written record(s) with no request behind them, ` +
|
|
701
775
|
`${grouped(collected.malformed)} line(s) that did not parse`,
|
|
702
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`);
|
|
703
779
|
if (f.main.requests + f.subagents.requests === 0) {
|
|
704
780
|
say(` no request on ${report.branch} is recorded here`);
|
|
705
781
|
say(localLine(report));
|
|
@@ -732,6 +808,14 @@ export function print(report, say) {
|
|
|
732
808
|
const parts = ["uncached", "written", "read", "output"].map((k) => `${k} ${grouped(h.ledger[k])} of ${grouped(h.host[k])} (${signed(h.difference[k])})`);
|
|
733
809
|
say(` host totals, session ${short(h.session)} (the last the host saved for ${h.cwd}, every branch): ${parts.join(", ")}`);
|
|
734
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
|
+
}
|
|
735
819
|
if (restart === null || restart.threshold === undefined) {
|
|
736
820
|
say(unjudged(priced));
|
|
737
821
|
return;
|
|
@@ -740,7 +824,9 @@ export function print(report, say) {
|
|
|
740
824
|
say(
|
|
741
825
|
` restart: session ${short(restart.session)} is at ${grouped(restart.context)} tokens and ` +
|
|
742
826
|
`${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)}
|
|
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}`}`,
|
|
744
830
|
);
|
|
745
831
|
}
|
|
746
832
|
|
package/cli/librarian.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { pathToFileURL } from "node:url";
|
|
|
22
22
|
|
|
23
23
|
import { inspect as inspectIndex, IndexError, recordType, dateOf, isInside } from "./index.mjs";
|
|
24
24
|
import { parseProvenance } from "./doctor.mjs";
|
|
25
|
+
import { alwaysTier, declaredContext, ESTIMATED_BYTES_PER_TOKEN, tokensOf, TOP_CONTRIBUTORS, treeOf } from "./context.mjs";
|
|
26
|
+
import { BOOT_CARD_UNIT, ENGINE_LINE, GATES_LINE, guidanceDeclaration, guidanceEdits, GUIDANCE_RULES_DIR, LEADS_LINE, parseUnit, resolveWorkspace, RULES_MARKER } from "./compile.mjs";
|
|
25
27
|
|
|
26
28
|
/** Raised when the pass cannot run, or cannot report honestly. Always exit 2. */
|
|
27
29
|
export class LibrarianError extends Error {
|
|
@@ -31,7 +33,7 @@ export class LibrarianError extends Error {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13"]);
|
|
36
|
+
const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14", "2.15", "2.16"]);
|
|
35
37
|
|
|
36
38
|
const NOT_A_RECORD = new Set(["README.md"]);
|
|
37
39
|
|
|
@@ -89,22 +91,32 @@ export function retireWhen(source) {
|
|
|
89
91
|
.trim();
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
/**
|
|
94
|
+
/** Explicit leading outcomes only; this classifies a report, never grants consent. */
|
|
93
95
|
export function proposalPending(source) {
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
const words = "accepted|rejected|revised|applied|withdrawn|superseded|ruled";
|
|
97
|
+
const outcome = new RegExp(`^(${words})\\b`, "i");
|
|
98
|
+
const outcomeWord = new RegExp(`\\b(${words}|pending)\\b`, "i");
|
|
99
|
+
let settled = false;
|
|
100
|
+
for (const line of source.split("\n")) {
|
|
101
|
+
const marker = line.match(/^\s*\*\*(decision|status)[.:]/i);
|
|
102
|
+
if (!marker) continue;
|
|
103
|
+
const declaration = line.replace(/[*_]/g, "").replace(/^\s*(decision|status)[.:]\s*/i, "").split(/\bbecause\b/i)[0].trim();
|
|
104
|
+
const ownerForm = marker[1].toLowerCase() === "decision" ? declaration.match(/^[^—{}|<>\n]{1,160}—\s*(.*)$/) : null;
|
|
105
|
+
const leading = outcome.test(declaration) ? declaration : ownerForm?.[1] ?? declaration;
|
|
106
|
+
const match = leading.match(outcome);
|
|
107
|
+
if (!match) {
|
|
108
|
+
if (outcomeWord.test(declaration) || /[{}|<>]/.test(declaration)) return true;
|
|
109
|
+
if (ownerForm && !/^\d{4}-\d{2}-\d{2}\b/.test(leading)) return true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const header = leading.split(/—|\bbecause\b/i)[0];
|
|
113
|
+
if (/[{}|<>?]/.test(header)) return true;
|
|
114
|
+
const rest = header.slice(match[0].length);
|
|
115
|
+
const alternative = rest.match(/\/|\bor\b|\bversus\b|\bvs\b|\b(?:and|but)\s+not\b/i);
|
|
116
|
+
if (alternative && outcomeWord.test(rest.slice(alternative.index))) return true;
|
|
117
|
+
settled = true;
|
|
118
|
+
}
|
|
119
|
+
return !settled;
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
// ---------------------------------------------------------------- Reading history
|
|
@@ -173,6 +185,58 @@ const listMarkdown = (dir) => {
|
|
|
173
185
|
return names.filter((n) => n.endsWith(".md") && !NOT_A_RECORD.has(n)).sort();
|
|
174
186
|
};
|
|
175
187
|
|
|
188
|
+
function contextHeadroom(dir, workspace) {
|
|
189
|
+
try {
|
|
190
|
+
const declared = declaredContext(workspace);
|
|
191
|
+
const tree = treeOf(dir, workspace);
|
|
192
|
+
const base = { budget: declared.budget, ratio: declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN, calibratedBy: declared.calibratedBy, drafts: [] };
|
|
193
|
+
if (tree === null) return { ...base, state: "unmeasured", bytes: null, tokens: null, remaining: null, reason: "no tree is declared" };
|
|
194
|
+
const root = fs.realpathSync(tree);
|
|
195
|
+
const measured = alwaysTier(root);
|
|
196
|
+
const bytes = measured.entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
197
|
+
const tokens = tokensOf(bytes, base.ratio);
|
|
198
|
+
const result = { ...base, state: "measured", bytes, tokens, remaining: base.budget === null ? null : base.budget - tokens,
|
|
199
|
+
exclusions: { missing: measured.missing, outside: measured.outside, tooDeep: measured.tooDeep, scoped: measured.scoped, unlisted: measured.unlisted } };
|
|
200
|
+
if (base.budget === null) return result;
|
|
201
|
+
const realDir = fs.realpathSync(dir);
|
|
202
|
+
const declaration = guidanceDeclaration(root, path.relative(root, realDir) || ".");
|
|
203
|
+
if (declaration === null) return result;
|
|
204
|
+
const realContext = fs.realpathSync(declaration.dir);
|
|
205
|
+
if (!isInside(realDir, realContext) || realContext === realDir) throw new LibrarianError("slots.context resolves outside its workspace");
|
|
206
|
+
const loaded = new Map(measured.entries.filter(entry => entry.label === "rule").map(entry => [path.resolve(entry.file), entry]));
|
|
207
|
+
for (const file of listMarkdown(declaration.dir)) {
|
|
208
|
+
const source = path.join(declaration.dir, file);
|
|
209
|
+
if (fs.realpathSync(source) !== path.resolve(source)) continue;
|
|
210
|
+
if (measured.entries.some(entry => fs.realpathSync(entry.file) === source)) continue;
|
|
211
|
+
const unit = parseUnit(path.basename(file, ".md"), fs.readFileSync(source, "utf8"), file);
|
|
212
|
+
if (unit.tier !== "always" || unit.name === BOOT_CARD_UNIT || unit.body.includes("@")) continue;
|
|
213
|
+
if (unit.body.split("\n").some(line => [ENGINE_LINE, GATES_LINE, LEADS_LINE].some(pattern => pattern.test(line)))) continue;
|
|
214
|
+
const target = path.join(root, GUIDANCE_RULES_DIR, file);
|
|
215
|
+
const entry = loaded.get(target);
|
|
216
|
+
if (!entry || fs.realpathSync(target) !== path.resolve(target)) continue;
|
|
217
|
+
result.drafts.push({ source: path.relative(realDir, source).split(path.sep).join("/"), target: path.relative(root, target).split(path.sep).join("/"),
|
|
218
|
+
from: "always", to: "on-read", bytes: entry.bytes, tokens: tokensOf(entry.bytes, base.ratio) });
|
|
219
|
+
}
|
|
220
|
+
if (result.drafts.length) {
|
|
221
|
+
const plan = guidanceEdits(realDir);
|
|
222
|
+
if (fs.realpathSync(plan.root) !== root) throw new LibrarianError("the compiler ownership plan does not cover the measured tree; no demotions can be drafted");
|
|
223
|
+
const resolved = resolveWorkspace(realDir);
|
|
224
|
+
if (fs.realpathSync(path.resolve(resolved.workspaceRoot, resolved.workspaceDir)) !== realDir) throw new LibrarianError("the compiler ownership plan does not cover the measured workspace; no demotions can be drafted");
|
|
225
|
+
const repairs = new Set([...plan.edits.map(edit => edit.file), ...plan.left]);
|
|
226
|
+
if (repairs.has(`${GUIDANCE_RULES_DIR}/${RULES_MARKER}`)) {
|
|
227
|
+
result.drafts = [];
|
|
228
|
+
result.draftNote = "Compiler ownership needs regeneration; recompile before drafting demotions.";
|
|
229
|
+
} else result.drafts = result.drafts.filter(draft => !repairs.has(draft.target));
|
|
230
|
+
}
|
|
231
|
+
result.drafts.sort((a, b) => b.bytes - a.bytes || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
|
|
232
|
+
result.drafts = result.drafts.slice(0, TOP_CONTRIBUTORS);
|
|
233
|
+
return result;
|
|
234
|
+
} catch (cause) {
|
|
235
|
+
if (cause instanceof LibrarianError) throw cause;
|
|
236
|
+
throw new LibrarianError(`the always tier could not be measured or drafted — ${cause.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
176
240
|
/** One workspace's pass, which never writes: `run` does, after it. */
|
|
177
241
|
export function passWorkspace(dir, { asOf, reviews, since } = {}) {
|
|
178
242
|
parseDate(asOf, "--as-of");
|
|
@@ -385,6 +449,7 @@ export function passWorkspace(dir, { asOf, reviews, since } = {}) {
|
|
|
385
449
|
handoffs: series,
|
|
386
450
|
mining,
|
|
387
451
|
consolidation,
|
|
452
|
+
context: contextHeadroom(dir, workspace),
|
|
388
453
|
};
|
|
389
454
|
}
|
|
390
455
|
|
|
@@ -509,8 +574,8 @@ export function renderReport(results, { asOf }) {
|
|
|
509
574
|
"",
|
|
510
575
|
`**Date:** ${asOf} · Filed by \`cli/librarian.mjs\` on a cron.`,
|
|
511
576
|
"",
|
|
512
|
-
|
|
513
|
-
"
|
|
577
|
+
`**State.** Ages are read from git history as of ${asOf}; sizes and context headroom describe the tree on arrival, before any index regeneration.`,
|
|
578
|
+
"Nothing here is a decision. This pass drafts; the maintainer disposes.",
|
|
514
579
|
"",
|
|
515
580
|
];
|
|
516
581
|
|
|
@@ -521,6 +586,25 @@ export function renderReport(results, { asOf }) {
|
|
|
521
586
|
}
|
|
522
587
|
const t = r.thresholds;
|
|
523
588
|
out.push(`## ${r.name}`, "");
|
|
589
|
+
const context = r.context;
|
|
590
|
+
if (context) {
|
|
591
|
+
const cap = context.budget === null ? "budget undeclared; headroom unjudged" : `budget ${context.budget} tokens`;
|
|
592
|
+
if (context.state === "unmeasured") out.push(`**Always tier — Claude Code project.** Not measured: ${context.reason}; ${cap}.`, "");
|
|
593
|
+
else {
|
|
594
|
+
const headroom = context.remaining === null ? cap : `${cap}; ~${context.remaining} tokens headroom`;
|
|
595
|
+
out.push(`**Always tier — Claude Code project.** ${context.bytes} B, ~${context.tokens} tokens at ${context.ratio} bytes per token (${context.calibratedBy ?? "unconfigured estimate"}); ${headroom}.`, "");
|
|
596
|
+
const e = context.exclusions;
|
|
597
|
+
out.push(`**Measurement limits.** ${e.missing.length} missing imports; ${e.outside.length} outside imports or links; ${e.tooDeep.length} depth-limited imports; ${e.scoped} path-scoped rules; ${e.unlisted} descriptions unavailable. Personal/parent instructions, auto memory, system/tool prompts and plugin descriptions are outside this project measurement.`, "");
|
|
598
|
+
for (const [label, values] of [["Missing", e.missing], ["Outside", e.outside], ["Depth-limited", e.tooDeep]]) {
|
|
599
|
+
if (values.length) out.push(`${label}: ${values.map(value => `\`${value}\``).join(", ")}.`, "");
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
out.push(`**Context demotion drafts.** ${context.drafts.length} conditional candidate(s); nothing applied. Pure, loaded, compiler-owned, matching always units only; boot cards, at-sign text, imported sources, expansions, links and stale targets are not drafted.`, "");
|
|
603
|
+
if (context.draftNote) out.push(context.draftNote, "");
|
|
604
|
+
for (const draft of context.drafts) {
|
|
605
|
+
out.push(`- **Draft: \`${draft.source}\`, \`${draft.from}\` → \`${draft.to}\`.** \`${draft.target}\` currently contributes ${draft.bytes} B (~${draft.tokens} tokens). If the maintainer decides this guidance can wait for an explicit read, change its tier, add a one-line \`description\`, preserve its body, recompile, and record before/after measurements. This is not a measured savings claim.`, "");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
524
608
|
out.push(
|
|
525
609
|
`**Store.** ${plural(r.counts.records, "record")}, ${plural(r.counts.rules, "rule")}, ` +
|
|
526
610
|
`${(r.counts.bytes / 1024).toFixed(1)} KB` +
|
|
@@ -543,7 +627,7 @@ export function renderReport(results, { asOf }) {
|
|
|
543
627
|
`${(r.handoffs.bytes / 1024).toFixed(1)} KB, oldest ${r.handoffs.oldest ? `\`${r.handoffs.oldest.file}\` at ${plural(r.handoffs.oldest.days, "day")}` : "none — the series is empty"}. ` +
|
|
544
628
|
"Index: " +
|
|
545
629
|
indexState(r.index.series.handoffs) +
|
|
546
|
-
". No threshold reaches this series and no demotion is drafted against it: it is " +
|
|
630
|
+
". No aggregate size or staleness threshold reaches this series and no staleness demotion is drafted against it: it is " +
|
|
547
631
|
"append-only, so the only repair a staleness draft could recommend is deleting the " +
|
|
548
632
|
"record the series exists to keep.",
|
|
549
633
|
"",
|