@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
@@ -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.');
@@ -0,0 +1,17 @@
1
+ export interface ShadowEligibility {
2
+ eligible: boolean;
3
+ decisions: number;
4
+ days: number;
5
+ wouldBlock: number;
6
+ warns: number;
7
+ excludedLockWaitFailures: number;
8
+ }
9
+ /** Existing lock-failure ledger rows remain readable without a schema change. */
10
+ export declare function isLockWaitFailure(row: Readonly<Record<string, unknown>>): boolean;
11
+ /**
12
+ * A reservation-lock timeout is an operational failure, not an observation of
13
+ * a threshold. Keep it in the audit ledger but do not let a burst of timeouts
14
+ * satisfy the observation count or start the enforcement waiting period.
15
+ * Older decision rows need no new fields and retain their existing behavior.
16
+ */
17
+ export declare function computeShadowEligibility(rows: ReadonlyArray<Readonly<Record<string, unknown>>>, now?: number): ShadowEligibility;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isLockWaitFailure = isLockWaitFailure;
4
+ exports.computeShadowEligibility = computeShadowEligibility;
5
+ const defaults_1 = require("./defaults");
6
+ /** Existing lock-failure ledger rows remain readable without a schema change. */
7
+ function isLockWaitFailure(row) {
8
+ return row.failClosed === true && typeof row.reason === 'string'
9
+ && /\bcould not acquire (?:the )?reservation lock\b/i.test(row.reason);
10
+ }
11
+ /**
12
+ * A reservation-lock timeout is an operational failure, not an observation of
13
+ * a threshold. Keep it in the audit ledger but do not let a burst of timeouts
14
+ * satisfy the observation count or start the enforcement waiting period.
15
+ * Older decision rows need no new fields and retain their existing behavior.
16
+ */
17
+ function computeShadowEligibility(rows, now = Date.now()) {
18
+ const decisions = rows.filter(row => !isLockWaitFailure(row));
19
+ const first = decisions.length ? Number(decisions[0].at) : now;
20
+ const days = (now - first) / 86_400_000;
21
+ return {
22
+ eligible: decisions.length >= defaults_1.SHADOW_MIN_DECISIONS && days >= defaults_1.SHADOW_MIN_DAYS,
23
+ decisions: decisions.length,
24
+ days,
25
+ wouldBlock: decisions.filter(row => row.wouldDeny === true).length,
26
+ warns: decisions.filter(row => row.verdict === 'WARN').length,
27
+ excludedLockWaitFailures: rows.length - decisions.length,
28
+ };
29
+ }
@@ -80,6 +80,8 @@ export interface GatewayOptions {
80
80
  }
81
81
  export declare class Gateway {
82
82
  private readonly home;
83
+ /** Shared local storage for optional content-free usage observations. */
84
+ get dataDirectory(): string;
83
85
  private readonly store;
84
86
  private readonly signer;
85
87
  private readonly now;
@@ -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;
@@ -50,6 +51,8 @@ function safeName(sessionId) {
50
51
  }
51
52
  class Gateway {
52
53
  home;
54
+ /** Shared local storage for optional content-free usage observations. */
55
+ get dataDirectory() { return this.home; }
53
56
  store;
54
57
  signer;
55
58
  now;
@@ -215,14 +218,7 @@ class Gateway {
215
218
  }
216
219
  const live = new Map(meta.liveSpawns);
217
220
  const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
218
- const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
219
- const reservation = tx.reserve({
220
- sessionId: event.sessionId,
221
- toolUseId: event.spawnId,
222
- observedSpawns: state.spawnCount,
223
- ceiling: policy.thresholds.fanout.stop,
224
- now: event.at,
225
- });
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 });
226
222
  const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
227
223
  const verdict = wouldBlock ? 'STOP' : report.verdict;
228
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;
@@ -16,14 +16,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.newCursor = newCursor;
17
17
  exports.normaliseLine = normaliseLine;
18
18
  exports.readIncremental = readIncremental;
19
+ exports.readSessionIncremental = readSessionIncremental;
19
20
  exports.readAll = readAll;
20
21
  const node_fs_1 = require("node:fs");
22
+ const node_path_1 = require("node:path");
23
+ const transcript_1 = require("../insights/transcript");
21
24
  const CHUNK = 64 * 1024;
22
25
  const MAX_LINE = 4 * 1024 * 1024;
