@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
@@ -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,11 +28,11 @@ 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
38
  const live_1 = require("../insights/live");
@@ -49,20 +49,31 @@ function inflateHookSession(raw) {
49
49
  surfaceReaders: new Map(raw.state.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
50
50
  };
51
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
+ }
52
63
  function loadSession(home, sessionId, firstEventAt) {
53
64
  try {
54
65
  const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
55
- const cursor = { ...raw.cursor, depthByUuid: new Map(raw.cursor.depthByUuid) };
56
- 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) };
57
68
  }
58
69
  catch {
59
- 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 };
60
71
  }
61
72
  }
62
73
  function saveSession(home, cursor, state, notified, lastReceipt) {
63
74
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
64
75
  const persisted = {
65
- cursor: { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid] },
76
+ cursor: persistCursor(cursor),
66
77
  state: {
67
78
  ...state,
68
79
  tokensByActiveMinute: [...state.tokensByActiveMinute],
@@ -88,32 +99,52 @@ function findingSignature(report) {
88
99
  .sort()
89
100
  .join('|');
90
101
  }
91
- function loadPolicy(home) {
92
- try {
93
- const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
94
- if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds)
95
- return parsed;
96
- }
97
- catch {
98
- /* no policy yet: shadow defaults */
99
- }
100
- return defaults_1.DEFAULT_POLICY;
101
- }
102
+ var policy_2 = require("../policy");
103
+ Object.defineProperty(exports, "loadPolicy", { enumerable: true, get: function () { return policy_2.loadPolicy; } });
102
104
  function recordDecision(home, entry) {
103
105
  (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
104
106
  (0, node_fs_1.appendFileSync)((0, node_path_1.join)(home, 'decisions.ndjson'), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
105
107
  }
106
108
  /** Refresh session state from the transcript. Cheap: only new bytes are read. */
107
109
  function refreshSession(home, sessionId, transcriptPath) {
108
- 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;
109
113
  const before = state.spawnCount;
110
- 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);
111
142
  for (const event of events)
112
143
  (0, session_1.applyEvent)(state, event);
113
144
  if (events.length > 0 && state.startedAt > events[0].at)
114
145
  state.startedAt = events[0].at;
115
146
  saveSession(home, cursor, state, notified, lastReceipt);
116
- return { cursor, state, newSpawns: state.spawnCount - before, notified };
147
+ return { cursor, state, newSpawns: Math.max(0, state.spawnCount - before), notified };
117
148
  }
118
149
  function rememberNotified(home, sessionId, signature) {
119
150
  try {
@@ -128,7 +159,7 @@ function rememberNotified(home, sessionId, signature) {
128
159
  }
129
160
  }
130
161
  function handlePreToolUse(input, home, now = Date.now()) {
131
- const observation = (0, live_1.observeTool)(home, input, 'claude', loadPolicy(home), now);
162
+ const observation = (0, live_1.observeTool)(home, input, 'claude', (0, policy_1.loadPolicy)(home), now);
132
163
  const output = handleSpawnPreToolUse(input, home, now);
133
164
  if (!observation.messages.length)
134
165
  return output;
@@ -139,7 +170,7 @@ function handleSpawnPreToolUse(input, home, now) {
139
170
  if (!SPAWN_TOOLS.has(toolName) || !input.session_id || !input.transcript_path) {
140
171
  return { continue: true, suppressOutput: true };
141
172
  }
142
- const policy = loadPolicy(home);
173
+ const policy = (0, policy_1.loadPolicy)(home);
143
174
  const store = new reservations_1.ReservationStore(home);
144
175
  try {
145
176
  const signer = receipt_1.ReceiptSigner.loadOrCreate(home);
@@ -152,14 +183,7 @@ function handleSpawnPreToolUse(input, home, now) {
152
183
  // Depth of the proposed child: the issuing agent's depth plus one. A hook
153
184
  // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
154
185
  const proposedDepth = input.agent_id ? 2 : 1;
155
- const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(home, state, now), now });
156
- const reservation = tx.reserve({
157
- sessionId: input.session_id,
158
- toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
159
- observedSpawns: state.spawnCount,
160
- ceiling: policy.thresholds.fanout.stop,
161
- now,
162
- });
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 });
163
187
  const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
164
188
  const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
165
189
  // The audited override: only consulted when a block is about to happen.
@@ -227,7 +251,9 @@ function handleSpawnPreToolUse(input, home, now) {
227
251
  function buildDenyReason(report, _reservation) {
228
252
  // Claude Code shows this reason to the user. A box reads as an alarm; a
229
253
  // sentence reads as a log line. Colour is off: the host decides rendering.
230
- return (0, render_1.renderStop)(report, { colour: false });
254
+ // Claude trims leading whitespace before adding Error:. A zero-width word
255
+ // joiner preserves the blank first line, keeping that prefix off the frame.
256
+ return '\u2060\n' + (0, render_1.renderStop)(report, { colour: false });
231
257
  }
232
258
  function deny(reason) {
233
259
  return {
@@ -0,0 +1,61 @@
1
+ import { type DollarRange, type PricingTable } from './pricing';
2
+ import { type TranscriptLocation } from './sessions';
3
+ import type { InsightTranscript } from './types';
4
+ export interface BlockReceipt {
5
+ line: number;
6
+ at: number | null;
7
+ sessionId?: string;
8
+ sessionDigest?: string;
9
+ verdict: 'STOP' | 'WARN';
10
+ detectors: string[];
11
+ spawnNumber: number | null;
12
+ blocked: boolean | null;
13
+ }
14
+ export interface ChildEconomics {
15
+ child: string;
16
+ kind: 'fork' | 'fresh';
17
+ forkContextRef: boolean;
18
+ tokens: number | null;
19
+ usd: DollarRange | null;
20
+ unknownModels: string[];
21
+ }
22
+ export interface ChildStatistics {
23
+ count: number;
24
+ measuredCount: number;
25
+ pricedCount: number;
26
+ medianTokens: number | null;
27
+ maxTokens: number | null;
28
+ medianUsd: DollarRange | null;
29
+ maxUsd: DollarRange | null;
30
+ }
31
+ export interface SessionEconomics {
32
+ available: boolean;
33
+ children: ChildEconomics[];
34
+ all: ChildStatistics;
35
+ forks: ChildStatistics;
36
+ fresh: ChildStatistics;
37
+ notes: string[];
38
+ }
39
+ export interface BlocksReport {
40
+ receipts: Array<BlockReceipt & {
41
+ session: string;
42
+ }>;
43
+ sessions: Array<{
44
+ session: string;
45
+ economics: SessionEconomics;
46
+ }>;
47
+ malformedReceiptLines: number;
48
+ }
49
+ /** Read signed envelopes and earlier flat rows without requiring new fields. */
50
+ export declare function readBlockReceipts(filename: string): {
51
+ receipts: BlockReceipt[];
52
+ malformedLines: number;
53
+ };
54
+ export declare function childStatistics(children: ChildEconomics[]): ChildStatistics;
55
+ /** Use the attribution parser for all hosts; only explicit lineage owns a child. */
56
+ export declare function readSessionEconomics(location: TranscriptLocation, locations: TranscriptLocation[], rates?: PricingTable, cache?: Map<string, InsightTranscript>): SessionEconomics;
57
+ export declare function blocksReport(home: string, session?: string, options?: {
58
+ locations?: TranscriptLocation[];
59
+ rates?: PricingTable;
60
+ }): BlocksReport;
61
+ export declare function renderBlocks(report: BlocksReport): string;
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readBlockReceipts = readBlockReceipts;
4
+ exports.childStatistics = childStatistics;
5
+ exports.readSessionEconomics = readSessionEconomics;
6
+ exports.blocksReport = blocksReport;
7
+ exports.renderBlocks = renderBlocks;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const receipt_1 = require("../receipt");
11
+ const live_1 = require("./live");
12
+ const pricing_1 = require("./pricing");
13
+ const render_1 = require("./render");
14
+ const sessions_1 = require("./sessions");
15
+ const transcript_1 = require("./transcript");
16
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
17
+ const identifier = (value) => typeof value === 'string' && /^[A-Za-z0-9_.:/-]{1,128}$/.test(value) ? value : undefined;
18
+ const count = (value) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
19
+ /** Read signed envelopes and earlier flat rows without requiring new fields. */
20
+ function readBlockReceipts(filename) {
21
+ let text;
22
+ try {
23
+ text = (0, node_fs_1.readFileSync)(filename, 'utf8');
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT')
27
+ return { receipts: [], malformedLines: 0 };
28
+ throw error;
29
+ }
30
+ const receipts = [];
31
+ let malformedLines = 0;
32
+ for (const [index, line] of text.split('\n').entries()) {
33
+ if (!line.trim())
34
+ continue;
35
+ let row;
36
+ try {
37
+ row = record(JSON.parse(line));
38
+ }
39
+ catch {
40
+ malformedLines++;
41
+ continue;
42
+ }
43
+ const payload = Object.keys(record(row.payload)).length ? record(row.payload) : row;
44
+ if (payload.verdict !== 'STOP' && payload.verdict !== 'WARN')
45
+ continue;
46
+ const measured = record(payload.measured);
47
+ const atValue = payload.at ?? payload.timestamp;
48
+ const at = typeof atValue === 'string' ? Date.parse(atValue) : typeof atValue === 'number' ? atValue : NaN;
49
+ const findings = Array.isArray(payload.findings) ? payload.findings.map(item => record(item).detector) : [];
50
+ const reasons = Array.isArray(payload.reasons) ? payload.reasons : [payload.detector, ...findings];
51
+ const detectors = [...new Set(reasons.flatMap(reason => {
52
+ if (typeof reason !== 'string')
53
+ return [];
54
+ const match = /^([a-z][a-z0-9_-]{0,63})(?::(?:WARN|STOP))?$/.exec(reason);
55
+ return match ? [match[1]] : [];
56
+ }))];
57
+ const sessionId = identifier(payload.sessionId ?? payload.session_id);
58
+ const sessionDigest = typeof payload.sessionDigest === 'string' && /^[a-f0-9]{64}$/i.test(payload.sessionDigest) ? payload.sessionDigest.toLowerCase() : undefined;
59
+ receipts.push({ line: index + 1, at: Number.isFinite(at) && Math.abs(at) <= 8.64e15 ? at : null,
60
+ ...(sessionId ? { sessionId } : {}), ...(sessionDigest ? { sessionDigest } : {}),
61
+ verdict: payload.verdict, detectors, spawnNumber: count(measured.sessionSpawns ?? payload.sessionSpawns ?? payload.spawns),
62
+ blocked: typeof payload.blocked === 'boolean' ? payload.blocked : null });
63
+ }
64
+ return { receipts, malformedLines };
65
+ }
66
+ const median = (values) => {
67
+ if (!values.length)
68
+ return null;
69
+ const sorted = [...values].sort((a, b) => a - b), middle = Math.floor(sorted.length / 2);
70
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
71
+ };
72
+ function childStatistics(children) {
73
+ const measured = children.filter(child => child.tokens !== null), priced = children.filter(child => child.usd !== null);
74
+ return { count: children.length, measuredCount: measured.length, pricedCount: priced.length,
75
+ medianTokens: median(measured.map(child => child.tokens)), maxTokens: measured.length ? Math.max(...measured.map(child => child.tokens)) : null,
76
+ // Dollar statistics never silently omit an unpriced child from the cohort.
77
+ medianUsd: children.length && priced.length === children.length ? { min: median(priced.map(child => child.usd.min)), max: median(priced.map(child => child.usd.max)) } : null,
78
+ maxUsd: children.length && priced.length === children.length ? { min: Math.max(...priced.map(child => child.usd.min)), max: Math.max(...priced.map(child => child.usd.max)) } : null };
79
+ }
80
+ const unavailable = (note) => ({ available: false, children: [], all: childStatistics([]), forks: childStatistics([]), fresh: childStatistics([]), notes: [note] });
81
+ const locationId = (location) => location.host === 'codex'
82
+ ? /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.exec(location.sessionId)?.[1] ?? location.sessionId
83
+ : location.sessionId;
84
+ /** Use the attribution parser for all hosts; only explicit lineage owns a child. */
85
+ function readSessionEconomics(location, locations, rates = {}, cache = new Map()) {
86
+ const load = (item, spawnedSession = false) => {
87
+ let parsed = cache.get(item.path);
88
+ if (!parsed) {
89
+ parsed = (0, transcript_1.readInsightTranscript)(item.path, { host: item.host, sessionId: locationId(item), spawnedSession });
90
+ cache.set(item.path, parsed);
91
+ }
92
+ return parsed;
93
+ };
94
+ let parent;
95
+ try {
96
+ parent = load(location);
97
+ }
98
+ catch {
99
+ return unavailable('Session transcript is missing or unreadable; child usage is unavailable.');
100
+ }
101
+ const parentIds = new Set([location.sessionId, locationId(location), parent.state.lineage?.sessionId].filter((value) => Boolean(value)));
102
+ const children = [], notes = [];
103
+ if (location.host === 'claude') {
104
+ const directory = (0, node_path_1.join)(location.path.replace(/\.jsonl$/, ''), 'subagents');
105
+ let names;
106
+ try {
107
+ names = (0, node_fs_1.readdirSync)(directory).filter(name => name.endsWith('.jsonl')).sort();
108
+ }
109
+ catch (error) {
110
+ if (error.code === 'ENOENT') {
111
+ names = [];
112
+ notes.push('No child transcript directory was found. This does not establish that the session spawned no agents.');
113
+ }
114
+ else
115
+ return unavailable('Child transcript directory is unreadable; child usage is unavailable.');
116
+ }
117
+ for (const name of names) {
118
+ try {
119
+ const child = load({ path: (0, node_path_1.join)(directory, name), host: 'claude', sessionId: (0, node_path_1.basename)(name, '.jsonl'), modifiedAt: 0 }, true);
120
+ if (child.state.lineage?.parentSessionId && !parentIds.has(child.state.lineage.parentSessionId)) {
121
+ notes.push('A child file with a different recorded parent was excluded.');
122
+ continue;
123
+ }
124
+ children.push({ id: (0, node_path_1.basename)(name, '.jsonl'), transcript: child });
125
+ }
126
+ catch {
127
+ notes.push('A child transcript could not be read; its usage is unavailable.');
128
+ }
129
+ }
130
+ }
131
+ else {
132
+ for (const item of locations.filter(candidate => candidate.host === 'codex' && candidate.path !== location.path)) {
133
+ try {
134
+ const child = load(item, true);
135
+ if (child.state.lineage?.parentSessionId && parentIds.has(child.state.lineage.parentSessionId))
136
+ children.push({ id: locationId(item), transcript: child });
137
+ }
138
+ catch {
139
+ notes.push('A Codex transcript could not be read; child discovery may be incomplete.');
140
+ }
141
+ }
142
+ notes.push('Codex children require an explicit parent thread id. Inherited usage without a verifiable boundary is unavailable.');
143
+ }
144
+ const parentUsage = new Set(parent.turns.map(turn => turn.id));
145
+ // Shared provider ids collapse both streaming updates and fork history copies.
146
+ const own = (0, transcript_1.deduplicateTurns)(children.flatMap(child => child.transcript.turns).filter(turn => !parentUsage.has(turn.id)));
147
+ const ownedByChild = new Map();
148
+ own.forEach(turn => ownedByChild.set(turn.sessionId, [...ownedByChild.get(turn.sessionId) ?? [], turn]));
149
+ const rows = children.map(({ id, transcript }) => {
150
+ const turns = ownedByChild.get(transcript.sessionId) ?? [];
151
+ const unknownBoundary = transcript.state.forkHistoryBoundaryUnknown === true;
152
+ const prices = turns.map(turn => (0, pricing_1.priceTurn)(turn, rates));
153
+ return { child: id, kind: transcript.state.lineage?.forked ? 'fork' : 'fresh', forkContextRef: transcript.state.lineage?.forkContextRef === true,
154
+ tokens: unknownBoundary || !turns.length ? null : turns.reduce((sum, turn) => sum + (0, render_1.totalTokens)(turn), 0),
155
+ usd: unknownBoundary || !turns.length ? null : (0, render_1.sumPrices)(prices.map(price => price.usd)),
156
+ unknownModels: [...new Set(prices.filter(price => price.usd === null).map(price => price.model ?? 'missing model id'))] };
157
+ });
158
+ if (!rows.length && !notes.length)
159
+ notes.push('No owned child transcripts were found; child economics are unavailable, not evidence of zero spawns.');
160
+ return { available: true, children: rows, all: childStatistics(rows), forks: childStatistics(rows.filter(child => child.kind === 'fork')),
161
+ fresh: childStatistics(rows.filter(child => child.kind === 'fresh')), notes: [...new Set(notes)] };
162
+ }
163
+ function blocksReport(home, session, options = {}) {
164
+ const { receipts, malformedLines } = readBlockReceipts((0, node_path_1.join)(home, 'receipts.ndjson'));
165
+ const locations = options.locations ?? (0, sessions_1.discoverInsightTranscripts)();
166
+ const aliases = new Map(), digests = new Map();
167
+ for (const location of locations)
168
+ for (const alias of new Set([location.sessionId, locationId(location)])) {
169
+ if (!aliases.has(alias))
170
+ aliases.set(alias, location);
171
+ if (!digests.has((0, receipt_1.sha256)(alias)))
172
+ digests.set((0, receipt_1.sha256)(alias), location);
173
+ }
174
+ let selected;
175
+ let requested = session;
176
+ if (session !== 'all') {
177
+ requested = session || process.env.AGENTGUARD_SESSION_ID || process.env.CLAUDE_SESSION_ID || process.env.CODEX_THREAD_ID;
178
+ try {
179
+ selected = (0, sessions_1.selectInsightTranscript)(requested, locations.filter(item => !/[\\/]subagents[\\/]/.test(item.path)));
180
+ }
181
+ catch {
182
+ if (!requested)
183
+ throw new Error('No current session transcript found. Use agentguard-burn blocks all for stored receipts.');
184
+ }
185
+ if (selected) {
186
+ aliases.set(selected.sessionId, selected);
187
+ digests.set((0, receipt_1.sha256)(selected.sessionId), selected);
188
+ }
189
+ }
190
+ const selectedIds = new Set([requested, selected?.sessionId, selected && locationId(selected)].filter((value) => Boolean(value)));
191
+ const selectedDigests = new Set([...selectedIds].map(receipt_1.sha256));
192
+ const sessions = new Map();
193
+ if (selected)
194
+ sessions.set(locationId(selected), selected);
195
+ const rows = receipts.filter(row => session === 'all' || (row.sessionId && selectedIds.has(row.sessionId)) || (row.sessionDigest && selectedDigests.has(row.sessionDigest))).map(row => {
196
+ const location = row.sessionId ? aliases.get(row.sessionId) : row.sessionDigest ? digests.get(row.sessionDigest) : undefined;
197
+ const id = location ? locationId(location) : row.sessionId ?? (row.sessionDigest ? `sha256:${row.sessionDigest}` : `unknown-row-${row.line}`);
198
+ sessions.set(id, location);
199
+ return { ...row, session: id };
200
+ });
201
+ const cache = new Map();
202
+ return { receipts: rows, sessions: [...sessions].map(([id, location]) => ({ session: id,
203
+ economics: location ? readSessionEconomics(location, locations, options.rates, cache) : unavailable('No matching local transcript; child usage is unavailable.') })), malformedReceiptLines: malformedLines };
204
+ }
205
+ function table(headers, rows) {
206
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index]?.length ?? 0)));
207
+ return [headers, ...rows].map(row => row.map((cell, index) => cell.padEnd(widths[index])).join(' ').trimEnd()).join('\n');
208
+ }
209
+ const shortSession = (session) => session.startsWith('sha256:') ? session.slice(0, 15) : session.slice(0, 8);
210
+ const number = (value) => value === null ? 'unavailable' : value.toLocaleString('en-US');
211
+ function renderBlocks(report) {
212
+ const sessions = new Map(report.sessions.map(session => [session.session, session.economics]));
213
+ const compact = (item) => !item?.count ? 'unavailable' :
214
+ `${item.count}${item.measuredCount !== item.count ? ` (${item.measuredCount} measured)` : ''}; ${number(item.medianTokens)}/${number(item.maxTokens)}; ${(0, live_1.dollars)(item.medianUsd)}/${(0, live_1.dollars)(item.maxUsd)}`;
215
+ const lines = ['AgentGuard blocks: stored WARN and STOP decisions',
216
+ 'Each child cell: transcript count; median/max measured tokens; median/max list USD.',
217
+ table(['Time (UTC)', 'Session', 'Verdict', 'Detector', 'Spawn', 'Blocked', 'All children', 'Fork children', 'Fresh children'], report.receipts.map(row => {
218
+ const economics = sessions.get(row.session);
219
+ return [row.at === null ? 'unknown' : new Date(row.at).toISOString(), shortSession(row.session), row.verdict, row.detectors.join(',') || 'unknown',
220
+ row.spawnNumber === null ? 'unknown' : String(row.spawnNumber), row.blocked === null ? 'unknown' : row.blocked ? 'yes' : 'no',
221
+ compact(economics?.all), compact(economics?.forks), compact(economics?.fresh)];
222
+ }))];
223
+ if (!report.receipts.length)
224
+ lines.push('No stored WARN or STOP receipts match this selection.');
225
+ for (const { session, economics } of report.sessions) {
226
+ lines.push('', `Session ${shortSession(session)}: its own child transcripts`);
227
+ if (economics.available)
228
+ lines.push(table(['Children', 'Count', 'Measured', 'Median tokens', 'Max tokens', 'Median list USD', 'Max list USD'], [['All', economics.all], ['Forks', economics.forks], ['Fresh', economics.fresh]].map(([label, stats]) => {
229
+ const item = stats;
230
+ return [String(label), item.count ? String(item.count) : '0 found', String(item.measuredCount), number(item.medianTokens), number(item.maxTokens), (0, live_1.dollars)(item.medianUsd), (0, live_1.dollars)(item.maxUsd)];
231
+ })));
232
+ lines.push(...economics.notes);
233
+ if (economics.children.some(child => child.tokens === null))
234
+ lines.push('Some children have no attributable usage or an unknown inherited-history boundary; their token statistics are unavailable.');
235
+ const unpriced = [...new Set(economics.children.flatMap(child => child.unknownModels))];
236
+ if (unpriced.length)
237
+ lines.push(`Tokens only for unpriced models: ${unpriced.join(', ')}. Dollar statistics require every child in the cohort to have a verified rate.`);
238
+ }
239
+ if (report.malformedReceiptLines)
240
+ lines.push(`${report.malformedReceiptLines} malformed receipt lines could not be read.`);
241
+ lines.push('', 'Measured usage is deduplicated by provider response id, including message.id; inherited parent usage is excluded.', 'Claude forks are marked fork-context-ref. Codex forks require host lineage metadata; other discovered children are fresh.', 'Child totals cover the full available transcript, not a prediction at the decision time. Token and dollar medians and maxima are computed separately.', 'Dollars are API list-price equivalents, not your bill. Missing cache TTL produces a price range. Unknown models have tokens only.');
242
+ return lines.join('\n');
243
+ }
@@ -1,4 +1,6 @@
1
- import type { InsightParseOptions, InsightTranscript, InsightTurn } from './types';
1
+ import type { InsightHost, InsightParseOptions, InsightTranscript, InsightTurn } from './types';
2
+ /** Provider response identity is shared by reporting and enforcement readers. */
3
+ export declare const providerResponseIdentity: (host: InsightHost, id: unknown) => string;
2
4
  /**
3
5
  * Parse complete JSONL records. Pass returned state when parsing appended lines.
4
6
  * `updatedTurns` replaces rows with the same id; it must never be blindly added