@agentguard-run/burn 0.2.5 → 0.2.7

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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +48 -17
  3. package/dist/src/calibrate.js +2 -3
  4. package/dist/src/cli.js +14 -3
  5. package/dist/src/conformance.d.ts +5 -2
  6. package/dist/src/conformance.js +30 -17
  7. package/dist/src/defaults.d.ts +5 -4
  8. package/dist/src/defaults.js +7 -6
  9. package/dist/src/detectors/evaluate.d.ts +4 -5
  10. package/dist/src/detectors/evaluate.js +13 -11
  11. package/dist/src/gateway.js +2 -8
  12. package/dist/src/history/claude-transcript.d.ts +20 -2
  13. package/dist/src/history/claude-transcript.js +56 -15
  14. package/dist/src/hook/pre-tool-use.d.ts +13 -9
  15. package/dist/src/hook/pre-tool-use.js +58 -32
  16. package/dist/src/insights/blocks.d.ts +61 -0
  17. package/dist/src/insights/blocks.js +243 -0
  18. package/dist/src/insights/transcript.d.ts +3 -1
  19. package/dist/src/insights/transcript.js +14 -1
  20. package/dist/src/insights/types.d.ts +7 -0
  21. package/dist/src/policy.d.ts +4 -0
  22. package/dist/src/policy.js +57 -0
  23. package/dist/src/replay/render.js +16 -14
  24. package/dist/src/replay/simulate.d.ts +5 -0
  25. package/dist/src/replay/simulate.js +23 -8
  26. package/dist/src/state/reservations.d.ts +2 -0
  27. package/dist/src/state/reservations.js +5 -1
  28. package/dist/src/state/spawn-window.d.ts +10 -0
  29. package/dist/src/state/spawn-window.js +25 -0
  30. package/dist/src/types.d.ts +2 -1
  31. package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
  32. package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
  33. package/fixtures/usage-dedup-session.jsonl +4 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7 (2026-09-19)
4
+
5
+ - Fix the STOP badge padding so every card row is 66 visible characters wide. Preserve the card wording.
6
+ - Use light box-drawing glyphs for the STOP frame after checking a real Claude Code hook denial. Start the frame below the host Error prefix so its edges align.
7
+ - Test both spawn and model-call cards with ANSI colour enabled and disabled, including wrapped advice.
8
+
9
+ ## 0.2.6 (2026-09-19)
10
+
11
+ - Fix inflated enforcement usage: repeated assistant content blocks and copied child histories now share the same provider response identity as `why`. Persist only usage deltas; rebuild old counters once without replacing their receipt chain head.
12
+ - Fix lifetime fan-out blocking: lifetime count is WARN only, the stop ceiling uses 120 active minutes, and spawn rate defaults to enforcement at 16 proposals within 15 active minutes. Depth above 2 still stops. Atomic reservations use both active windows, including parallel proposals.
13
+ - Fill missing policy fields from defaults with one notice. Preserve explicit settings, including older advisory spawn-rate policies, and bind the normalized policy into new receipt digests.
14
+ - Keep sustained thresholds at WARN 3.5B and STOP 5B. They now fire later because duplicate usage no longer advances the counters. Calibration uses corrected usage and excludes bursts rather than long lifetime spawn counts.
15
+ - Add `blocks [session|all]` with signed receipt context and measured child tokens and list-price economics, separating forks and fresh agents. Reuse the local attribution parser and existing verified pricing table; no transcript contents enter receipts.
16
+ - Keep hook WARN and STOP card text, layout and branding unchanged.
17
+ - Tests: 150 passing by default, one opt-in stress test skipped. The separate 240-process stress run admits exactly 40, denies 200, signs 240 receipts and has no lock failures. All 14 conformance checks pass.
18
+
3
19
  ## 0.2.5 (2026-09-18)
4
20
 
5
21
  - Attribute session usage from measured deltas: fixed-prefix context, cached history, tool output, repeated Read results and conversation. Use bytes only to split a measured increment, with mixed intervals counted as shared.
package/README.md CHANGED
@@ -56,23 +56,54 @@ Merge these fields into the existing policy. All existing thresholds and modes r
56
56
 
57
57
  ## Two safety planes
58
58
 
