@agentguard-run/burn 0.2.3 → 0.2.6

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 (54) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/README.md +105 -20
  3. package/dist/src/adapters/codex.js +11 -1
  4. package/dist/src/adapters/cursor.js +2 -2
  5. package/dist/src/calibrate.js +2 -3
  6. package/dist/src/cli.js +53 -15
  7. package/dist/src/conformance.d.ts +5 -2
  8. package/dist/src/conformance.js +30 -17
  9. package/dist/src/defaults.d.ts +7 -4
  10. package/dist/src/defaults.js +9 -6
  11. package/dist/src/detectors/evaluate.d.ts +4 -5
  12. package/dist/src/detectors/evaluate.js +13 -11
  13. package/dist/src/eligibility.d.ts +17 -0
  14. package/dist/src/eligibility.js +29 -0
  15. package/dist/src/gateway.d.ts +2 -0
  16. package/dist/src/gateway.js +4 -8
  17. package/dist/src/history/claude-transcript.d.ts +20 -2
  18. package/dist/src/history/claude-transcript.js +56 -15
  19. package/dist/src/hook/pre-tool-use.d.ts +13 -9
  20. package/dist/src/hook/pre-tool-use.js +63 -31
  21. package/dist/src/insights/attribution.d.ts +4 -0
  22. package/dist/src/insights/attribution.js +151 -0
  23. package/dist/src/insights/blocks.d.ts +61 -0
  24. package/dist/src/insights/blocks.js +243 -0
  25. package/dist/src/insights/live.d.ts +53 -0
  26. package/dist/src/insights/live.js +211 -0
  27. package/dist/src/insights/pace.d.ts +34 -0
  28. package/dist/src/insights/pace.js +54 -0
  29. package/dist/src/insights/pricing.d.ts +48 -0
  30. package/dist/src/insights/pricing.js +139 -0
  31. package/dist/src/insights/render.d.ts +8 -0
  32. package/dist/src/insights/render.js +126 -0
  33. package/dist/src/insights/sessions.d.ts +12 -0
  34. package/dist/src/insights/sessions.js +51 -0
  35. package/dist/src/insights/transcript.d.ts +14 -0
  36. package/dist/src/insights/transcript.js +505 -0
  37. package/dist/src/insights/types.d.ts +164 -0
  38. package/dist/src/insights/types.js +4 -0
  39. package/dist/src/install.js +14 -5
  40. package/dist/src/policy.d.ts +4 -0
  41. package/dist/src/policy.js +57 -0
  42. package/dist/src/replay/render.js +4 -2
  43. package/dist/src/replay/simulate.d.ts +5 -0
  44. package/dist/src/replay/simulate.js +23 -8
  45. package/dist/src/state/reservations.d.ts +7 -5
  46. package/dist/src/state/reservations.js +60 -45
  47. package/dist/src/state/spawn-window.d.ts +10 -0
  48. package/dist/src/state/spawn-window.js +25 -0
  49. package/dist/src/types.d.ts +8 -1
  50. package/docs/USAGE_AND_PRICING.md +132 -0
  51. package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
  52. package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
  53. package/fixtures/usage-dedup-session.jsonl +4 -0
  54. package/package.json +4 -3
package/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ # Changelog
2
+
3
+ ## 0.2.6 (2026-09-19)
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - Keep hook WARN and STOP card text, layout and branding unchanged.
11
+ - 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.
12
+
13
+ ## 0.2.5 (2026-09-18)
14
+
15
+ - 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.
16
+ - Preserve every recorded token across output, rewrites, child fan-out and the residual. The Method footer explains each bucket, baseline resets and the prior-output subtraction retained in the residual.
17
+ - Keep baseline and event state across responses for correct last-turn shares and category pricing. Repeated usage snapshots retain their original event interval; old pace caches rebuild local metadata once.
18
+ - Validate every bucket against a constructed session, including UTF-8 weights, integer rounding, prefix changes, duplicate events and per-category conservation.
19
+ - Tests: 120 passing by default, one opt-in stress test skipped.
20
+
21
+ ## 0.2.4 (2026-09-18)
22
+
23
+ - Add local `why`, `rewrites`, `pace`, `statusline` and `pricing` commands. Assistant usage is reconciled by response identity, with unknown attribution kept explicit and child usage identified from transcript metadata.
24
+ - Explain full-prefix cache writes, measured lifetimes, cause evidence and API list cost. Warn on configurable rewrite spend and heavy context crossings without blocking ordinary tools.
25
+ - Observe every Claude Code and Codex tool call. Track cached and uncached ten-minute pace. Forecast limits only from fresh host percentage observations; provide a companion terminal line where Codex cannot run a custom footer command.
26
+ - Bundle dated model pricing and local overrides. Keep unknown models unpriced, content out of persisted metadata, and new reporting paths free of network calls.
27
+ - Raise reservation lock wait to eight seconds, serialize stale-lock retirement, and exclude operational lock-wait failures from enforce eligibility. Old ledgers and receipts remain readable.
28
+ - Validate a 240-process burst: exactly 40 admitted, 200 denied, 240 signed receipts, zero lock failures and a valid chain.
29
+ - Tests: 106 passing by default, one opt-in stress test skipped. The separate stress run also passes.
30
+
1
31
  ## 0.2.3 (2026-09-15)
