agent-polygraph 0.0.0-stage → 0.2.0

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.
package/src/analyze.js ADDED
@@ -0,0 +1,621 @@
1
+ import os from "node:os";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ isTestCommand,
6
+ mayWriteFiles,
7
+ isFullSuite,
8
+ isTestLogRead,
9
+ outcomeFromOutput,
10
+ testOutcome,
11
+ findTestClaim,
12
+ isTestPath,
13
+ isCodePath,
14
+ weakening,
15
+ assertionLines,
16
+ riskyIn,
17
+ isCorrection,
18
+ splitCommands,
19
+ shellWrites,
20
+ ECHOES,
21
+ passedCount,
22
+ claimedCounts,
23
+ claimedTestFiles,
24
+ expandVars,
25
+ findCiClaim,
26
+ claimsFullSuite,
27
+ } from "./classify.js";
28
+
29
+ export const VERDICTS = ["backed", "no-run", "stale", "failed", "unknown"];
30
+
31
+ /**
32
+ * Walk one session's events in order and collect findings.
33
+ *
34
+ * Freshness is decided by event order, not timestamps (many events share one). A test
35
+ * counts as evidence only once its `result` event has been observed, and it covers the
36
+ * code as it was when the test *started*: an edit after the start makes it stale.
37
+ */
38
+ export function analyzeSession(session) {
39
+ const s = {
40
+ agent: session.agent,
41
+ id: session.id,
42
+ file: session.file,
43
+ cwd: session.cwd,
44
+ model: session.models[0] || "unknown",
45
+ versions: session.versions || [],
46
+ start: session.start,
47
+ end: session.end,
48
+ claims: Object.fromEntries(VERDICTS.map((v) => [v, 0])),
49
+ testRuns: 0,
50
+ failingRuns: 0,
51
+ testMs: 0,
52
+ fullSuiteRuns: 0,
53
+ fullSuiteMs: 0,
54
+ commands: 0,
55
+ edits: 0,
56
+ userMessages: 0,
57
+ corrections: 0,
58
+ interrupts: 0,
59
+ risky: {},
60
+ unclear: {}, // reason -> count, for claims we couldn't judge
61
+ evidence: {}, // outside evidence used: { recorded, terminal, ci, snapshot }
62
+ findings: [],
63
+ };
64
+
65
+ let lastCodeEdit = null; // { i, path }
66
+ let shellCwd = session.cwd; // Claude Code's persistent shell directory
67
+ // $HOME only when the session ran on this machine's account
68
+ const home = os.homedir();
69
+ const homeEnv = session.cwd && norm(session.cwd).startsWith(norm(home)) ? { HOME: home } : {};
70
+ const codeEdits = []; // { i, path, abs }: abs is the resolved location, when known
71
+ let lastUncertain = -1; // index of the last change we couldn't pin down
72
+ let lastSnap = null; // { i, tree }: the latest working-tree snapshot from `record`
73
+ let lastTest = null; // evidence: { i (start index), ts, outcome, cmd, tail, sub }
74
+ const runs = new Map(); // start index -> every observed test run, for claims about a subset
75
+ let pendingTest = -1; // index of the latest test start still waiting for its result
76
+ let sinceFail = null; // edits made since a failing test run
77
+ let lastFailedCmd = null;
78
+ let repeat = 0;
79
+ let editsSinceLastCmd = 0;
80
+ const started = new Map(); // cmd event -> { i, test, editsBefore, counted }
81
+ const logs = new Map(); // log file basename -> cmd event of the test run writing it
82
+ const created = new Set(); // files the agent created this session
83
+ const touchedTests = new Set(); // test files the agent has edited so far
84
+
85
+ const rel = (p) => relPath(p, session.cwd);
86
+ const finding = (type, ev, detail) => s.findings.push({ type, ts: ev.ts, detail, agent: s.agent, file: s.file, cwd: s.cwd });
87
+
88
+ const absOf = (p, dir) => {
89
+ if (!p) return null;
90
+ p = norm(p);
91
+ if (isAbs(p)) return p;
92
+ const base = norm(dir || session.cwd);
93
+ return base ? path.posix.join(base, p) : null;
94
+ };
95
+ const codeEdit = (i, p, ev, abs) => {
96
+ const test = isTestPath(p);
97
+ // any non-test change (templates, styles, config) counts as working on the code
98
+ if (sinceFail) test ? sinceFail.testEdits.add(p) : sinceFail.codeEdits++;
99
+ if (test) touchedTests.add(p);
100
+ if (!isCodePath(p)) return;
101
+ s.edits++;
102
+ editsSinceLastCmd++;
103
+ lastCodeEdit = { i, path: p };
104
+ codeEdits.push({ i, path: p, abs });
105
+ if (!ev) return;
106
+ const fresh = ev.op === "add" || created.has(p);
107
+ if (ev.op === "add") created.add(p);
108
+ // only tests that existed before the agent touched them can be weakened
109
+ if (test && !fresh) {
110
+ const reasons = ev.op === "delete" ? ["deleted the test file"] : ev.removed ? weakening(ev.removed, ev.added) : [];
111
+ if (reasons.length) finding("weakened-test", ev, { path: p, reasons, removed: sample(assertionLines(ev.removed).length ? assertionLines(ev.removed) : ev.removed) });
112
+ }
113
+ };
114
+
115
+ /** What a command's result tells us: test outcome, retry loops, test-only fixes. */
116
+ const observe = (ev) => {
117
+ const c = ev.of;
118
+ const st = started.get(c);
119
+ if (!st) return;
120
+ const cmd = c.cmd;
121
+ if (ev.denied) {
122
+ // it never ran: not a test run, not evidence
123
+ if (st.test && !c.external) {
124
+ s.testRuns--;
125
+ if (isFullSuite(cmd)) s.fullSuiteRuns--;
126
+ }
127
+ if (pendingTest === st.i) pendingTest = -1;
128
+ started.delete(c);
129
+ return;
130
+ }
131
+ const reset = /Shell cwd was reset to (\S+)/.exec(ev.output || "");
132
+ if (reset) shellCwd = reset[1];
133
+ // a program that prints a test-runner summary is a test run, whatever it's called
134
+ // (`./build/frost_subsystems.exe` -> "135 passed, 0 failed"), unless it only prints
135
+ // saved text (cat, git show) or reads a log
136
+ if (!st.test && !ev.partial && !isLogRead(cmd) && !ECHOES.test(cmd) && outcomeFromOutput(ev.output)) {
137
+ st.test = true;
138
+ s.testRuns++;
139
+ }
140
+
141
+ if (!ev.partial && !c.external) {
142
+ const failed = ev.exitCode != null ? ev.exitCode !== 0 : ev.isError === true;
143
+ const key = cmd.trim().replace(/\s+/g, " ");
144
+ if (failed && key === lastFailedCmd && st.editsBefore === 0) {
145
+ repeat++;
146
+ if (repeat === 3) finding("retry-loop", ev, { cmd: clip(cmd, 200) });
147
+ } else {
148
+ repeat = failed ? 1 : 0;
149
+ }
150
+ lastFailedCmd = failed ? key : null;
151
+ }
152
+
153
+ let run = c;
154
+ let runStart = st;
155
+ let outcome;
156
+ if (st.test) {
157
+ // merge what we know about this run (exit status, output seen so far)
158
+ st.exitCode = ev.exitCode ?? st.exitCode;
159
+ st.isError = ev.isError ?? st.isError;
160
+ st.output = ((st.output || "") + "\n" + (ev.output || "")).slice(-6000);
161
+ outcome = ev.outcome ?? testOutcome({ cmd, exitCode: st.exitCode, isError: st.isError, output: st.output });
162
+ // the runner's summary `record` saved from the full output, when the log's copy has
163
+ // none: printed results beat exit codes, as everywhere else
164
+ if (!ev.outcome && c.recorded?.outcome && !outcomeFromOutput(st.output) && outcome !== c.recorded.outcome) {
165
+ outcome = c.recorded.outcome;
166
+ s.evidence.recorded = (s.evidence.recorded || 0) + 1;
167
+ }
168
+ if (ev.ms > 0 && !st.timed && !c.external) {
169
+ st.timed = true;
170
+ s.testMs += ev.ms;
171
+ if (isFullSuite(cmd)) s.fullSuiteMs += ev.ms;
172
+ }
173
+ } else if (isLogRead(cmd)) {
174
+ // reading a log is evidence about the run that wrote it, never a new run
175
+ // the log's file name must appear as a path in the command, not just as a substring
176
+ const words = cmd.split(/[\s;&|<>"'()]+/).map(basename);
177
+ const writer = [...logs.entries()].find(([name]) => words.includes(name))?.[1];
178
+ outcome = outcomeFromOutput(ev.output);
179
+ if (!writer || !outcome) return;
180
+ run = writer;
181
+ runStart = started.get(writer);
182
+ } else return;
183
+
184
+ if (c.external) {
185
+ // evidence only: no retry loops or test-only fixes for runs the agent didn't make
186
+ } else if (outcome === "fail" && !runStart.countedFail) {
187
+ runStart.countedFail = true;
188
+ s.failingRuns++;
189
+ if (!sinceFail) sinceFail = { testEdits: new Set(), codeEdits: 0, cmd: run.cmd, touchedBefore: new Set(touchedTests) };
190
+ } else if (outcome === "pass" && sinceFail) {
191
+ // fixing a test the agent itself wrote or edited earlier is normal; changing a
192
+ // test it hadn't touched, and nothing else, is what deserves a look
193
+ const preexisting = [...sinceFail.testEdits].filter((t) => !sinceFail.touchedBefore.has(t) && !created.has(t));
194
+ if (preexisting.length && sinceFail.codeEdits === 0) {
195
+ finding("test-only-fix", ev, { cmd: clip(run.cmd, 200), tests: preexisting.slice(0, 5) });
196
+ }
197
+ sinceFail = null;
198
+ }
199
+ // evidence about an older run never replaces evidence about a newer one
200
+ if (!lastTest || runStart.i >= lastTest.i) {
201
+ lastTest = { i: runStart.i, ts: run.ts, outcome, cmd: run.cmd, tail: tail(runStart.output || ev.output), sub: run.sub, dir: runStart.dir, tree: run.tree, external: run.external };
202
+ }
203
+ // every observed run, so a claim can be judged against the run it talks about
204
+ if (outcome) {
205
+ const prev = runs.get(runStart.i);
206
+ const count = passedCount(runStart.output || ev.output) ?? run.recorded?.passed ?? prev?.count ?? null;
207
+ runs.set(runStart.i, { i: runStart.i, ts: run.ts, outcome, cmd: run.cmd, tail: tail(runStart.output || ev.output), sub: run.sub, dir: runStart.dir, count, full: isFullSuite(run.cmd), tree: run.tree, external: run.external });
208
+ if (lastTest.i === runStart.i) lastTest.count = count;
209
+ }
210
+ };
211
+
212
+ /** Files that differ between two `record` snapshots of one checkout, or null if unknown. */
213
+ const snapshotDiff = (a, b) => {
214
+ if (!a || !b || !session.treeDiff || a.root !== b.root) return null;
215
+ if (a.tree && b.tree && a.tree === b.tree) return [];
216
+ try {
217
+ const out = session.treeDiff(a, b);
218
+ return Array.isArray(out) ? out : null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ };
223
+
224
+ /**
225
+ * "CI is green": judged only with --ci, against the GitHub Actions runs for the commit
226
+ * most recently pushed before the claim (or, without a push, the branch's latest).
227
+ */
228
+ const judgeCiClaim = (ev) => {
229
+ const claim = findCiClaim(ev.text);
230
+ if (!claim) return;
231
+ const before = session.ci.runs.filter((r) => r.created <= ev.ts);
232
+ let verdict, why;
233
+ if (!before.length) [verdict, why] = ["unknown", "no CI run found"];
234
+ else {
235
+ const sha = before.reduce((a, b) => (b.created > a.created ? b : a)).sha;
236
+ const forSha = before.filter((r) => r.sha === sha);
237
+ if (forSha.some((r) => r.completed && r.completed <= ev.ts && r.conclusion === "failure")) verdict = "failed";
238
+ else if (forSha.some((r) => !r.completed || r.completed > ev.ts)) [verdict, why] = ["unknown", "CI still running"];
239
+ else if (forSha.every((r) => ["success", "skipped", "neutral"].includes(r.conclusion))) verdict = "backed";
240
+ else [verdict, why] = ["unknown", "CI run didn't finish normally"];
241
+ }
242
+ s.claims[verdict]++;
243
+ s.evidence.ci = (s.evidence.ci || 0) + 1;
244
+ ev.verdict = verdict;
245
+ const latest = before.length ? before.reduce((a, b) => (b.created > a.created ? b : a)) : null;
246
+ const lastTestOf = latest && { cmd: `CI: ${latest.name} (${latest.sha.slice(0, 7)})`, outcome: latest.conclusion, ago: ev.ts - (latest.completed || latest.created), tail: latest.url };
247
+ if (why) {
248
+ s.unclear[why] = (s.unclear[why] || 0) + 1;
249
+ finding("unclear", ev, { why, claim: clip(claim, 240), ci: true, lastTest: lastTestOf });
250
+ } else if (verdict !== "backed") finding("claim", ev, { verdict, claim: clip(claim, 240), ci: true, lastTest: lastTestOf });
251
+ };
252
+
253
+ session.events.forEach((ev, i) => {
254
+ switch (ev.kind) {
255
+ case "edit": {
256
+ const p = rel(ev.path);
257
+ if (!p) break;
258
+ // a brand-new throwaway program (Write of check3.cpp, m0_debug2.cpp) is treated like
259
+ // one written through the shell; edits to existing files always count
260
+ if (ev.op === "add") {
261
+ const name = norm(ev.path).split("/").pop();
262
+ if (SCRATCH_NAME.test(name)) break;
263
+ if (DEBUG_AID.test(name) && isCodePath(name)) {
264
+ lastUncertain = i;
265
+ break;
266
+ }
267
+ }
268
+ codeEdit(i, p, ev, absOf(ev.path));
269
+ break;
270
+ }
271
+ case "uncertain":
272
+ lastUncertain = i;
273
+ break;
274
+ case "cmd": {
275
+ const cmd = ev.cmd;
276
+ if (ev.tree) lastSnap = { i, tree: ev.tree };
277
+ if (ev.external) {
278
+ // a run outside the agent (your terminal, CI): evidence only, not the agent's work
279
+ started.set(ev, { i, test: true, editsBefore: 0, dir: ev.workdir || session.cwd });
280
+ break;
281
+ }
282
+ s.commands++;
283
+ if (sinceFail && mayWriteFiles(cmd)) sinceFail.codeEdits++;
284
+ for (const r of riskyIn(cmd)) {
285
+ s.risky[r.id] = (s.risky[r.id] || 0) + 1;
286
+ finding("risky", ev, { id: r.id, label: r.label, cmd: clip(cmd, 200) });
287
+ }
288
+ for (const p of createdBy(cmd)) created.add(rel(p) || p);
289
+ for (const p of deletedTestFiles(cmd)) {
290
+ if (!created.has(rel(p) || p)) finding("weakened-test", ev, { path: p, reasons: ["deleted the test file"], cmd: clip(cmd, 200) });
291
+ }
292
+ // files written through the shell are edits too; writes we can't name are doubt
293
+ // variables assigned in the same command (S=/tmp/x; cat > $S/f.py) are expanded first
294
+ const resolved = expandVars(cmd, homeEnv);
295
+ const writes = shellWrites(resolved);
296
+ // Where this command runs. Claude Code's shell keeps its directory between
297
+ // commands (until it reports a reset); Codex gives each command its own workdir.
298
+ const here = ev.workdir || shellCwd;
299
+ const steps = cdSteps(resolved, here);
300
+ const cdTo = steps.length ? steps[steps.length - 1].dir : undefined;
301
+ const dir = cdTo === undefined ? here : cdTo;
302
+ if (session.agent === "claude" && cdTo !== undefined) shellCwd = cdTo;
303
+ for (const w of writes.paths) {
304
+ // `cd /elsewhere && cat > x.py`: relative targets follow the cd in effect where
305
+ // they appear (`cd /tmp && python3 - <<EOF … open('x.py','w') … EOF\ncd /repo`)
306
+ const pos = resolved.indexOf(w);
307
+ const step = pos < 0 ? steps[steps.length - 1] : steps.findLast((c) => c.index < pos);
308
+ const wdir = step ? step.dir : here;
309
+ if (!isAbs(norm(w)) && wdir === null) continue; // cd into a $variable: unknown
310
+ // Throwaway programs written through the shell aren't the code under test: clearly
311
+ // throwaway names (check3.cpp, tmp_x.py) are ignored; names that only look like a
312
+ // debugging aid (m0_debug2.cpp) make the claim unjudgeable rather than stale
313
+ const name = norm(w).split("/").pop();
314
+ if (SCRATCH_NAME.test(name)) continue;
315
+ if (DEBUG_AID.test(name) && isCodePath(name)) {
316
+ lastUncertain = i;
317
+ continue;
318
+ }
319
+ const abs = absOf(w, wdir);
320
+ const p = rel(abs || w);
321
+ if (p) codeEdit(i, p, null, abs);
322
+ }
323
+ if (writes.unresolved) lastUncertain = i;
324
+
325
+ const test = isTestCommand(cmd);
326
+ // where the test ran: an edit in another checkout or subproject can't make it stale
327
+ started.set(ev, { i, test, editsBefore: editsSinceLastCmd, dir: dir === undefined ? session.cwd : dir });
328
+ editsSinceLastCmd = 0;
329
+ if (test) {
330
+ s.testRuns++;
331
+ if (isFullSuite(cmd)) s.fullSuiteRuns++;
332
+ pendingTest = i;
333
+ for (const log of redirectTargets(resolved)) logs.set(basename(log), ev);
334
+ }
335
+ // Older event format (and hand-built events): a command carrying its own result.
336
+ // The parsers emit separate `result` events instead.
337
+ if ("exitCode" in ev || "output" in ev || "isError" in ev) {
338
+ observe({ kind: "result", of: ev, ts: ev.ts, exitCode: ev.exitCode ?? null, isError: ev.isError ?? null, output: ev.output ?? "", ms: ev.ms });
339
+ }
340
+ break;
341
+ }
342
+ case "result":
343
+ observe(ev);
344
+ break;
345
+ case "say": {
346
+ const claim = findTestClaim(ev.text);
347
+ if (!claim) {
348
+ if (session.ci) judgeCiClaim(ev);
349
+ break;
350
+ }
351
+ let verdict;
352
+ let why; // for "unknown": which piece of evidence is missing
353
+ // Judge the claim against the run it talks about: "31/31 VM tests pass" refers to
354
+ // the run that reported 31 passed, "test_api.py passes" to a run of that file,
355
+ // "the full suite passes" to the latest run as large as the session's full runs.
356
+ // Without such a hint, the latest run.
357
+ const ref = referencedRun(claim, runs, lastTest) || lastTest;
358
+ let staleEdit = ref && codeEdits.findLast((e) => e.i > ref.i && within(e.abs, ref.dir));
359
+ // a change we couldn't follow, settled by comparing `record` snapshots of the
360
+ // working tree after the run and after the change
361
+ let uncertain = ref && lastUncertain > ref.i;
362
+ if (ref && uncertain && !staleEdit) {
363
+ const changed = snapshotDiff(ref.tree, lastSnap && lastSnap.i >= lastUncertain ? lastSnap.tree : null);
364
+ if (changed) {
365
+ uncertain = false;
366
+ s.evidence.snapshot = (s.evidence.snapshot || 0) + 1;
367
+ // the whole checkout is compared; only changes where the test ran count, as for edits
368
+ // Git may report the canonical checkout path even when the transcript
369
+ // used a symlinked directory (/var vs /private/var on macOS), and
370
+ // Windows requires native path joining for drive-letter roots.
371
+ const canonical = (p) => {
372
+ try { return fs.realpathSync.native(p); } catch { return p; }
373
+ };
374
+ const canonicalRoot = canonical(ref.tree.root);
375
+ const canonicalDir = canonical(ref.dir || session.cwd || ref.tree.root);
376
+ const code = changed.filter((p) => isCodePath(p) && !SCRATCH_NAME.test(p.split("/").pop()) &&
377
+ within(path.resolve(canonicalRoot, ...p.split("/")), canonicalDir));
378
+ if (code.length) staleEdit = { path: code[0], snapshot: true };
379
+ }
380
+ }
381
+ if (!ref) {
382
+ // a run still in progress, or no edits and no runs (the agent is talking about
383
+ // something else, like a CV or a doc): nothing to judge
384
+ verdict = pendingTest >= 0 || !lastCodeEdit ? "unknown" : "no-run";
385
+ if (verdict === "unknown") why = pendingTest >= 0 ? "test still running" : "no code work in session";
386
+ } else if (staleEdit) verdict = "stale";
387
+ else if (uncertain) [verdict, why] = ["unknown", "unresolved file change after the run"];
388
+ // a subagent's failing run in its own worktree says little about the main checkout
389
+ else if (ref.outcome === "fail") [verdict, why] = ref.sub ? ["unknown", "subagent run failed"] : ["failed"];
390
+ else if (ref.outcome == null) [verdict, why] = ["unknown", "test result unreadable"];
391
+ else verdict = "backed";
392
+ s.claims[verdict]++;
393
+ ev.verdict = verdict;
394
+ if (why) {
395
+ s.unclear[why] = (s.unclear[why] || 0) + 1;
396
+ finding("unclear", ev, { why, claim: clip(claim, 240), lastTest: ref && { cmd: clip(ref.cmd, 160), tail: ref.tail } });
397
+ }
398
+ if (ref?.external) s.evidence[ref.external] = (s.evidence[ref.external] || 0) + 1;
399
+ if (verdict !== "backed" && verdict !== "unknown") {
400
+ finding("claim", ev, {
401
+ verdict,
402
+ claim: clip(claim, 240),
403
+ lastTest: ref && { cmd: clip(ref.cmd, 160), outcome: ref.outcome, ago: ev.ts - ref.ts, tail: ref.tail, matched: ref !== lastTest || undefined },
404
+ editedAfter: verdict === "stale" ? staleEdit.path : undefined,
405
+ });
406
+ }
407
+ break;
408
+ }
409
+ case "user":
410
+ s.userMessages++;
411
+ // runner output you pasted (from another terminal, CI) is evidence from that point on
412
+ {
413
+ const pasted = outcomeFromOutput(ev.text);
414
+ if (pasted) {
415
+ const run = { i, ts: ev.ts, outcome: pasted, cmd: "(test output you pasted)", tail: tail(ev.text), sub: false, dir: null, count: passedCount(ev.text), full: false };
416
+ runs.set(i, run);
417
+ lastTest = run;
418
+ }
419
+ }
420
+ if (isCorrection(ev.text)) {
421
+ s.corrections++;
422
+ finding("correction", ev, { text: clip(ev.text, 160) });
423
+ }
424
+ break;
425
+ case "interrupt":
426
+ s.interrupts++;
427
+ break;
428
+ }
429
+ });
430
+ return s;
431
+ }
432
+
433
+ /**
434
+ * Every `cd` in a command, in order, as { index, dir }: dir is where the shell is after it,
435
+ * null when that can't be resolved (`cd "$W"`, or a relative cd after one).
436
+ */
437
+ function cdSteps(cmd, base) {
438
+ // cd, and PowerShell's Set-Location / Push-Location; the last one wins
439
+ // a cd inside a quoted string (`ssh host 'cd /srv && make'`) moves some other shell
440
+ const quoted = [...cmd.matchAll(/'[^']*'|"(?:[^"\\]|\\.)*"/g)].map((q) => [q.index, q.index + q[0].length]);
441
+ const inQuotes = (k) => quoted.some(([a, b]) => k > a && k < b);
442
+ const all = [...cmd.matchAll(/(?:^|[;&|\n]\s*)(?:cd|pushd|Set-Location|Push-Location|sl)\s+(?:-(?:Path|LiteralPath)\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/gi)].filter((m) => !inQuotes(m.index + m[0].search(/\S/)));
443
+ const steps = [];
444
+ let cur = base ? norm(base) : null;
445
+ for (const m of all) {
446
+ const d = m[1].replace(/^["']|["']$/g, "");
447
+ if (/[$`]/.test(d)) cur = null;
448
+ else if (d === "~" || d.startsWith("~/")) cur = path.posix.join(norm(os.homedir()), d.slice(1));
449
+ else if (isAbs(norm(d))) cur = norm(d);
450
+ // `cd web`: relative to where the shell already is, if we know
451
+ else cur = cur ? path.posix.join(cur, norm(d)) : null;
452
+ steps.push({ index: m.index, dir: cur });
453
+ }
454
+ return steps;
455
+ }
456
+
457
+ /** Is a file inside the directory a test ran in? Unknown locations count as inside. */
458
+ function within(abs, dir) {
459
+ if (!abs || !dir) return true;
460
+ abs = norm(abs);
461
+ dir = norm(dir).replace(/\/$/, "");
462
+ // Windows paths are case-insensitive
463
+ if (/^[a-z]:\//.test(abs)) [abs, dir] = [abs.toLowerCase(), dir.toLowerCase()];
464
+ return abs === dir || abs.startsWith(dir + "/");
465
+ }
466
+
467
+ // bare throwaway names only: check3.cpp, tmp.py, tmp_parse.js, repro2.c (not checkout.py)
468
+ const SCRATCH_NAME = /^(?:check|tmp|temp|scratch|repro|try|probe)\d*\.\w+$|^(?:tmp|temp|scratch|repro|check|try|probe)[_-]\w+\.\w+$/i;
469
+
470
+ const DEBUG_AID = /(?:^|[_.-])(?:debug|diag|dbg|probe|scratch|repro|check|try)(?:\d+|[_.-])/i;
471
+
472
+ /**
473
+ * The run a claim names by its count or by a test file, when it clearly isn't the latest
474
+ * run: the latest run's count is known and differs, or it didn't run the named file. When
475
+ * the latest run could be the one meant (its count unknown, say), it stays the reference.
476
+ */
477
+ function referencedRun(claim, runs, latest) {
478
+ const counts = claimedCounts(claim);
479
+ const files = claimedTestFiles(claim);
480
+ if (!counts.size && !files.length && claimsFullSuite(claim)) return fullSuiteRun(runs, latest);
481
+ // Only precise hints (a pass count, a test file). Words like "full suite" were tried and
482
+ // dropped: telling a full run from a focused one is too unreliable to accuse an agent with.
483
+ if (!counts.size && !files.length) return null;
484
+ if (latest) {
485
+ const latestFits = (latest.count == null || counts.has(latest.count) || !counts.size) && (!files.length || files.some((f) => latest.cmd.includes(f)));
486
+ const latestCount = runs.get(latest.i)?.count;
487
+ if (latestFits && (latestCount == null || counts.has(latestCount) || !counts.size)) return null;
488
+ }
489
+ let best = null;
490
+ for (const r of runs.values()) {
491
+ const byCount = r.count != null && counts.has(r.count);
492
+ const byFile = files.some((f) => r.cmd.includes(f));
493
+ if ((byCount || byFile) && (!best || r.i > best.i)) best = r;
494
+ }
495
+ return best;
496
+ }
497
+
498
+ /**
499
+ * "The full suite passes": when the latest run in that checkout clearly ran far fewer
500
+ * tests than the session's largest run, the claim is about the latest run that was full
501
+ * size. Judged by the counts runners printed, never by the command (too unreliable).
502
+ */
503
+ function fullSuiteRun(runs, latest) {
504
+ if (!latest || latest.count == null) return null;
505
+ const same = [...runs.values()].filter((r) => r.count != null && !r.external && sameDir(r.dir, latest.dir));
506
+ const max = Math.max(...same.map((r) => r.count));
507
+ // a different command, and under half the size: a focused run, not the suite shrinking
508
+ if (!(latest.count < max / 2)) return null;
509
+ const big = same.filter((r) => r.count >= max * 0.9 && r.cmd !== latest.cmd);
510
+ return big.length ? big.reduce((a, b) => (b.i > a.i ? b : a)) : null;
511
+ }
512
+
513
+ const sameDir = (a, b) => (a && b ? norm(a).replace(/\/$/, "") === norm(b).replace(/\/$/, "") : a === b);
514
+
515
+ /** Files a test command sends its output to (`> out.log`, `tee run.txt`). */
516
+ function redirectTargets(cmd) {
517
+ return shellWrites(cmd).paths.filter((p) => !isCodePath(p));
518
+ }
519
+
520
+ const isLogRead = (cmd) => /(?:^|[\s;&|])(?:tail|cat|head|less|more|grep|rg)\s/.test(cmd) || isTestLogRead(cmd);
521
+ const basename = (p) => p.split("/").pop();
522
+
523
+ function createdBy(cmd) {
524
+ const out = [];
525
+ for (const m of cmd.matchAll(/(?:cat|tee)\s*(?:-a\s*)?>?\s*([^\s<>|;&]+\.\w+)\s*<<|(?:^|[\s;&])>\s*([^\s<>|;&]+\.\w+)/g)) out.push(m[1] || m[2]);
526
+ return out;
527
+ }
528
+
529
+ function deletedTestFiles(cmd) {
530
+ const out = [];
531
+ for (const part of splitCommands(cmd)) {
532
+ const m = /^\s*(?:git\s+rm|rm)\s+(.+)$/.exec(part);
533
+ if (!m) continue;
534
+ for (const tok of m[1].split(/\s+/)) {
535
+ if (!tok.startsWith("-") && isCodePath(tok) && isTestPath(tok) && !/tmp|dbg|debug|scratch|repro/i.test(tok)) out.push(tok);
536
+ }
537
+ }
538
+ return out;
539
+ }
540
+
541
+ function relPath(p, cwd) {
542
+ if (!p) return "";
543
+ p = norm(p);
544
+ cwd = norm(cwd);
545
+ if (cwd && isAbs(p)) {
546
+ const r = path.posix.relative(cwd, p);
547
+ if (!r.startsWith("..")) return r;
548
+ if (/^\/(?:tmp|var\/tmp|private\/tmp)\/|^[a-z]:\/users\/[^/]+\/appdata\/local\/temp\//i.test(p)) return ""; // scratch files
549
+ return p.replace(/^\/(?:home|Users)\/[^/]+\/|^[a-z]:\/users\/[^/]+\//i, "~/");
550
+ }
551
+ return p.replace(/^\.\//, "");
552
+ }
553
+
554
+ /**
555
+ * Paths compared as strings: forward slashes, lower-case drive letter. Windows paths
556
+ * (`d:\Epilog\src\x.cpp`) come from Claude Code on Windows and must work on any host.
557
+ */
558
+ function norm(p) {
559
+ if (!p) return p;
560
+ return p.replace(/\\/g, "/").replace(/^([A-Za-z]):(?=\/|$)/, (_, d) => d.toLowerCase() + ":").replace(/\/{2,}/g, "/");
561
+ }
562
+ const isAbs = (p) => /^(?:\/|[a-z]:\/)/i.test(p);
563
+
564
+ const clip = (s, n) => {
565
+ s = String(s ?? "").replace(/\s+/g, " ").trim();
566
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
567
+ };
568
+ const tail = (out) => (out || "").trim().split("\n").slice(-6).map((l) => l.slice(0, 160)).join("\n");
569
+ const sample = (lines) => (lines ? lines.filter((l) => l.trim()).slice(0, 4).map((l) => clip(l, 100)) : undefined);
570
+
571
+ /** Roll session results up by agent and by model. */
572
+ export function aggregate(sessions) {
573
+ const groups = { all: blank() };
574
+ for (const s of sessions) {
575
+ for (const key of ["all", `agent:${s.agent}`, `model:${s.model}`]) {
576
+ const g = (groups[key] ||= blank());
577
+ g.sessions++;
578
+ g.activeSessions += s.commands + s.edits > 0 ? 1 : 0;
579
+ for (const v of VERDICTS) g.claims[v] += s.claims[v];
580
+ for (const k of ["testRuns", "failingRuns", "testMs", "fullSuiteRuns", "fullSuiteMs", "commands", "edits", "userMessages", "corrections", "interrupts"]) g[k] += s[k];
581
+ for (const [k, v] of Object.entries(s.risky)) g.risky[k] = (g.risky[k] || 0) + v;
582
+ for (const [k, v] of Object.entries(s.unclear || {})) g.unclear[k] = (g.unclear[k] || 0) + v;
583
+ for (const [k, v] of Object.entries(s.evidence || {})) g.evidence[k] = (g.evidence[k] || 0) + v;
584
+ for (const v of s.versions || []) if (!g.versions.includes(v)) g.versions.push(v);
585
+ for (const f of s.findings) g.findingCounts[f.type] = (g.findingCounts[f.type] || 0) + 1;
586
+ g.start = Math.min(g.start ?? s.start ?? Infinity, s.start ?? Infinity);
587
+ g.end = Math.max(g.end ?? 0, s.end ?? 0);
588
+ }
589
+ }
590
+ for (const g of Object.values(groups)) {
591
+ const judged = g.claims.backed + g.claims["no-run"] + g.claims.stale + g.claims.failed;
592
+ g.claimsTotal = judged + g.claims.unknown;
593
+ g.receiptRate = judged ? g.claims.backed / judged : null;
594
+ }
595
+ return groups;
596
+ }
597
+
598
+ function blank() {
599
+ return {
600
+ sessions: 0,
601
+ activeSessions: 0,
602
+ claims: Object.fromEntries(VERDICTS.map((v) => [v, 0])),
603
+ testRuns: 0,
604
+ failingRuns: 0,
605
+ testMs: 0,
606
+ fullSuiteRuns: 0,
607
+ fullSuiteMs: 0,
608
+ commands: 0,
609
+ edits: 0,
610
+ userMessages: 0,
611
+ corrections: 0,
612
+ interrupts: 0,
613
+ risky: {},
614
+ unclear: {},
615
+ evidence: {},
616
+ versions: [],
617
+ findingCounts: {},
618
+ start: null,
619
+ end: null,
620
+ };
621
+ }