59
- Originally fitted against 412 real Claude Code sessions, then replayed on
60
- 2026-09-18 against 453 sessions. The history shows two independent
61
- failure shapes that one detector cannot cover:
62
-
63
- | Plane | Catches | Rule | Validated on |
64
- |---|---|---|---|
65
- | Structural | fan-out storms: many agents re-sending context | WARN 24 spawns, STOP before spawn 41, no agents-spawning-agents past depth 2 | 59-, 190- and 226-spawn sessions; the 190-spawn session has a 4.35B post-STOP tail (92%) |
66
- | Economic | slow grinds: few agents, enormous total | WARN 3.5B, STOP 5B tokens per session | the 9.15B / 26-spawn session the spawn cap could not see |
67
-
68
- The 2026-09-18 replay snapshot: **5 STOP, 3 WARN, 445 clean across 453
69
- sessions and 662 spawns**. Replay hero: **30%**. **10.80B** of **36.30B**
70
- recorded tokens came after a STOP boundary; API-list scenario **$4,832**.
71
- This is an upper bound assuming no override or restart. Replay does not label
72
- false positives. Active histories can change the next replay result. The existing replay
73
- counters retain their published behavior and can include repeated streaming
74
- usage records. The new `why` report reconciles provider responses, so its
75
- totals can differ. Do not treat replay as a reconciled invoice.
59
+ Structural limits use active time rather than the age of the session. The
60
+ lifetime spawn count warns at 24 but cannot stop a session on its own. The
61
+ fan-out ceiling permits 40 spawns within the last 120 active minutes and
62
+ stops the next proposal. The separate spawn-rate rule warns at 8 and stops
63
+ candidate 16 within 15 active minutes. Depth above 2 still stops.
64
+
65
+ Economic limits warn at 3.5B and stop at 5B measured tokens per session.
66
+ Repeated Claude assistant content blocks share one provider response id.
67
+ Their usage and copied child history count once, including later increases
68
+ to a response's recorded usage. Old session counters rebuild once from the
69
+ parent and its available child transcripts, retaining the signed chain head.
70
+ The rebuild places events chronologically. A child usage record discovered
71
+ late on a later hook enters the current active-minute bucket, while session
72
+ and bucket totals remain conserved.
73
+ The same numeric sustained thresholds now fire later because inflated usage
74
+ has been removed. Earlier replay figures in the changelog used the old counts.
75
+
76
+ Existing policy files keep explicit settings. Missing fields are filled in
77
+ memory, with a one-time notice. In particular, an old explicit
78
+ `spawnRate.enforce: false` remains advisory until you change it. New policies
79
+ default to `true`; `fanout.windowActiveMinutes` defaults to 120. Run calibration
80
+ only when you want it to write a new shadow policy, or set `AGENTGUARD_HOME`
81
+ to a scratch directory to review fitted values first.
82
+
83
+ ## Price a recorded block
84
+
85
+ ```sh
86
+ agentguard-burn blocks
87
+ agentguard-burn blocks SESSION_ID
88
+ agentguard-burn blocks all
89
+ ```
90
+
91
+ Each WARN or STOP receipt gets one row with its time, session, detector
92
+ reasons and spawn ordinal. The row includes the session's available child
93
+ count, median and maximum tokens, and median and maximum API list-price
94
+ dollars per child. Forks and fresh agents are reported separately. The
95
+ report reuses the attribution parser and excludes copied provider responses.
96
+ It reads recorded usage only and makes no network calls.
97
+
98
+ These are list-price equivalents, not a bill or a claim of money saved.
99
+ Economics describe the currently available child transcripts of that session,
100
+ not only the children completed before the receipt. Missing child history or
101
+ unknown model pricing stays unavailable. Unknown cache lifetimes produce a
102
+ price range. A fork is identified by its transcript's fork metadata. Codex child discovery
103
+ requires an explicit parent thread id; unknown inherited-history boundaries
104
+ stay unavailable. Use `blocks all json` for structured output. Session
105
+ attribution uses local receipt identifiers without copying transcript content
106
+ into the ledger.
76
107
 
77
108
  Cache-read ratio was about 98% in the original calibration, healthy and pathological alike. It
78
109
  is shown as an explanation and never used to decide.
@@ -23,7 +23,6 @@
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.calibrate = calibrate;
25
25
  const defaults_1 = require("./defaults");
26
- const IMMUTABLE_MAX_SPAWNS = 40;
27
26
  const IMMUTABLE_MAX_TOKENS = 6_000_000_000;