2
32
 
3
33
  - Replay evidence refreshed from this machine: 4 STOP, 3 WARN, 431 clean across
@@ -16,8 +46,6 @@
16
46
  identifiers, and stop labelling every omitted replay session as clean.
17
47
  - Tests: 47 passing by default, one stress test skipped unless AGENTGUARD_STRESS=1.
18
48
 
19
- # Changelog
20
-
21
49
  ## 0.2.2 (2026-09-03)
22
50
 
23
51
  - Codex: `status` and `init codex --write` now say, every time, that the
package/README.md CHANGED
@@ -1,28 +1,109 @@
1
1
  # @agentguard-run/burn
2
2
 
3
- Local runaway-agent circuit breaker for AI coding agents.
3
+ See where a coding session's tokens went and what the next heavy turn could cost in time and limits. Burn explains recorded usage, warns about large cache rewrites and keeps its existing runaway-session circuit breaker. Everything stays on this machine.
4
4
 
5
- Raw token counters did not help. You see "360k tokens" and keep going. This
6
- tool does the three things a counter does not: it decides whether a session is
7
- pathological, it blocks the next agent spawn when it is, and it tells you what
8
- to do about it. Nothing leaves the machine.
5
+ ## Install in five lines
6
+
7
+ ```sh
8
+ npm i -g @agentguard-run/burn
9
+ agentguard-burn init claude
10
+ agentguard-burn init codex
11
+ agentguard-burn statusline
12
+ agentguard-burn why
13
+ ```
14
+
15
+ The two init commands print hook configuration for review. Merge the relevant snippet into your host configuration. They do not write it. For Codex, review and trust the hook through `/hooks`. Upgrading an existing hook requires the new snippet: it matches every tool so pace can update on ordinary tool calls, while admission still gates spawns.
16
+
17
+ For Claude Code, configure its `statusLine` command as `agentguard-burn statusline`. It receives the host JSON on stdin. Codex 0.154.0 has no external status command slot. Use `agentguard-burn statusline SESSION_ID` in a companion terminal and the native Codex limit indicators. See [host fields and setup](docs/USAGE_AND_PRICING.md#what-the-hosts-expose).
18
+
19
+ ## Why this session
20
+
21
+ ```sh
22
+ agentguard-burn why
23
+ agentguard-burn why SESSION_ID
24
+ agentguard-burn rewrites SESSION_ID
25
+ agentguard-burn rewrites all
26
+ agentguard-burn pace SESSION_ID
27
+ agentguard-burn pricing
28
+ ```
29
+
30
+ A session argument can also be a transcript path. Without one, Burn uses the session environment variable when available, then the most recently modified local transcript. Claude child transcripts are included in `why`. Token shares are shares of recorded tokens, not shares of dollars. Repeated usage records for one provider response count once. Copied Codex history is reconciled before counting new usage.
31
+
32
+ The table covers instruction stack + system, history re-sent, repeated file reads, subagent fan-out, tool output, conversation, full-prefix rewrites, output and unattributed usage. The fixed-prefix baseline is the first assistant response's measured context. It includes the initial user message, so the label is an operational baseline rather than a direct measurement of instruction files. A rewrite classified as prefix change resets that baseline and is counted in the footer; the new baseline can include history already present at that point.
33
+
34
+ Later cache reads up to the baseline go to instruction stack + system. Reads above it go to history re-sent. Fresh input plus cache creation is the measured arriving increment. Burn subtracts the previous response's output before assigning that increment to intervening tool results and user messages. Result byte sizes only split that measured total; bytes are never converted into tokens. A result for a previously read Read path goes to re-read files. Mixed user and tool intervals are marked shared and counted in the footer.
35
+
36
+ Rewrite tokens and child transcript usage keep their own buckets. Prior output deducted from an input increment remains in the unattributed residual so every recorded input and output token is counted exactly once. Missing event evidence also remains there. Integer allocation preserves every token, and the Method footer explains each row.
37
+
38
+ A full-prefix rewrite writes more than 150,000 tokens and more than half the actual input context. Burn reports the observed cache lifetime and surrounding evidence: idle beyond that lifetime, immediate compaction, first subagent response, or residual prefix change. An unknown lifetime stays unknown. Each write is priced at its measured lifetime, or a range when that lifetime is unavailable.
39
+
40
+ Every tool hook updates a local pace file under the Burn home. Pace uses ten wall-clock minutes: cached means cache reads; uncached means fresh input, writes and output. The next-hour projection assumes that pace continues. A time-to-limit estimate requires rising, fresh host percentage samples from the same quota pool and reset window. No token-to-limit conversion or cache weighting is assumed. Without sufficient host data, the line says why the limit is unknown.
41
+
42
+ The new warnings are advisory in both modes. Cache rewrites warn once when their last-hour API list equivalent exceeds $5. A context crossing 500,000 tokens warns about processing time and compaction, estimates a cache rewrite, and suggests a fresh session with a handoff note. These warnings do not deny a tool call.
43
+
44
+ Optional settings in your existing `burn-policy.json`:
45
+
46
+ ```json
47
+ {
48
+ "insights": {
49
+ "rewriteWarnDollarsPerHour": 5,
50
+ "heavyTurnTokens": 500000
51
+ }
52
+ }
53
+ ```
54
+
55
+ Merge these fields into the existing policy. All existing thresholds and modes remain supported. Prices are exact-model API list equivalents, not a subscription bill. Unknown models show tokens only. Every rate has a source and verification date. A local override file and full pricing table are documented in [Usage and pricing](docs/USAGE_AND_PRICING.md).
9
56
 
10
57
  ## Two safety planes
11
58
 
12
- Originally fitted against 412 real Claude Code sessions, then replayed on
13
- 2026-09-15 against 438 sessions. The history shows two independent
14
- failure shapes that one detector cannot cover:
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
15
84
 
16
- | Plane | Catches | Rule | Validated on |
17
- |---|---|---|---|
18
- | 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%) |
19
- | 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 |
85
+ ```sh
86
+ agentguard-burn blocks
87
+ agentguard-burn blocks SESSION_ID
88
+ agentguard-burn blocks all
89
+ ```
20
90
 