23
26
  const SPAWN_TOOLS = new Set(['Agent', 'Task']);
24
27
  const SURFACE_TOOLS = new Set(['Read', 'Grep', 'Glob']);
25
28
  function newCursor() {
26
- return { offset: 0, size: 0, malformedLines: 0, depthByUuid: new Map() };
29
+ return { offset: 0, size: 0, malformedLines: 0, depthByUuid: new Map(), usageVersion: 1, usageByMessage: new Map() };
27
30
  }
28
31
  function toMillis(value) {
29
32
  if (typeof value !== 'string' || value.length === 0)
@@ -40,11 +43,22 @@ function normaliseLine(raw, cursor) {
40
43
  if (at === null)
41
44
  return null;
42
45
  const usage = raw.message?.usage ?? {};
43
- const cacheRead = nonNegative(usage.cache_read_input_tokens);
44
- const tokens = nonNegative(usage.input_tokens) +
45
- nonNegative(usage.output_tokens) +
46
- nonNegative(usage.cache_creation_input_tokens) +
47
- cacheRead;
46
+ const snapshot = [nonNegative(usage.input_tokens), nonNegative(usage.output_tokens),
47
+ nonNegative(usage.cache_creation_input_tokens), nonNegative(usage.cache_read_input_tokens)];
48
+ let tokens = snapshot.reduce((sum, value) => sum + value, 0);
49
+ let cacheRead = snapshot[3];
50
+ // Claude writes one row per content block, all carrying the same message.id.
51
+ // Fork copies preserve that id too. Match the attribution parser's global
52
+ // identity and per-category maxima, emitting only newly observed usage.
53
+ if (raw.message?.id !== undefined && tokens > 0) {
54
+ const id = (0, transcript_1.providerResponseIdentity)('claude', raw.message.id);
55
+ cursor.usageByMessage ??= new Map();
56
+ const previous = cursor.usageByMessage.get(id) ?? [0, 0, 0, 0];
57
+ const merged = snapshot.map((value, index) => Math.max(value, previous[index]));
58
+ tokens = merged.reduce((sum, value, index) => sum + value - previous[index], 0);
59
+ cacheRead = merged[3] - previous[3];
60
+ cursor.usageByMessage.set(id, merged);
61
+ }
48
62
  // Depth attribution: a line's depth is its parent's depth, plus one if the
49
63
  // host flagged it as a sidechain (subagent) line. Unknown parents are root.
50
64
  const parentDepth = raw.parentUuid ? cursor.depthByUuid.get(raw.parentUuid) ?? 0 : 0;
@@ -91,17 +105,14 @@ function normaliseLine(raw, cursor) {
91
105
  parentUuid: raw.parentUuid,
92
106
  };
93
107
  }
94
- /**
95
- * Read every complete line appended since the cursor. Returns the new events
96
- * and advances the cursor. O(new bytes), never O(file size) after the first
97
- * read.
98
- */
99
- function readIncremental(path, cursor) {
108
+ function readIncremental(path, cursor, options = {}) {
100
109
  let fd;
101
110
  try {
102
111
  fd = (0, node_fs_1.openSync)(path, 'r');
103
112
  }
104
- catch {
113
+ catch (error) {
114
+ if (options.throwOnOpenError)
115
+ throw error;
105
116
  return [];
106
117
  }
107
118
  try {
@@ -167,9 +178,39 @@ function readIncremental(path, cursor) {
167
178
  (0, node_fs_1.closeSync)(fd);
168
179
  }
169
180
  }
170
- /** Convenience for replay and tests: read a whole transcript from the start. */
181
+ /**
182
+ * Fold the parent's tool events and its stored children's usage into one
183
+ * session. Child histories can contain copied parent tool calls, so only their
184
+ * usage deltas contribute here. The parent's observed spawn count stays intact.
185
+ */
186
+ function readSessionIncremental(path, cursor, options = {}) {
187
+ cursor.usageByMessage ??= new Map();
188
+ cursor.children ??= new Map();
189
+ const events = readIncremental(path, cursor, options);
190
+ const directory = (0, node_path_1.join)(path.replace(/\.jsonl$/, ''), 'subagents');
191
+ let names;
192
+ try {
193
+ names = (0, node_fs_1.readdirSync)(directory).filter(name => name.endsWith('.jsonl')).sort();
194
+ }
195
+ catch (error) {
196
+ if (error.code === 'ENOENT')
197
+ return events;
198
+ throw error;
199
+ }
200
+ for (const name of names) {
201
+ const child = cursor.children.get(name) ?? newCursor();
202
+ child.usageByMessage = cursor.usageByMessage;
203
+ cursor.children.set(name, child);
204
+ for (const event of readIncremental((0, node_path_1.join)(directory, name), child, options)) {
205
+ if (event.tokens > 0 || event.cacheRead > 0)
206
+ events.push({ ...event, spawns: [], surfaces: [], sidechain: true });
207
+ }
208
+ }
209
+ return events.sort((a, b) => a.at - b.at);
210
+ }
211
+ /** Convenience for replay and tests: read a session and its own stored children. */
171
212
  function readAll(path) {
172
213
  const cursor = newCursor();
173
- const events = readIncremental(path, cursor);
214
+ const events = readSessionIncremental(path, cursor);
174
215
  return { events, cursor };
175
216
  }
@@ -14,8 +14,8 @@
14
14
  * cannot fail closed if Claude Code times the hook out, so the hot path does
15
15
  * as little as possible.
16
16
  */
17
- import { type ReaderCursor } from '../history/claude-transcript';
18
- import type { BurnReport, Policy, SessionState } from '../types';
17
+ import { type ReaderCursor, type UsageSnapshot } from '../history/claude-transcript';
18
+ import type { BurnReport, SessionState } from '../types';
19
19
  export interface HookInput {
20
20
  session_id?: string;
21
21
  transcript_path?: string;
@@ -35,13 +35,17 @@ export interface HookOutput {
35
35
  permissionDecisionReason: string;
36
36
  };
37
37
  }
38
+ export interface PersistedReaderCursor {
39
+ offset: number;
40
+ size: number;
41
+ malformedLines: number;
42
+ depthByUuid: [string, number][];
43
+ usageVersion?: 1;
44
+ usageByMessage?: [string, UsageSnapshot][];
45
+ children?: [string, PersistedReaderCursor][];
46
+ }
38
47
  export interface PersistedSession {
39
- cursor: {
40
- offset: number;
41
- size: number;
42
- malformedLines: number;
43
- depthByUuid: [string, number][];
44
- };
48
+ cursor: PersistedReaderCursor;
45
49
  state: Omit<SessionState, 'tokensByActiveMinute' | 'spawnsByActiveMinute' | 'surfaceReaders'> & {
46
50
  tokensByActiveMinute: [number, number][];
47
51
  spawnsByActiveMinute: [number, number][];
@@ -60,7 +64,7 @@ export declare function inflateHookSession(raw: PersistedSession): SessionState;
60
64
  * and the one that matters is the nineteenth.
61
65
  */
62
66
  export declare function findingSignature(report: BurnReport): string;
63
- export declare function loadPolicy(home: string): Policy;
67
+ export { loadPolicy } from '../policy';
64
68
  /** Refresh session state from the transcript. Cheap: only new bytes are read. */
65
69
  export declare function refreshSession(home: string, sessionId: string, transcriptPath: string): {
66
70
  cursor: ReaderCursor;
@@ -16,9 +16,9 @@
16
16
  * as little as possible.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.loadPolicy = void 0;
19
20
  exports.inflateHookSession = inflateHookSession;
20
21
  exports.findingSignature = findingSignature;
21
- exports.loadPolicy = loadPolicy;
22
22
  exports.refreshSession = refreshSession;
23
23
  exports.handlePreToolUse = handlePreToolUse;
24
24
  exports.settingsSnippet = settingsSnippet;
@@ -28,13 +28,14 @@ const receipt_1 = require("../receipt");
28
28
  const account_1 = require("../state/account");
29
29
  const node_fs_1 = require("node:fs");
30
30
  const node_path_1 = require("node:path");
31
- const evaluate_1 = require("../detectors/evaluate");
31
+ const spawn_window_1 = require("../state/spawn-window");
32
32
  const claude_transcript_1 = require("../history/claude-transcript");
33
33
  const reservations_1 = require("../state/reservations");
34
34
  const session_1 = require("../state/session");
35
- const defaults_1 = require("../defaults");
35
+ const policy_1 = require("../policy");
36
36
  const override_1 = require("../override");
37
37
  const render_1 = require("../replay/render");
38
+ const live_1 = require("../insights/live");
38
39
  const SPAWN_TOOLS = new Set(['Agent', 'Task']);
39
40
  function sessionFile(home, sessionId) {
40
41
  return (0, node_path_1.join)(home, 'sessions', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
@@ -48,20 +49,31 @@ function inflateHookSession(raw) {
48
49
  surfaceReaders: new Map(raw.state.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
49
50
  };
50
51
  }
52
+ function inflateCursor(raw, shared) {
53
+ const usageByMessage = shared ?? new Map(raw.usageByMessage ?? []);
54
+ return { ...raw, depthByUuid: new Map(raw.depthByUuid), usageByMessage,
55
+ children: new Map((raw.children ?? []).map(([name, child]) => [name, inflateCursor(child, usageByMessage)])) };
56
+ }
57
+ function persistCursor(cursor, child = false) {
58
+ return { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid],
59
+ usageVersion: cursor.usageVersion,
60
+ ...(!child ? { usageByMessage: [...(cursor.usageByMessage ?? [])] } : {}),
61
+ ...(cursor.children?.size ? { children: [...cursor.children].map(([name, value]) => [name, persistCursor(value, true)]) } : {}) };
62
+ }
51
63
  function loadSession(home, sessionId, firstEventAt) {
52
64
  try {
53
65
  const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
54
- const cursor = { ...raw.cursor, depthByUuid: new Map(raw.cursor.depthByUuid) };
55
- return { cursor, state: inflateHookSession(raw), notified: raw.notified ?? '', lastReceipt: raw.lastReceipt ?? null };
66
+ return { cursor: inflateCursor(raw.cursor), state: inflateHookSession(raw), notified: raw.notified ?? '', lastReceipt: raw.lastReceipt ?? null,
67
+ fresh: false, needsRebuild: raw.cursor.usageVersion !== 1 || !Array.isArray(raw.cursor.usageByMessage) };
56
68
  }
57
69
  catch {
58
- return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '', lastReceipt: null };
70
+ return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '', lastReceipt: null, fresh: true, needsRebuild: false };
59
71
  }
60
72
  }
61
73
  function saveSession(home, cursor, state, notified, lastReceipt) {
62
74
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
63
75
  const persisted = {
64
- cursor: { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid] },
76
+ cursor: persistCursor(cursor),
65
77
  state: {
66
78
  ...state,
67
79
  tokensByActiveMinute: [...state.tokensByActiveMinute],
@@ -87,32 +99,52 @@ function findingSignature(report) {
87
99
  .sort()
88
100
  .join('|');
89
101
  }
90
- function loadPolicy(home) {
91
- try {
92
- const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
93
- if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds)
94
- return parsed;
95
- }
96
- catch {
97
- /* no policy yet: shadow defaults */
98
- }
99
- return defaults_1.DEFAULT_POLICY;
100
- }
102
+ var policy_2 = require("../policy");
103
+ Object.defineProperty(exports, "loadPolicy", { enumerable: true, get: function () { return policy_2.loadPolicy; } });
101
104
  function recordDecision(home, entry) {
102
105
  (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
103
106
  (0, node_fs_1.appendFileSync)((0, node_path_1.join)(home, 'decisions.ndjson'), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
104
107
  }
105
108
  /** Refresh session state from the transcript. Cheap: only new bytes are read. */
106
109
  function refreshSession(home, sessionId, transcriptPath) {
107
- const { cursor, state, notified, lastReceipt } = loadSession(home, sessionId, Date.now());
110
+ const loaded = loadSession(home, sessionId, Date.now());
111
+ let { cursor, state, fresh } = loaded;
112
+ const { notified, lastReceipt } = loaded;
108
113
  const before = state.spawnCount;
109
- const events = (0, claude_transcript_1.readIncremental)(transcriptPath, cursor);
114
+ // Old offsets cannot tell us which repeated rows inflated their totals.
115
+ // Rebuild once from locally recorded evidence, retaining the signed chain
116
+ // head and notification state. Never replace old counts with an empty read
117
+ // when the transcript has disappeared or cannot be opened.
118
+ if (loaded.needsRebuild) {
119
+ try {
120
+ (0, node_fs_1.accessSync)(transcriptPath, node_fs_1.constants.R_OK);
121
+ }
122
+ catch {
123
+ return { cursor, state, newSpawns: 0, notified };
124
+ }
125
+ cursor = (0, claude_transcript_1.newCursor)();
126
+ state = (0, session_1.newSessionState)(sessionId, Date.now());
127
+ fresh = true;
128
+ }
129
+ let events;
130
+ try {
131
+ events = (0, claude_transcript_1.readSessionIncremental)(transcriptPath, cursor, { throwOnOpenError: loaded.needsRebuild });
132
+ }
133
+ catch (error) {
134
+ if (!loaded.needsRebuild)
135
+ throw error;
136
+ // Opening can fail after the access check, including on a stored child.
137
+ // Retain the old cursor so the next refresh retries the complete rebuild.
138
+ return { cursor: loaded.cursor, state: loaded.state, newSpawns: 0, notified };
139
+ }
140
+ if (fresh && events.length > 0)
141
+ state = (0, session_1.newSessionState)(sessionId, events[0].at);
110
142
  for (const event of events)
111
143
  (0, session_1.applyEvent)(state, event);
112
144
  if (events.length > 0 && state.startedAt > events[0].at)
113
145
  state.startedAt = events[0].at;
114
146
  saveSession(home, cursor, state, notified, lastReceipt);
115
- return { cursor, state, newSpawns: state.spawnCount - before, notified };
147
+ return { cursor, state, newSpawns: Math.max(0, state.spawnCount - before), notified };
116
148
  }
117
149
  function rememberNotified(home, sessionId, signature) {
118
150
  try {
@@ -127,11 +159,18 @@ function rememberNotified(home, sessionId, signature) {
127
159
  }
128
160
  }
129
161
  function handlePreToolUse(input, home, now = Date.now()) {
162
+ const observation = (0, live_1.observeTool)(home, input, 'claude', (0, policy_1.loadPolicy)(home), now);
163
+ const output = handleSpawnPreToolUse(input, home, now);
164
+ if (!observation.messages.length)
165
+ return output;
166
+ return { ...output, suppressOutput: false, systemMessage: [output.systemMessage, ...observation.messages].filter(Boolean).join('\n') };
167
+ }
168
+ function handleSpawnPreToolUse(input, home, now) {
130
169
  const toolName = input.tool_name ?? '';
131
170
  if (!SPAWN_TOOLS.has(toolName) || !input.session_id || !input.transcript_path) {
132
171
  return { continue: true, suppressOutput: true };
133
172
  }
134
- const policy = loadPolicy(home);
173
+ const policy = (0, policy_1.loadPolicy)(home);
135
174
  const store = new reservations_1.ReservationStore(home);
136
175
  try {
137
176
  const signer = receipt_1.ReceiptSigner.loadOrCreate(home);
@@ -144,14 +183,7 @@ function handlePreToolUse(input, home, now = Date.now()) {
144
183
  // Depth of the proposed child: the issuing agent's depth plus one. A hook
145
184
  // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
146
185
  const proposedDepth = input.agent_id ? 2 : 1;
147
- const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(home, state, now), now });
148
- const reservation = tx.reserve({
149
- sessionId: input.session_id,
150
- toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
151
- observedSpawns: state.spawnCount,
152
- ceiling: policy.thresholds.fanout.stop,
153
- now,
154
- });
186
+ const { report, reservation } = (0, spawn_window_1.evaluateSpawnReservation)(tx, state, policy.thresholds, proposedDepth, input.tool_use_id ?? `${input.session_id}:${now}`, now, { sessions: (0, account_1.readAccountSessions)(home, state, now), now });
155
187
  const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
156
188
  const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
157
189
  // The audited override: only consulted when a block is about to happen.
@@ -231,7 +263,7 @@ function deny(reason) {
231
263
  function settingsSnippet(command) {
232
264
  return {
233
265
  hooks: {
234
- PreToolUse: [{ matcher: '^(Agent|Task)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
266
+ PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 15 }] }],
235
267
  },
236
268
  };
237
269
  }
@@ -0,0 +1,4 @@
1
+ import { type AttributionSummary, type InsightTurn, type RewriteEvidence } from './types';
2
+ export declare function classifyRewrite(turn: InsightTurn): RewriteEvidence | null;
3
+ /** Partition measured usage; byte lengths only divide a measured increment among its recorded events. */
4
+ export declare function attributeTurns(turns: InsightTurn[]): AttributionSummary;