28
27
  /**
29
28
  * Sessions below this are not evidence about what a working session costs.
@@ -69,10 +68,10 @@ function calibrate(sessions, base = defaults_1.DEFAULT_THRESHOLDS) {
69
68
  if (trivial > 0) {
70
69
  notes.push(`${trivial} short session(s) under ${MIN_WORKING_SESSION_TOKENS / 1e6}M tokens ignored; they are not evidence about working-session cost.`);
71
70
  }
72
- const healthy = working.filter((s) => s.spawns <= IMMUTABLE_MAX_SPAWNS && s.totalTokens <= IMMUTABLE_MAX_TOKENS);
71
+ const healthy = working.filter((s) => !s.fanoutStop && !s.spawnRateStop && s.totalTokens <= IMMUTABLE_MAX_TOKENS);
73
72
  const excluded = working.length - healthy.length;
74
73
  if (excluded > 0) {
75
- notes.push(`${excluded} session(s) excluded as already pathological (>${IMMUTABLE_MAX_SPAWNS} spawns or >6B tokens). They must not define normal.`);
74
+ notes.push(`${excluded} session(s) excluded as already pathological (fan-out, depth or spawn-rate STOP, or >6B tokens). They must not define normal.`);
76
75
  }
77
76
  const tokens = winsorise(healthy.map((s) => s.totalTokens)).sort((a, b) => a - b);
78
77
  const rawWarn = quantile(tokens, 0.9);
package/dist/src/cli.js CHANGED
@@ -38,6 +38,7 @@ const sessions_1 = require("./insights/sessions");
38
38
  const transcript_1 = require("./insights/transcript");
39
39
  const live_1 = require("./insights/live");
40
40
  const render_2 = require("./insights/render");
41
+ const blocks_1 = require("./insights/blocks");
41
42
  const HOME = process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
42
43
  const PROXY_HOSTS = ['ollama', 'vllm', 'lm-studio', 'openai-compatible'];
43
44
  function readStdinJson() {
@@ -81,6 +82,12 @@ async function main(argv) {
81
82
  };
82
83
  const has = (name) => rest.includes(name);
83
84
  switch (command) {
85
+ case 'blocks': {
86
+ const session = rest.find(value => value !== 'json' && value !== '--json');
87
+ const report = (0, blocks_1.blocksReport)(HOME, session, { rates: (0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME)) });
88
+ process.stdout.write((rest.includes('json') || has('--json') ? JSON.stringify(report, null, 2) : (0, blocks_1.renderBlocks)(report)) + '\n');
89
+ return 0;
90
+ }
84
91
  case 'why': {
85
92
  const location = (0, sessions_1.selectInsightTranscript)(rest.find(value => value !== 'json'));
86
93
  const transcript = (0, transcript_1.readInsightSession)(location.path, { host: location.host, sessionId: location.sessionId });
@@ -142,7 +149,8 @@ async function main(argv) {
142
149
  `Calibrated from ${result.sessionsUsed} of ${result.sessionsTotal} sessions (${result.sessionsExcluded} excluded as pathological).`,
143
150
  ` raw p90 ${(result.rawWarnP90 / 1e9).toFixed(2)}B raw p99 ${(result.rawStopP99 / 1e9).toFixed(2)}B`,
144
151
  ` sustained WARN ${(t.sustained.warnTokens / 1e9).toFixed(2)}B STOP ${(t.sustained.stopTokens / 1e9).toFixed(2)}B`,
145
- ` fan-out WARN ${t.fanout.warn} STOP ${t.fanout.stop} (not calibrated; validated on real runaway)`,
152
+ ` fan-out lifetime WARN ${t.fanout.warn}; STOP above ${t.fanout.stop} spawns in ${t.fanout.windowActiveMinutes ?? 120} active minutes; depth above ${t.fanout.maxDepth}`,
153
+ ` spawn-rate WARN ${t.spawnRate?.warn ?? 8}; STOP ${t.spawnRate?.stop ?? 16} in ${t.spawnRate?.windowActiveMinutes ?? 15} active minutes; ${t.spawnRate?.enforce === false ? 'shadow only' : 'enforcing in enforce mode'}`,
146
154
  ` burn-debt ${t.burnDebt.enabled ? `ON, baseline ${Math.round(t.burnDebt.baselinePerActiveMinute / 1e6)}M/active-min` : 'off'}`,
147
155
  ...result.notes.map((n) => ` note: ${n}`),
148
156
  `Policy written to ${(0, node_path_1.join)(HOME, 'burn-policy.json')} in SHADOW mode.`,
@@ -157,7 +165,9 @@ async function main(argv) {
157
165
  const override = (0, override_1.readOverride)(HOME);
158
166
  process.stdout.write([
159
167
  `mode: ${policy.mode}${policy.mode === 'enforce' ? ' (the next STOP blocks)' : ' (recording only; nothing is blocked)'}`,
160
- `thresholds: fan-out ${policy.thresholds.fanout.warn}/${policy.thresholds.fanout.stop} sustained ${(policy.thresholds.sustained.warnTokens / 1e9).toFixed(1)}B/${(policy.thresholds.sustained.stopTokens / 1e9).toFixed(1)}B local-compute warn at ${lc?.warnConcurrent ?? 4} concurrent${lc?.stopConcurrent ? `, stop at ${lc.stopConcurrent}` : ' (no stop set)'}`,
168
+ `thresholds: fan-out lifetime WARN ${policy.thresholds.fanout.warn}; STOP above ${policy.thresholds.fanout.stop} spawns in ${policy.thresholds.fanout.windowActiveMinutes ?? 120} active minutes; depth above ${policy.thresholds.fanout.maxDepth}`,
169
+ `spawn-rate: WARN ${policy.thresholds.spawnRate?.warn ?? 8}; STOP ${policy.thresholds.spawnRate?.stop ?? 16} in ${policy.thresholds.spawnRate?.windowActiveMinutes ?? 15} active minutes; ${policy.thresholds.spawnRate?.enforce === false ? 'shadow only' : 'enforcing in enforce mode'}`,
170
+ `sustained: ${(policy.thresholds.sustained.warnTokens / 1e9).toFixed(1)}B/${(policy.thresholds.sustained.stopTokens / 1e9).toFixed(1)}B local-compute warn at ${lc?.warnConcurrent ?? 4} concurrent${lc?.stopConcurrent ? `, stop at ${lc.stopConcurrent}` : ' (no stop set)'}`,
161
171
  policy.calibration ? `calibrated from ${policy.calibration.sessionsSampled} sessions` : 'using shipped defaults (run: agentguard-burn calibrate)',
162
172
  `shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
163
173
  ` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
@@ -292,7 +302,8 @@ async function main(argv) {
292
302
  return 0;
293
303
  }
294
304
  default:
295
- process.stdout.write('agentguard-burn <why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
305
+ process.stdout.write('agentguard-burn <blocks|why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
306
+ ' blocks [session|all] stored WARN/STOP receipts and that session\'s child usage\n' +
296
307
  ' why [session] token attribution and API list-price equivalents\n' +
297
308
  ' rewrites [session|all] full-prefix cache writes by cause\n' +
298
309
  ' pace [session] ten-minute pace and next-hour projection\n' +
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Conformance: the same failure, through every door, stops at the same step.
3
3
  *
4
- * Two fixtures, fitted on real sessions:
4
+ * Fixtures, fitted on real sessions:
5
5
  *
6
6
  * the storm 42 candidate spawns. First WARN must be spawn 24, first STOP
7
- * must be spawn 41. Every spawn-capable adapter replays it.
7
+ * must be spawn 41 with spawn-rate advisory. Every spawn-capable
8
+ * adapter replays it.
9
+ * rate default policy warns at candidate 8 and stops candidate 16.
10
+ * Pending proposals count even before a transcript sees them.
8
11
  * the grind model calls of 250M tokens each. First WARN must be the call
9
12
  * after 3.5B, first STOP the call after 5B. Every usage-capable
10
13
  * adapter replays it.
@@ -2,10 +2,13 @@
2
2
  /**
3
3
  * Conformance: the same failure, through every door, stops at the same step.
4
4
  *
5
- * Two fixtures, fitted on real sessions:
5
+ * Fixtures, fitted on real sessions:
6
6
  *
7
7
  * the storm 42 candidate spawns. First WARN must be spawn 24, first STOP
8
- * must be spawn 41. Every spawn-capable adapter replays it.
8
+ * must be spawn 41 with spawn-rate advisory. Every spawn-capable
9
+ * adapter replays it.
10
+ * rate default policy warns at candidate 8 and stops candidate 16.
11
+ * Pending proposals count even before a transcript sees them.
9
12
  * the grind model calls of 250M tokens each. First WARN must be the call
10
13
  * after 3.5B, first STOP the call after 5B. Every usage-capable
11
14
  * adapter replays it.
@@ -35,11 +38,11 @@ const STOP_AT = defaults_1.DEFAULT_THRESHOLDS.fanout.stop + 1; // 41
35
38
  const CALL_TOKENS = 250_000_000;
36
39
  const GRIND_WARN_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.warnTokens / CALL_TOKENS) + 1; // 15
37
40
  const GRIND_STOP_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.stopTokens / CALL_TOKENS) + 1; // 21
38
- function freshHome() {
41
+ function freshHome(enforceRate = false) {
39
42
  const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'agb-conf-'));
40
43
  // These fixtures share a home to inspect all receipts. Test only the
41
44
  // session boundaries here; account concurrency has its own integration tests.
42
- const policy = { mode: 'enforce', thresholds: { ...defaults_1.DEFAULT_THRESHOLDS, account: { ...defaults_1.DEFAULT_THRESHOLDS.account, warnConcurrentSessions: Number.MAX_SAFE_INTEGER } } };
45
+ const policy = { mode: 'enforce', thresholds: { ...defaults_1.DEFAULT_THRESHOLDS, spawnRate: { ...defaults_1.DEFAULT_THRESHOLDS.spawnRate, enforce: enforceRate }, account: { ...defaults_1.DEFAULT_THRESHOLDS.account, warnConcurrentSessions: Number.MAX_SAFE_INTEGER } } };
43
46
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), JSON.stringify(policy));
44
47
  return home;
45
48
  }
@@ -52,6 +55,7 @@ function boundaries(steps) {
52
55
  // Hook adapters only expose the first finding's summary. Fan-out is evaluated
53
56
  // first in the core, so when it fires it is the one in the message.
54
57
  const FANOUT_WARN = /agent spawns this session/;
58
+ const RATE_WARN = /spawns in the last 15 active minutes/;
55
59
  function stepFromMessage(denied, message, warnPattern) {
56
60
  if (denied)
57
61
  return 'STOP';
@@ -60,34 +64,36 @@ function stepFromMessage(denied, message, warnPattern) {
60
64
  return warnPattern.test(message) ? 'WARN' : 'WARN:other';
61
65
  }
62
66
  // ---- storms --------------------------------------------------------------
63
- function stormRaw(home) {
64
- const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: 'storm-raw', home });
67
+ function stormRaw(home, rate = false, startObserved = true) {
68
+ const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: `${rate ? 'rate' : 'storm'}-raw${startObserved ? '' : '-pending'}`, home });
65
69
  const steps = [];
66
70
  for (let i = 1; i <= STORM; i++) {
67
71
  const lease = burn.beforeSpawn({ parentDepth: 0 });
68
72
  const d = lease.decision;
69
- const fanout = d.report.findings.find((f) => f.detector === 'fanout');
73
+ const fanout = d.report.findings.find((f) => f.detector === (rate ? 'spawn_rate' : 'fanout'));
70
74
  steps.push(d.blocked ? 'STOP' : fanout?.verdict === 'WARN' ? 'WARN' : d.verdict === 'WARN' ? 'WARN:other' : 'OK');
71
- if (!d.blocked)
75
+ if (!d.blocked && startObserved)
72
76
  lease.started();
73
77
  }
74
78
  return boundaries(steps);
75
79
  }
76
- function stormCursor(home) {
80
+ function stormCursor(home, rate = false) {
81
+ const sessionId = rate ? 'rate-cursor' : 'storm-cursor';
77
82
  const gateway = new gateway_1.Gateway(home);
78
83
  const steps = [];
79
84
  for (let i = 1; i <= STORM; i++) {
80
- const out = (0, cursor_1.handleCursorHook)({ hook_event_name: 'subagentStart', conversation_id: 'storm-cursor', subagent_id: `sub-${i}`, parent_conversation_id: 'storm-cursor' }, gateway);
81
- steps.push(stepFromMessage(out.permission === 'deny', out.agent_message, FANOUT_WARN));
85
+ const out = (0, cursor_1.handleCursorHook)({ hook_event_name: 'subagentStart', conversation_id: sessionId, subagent_id: `sub-${i}`, parent_conversation_id: sessionId }, gateway);
86
+ steps.push(stepFromMessage(out.permission === 'deny', out.agent_message, rate ? RATE_WARN : FANOUT_WARN));
82
87
  }
83
88
  return boundaries(steps);
84
89
  }
85
- function stormCodex(home) {
90
+ function stormCodex(home, rate = false) {
91
+ const sessionId = rate ? 'rate-codex' : 'storm-codex';
86
92
  const gateway = new gateway_1.Gateway(home);
87
93
  const steps = [];
88
94
  for (let i = 1; i <= STORM; i++) {
89
- const out = (0, codex_1.handleCodexHook)({ hook_event_name: 'PreToolUse', session_id: 'storm-codex', tool_name: 'Agent', tool_use_id: `call-${i}` }, gateway);
90
- steps.push(stepFromMessage(out.hookSpecificOutput?.permissionDecision === 'deny', out.systemMessage, FANOUT_WARN));
95
+ const out = (0, codex_1.handleCodexHook)({ hook_event_name: 'PreToolUse', session_id: sessionId, tool_name: 'spawn_agent', tool_use_id: `call-${i}` }, gateway);
96
+ steps.push(stepFromMessage(out.hookSpecificOutput?.permissionDecision === 'deny', out.systemMessage, rate ? RATE_WARN : FANOUT_WARN));
91
97
  }
92
98
  return boundaries(steps);
93
99
  }
@@ -221,17 +227,23 @@ async function composite(home) {
221
227
  // ---- runner --------------------------------------------------------------
222
228
  async function runConformance() {
223
229
  const home = freshHome();
230
+ const rateHome = freshHome(true);
224
231
  const checks = [];
225
232
  const expectStorm = (name, b) => checks.push({
226
233
  name,
227
234
  ok: b.firstWarn === WARN_AT && b.firstStop === STOP_AT,
228
235
  detail: `fan-out WARN at ${b.firstWarn} (want ${WARN_AT}), STOP at ${b.firstStop} (want ${STOP_AT})${b.firstAnyWarn !== null && b.firstAnyWarn < WARN_AT ? `; advisory spawn-rate WARN at ${b.firstAnyWarn}` : ''}`,
229
236
  });
237
+ const expectRate = (name, b) => checks.push({ name, ok: b.firstWarn === defaults_1.DEFAULT_THRESHOLDS.spawnRate.warn && b.firstStop === defaults_1.DEFAULT_THRESHOLDS.spawnRate.stop, detail: `default spawn-rate WARN at ${b.firstWarn} (want 8), STOP at ${b.firstStop} (want 16)` });
230
238
  const expectGrind = (name, b) => checks.push({ name, ok: b.firstWarn === GRIND_WARN_CALL && b.firstStop === GRIND_STOP_CALL, detail: `first WARN call ${b.firstWarn} (want ${GRIND_WARN_CALL}), first STOP call ${b.firstStop} (want ${GRIND_STOP_CALL})` });
231
239
  try {
232
240
  expectStorm('storm · raw middleware', stormRaw(home));
233
241
  expectStorm('storm · cursor subagentStart', stormCursor(home));
234
242
  expectStorm('storm · codex PreToolUse', stormCodex(home));
243
+ expectRate('rate · raw middleware', stormRaw(rateHome, true));
244
+ expectRate('rate · cursor subagentStart', stormCursor(rateHome, true));
245
+ expectRate('rate · codex PreToolUse', stormCodex(rateHome, true));
246
+ expectRate('rate · pending only, no observed spawns', stormRaw(rateHome, true, false));
235
247
  checks.push({ name: 'depth · raw middleware', ok: depthRaw(home), detail: 'depth 1 and 2 admitted, depth 3 denied' });
236
248
  expectGrind('grind · raw middleware', grindRaw(home));
237
249
  const p = await grindProxy(home);
@@ -241,17 +253,18 @@ async function runConformance() {
241
253
  const c = await composite(home);
242
254
  checks.push({ name: 'composite · middleware tree + proxy usage, one session', ok: c.ok, detail: c.detail });
243
255
  // Receipts: every spawn decision above was signed; verify the chain tail.
244
- const receipts = require('node:fs')
245
- .readFileSync((0, node_path_1.join)(home, 'receipts.ndjson'), 'utf8')
256
+ const receipts = [home, rateHome].flatMap((directory) => require('node:fs')
257
+ .readFileSync((0, node_path_1.join)(directory, 'receipts.ndjson'), 'utf8')
246
258
  .split('\n')
247
259
  .filter(Boolean)
248
- .map((l) => JSON.parse(l));
260
+ .map((l) => JSON.parse(l)));
249
261
  const allValid = receipts.every((r) => (0, receipt_1.verifyReceipt)(r));
250
262
  const contentFree = !receipts.some((r) => JSON.stringify(r.payload).match(/transcript|prompt|\/Users\/|content/i));
251
263
  checks.push({ name: `receipts · ${receipts.length} signed, all verify, content-free`, ok: allValid && contentFree && receipts.length > 0, detail: `${receipts.length} receipts, verify=${allValid}, content-free=${contentFree}` });
252
264
  }
253
265
  finally {
254
266
  (0, node_fs_1.rmSync)(home, { recursive: true, force: true });
267
+ (0, node_fs_1.rmSync)(rateHome, { recursive: true, force: true });
255
268
  }
256
269
  const ok = checks.every((c) => c.ok);
257
270
  const text = [
@@ -5,10 +5,11 @@ import type { Policy, Thresholds } from './types';
5
5
  * Originally fitted against 412 real Claude Code sessions. Replayed on the
6
6
  * author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
7
7
  * 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
8
- * Legacy replay counters can include repeated streaming usage records. The
9
- * new insights report reconciles provider responses instead.
10
- * Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
11
- * sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
8
+ * That historical snapshot used inflated repeated streaming usage counts and
9
+ * a lifetime fan-out ceiling. Version 0.2.6 deduplicates usage and limits fan-out
10
+ * to 120 active minutes. Lifetime 24 warns; above 40 in the window stops.
11
+ * Spawn rate 8/16 over 15 active minutes warns/stops by default. Sustained
12
+ * thresholds remain 3.5B/5B pending review of deduplicated calibration.
12
13
  * This is a dated replay snapshot and an upper bound, not labelled evidence
13
14
  * of false-positive rates or guaranteed savings.
14
15
  *
@@ -7,10 +7,11 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
7
7
  * Originally fitted against 412 real Claude Code sessions. Replayed on the
8
8
  * author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
9
9
  * 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
10
- * Legacy replay counters can include repeated streaming usage records. The
11
- * new insights report reconciles provider responses instead.
12
- * Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
13
- * sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
10
+ * That historical snapshot used inflated repeated streaming usage counts and
11
+ * a lifetime fan-out ceiling. Version 0.2.6 deduplicates usage and limits fan-out
12
+ * to 120 active minutes. Lifetime 24 warns; above 40 in the window stops.
13
+ * Spawn rate 8/16 over 15 active minutes warns/stops by default. Sustained
14
+ * thresholds remain 3.5B/5B pending review of deduplicated calibration.
14
15
  * This is a dated replay snapshot and an upper bound, not labelled evidence
15
16
  * of false-positive rates or guaranteed savings.
16
17
  *
@@ -24,7 +25,7 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
24
25
  * absent from these thresholds.
25
26
  */