21
- The 2026-09-15 replay snapshot: **4 STOP, 3 WARN, 431 clean across 438
22
- sessions and 616 spawns**. Replay hero: **32%** **10.27B** of **32.58B**
23
- observed tokens came after a STOP boundary; API-list scenario **$4,639**.
24
- This is an upper bound assuming no override or restart. Replay does not label
25
- false positives. Active histories can change the next replay result.
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.
26
107
 
27
108
  Cache-read ratio was about 98% in the original calibration, healthy and pathological alike. It
28
109
  is shown as an explanation and never used to decide.
@@ -85,7 +166,7 @@ What each host can actually see is stated, not implied:
85
166
  | Ollama proxy | none | none | authoritative | `prompt_eval_count` + `eval_count` on the final chunk |
86
167
  | vLLM / LM Studio / OpenAI-compatible proxy | none | none | authoritative when the server sends `usage`, else reported missing | non-streaming `usage`, or the final SSE usage event |
87
168
  | Cursor (beta) | authoritative | estimated | none | native `subagentStart` deny; hosted-model usage is never exposed |
88
- | Codex (beta) | authoritative | estimated | estimated | `PreToolUse` on `spawn_agent`; live deny, allow and override canary passed on codex-cli 0.151.0; transcript parsed best-effort |
169
+ | Codex (beta) | authoritative | estimated | estimated | `PreToolUse` on every tool, with admission on `spawn_agent`; live deny, allow and override canary passed on codex-cli 0.151.0; transcript parsed best-effort |
89
170
 
90
171
  An `OK` from a host that cannot see usage is an OK about spawns, and `status`
91
172
  says `usage:n/a` next to it. Missing usage never becomes a guessed zero.
@@ -211,6 +292,11 @@ grabbed the instance it judged, and every write is fenced on the holder's
211
292
  own nonce still being on the path. In 0.2.3, bounded, staggered retries prevent
