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/LICENSE +21 -0
- package/README.md +232 -2
- package/bin/polygraph.js +274 -0
- package/package.json +41 -4
- package/src/analyze.js +621 -0
- package/src/ci.js +204 -0
- package/src/classify.js +506 -0
- package/src/demo.js +120 -0
- package/src/guard.js +240 -0
- package/src/jsonl.js +23 -0
- package/src/record.js +402 -0
- package/src/report/format.js +87 -0
- package/src/report/html.js +101 -0
- package/src/report/terminal.js +160 -0
- package/src/safe-config.js +85 -0
- package/src/scan.js +60 -0
- package/src/shell/record.sh +73 -0
- package/src/sources/claude.js +300 -0
- package/src/sources/codex.js +476 -0
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
|
+
}
|