26
27
  exports.DEFAULT_THRESHOLDS = {
27
- fanout: { warn: 24, stop: 40, maxDepth: 2 },
28
+ fanout: { warn: 24, stop: 40, maxDepth: 2, windowActiveMinutes: 120 },
28
29
  sustained: { warnTokens: 3_500_000_000, stopTokens: 5_000_000_000 },
29
30
  burnDebt: {
30
31
  baselinePerActiveMinute: 0,
@@ -33,7 +34,7 @@ exports.DEFAULT_THRESHOLDS = {
33
34
  stopDebt: 1_500_000_000,
34
35
  enabled: false,
35
36
  },
36
- spawnRate: { windowActiveMinutes: 15, warn: 8, stop: 16, enforce: false },
37
+ spawnRate: { windowActiveMinutes: 15, warn: 8, stop: 16, enforce: true },
37
38
  duplicate: { warnReaders: 3 },
38
39
  account: {
39
40
  stopTokens: null,
@@ -5,15 +5,14 @@
5
5
  * failure modes:
6
6
  *
7
7
  * structural - fan-out. Many agents, each re-sending context. The 190-spawn
8
- * session. Caught by an absolute spawn cap.
8
+ * session. Caught by spawn windows over active time.
9
9
  * economic - sustained burn. Few agents, long session, enormous total. The
10
10
  * 9.15B session with only 26 spawns, which a spawn cap cannot
11
11
  * see. Caught by a cumulative token ceiling and burn debt.
12
12
  *
13
- * Verdict is the maximum severity across findings. Advisory detectors (spawn
14
- * rate, duplicate work, cache ratio) can raise WARN but never STOP, because
15
- * each has a plausible benign explanation and a false STOP is what gets a
16
- * safety tool uninstalled.
13
+ * Verdict is the maximum severity across findings. Lifetime fan-out and
14
+ * duplicate work are advisory; only a recent burst, depth or economic
15
+ * threshold can stop new work. Spawn rate can be made advisory by policy.
17
16
  */
18
17
  import type { BurnReport, SessionState, Thresholds } from '../types';
19
18
  /**
@@ -6,20 +6,20 @@
6
6
  * failure modes:
7
7
  *
8
8
  * structural - fan-out. Many agents, each re-sending context. The 190-spawn
9
- * session. Caught by an absolute spawn cap.
9
+ * session. Caught by spawn windows over active time.
10
10
  * economic - sustained burn. Few agents, long session, enormous total. The
11
11
  * 9.15B session with only 26 spawns, which a spawn cap cannot
12
12
  * see. Caught by a cumulative token ceiling and burn debt.
13
13
  *
14
- * Verdict is the maximum severity across findings. Advisory detectors (spawn
15
- * rate, duplicate work, cache ratio) can raise WARN but never STOP, because
16
- * each has a plausible benign explanation and a false STOP is what gets a
17
- * safety tool uninstalled.
14
+ * Verdict is the maximum severity across findings. Lifetime fan-out and
15
+ * duplicate work are advisory; only a recent burst, depth or economic
16
+ * threshold can stop new work. Spawn rate can be made advisory by policy.
18
17
  */
19
18
  Object.defineProperty(exports, "__esModule", { value: true });
20
19
  exports.evaluate = evaluate;
21
20
  exports.fmt = fmt;
22
21
  const account_1 = require("../state/account");
22
+ const defaults_1 = require("../defaults");
23
23
  const session_1 = require("../state/session");
24
24
  const RANK = { OK: 0, WARN: 1, STOP: 2 };
25
25
  function worst(a, b) {
@@ -36,12 +36,14 @@ function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
36
36
  // ---- structural plane: fan-out --------------------------------------
37
37
  // Count the proposal itself. "Allow through 40, deny candidate 41."
38
38
  const effectiveSpawns = state.spawnCount + (proposedSpawnDepth !== null ? 1 : 0);
39
- if (effectiveSpawns > thresholds.fanout.stop) {
39
+ const fanoutWindow = thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes;
40
+ const recentFanout = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, fanoutWindow) + (proposedSpawnDepth !== null ? 1 : 0);
41
+ if (recentFanout > thresholds.fanout.stop) {
40
42
  findings.push({
41
43
  detector: 'fanout',
42
44
  verdict: 'STOP',
43
- summary: `Spawn ${effectiveSpawns} would exceed the fan-out ceiling of ${thresholds.fanout.stop}.`,
44
- observed: effectiveSpawns,
45
+ summary: `Spawn ${recentFanout} would exceed the fan-out ceiling of ${thresholds.fanout.stop}.`,
46
+ observed: recentFanout,
45
47
  threshold: thresholds.fanout.stop,
46
48
  });
47
49
  verdict = worst(verdict, 'STOP');
@@ -110,8 +112,8 @@ function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
110
112
  verdict = worst(verdict, 'WARN');
111
113
  }
112
114
  }
113
- // ---- advisory: spawn rate over active time -------------------------
114
- const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes);
115
+ // ---- structural plane: spawn rate over active time -----------------
116
+ const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes) + (proposedSpawnDepth !== null ? 1 : 0);
115
117
  if (recentSpawns >= thresholds.spawnRate.stop) {
116
118
  const severity = thresholds.spawnRate.enforce ? 'STOP' : 'WARN';
117
119
  findings.push({
@@ -199,7 +201,7 @@ function prescribe(state, findings, t) {
199
201
  out.push(`Let the ${Math.min(state.spawnCount, 4)} most useful running agents finish; do not replace them.`);
200
202
  }
201
203
  else if (has('fanout')) {
202
- out.push(`You are at ${state.spawnCount} spawns; ${t.fanout.stop - state.spawnCount} remain before the ceiling. Plan for it.`);
204
+ out.push(`You are at ${state.spawnCount} spawns; ${Math.max(0, t.fanout.stop - (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, t.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes))} remain before the ceiling. Plan for it.`);
203
205
  }
204
206
  if (state.maxDepth >= 2) {
205
207
  out.push('Agents are spawning agents. Make the root orchestrator the only process allowed to spawn.');
@@ -41,6 +41,7 @@ const pre_tool_use_1 = require("./hook/pre-tool-use");
41
41
  const override_1 = require("./override");
42
42
  const receipt_1 = require("./receipt");
43
43
  const reservations_1 = require("./state/reservations");
44
+ const spawn_window_1 = require("./state/spawn-window");
44
45
  const account_1 = require("./state/account");
45
46
  const session_1 = require("./state/session");
46
47
  const MAX_SEEN_EVENTS = 4000;
@@ -217,14 +218,7 @@ class Gateway {
217
218
  }
218
219
  const live = new Map(meta.liveSpawns);
219
220
  const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
220
- const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
221
- const reservation = tx.reserve({
222
- sessionId: event.sessionId,
223
- toolUseId: event.spawnId,
224
- observedSpawns: state.spawnCount,
225
- ceiling: policy.thresholds.fanout.stop,
226
- now: event.at,
227
- });
221
+ const { report, reservation } = (0, spawn_window_1.evaluateSpawnReservation)(tx, state, policy.thresholds, proposedDepth, event.spawnId, event.at, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
228
222
  const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
229
223
  const verdict = wouldBlock ? 'STOP' : report.verdict;
230
224
  // Session-scope STOPs need a session we trust. Fan-out is session scope.
@@ -12,6 +12,8 @@
12
12
  * truncated or replaced file resets the cursor rather than reading garbage.
13
13
  */
14
14
  import type { BurnEvent } from '../types';
15
+ /** Component maxima for one provider response, never transcript contents. */
16
+ export type UsageSnapshot = [input: number, output: number, cacheCreation: number, cacheRead: number];
15
17
  export interface ReaderCursor {
16
18
  /** Byte just after the last complete newline we processed. */
17
19
  offset: number;
@@ -20,6 +22,11 @@ export interface ReaderCursor {
20
22
  malformedLines: number;
21
23
  /** Resolved depth per line uuid, for spawn-depth attribution. */
22
24
  depthByUuid: Map<string, number>;
25
+ /** Optional and additive so callers with pre-0.2.6 cursors still load. */
26
+ usageVersion?: 1;
27
+ usageByMessage?: Map<string, UsageSnapshot>;
28
+ /** Child file cursors share usageByMessage with their parent. */
29
+ children?: Map<string, ReaderCursor>;
23
30
  }
24
31
  export declare function newCursor(): ReaderCursor;
25
32
  interface RawLine {
@@ -28,6 +35,7 @@ interface RawLine {
28
35
  parentUuid?: string;
29
36
  isSidechain?: boolean;
30
37
  message?: {
38
+ id?: unknown;
31
39
  usage?: Record<string, number | undefined>;
32
40
  content?: unknown;
33
41
  };
@@ -39,8 +47,18 @@ export declare function normaliseLine(raw: RawLine, cursor: ReaderCursor): BurnE
39
47
  * and advances the cursor. O(new bytes), never O(file size) after the first
40
48
  * read.
41
49
  */
42
- export declare function readIncremental(path: string, cursor: ReaderCursor): BurnEvent[];
43
- /** Convenience for replay and tests: read a whole transcript from the start. */
50
+ export interface ReadOptions {
51
+ /** Legacy-state rebuilds must not replace totals after an unreadable file. */
52
+ throwOnOpenError?: boolean;
53
+ }
54
+ export declare function readIncremental(path: string, cursor: ReaderCursor, options?: ReadOptions): BurnEvent[];
55
+ /**
56
+ * Fold the parent's tool events and its stored children's usage into one
57
+ * session. Child histories can contain copied parent tool calls, so only their
58
+ * usage deltas contribute here. The parent's observed spawn count stays intact.
59
+ */
60
+ export declare function readSessionIncremental(path: string, cursor: ReaderCursor, options?: ReadOptions): BurnEvent[];
61
+ /** Convenience for replay and tests: read a session and its own stored children. */
44
62
  export declare function readAll(path: string): {
45
63
  events: BurnEvent[];
46
64
  cursor: ReaderCursor;