212
293
  lock waiters from starving the holder. The Sep 15 audit includes one successful
213
294
  240-process run after that fix; it does not establish a 192-run guarantee.
295
+ In 0.2.4, the wait is eight seconds and retirement is serialized before
296
+ renaming an abandoned lock. The fresh 240-process run admitted exactly 40,
297
+ denied 200, signed 240 receipts and had zero lock failures. Operational
298
+ lock-wait failures do not count toward enforce eligibility. See the
299
+ [conservative recovery procedure](docs/USAGE_AND_PRICING.md#reservation-lock-recovery).
214
300
 
215
301
  Single-machine by design. Two laptops on one account do not share state, and
216
302
  that is stated rather than hidden.
@@ -228,5 +314,4 @@ a would-block decision in shadow and blocks new work only in enforce mode.
228
314
  ## What it never does
229
315
 
230
316
  No prompts, responses, file contents, or tool inputs are persisted or rendered.
231
- No telemetry. No provider-quota guesses: it projects against your configured
232
- policy, never against a subscription allowance it cannot see.
317
+ No telemetry. No provider-quota guesses. Account forecasts use only observed host percentages, never an invented allowance or cache weighting.
@@ -33,11 +33,21 @@ const node_crypto_1 = require("node:crypto");
33
33
  const node_fs_1 = require("node:fs");
34
34
  const events_1 = require("../events");
35
35
  const render_1 = require("../replay/render");
36
+ const live_1 = require("../insights/live");
37
+ const pre_tool_use_1 = require("../hook/pre-tool-use");
36
38
  /** Fields this renderer must never emit. Codex rejects the whole object if they appear. */
37
39
  exports.CODEX_FORBIDDEN_FIELDS = ['continue', 'stopReason', 'suppressOutput', 'decision', 'reason'];
38
40
  const HOST = 'codex';
39
41
  const SPAWN_TOOLS = new Set(['Agent', 'spawn_agent', 'Task']);
40
42
  function handleCodexHook(raw, gateway, now = Date.now()) {
43
+ const parsed = parse(raw);
44
+ if (!parsed)
45
+ return {};
46
+ const observation = (0, live_1.observeTool)(gateway.dataDirectory, { session_id: parsed.sessionId, transcript_path: parsed.transcriptPath, tool_name: parsed.toolName }, 'codex', (0, pre_tool_use_1.loadPolicy)(gateway.dataDirectory), now);
47
+ const output = parsed.event === 'PreToolUse' && SPAWN_TOOLS.has(parsed.toolName) ? handleCodexSpawnHook(raw, gateway, now) : {};
48
+ return observation.messages.length ? { ...output, systemMessage: [output.systemMessage, ...observation.messages].filter(Boolean).join('\n') } : output;
49
+ }
50
+ function handleCodexSpawnHook(raw, gateway, now) {
41
51
  const input = parse(raw);
42
52
  if (!input)
43
53
  return {};
@@ -190,7 +200,7 @@ function walk(value, path) {
190
200
  function codexHooksSnippet(command) {
191
201
  return {
192
202
  hooks: {
193
- PreToolUse: [{ matcher: '^(Agent|spawn_agent)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
203
+ PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 15 }] }],
194
204
  },
195
205
  };
196
206
  }
@@ -127,8 +127,8 @@ function cursorHooksSnippet(command) {
127
127
  return {
128
128
  version: 1,
129
129
  hooks: {
130
- subagentStart: [{ command, timeout: 5, failClosed: true }],
131
- subagentStop: [{ command, timeout: 5 }],
130
+ subagentStart: [{ command, timeout: 15, failClosed: true }],
131
+ subagentStop: [{ command, timeout: 15 }],
132
132
  sessionEnd: [{ command, timeout: 3 }],
133
133
  },
134
134
  };
@@ -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
@@ -33,6 +33,12 @@ const server_1 = require("./proxy/server");
33
33
  const render_1 = require("./replay/render");
34
34
  const simulate_1 = require("./replay/simulate");
35
35
  const status_1 = require("./status");
36
+ const eligibility_1 = require("./eligibility");
37
+ const sessions_1 = require("./insights/sessions");
38
+ const transcript_1 = require("./insights/transcript");
39
+ const live_1 = require("./insights/live");
40
+ const render_2 = require("./insights/render");
41
+ const blocks_1 = require("./insights/blocks");
36
42
  const HOME = process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
37
43
  const PROXY_HOSTS = ['ollama', 'vllm', 'lm-studio', 'openai-compatible'];
38
44
  function readStdinJson() {
@@ -66,18 +72,7 @@ function readDecisions() {
66
72
  .filter((x) => x !== null);
67
73
  }
68
74
  function shadowEligibility() {
69
- const decisions = readDecisions();
70
- const first = decisions.length ? Number(decisions[0].at) : Date.now();
71
- const days = (Date.now() - first) / 86_400_000;
72
- const wouldBlock = decisions.filter((d) => d.wouldDeny === true).length;
73
- const warns = decisions.filter((d) => d.verdict === 'WARN').length;
74
- return {
75
- eligible: decisions.length >= defaults_1.SHADOW_MIN_DECISIONS && days >= defaults_1.SHADOW_MIN_DAYS,
76
- decisions: decisions.length,
77
- days,
78
- wouldBlock,
79
- warns,
80
- };
75
+ return (0, eligibility_1.computeShadowEligibility)(readDecisions());
81
76
  }
82
77
  async function main(argv) {
83
78
  const [command, ...rest] = argv;
@@ -87,6 +82,39 @@ async function main(argv) {
87
82
  };
88
83
  const has = (name) => rest.includes(name);
89
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
+ }
91
+ case 'why': {
92
+ const location = (0, sessions_1.selectInsightTranscript)(rest.find(value => value !== 'json'));
93
+ const transcript = (0, transcript_1.readInsightSession)(location.path, { host: location.host, sessionId: location.sessionId });
94
+ process.stdout.write(rest.includes('json') ? JSON.stringify(transcript, null, 2) + '\n' : (0, render_2.renderWhy)(transcript, (0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME))) + '\n');
95
+ return 0;
96
+ }
97
+ case 'rewrites': {
98
+ const session = rest.find(value => value !== 'json');
99
+ const locations = session === 'all' ? (0, sessions_1.discoverInsightTranscripts)() : [(0, sessions_1.selectInsightTranscript)(session)];
100
+ const transcripts = locations.map(location => (0, transcript_1.readInsightTranscript)(location.path, { host: location.host, sessionId: location.sessionId }));
101
+ const rates = (0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME));
102
+ process.stdout.write(rest.includes('json') ? JSON.stringify((0, render_2.rewriteReport)(transcripts, rates), null, 2) + '\n' : (0, render_2.renderRewrites)(transcripts, rates) + '\n');
103
+ return 0;
104
+ }
105
+ case 'pricing': {
106
+ process.stdout.write((0, render_2.renderPricing)((0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME))) + '\n');
107
+ return 0;
108
+ }
109
+ case 'statusline':
110
+ case 'pace': {
111
+ const raw = command === 'statusline' && !process.stdin.isTTY ? readStdinJson() : null;
112
+ const payload = raw && typeof raw === 'object' ? raw : {};
113
+ const location = (0, sessions_1.selectInsightTranscript)(payload.transcript_path || rest[0] || payload.session_id);
114
+ const observation = (0, live_1.observeTool)(HOME, { ...payload, session_id: payload.session_id || location.sessionId, transcript_path: location.path }, location.host, (0, pre_tool_use_1.loadPolicy)(HOME));
115
+ process.stdout.write((observation.snapshot ? (0, live_1.renderStatusLine)(observation.snapshot) : 'AgentGuard pace unavailable: local usage metadata is not readable.') + '\n');
116
+ return observation.snapshot ? 0 : 1;
117
+ }
90
118
  case 'hook': {
91
119
  const raw = (0, node_fs_1.readFileSync)(0, 'utf8');
92
120
  let input = {};
@@ -121,7 +149,8 @@ async function main(argv) {
121
149
  `Calibrated from ${result.sessionsUsed} of ${result.sessionsTotal} sessions (${result.sessionsExcluded} excluded as pathological).`,
122
150
  ` raw p90 ${(result.rawWarnP90 / 1e9).toFixed(2)}B raw p99 ${(result.rawStopP99 / 1e9).toFixed(2)}B`,
123
151
  ` sustained WARN ${(t.sustained.warnTokens / 1e9).toFixed(2)}B STOP ${(t.sustained.stopTokens / 1e9).toFixed(2)}B`,
124
- ` 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'}`,
125
154
  ` burn-debt ${t.burnDebt.enabled ? `ON, baseline ${Math.round(t.burnDebt.baselinePerActiveMinute / 1e6)}M/active-min` : 'off'}`,
126
155
  ...result.notes.map((n) => ` note: ${n}`),
127
156
  `Policy written to ${(0, node_path_1.join)(HOME, 'burn-policy.json')} in SHADOW mode.`,
@@ -136,11 +165,14 @@ async function main(argv) {
136
165
  const override = (0, override_1.readOverride)(HOME);
137
166
  process.stdout.write([
138
167
  `mode: ${policy.mode}${policy.mode === 'enforce' ? ' (the next STOP blocks)' : ' (recording only; nothing is blocked)'}`,
139
- `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)'}`,
140
171
  policy.calibration ? `calibrated from ${policy.calibration.sessionsSampled} sessions` : 'using shipped defaults (run: agentguard-burn calibrate)',
141
172
  `shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
142
173
  ` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
143
174
  `eligible for enforcement: ${e.eligible ? 'yes (agentguard-burn enforce)' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
175
+ `lock-wait failures excluded from eligibility: ${e.excludedLockWaitFailures}`,
144
176
  override ? `override ACTIVE: ${override.once ? 'the next STOP' : `every STOP until ${new Date(override.until).toLocaleTimeString()}`} passes ("${override.reason}")` : '',
145
177
  '',
146
178
  (0, status_1.renderHostHealth)(['claude', 'cursor', 'codex'].map((h) => (0, install_1.health)(h))),
@@ -270,7 +302,13 @@ async function main(argv) {
270
302
  return 0;
271
303
  }
272
304
  default:
273
- process.stdout.write('agentguard-burn <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' +
307
+ ' why [session] token attribution and API list-price equivalents\n' +
308
+ ' rewrites [session|all] full-prefix cache writes by cause\n' +
309
+ ' pace [session] ten-minute pace and next-hour projection\n' +
310
+ ' statusline [session] one-line local status; accepts Claude status JSON\n' +
311
+ ' pricing verified model rates and local overrides\n' +
274
312
  ' replay [files...] [--json] [--top N] [--min-tokens N]\n' +
275
313
  ' calibrate fit thresholds to your history (writes shadow policy)\n' +
276
314
  ' status mode, shadow observations, eligibility, hook health, every host\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 = [
@@ -3,10 +3,13 @@ import type { Policy, Thresholds } from './types';
3
3
  * Shipped thresholds.
4
4
  *
5
5
  * Originally fitted against 412 real Claude Code sessions. Replayed on the
6
- * author's machine on 2026-09-15: 438 sessions, 616 spawns, 32.58B tokens;
7
- * 4 STOP, 3 WARN, 431 clean. The observed post-STOP tail is 10.27B (32%).
8
- * Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
9
- * sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
6
+ * author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
7
+ * 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
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.
10
13
  * This is a dated replay snapshot and an upper bound, not labelled evidence
11
14
  * of false-positive rates or guaranteed savings.
12
15
  *
@@ -5,10 +5,13 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
5
5
  * Shipped thresholds.
6
6
  *
7
7
  * Originally fitted against 412 real Claude Code sessions. Replayed on the
8
- * author's machine on 2026-09-15: 438 sessions, 616 spawns, 32.58B tokens;
9
- * 4 STOP, 3 WARN, 431 clean. The observed post-STOP tail is 10.27B (32%).
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
+ * author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
9
+ * 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
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.
12
15
  * This is a dated replay snapshot and an upper bound, not labelled evidence
13
16
  * of false-positive rates or guaranteed savings.
14
17
  *
@@ -22,7 +25,7 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
22
25
  * absent from these thresholds.
23
26
  */
24
27
  exports.DEFAULT_THRESHOLDS = {
25
- fanout: { warn: 24, stop: 40, maxDepth: 2 },
28
+ fanout: { warn: 24, stop: 40, maxDepth: 2, windowActiveMinutes: 120 },
26
29
  sustained: { warnTokens: 3_500_000_000, stopTokens: 5_000_000_000 },
27
30
  burnDebt: {
28
31
  baselinePerActiveMinute: 0,
@@ -31,7 +34,7 @@ exports.DEFAULT_THRESHOLDS = {
31
34
  stopDebt: 1_500_000_000,
32
35
  enabled: false,
33
36
  },
34
- spawnRate: { windowActiveMinutes: 15, warn: 8, stop: 16, enforce: false },
37
+ spawnRate: { windowActiveMinutes: 15, warn: 8, stop: 16, enforce: true },
35
38
  duplicate: { warnReaders: 3 },
36
39
  account: {
37
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
  /**