@sleepy_panda_srl/portulan 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +18 -6
  2. package/cli/README.md +56 -4
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +9 -67
  12. package/cli/form.mjs +35 -19
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +7 -2
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/ritual-contexts.mjs +103 -0
  34. package/cli/sessions.mjs +2 -1
  35. package/cli/spend-analysis.mjs +111 -0
  36. package/cli/spend-offer.md +53 -0
  37. package/cli/spend-offer.mjs +93 -0
  38. package/cli/stop-diagnostics.mjs +114 -0
  39. package/cli/stop-gate.mjs +133 -90
  40. package/cli/stop-policy.mjs +14 -0
  41. package/cli/symbols.mjs +55 -8
  42. package/cli/telemetry.mjs +19 -12
  43. package/cli/upgrade.mjs +62 -44
  44. package/cli/vendor.mjs +210 -126
  45. package/core/operating/context.md +368 -60
  46. package/core/operating/evolution.md +3 -2
  47. package/core/operating/loop.md +9 -4
  48. package/core/operating/sessions.md +6 -15
  49. package/core/personas/README.md +52 -10
  50. package/core/personas/implementer.md +3 -0
  51. package/core/personas/librarian.md +3 -0
  52. package/core/personas/reviewer.md +3 -0
  53. package/core/templates/handoff.md +6 -2
  54. package/evals/ab/personas/README.md +111 -0
  55. package/evals/ab/personas/acquisition.json +330 -0
  56. package/evals/ab/personas/bindings/implementer.txt +43 -0
  57. package/evals/ab/personas/bindings/librarian.txt +40 -0
  58. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  59. package/evals/ab/personas/corpus.json +69 -0
  60. package/evals/ab/personas/grader.mjs +171 -0
  61. package/evals/ab/personas/implementer.json +774 -0
  62. package/evals/ab/personas/implementer.md +22 -0
  63. package/evals/ab/personas/librarian.json +758 -0
  64. package/evals/ab/personas/librarian.md +22 -0
  65. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  66. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  67. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  68. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  69. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  70. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  71. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  72. package/evals/ab/personas/reviewer.json +758 -0
  73. package/evals/ab/personas/reviewer.md +22 -0
  74. package/evals/releases/0.4.0.json +154 -0
  75. package/evals/releases/0.4.0.md +69 -0
  76. package/evals/releases/0.4.1.json +154 -0
  77. package/evals/releases/0.4.1.md +69 -0
  78. package/package.json +6 -2
  79. package/packs/rituals/checkpoints/README.md +35 -0
  80. package/packs/rituals/checkpoints/pack.json +3 -2
  81. package/packs/tools/local-llm/README.md +46 -6
  82. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  83. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  84. package/spec/README.md +37 -4
  85. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  86. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  87. package/spec/migrations/README.md +6 -0
  88. package/spec/pack.schema.json +12 -3
  89. package/spec/slots.md +262 -117
  90. package/spec/workspace.schema.json +67 -11
package/cli/ledger.mjs CHANGED
@@ -38,6 +38,7 @@ export const KEY_LIMIT = 200;
38
38
 
39
39
  /** The classes a request is billed in, in the order a report prints them. */
40
40
  export const CLASSES = ["uncached", "written1h", "written5m", "writtenUnknown", "read", "output"];
41
+ export const RECORDED_ROLES = ["implementer", "reviewer", "librarian", "supervisor", "local-worker"];
41
42
 
42
43
  export class LedgerError extends Error {
43
44
  constructor(message) {
@@ -84,33 +85,55 @@ export function mayHold(name, root) {
84
85
  }
85
86
 
86
87
  /** null for a record that is not an API response with usage; no content field is ever read. */
88
+ export const INPUT_USAGE_MASK = 1 | 4 | 8;
89
+ export const inputUnusable = r => Boolean((r.usageMissing & INPUT_USAGE_MASK) || (r.usageUnsafe & 1));
90
+
91
+ export function usageOf(usage) {
92
+ const lifetimes = usage.cache_creation !== null && typeof usage.cache_creation === "object" ? usage.cache_creation : {};
93
+ const written1h = count(lifetimes.ephemeral_1h_input_tokens);
94
+ const written5m = count(lifetimes.ephemeral_5m_input_tokens);
95
+ const writeParts = (lifetimes.ephemeral_5m_input_tokens === undefined ? 0 : 1) | (lifetimes.ephemeral_1h_input_tokens === undefined ? 0 : 2);
96
+ const invalid = value => value !== undefined && (!Number.isSafeInteger(value) || value < 0);
97
+ const inputUnsafe = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, lifetimes.ephemeral_1h_input_tokens, lifetimes.ephemeral_5m_input_tokens].some(invalid) ||
98
+ !Number.isSafeInteger(count(usage.input_tokens) + Math.max(count(usage.cache_creation_input_tokens), written1h + written5m) + count(usage.cache_read_input_tokens)) ||
99
+ (usage.cache_creation_input_tokens !== undefined && count(usage.cache_creation_input_tokens) < written1h + written5m);
100
+ return {
101
+ usageMissing: (usage.input_tokens === undefined ? 1 : 0) | (usage.output_tokens === undefined ? 2 : 0) |
102
+ (usage.cache_read_input_tokens === undefined ? 4 : 0) | (usage.cache_creation_input_tokens === undefined && writeParts !== 3 ? 8 : 0),
103
+ usageUnsafe: (inputUnsafe ? 1 : 0) | (invalid(usage.output_tokens) ? 2 : 0),
104
+ writeTotal: Number.isSafeInteger(usage.cache_creation_input_tokens) && usage.cache_creation_input_tokens >= 0 ? usage.cache_creation_input_tokens : null,
105
+ writeParts,
106
+ uncached: count(usage.input_tokens),
107
+ written1h,
108
+ written5m,
109
+ writtenUnknown: Math.max(0, count(usage.cache_creation_input_tokens) - written1h - written5m),
110
+ read: count(usage.cache_read_input_tokens),
111
+ output: count(usage.output_tokens),
112
+ };
113
+ }
114
+
87
115
  function requestOf(record) {
88
116
  if (record === null || typeof record !== "object" || record.type !== "assistant") return null;
89
117
  const message = record.message;
90
118
  if (message === null || typeof message !== "object") return null;
91
119
  const usage = message.usage;
92
120
  if (usage === null || typeof usage !== "object") return null;
93
- // The host's own records, an interruption or an API error, with no request behind them.
121
+ // Synthetic host records have no request behind them.
94
122
  if (message.model === "<synthetic>") return { synthetic: true };
95
- const lifetimes = usage.cache_creation !== null && typeof usage.cache_creation === "object" ? usage.cache_creation : {};
96
- const written1h = count(lifetimes.ephemeral_1h_input_tokens);
97
- const written5m = count(lifetimes.ephemeral_5m_input_tokens);
98
123
  const at = Date.parse(record.timestamp);
124
+ const roleParts = text(record.attributionAgent)?.split(":");
125
+ const role = roleParts?.every(part => /^[a-zA-Z0-9_-]+$/.test(part)) && RECORDED_ROLES.includes(roleParts.at(-1)) ? roleParts.at(-1) : null;
99
126
  return {
100
127
  id: text(message.id) ?? text(record.requestId) ?? text(record.uuid),
101
128
  model: text(message.model),
102
129
  effort: text(record.effort),
130
+ role,
131
+ ...usageOf(usage),
103
132
  branch: text(record.gitBranch),
104
133
  cwd: text(record.cwd),
105
134
  at: Number.isFinite(at) ? at : null,
106
135
  sidechain: record.isSidechain === true,
107
136
  agent: text(record.agentId),
108
- uncached: count(usage.input_tokens),
109
- written1h,
110
- written5m,
111
- writtenUnknown: Math.max(0, count(usage.cache_creation_input_tokens) - written1h - written5m),
112
- read: count(usage.cache_read_input_tokens),
113
- output: count(usage.output_tokens),
114
137
  };
115
138
  }
116
139
 
@@ -120,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
- /** The local runner's receipt format, a whole line or nothing: a format, so any line that matches it is read as one. */
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
- if (![attempts, input, output, time].every((v) => v === null || Number.isSafeInteger(v))) return null;
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 source = fs.readFileSync(file, "utf8");
222
+ export function readTranscript(file, { end = null } = {}) {
223
+ const bytes = fs.readFileSync(file, end === null ? "utf8" : undefined);
224
+ if (end !== null && (!Number.isSafeInteger(end) || end < 0 || end > bytes.length)) throw new LedgerError("the bounded transcript snapshot changed");
225
+ const source = end === null ? bytes : bytes.subarray(0, end).toString("utf8");
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
- const figures = sessionFigures();
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
- // The host writes a request again per content block, and its output count may grow: keep each largest.
266
+ // Merge growing block counters; unclassified writes remain the residual of the reported total.
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
- /** Latest request ids remembered to pass over repeats: the host writes a request's blocks back to back. */
321
+ /** Hot IDs only; raw streams need canonical confirmation. */
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
- return { multipliers, horizon, restart: value.restart ?? null };
410
+ const resume = value.resume === undefined ? null : figure(shaped(value.resume, "at `resume` ", ["tokens"], true).tokens, "at `resume` ", "tokens", "resume");
411
+ return { multipliers, horizon, restart: value.restart ?? null, resume };
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
- /** `C* ≈ F × (1 + m_w / (n × m_r))`, in whole tokens. */
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
- 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`);
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 = 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 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 };
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
- return figureOf(transcript.figures, options);
460
+ const main = transcript.requests?.filter(r => !r.sidechain) ?? [];
461
+ if (!main.length) return figureOf(transcript.figures, options);
462
+ const first = main[Math.max(0, main.findLastIndex(r => r.compacted))], last = main.at(-1);
463
+ if ([first, last].some(inputUnusable)) return { unavailable: "usage" };
464
+ const lifetime = main.findLast(r => lifetimeOf(r) !== null);
465
+ return figureOf({ ...transcript.figures, fresh: contextOf(first), last: contextOf(last), freshLifetime: lifetimeOf(first), lifetime: lifetime ? lifetimeOf(lifetime) : null }, options);
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 ?? HORIZON };
664
- const restart = latest === null ? null : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
734
+ const priced = { declared: spend.multipliers, horizon: spend.horizon ?? null, resume: spend.resume ?? null };
735
+ const last = latest?.transcript.requests.findLast(r => !r.sidechain);
736
+ const restart = latest === null ? null : last?.branch !== branch || !latest.requests.includes(last)
737
+ ? { session: latest.session, unavailable: "branch-scope" }
738
+ : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
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: 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
- /** Whether a proposal still waits on the human gate: anything short of a stated verdict does, as this feeds a nag, never a gate. */
94
+ /** Explicit leading outcomes only; this classifies a report, never grants consent. */
93
95
  export function proposalPending(source) {
94
- const lines = source.split("\n");
95
- const parts = [];
96
- for (let i = 0; i < lines.length; i += 1) {
97
- if (!/^\s*\*\*(decision|status)\b/i.test(lines[i])) continue;
98
- let end = i;
99
- while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
100
- parts.push(lines.slice(i, end + 1).join(" "));
101
- i = end;
102
- }
103
- if (!parts.length) return true;
104
- const text = parts.join(" ").replace(/[*_`]/g, "");
105
- if (/\bpending\b/i.test(text)) return true;
106
- if (/[{}]|\|/.test(text)) return true; // an unfilled template line is not a decision
107
- return !/\b(accepted|rejected|revised|applied|withdrawn|superseded)\b/i.test(text);
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
- "**State.** Every figure below is **as of " + asOf + "**, read from git history rather than from the",
513
- "filesystem, and nothing here is a decision. This pass drafts; the maintainer disposes.",
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
  "",