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.
@@ -0,0 +1,506 @@
1
+ // Pattern-based classifiers. Every rule here leans conservative: when a signal is
2
+ // ambiguous we prefer to miss it rather than accuse an agent of something it didn't do.
3
+
4
+ // `(?![-.\w])` stops `pytest-full.log` or `jest.config.js` from counting as a run.
5
+ const END = String.raw`(?![-.\w/])`;
6
+ const TEST_RUNNERS = new RegExp(
7
+ [
8
+ String.raw`\bpy\.?test` + END,
9
+ String.raw`\bpython[\d.]*\s+-m\s+(?:pytest|unittest)` + END,
10
+ String.raw`(?:^|\s)(?:tox|nox)` + END,
11
+ String.raw`\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:-\S+\s+)*test` + END, // also `npm run -s test`
12
+ String.raw`(?:^|\s|\/)(?:jest|vitest|mocha|ava)` + END,
13
+ String.raw`\bplaywright\s+test` + END,
14
+ String.raw`\bgo\s+test` + END,
15
+ String.raw`\bcargo\s+(?:test|nextest)` + END,
16
+ String.raw`\b(?:mvn|mvnw)\b[^|;&]*\s(?:test|verify)` + END,
17
+ String.raw`\bgradlew?\b[^|;&]*[\s:]test` + END,
18
+ String.raw`(?:^|\s|\/)rspec` + END,
19
+ String.raw`(?:^|\s|\/)phpunit` + END,
20
+ String.raw`\bdotnet\s+test` + END,
21
+ String.raw`(?:^|\s)ctest` + END,
22
+ String.raw`\bmake\s+(?:test|check|tests)` + END,
23
+ String.raw`\b(?:deno|swift|mix)\s+test` + END,
24
+ String.raw`\bzig\s+build\s+test` + END,
25
+ String.raw`(?:^|\s)bats` + END,
26
+ String.raw`\b(?:node|bun)\s+(?:[^|;&\n]*\s)?--test` + END,
27
+ String.raw`\bbun\s+test` + END,
28
+ String.raw`\bpython[\d.]*\s+(?:-\w+\s+)*\S*\btest_\w+\.py\b`,
29
+ String.raw`\bnode\s+(?:--\S+\s+)*\S*\.(?:test|spec)\.[cm]?js\b`,
30
+ ].join("|"),
31
+ );
32
+
33
+ const NOT_A_RUN = /\b(?:install|add|uninstall|which|whereis)\b|\bimport\s+(?:pytest|unittest)\b|(?:^|\s)(?:--version|--help|--collect-only|--co|--list|-h)(?=\s|$)|\bman\s|\bcat\s|\bgrep\s|\brg\s|\bsed\s/;
34
+
35
+ /** A run with no file, directory, node id or -k filter: the whole suite. */
36
+ export function isFullSuite(cmd) {
37
+ return splitCommands(cmd).some((part) => {
38
+ part = part.split(/\|(?!\|)/)[0];
39
+ const m = TEST_RUNNERS.exec(part);
40
+ if (!m || NOT_A_RUN.test(part)) return false;
41
+ const args = part.slice(m.index + m[0].length);
42
+ return !/(?:^|\s)(?:-k|-m|--grep|-t|--testNamePattern|-run|--filter)\b|::|\.(?:py|[cm]?[jt]sx?|go|rb|rs)\b|(?:^|\s)(?:\.\/)?[\w.-]+\/[\w./-]*|(?:^|\s)tests?\.[\w.]+/.test(args);
43
+ });
44
+ }
45
+
46
+ // Quoted text is data (JSON payloads, commit messages, echo) unless a shell runs it.
47
+ const RUNS_QUOTED = /(?:\b(?:ba|z)?sh\s+-\w*c|\bssh\b[^'"|;&]*|\b(?:docker|podman)\s+(?:exec|run)\b[^'"|;&]*|\bkubectl\s+exec\b[^'"|;&]*|\b(?:timeout|nice|env|xargs)\b[^'"|;&]*)\s*$/;
48
+
49
+ export function dropDataQuotes(cmd) {
50
+ return cmd.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (q, offset) =>
51
+ !/\s/.test(q) || RUNS_QUOTED.test(cmd.slice(Math.max(0, offset - 200), offset)) ? q : "Q",
52
+ );
53
+ }
54
+
55
+ export function isTestCommand(cmd) {
56
+ if (!cmd) return false;
57
+ cmd = dropDataQuotes(stripHeredocs(cmd)); // heredoc bodies first: their apostrophes are not quotes
58
+ // Look at each simple command so `pip install pytest && pytest` still counts, and only
59
+ // at what precedes a pipe: `npm test | grep fail` is a run, `grep pytest x` is not.
60
+ return splitCommands(cmd).some((part) => {
61
+ const head = part.split(/\|(?!\|)/)[0];
62
+ return (TEST_RUNNERS.test(head) || runsTestScript(head)) && !NOT_A_RUN.test(head);
63
+ });
64
+ }
65
+
66
+ const INTERPRETER = /^(?:\S*[\\/])?(?:python[\d.]*|node|bash|sh|zsh|pwsh|powershell(?:\.exe)?|ruby|perl|php|deno|bun|tsx|ts-node)$/i;
67
+
68
+ /**
69
+ * Projects with their own runner: a script under tests/ (`pwsh -File tests/runner/run.ps1`,
70
+ * `bash tests/run.sh`) or a program named like a test (`./build/frost_smoke.exe`).
71
+ */
72
+ function runsTestScript(part) {
73
+ const words = part.trim().replace(/^(?:\w+=\S*\s+)*(?:(?:sudo|nice|env|timeout\s+\S+)\s+)*/, "").split(/\s+/);
74
+ let target;
75
+ if (INTERPRETER.test(words[0])) {
76
+ // a script argument that is a test: `pwsh -File tests/run.ps1`, `python main.py run tests/t.cfml`
77
+ const args = words.slice(1).filter((w) => /[\\/]|\.(?:py|[cm]?js|ts|sh|ps1|rb|pl|php)["']?$/.test(w)).map((w) => w.replace(/^["']|["']$/g, ""));
78
+ return args.some((t) => /(?:^|[\\/])(?:tests?|specs?)[\\/]/i.test(t) || /(?:^|[_.-])(?:tests?|smoke|specs?)(?:[_.-]|$)/i.test(t.split(/[\\/]/).pop()));
79
+ } else if (/[\\/]/.test(words[0])) {
80
+ target = words[0]; // a program run by path
81
+ }
82
+ if (!target) return false;
83
+ target = target.replace(/^["']|["']$/g, "");
84
+ const base = target.split(/[\\/]/).pop();
85
+ return /(?:^|[\\/])(?:tests?|specs?)[\\/]/i.test(target) || /(?:^|[_.-])(?:tests?|smoke|specs?)(?:[_.-]|$)/i.test(base);
86
+ }
87
+
88
+ // Commands whose output can contain text saved earlier, e.g. an old test log or fixture.
89
+ export const ECHOES = /(?:^|[\s;&|(])(?:cat|less|more|head|tail|sed|awk|grep|rg|jq|nl|bat|xxd|type|Get-Content|git\s+(?:show|log|diff|blame|grep))\b/;
90
+
91
+ export function splitCommands(cmd) {
92
+ return stripHeredocs(cmd).split(/\s*(?:&&|\|\||;|\n)\s*/).filter(Boolean);
93
+ }
94
+
95
+ /** Drop heredoc bodies: code and data inside them are not commands being run. */
96
+ export function stripHeredocs(cmd) {
97
+ return cmd.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?(?:\n\s*\2\s*(?=\n|$)|$)/g, " HEREDOC_BODY");
98
+ }
99
+
100
+ // A pipe hides the test runner's exit status unless pipefail is set.
101
+ export function isPiped(cmd) {
102
+ if (/pipefail/.test(cmd)) return false;
103
+ return splitCommands(cmd).some((part) => TEST_RUNNERS.test(part) && /[^|]\|[^|]/.test(part));
104
+ }
105
+
106
+ // Only runner summary lines count: stray tracebacks or "1 error" from a linter in the
107
+ // same command output must not be mistaken for a test failure.
108
+ const OUTPUT_FAIL = [
109
+ /\b[1-9]\d* (?:failed|errors?)\b[^\n]* in [\d.]+m?s\b/, // pytest: "2 failed, 10 passed in 1.2s"
110
+ /^FAILED \((?:failures|errors)=\d+/m, // unittest
111
+ /\bTests?:\s+[1-9]\d* failed\b/, // jest / vitest
112
+ /\bTest Files\s+[1-9]\d* failed\b/,
113
+ /^--- FAIL:|^FAIL\s+\S+\s+[\d.]+s$/m, // go
114
+ /\btest result: FAILED\b/, // cargo
115
+ /^FAILURES!$/m, // phpunit
116
+ /\b\d+ examples?, [1-9]\d* failures?\b/, // rspec
117
+ /\bFailed!\s+-\s+Failed:\s+[1-9]/, // dotnet
118
+ /(?:^|ℹ pass \d+\s+)(?:ℹ|#) fail [1-9]\d*\s*$/m, // node --test (spec and TAP reporters; also joined on one line)
119
+ /\b\d+ passed,? [1-9]\d* failed\b|\b[1-9]\d* failed,? \d+ passed\b/i, // custom runners
120
+ /\bTests run: \d+, Failures: (?:[1-9]|\d+, Errors: [1-9])/, // maven surefire
121
+ /\btests="?\d+"? failures="?(?:[1-9]|\d+"? errors="?[1-9])/, // junit-style counts
122
+ ];
123
+ const OUTPUT_PASS = [
124
+ /\b\d+ passed\b[^\n]* in [\d.]+m?s\b/,
125
+ /\bTests?:\s+\d+ passed(?:, \d+ total)?\b/,
126
+ /^ok\s+\S+\s+[\d.]+s/m,
127
+ /\btest result: ok\b/,
128
+ /\b\d+ examples?, 0 failures\b/,
129
+ /^Ran \d+ tests? in [\d.]+s\s+OK\b/m,
130
+ /\bPassed!\s+-\s+Failed:\s+0\b/,
131
+ /(?:^|ℹ pass \d+\s+)(?:ℹ|#) fail 0\s*$/m,
132
+ /\b\d+ passed,? 0 failed\b|\b0 failed,? \d+ passed\b/i, // custom runners
133
+ /\b(\d+)\s*\/\s*\1\s+(?:tests?\s+)?passed\b/i,
134
+ /\bTests run: [1-9]\d*, Failures: 0, Errors: 0\b/,
135
+ /\btests="?[1-9]\d*"? failures="?0"? errors="?0\b/,
136
+ ];
137
+
138
+ /** `tail pytest.log` and friends: evidence from a run started earlier in the background. */
139
+ export function isTestLogRead(cmd) {
140
+ return /(?:^|[\s;&|])(?:tail|cat|head|less)\s[^|;&\n]*\S*(?:py)?test\S*\.(?:log|txt|out)\b/i.test(cmd);
141
+ }
142
+
143
+ /** Read pass/fail from what the runner printed. Returns "pass", "fail" or null. */
144
+ export function outcomeFromOutput(text) {
145
+ if (!text) return null;
146
+ if (OUTPUT_FAIL.some((rx) => rx.test(text))) return "fail";
147
+ if (OUTPUT_PASS.some((rx) => rx.test(text))) return "pass";
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Combine exit status and printed output. Printed failures beat exit 0 (pipes and
153
+ * `|| true` hide exit codes); with a pipe and no recognisable summary we don't know.
154
+ */
155
+ export function testOutcome({ cmd, exitCode, isError, output }) {
156
+ const printed = outcomeFromOutput(output);
157
+ if (printed) return printed;
158
+ // Gradle prints BUILD SUCCESSFUL/FAILED for every task; for a test task it is the result
159
+ if (/\bgradlew?\b[^|;&]*[\s:]test\b/.test(cmd) && output) {
160
+ if (/\bBUILD FAILED\b|There were failing tests/.test(output)) return "fail";
161
+ if (/\bBUILD SUCCESSFUL\b/.test(output)) return "pass";
162
+ }
163
+ if (isPiped(cmd) || /\|\|\s*(?:true|:|exit 0)/.test(cmd)) return null;
164
+ const code = exitCode ?? (isError === true ? 1 : isError === false ? 0 : null);
165
+ if (code == null) return null;
166
+ // The exit status belongs to the test only when the shell structure says so: the test
167
+ // runs last after nothing but setup (`cd x && pytest`, `ulimit -n 4096; pytest`), or a
168
+ // pure `&&` chain exits 0 (every step, the test included, succeeded). `npm test; true`
169
+ // exits 0 because of `true`, and a non-zero `make && pytest` may never have run pytest.
170
+ const { parts, seps } = chain(cmd);
171
+ const last = parts.length - 1;
172
+ const testLast = isTestCommand(parts[last]);
173
+ // the last command always runs after `;` or a newline, so its status is the script's;
174
+ // after `&&` (or under `set -e`) an earlier failure could stop the script first
175
+ const lastAlwaysRuns = seps.every((s, i) => s !== "&&" || SETUP.test(parts[i] || "")) && !/\bset\s+-\w*e/.test(cmd);
176
+ if (testLast && (code === 0 || lastAlwaysRuns || parts.slice(0, last).every((p) => SETUP.test(p)))) return code === 0 ? "pass" : "fail";
177
+ if (code === 0 && seps.every((s) => s === "&&") && parts.some((p) => isTestCommand(p))) return "pass";
178
+ return null;
179
+ }
180
+
181
+ const SETUP = /^\s*(?:cd|pushd|export|source|\.|ulimit|set|unset|umask|nvm\s+use|conda\s+activate|(?:\w+=\S*\s*)+$)/;
182
+
183
+ /** Simple commands and the separators between them (heredoc bodies and quoted text ignored). */
184
+ function chain(cmd) {
185
+ const flat = stripHeredocs(cmd).replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "Q");
186
+ const seps = flat.match(/&&|\|\||;|\n/g) || [];
187
+ const parts = splitCommands(cmd);
188
+ return { parts: parts.length ? parts : [cmd], seps };
189
+ }
190
+
191
+ /**
192
+ * Files a shell command writes. `paths` are the targets we can name (sed -i, perl -i,
193
+ * redirects, tee, cp/mv destinations); `unresolved` is set for writes we can't pin to a
194
+ * file (scripts calling write_text, git checkout/apply/merge, patch).
195
+ */
196
+ export function shellWrites(cmd) {
197
+ const flat = stripHeredocs(cmd).replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "Q");
198
+ const paths = [];
199
+ for (const part of flat.split(/\s*(?:&&|\|\||;|\n|\|)\s*/)) {
200
+ const words = part.trim().split(/\s+/);
201
+ const tool = words.find((w) => !/^\w+=/.test(w) && w !== "sudo");
202
+ if ((tool === "sed" && words.some((w) => /^-\w*i/.test(w))) || (tool === "perl" && words.some((w) => /^-\w*i/.test(w)))) {
203
+ paths.push(...words.slice(1).filter((w) => !w.startsWith("-") && w !== "Q" && /[./]/.test(w) && !/^[>|]/.test(w)));
204
+ }
205
+ if ((tool === "cp" || tool === "mv") && words.length >= 3) paths.push(words[words.length - 1]);
206
+ for (const m of part.matchAll(/(?:^|[^\d&<>])>{1,2}\s*([^\s;&|<>]+)/g)) if (m[1] !== "/dev/null" && m[1] !== "Q") paths.push(m[1]);
207
+ for (const m of part.matchAll(/\btee\s+(?:-a\s+)?([^\s;&|<>]+)/g)) if (m[1] !== "/dev/null") paths.push(m[1]);
208
+ // PowerShell
209
+ for (const m of part.matchAll(/\b(?:Set-Content|Add-Content|Out-File)\b(?:\s+-(?:Path|FilePath|LiteralPath))?\s+([^\s;&|<>-][^\s;&|<>]*)/gi)) paths.push(m[1]);
210
+ }
211
+ // Git operations that rewrite the working tree (not merge-base, not checkout -b).
212
+ let unresolved = /\bgit\s+(?:apply|checkout(?!\s+-[bB]\b)|restore|stash\s+pop|merge(?!-)|cherry-pick|am|revert|reset\s+--(?:hard|merge|keep))\b|\bpatch\s+-|\bapply_patch\b/.test(cmd);
213
+ // A script (python/node) that writes files: the files it clearly writes are edits
214
+ // (source files among them; reports and logs are ignored). Files it only reads are not.
215
+ // A write whose target we can't name leaves the change unresolved.
216
+ const script = scriptWrites(cmd);
217
+ paths.push(...script.paths.filter((p) => isCodePath(p)));
218
+ if (script.unresolved) unresolved = true;
219
+ // `$S/corr.py` or `$(mktemp)`: a path we can't resolve, almost always a scratch file
220
+ const named = paths.filter((p) => p && p !== "Q" && !/[$`]/.test(p));
221
+ return { paths: [...new Set(named)], unresolved };
222
+ }
223
+
224
+ const LIT = String.raw`["']([\w.~\/-]+\.[A-Za-z]{1,5})["']`;
225
+ const MODE_W = String.raw`\s*,\s*(?:mode\s*=\s*)?["'][wax]`;
226
+ // every place a script writes a file (not sys.stdout.write, not reads)
227
+ const WRITE_SITES = new RegExp(String.raw`\.(?:write_text|write_bytes)\(|\bopen\([^()]*(?:\([^()]*\)[^()]*)*${MODE_W}|\.open\(\s*["'][wax]|\bwriteFileSync\(|\bappendFileSync\(|\.extractall\(|\bshutil\.(?:copy\w*|move)\(|\bos\.(?:replace|rename)\(`, "g");
228
+ // what a path variable can be built from: Path('x.py'), root / 'x.py', os.path.join(root, 'x.py')
229
+ const PATH_EXPR = String.raw`(?:(?:pathlib\.)?Path\(\s*(?:[\w.]+\s*,\s*)?|(?:os\.)?path\.join\([^)]*?,\s*|(?:[\w.]+(?:\(\))?\s*\/\s*)+)?`;
230
+
231
+ /** Files a python/node script in a command writes: { paths, unresolved }. */
232
+ function scriptWrites(cmd) {
233
+ const sites = (cmd.match(WRITE_SITES) || []).length;
234
+ if (!sites) return { paths: [], unresolved: false };
235
+ const paths = [];
236
+ let resolved = 0;
237
+ const add = (p) => {
238
+ paths.push(p);
239
+ resolved++;
240
+ };
241
+ // open('x.py', 'w'), Path('x.py').write_text(…), writeFileSync('x.js', …)
242
+ for (const m of cmd.matchAll(new RegExp(String.raw`\bopen\(\s*${LIT}${MODE_W}`, "g"))) add(m[1]);
243
+ for (const m of cmd.matchAll(new RegExp(String.raw`Path\(\s*${LIT}\s*\)\s*\.(?:write_text|write_bytes|open\(\s*["'][wax])`, "g"))) add(m[1]);
244
+ for (const m of cmd.matchAll(new RegExp(String.raw`\(\s*(?:[\w.]+(?:\(\))?\s*\/\s*)+${LIT}\s*\)\s*\.(?:write_text|write_bytes)\(`, "g"))) add(m[1]);
245
+ for (const m of cmd.matchAll(new RegExp(String.raw`\b(?:writeFileSync|appendFileSync)\(\s*${LIT}`, "g"))) add(m[1]);
246
+ // p = Path('x.py') or p = 'x.py', later p.write_text(…) / open(p, 'w') / writeFileSync(p, …)
247
+ for (const m of cmd.matchAll(new RegExp(String.raw`(?:\b(?:const|let|var)\s+)?\b([A-Za-z_]\w*)\s*=\s*${PATH_EXPR}${LIT}`, "g"))) {
248
+ const [, v, p] = m;
249
+ const writes = cmd.match(new RegExp(String.raw`\b${v}\s*\.(?:write_text|write_bytes)\(|\bopen\(\s*${v}${MODE_W}|\b${v}\.open\(\s*["'][wax]|\b(?:writeFileSync|appendFileSync)\(\s*${v}\b`, "g"));
250
+ if (writes) {
251
+ paths.push(p);
252
+ resolved += writes.length;
253
+ }
254
+ }
255
+ return { paths: [...new Set(paths)], unresolved: resolved < sites };
256
+ }
257
+
258
+ /**
259
+ * Could this command have changed any file at all? Broader than shellWrites: the
260
+ * test-only-fix check uses it so a fix made through the shell isn't missed.
261
+ */
262
+ export function mayWriteFiles(cmd) {
263
+ return /\bsed\s+(?:-\w*\s+)*-i|\bperl\s+-\w*i|write_text\(|write_bytes\(|\.write\(|open\([^)]*['"][wa]|\btee\b|\bapply_patch\b|\bgit\s+(?:apply|checkout|restore|stash\s+pop|merge|cherry-pick|am|revert)\b|\b(?:mv|cp|patch)\s|(?:^|[^\d&])>{1,2}\s*(?!\/dev\/null|&)\S/.test(cmd);
264
+ }
265
+
266
+ // --- claims -------------------------------------------------------------------------
267
+
268
+ const N = String.raw`\d[\d,]*`; // "1,760"
269
+ const CLAIM_PATTERNS = [
270
+ new RegExp(String.raw`\ball\s+(?:${N}\s+)?(?:of\s+the\s+)?(?:[\w-]+\s+){0,3}tests?\s+(?:now\s+|still\s+)?(?:pass(?:es|ed)?|are\s+passing|succeed(?:ed)?|are\s+green)\b`, "i"),
271
+ /\b(?:the\s+)?(?:[\w-]+\s+)?tests?\s+(?:now\s+|all\s+|still\s+|both\s+)?(?:pass(?:es|ed)?|are\s+(?:all\s+)?passing|is\s+passing|succeed(?:ed)?|are\s+(?:all\s+)?green)\b/i,
272
+ /\b(?:test\s+suite|suite)\s+(?:now\s+|still\s+)?(?:is\s+)?(?:green|passes|passed|passing)\b/i,
273
+ new RegExp(String.raw`\b${N}\s*\/\s*${N}\s+(?:[\w-]+\s+){0,2}tests?\s+pass`, "i"),
274
+ new RegExp(String.raw`\b${N}\s+passed\b|\b${N}\s+(?:[\w-]+\s+){0,2}tests?\s+passed\b`, "i"),
275
+ /\ball\s+green\b/i,
276
+ /\btests?\s+(?:are\s+)?(?:still\s+)?green\b/i,
277
+ /✅\s*(?:all\s+)?tests?\b/i,
278
+ // a test file named as passing: "test_api.py passes", "cart.test.ts is green"
279
+ /\b[\w.-]*(?:test_[\w-]+|[\w-]+[._]test|[\w-]+\.spec)\.[a-z]{1,4}\b(?:\s+\w+){0,2}?\s+(?:pass(?:es|ed)?|is\s+passing|is\s+green)\b/i,
280
+ /\b(\d[\d,]*)\s*\/\s*\1\s+(?:[\w-]+\s+){0,2}tests?\b/i, // "447/447 tests"
281
+ ];
282
+
283
+ // The general rule behind the patterns above: a test noun and a pass verb in one
284
+ // sentence ("the branch passes 2071 tests", "they all pass (51 tests)").
285
+ // Within five words either way, and not a compound like "1,503-test VM pass".
286
+ const NEAR = [
287
+ /(?<!-)\b(?:tests?|suite|specs?)\b(?:\W+\w+){0,5}?\W+(?:pass(?:es|ed)?|passing|green|succeed(?:s|ed)?)\b/i,
288
+ /\b(?:pass(?:es|ed)?|passing)\b(?:\W+[\w,]+){0,5}?\W+tests?\b/i,
289
+ /\btests?\b.*\ball\s+[\d,]+\s+(?:now\s+)?pass(?:ed)?\b/i, // "added tests …, so all 35 pass"
290
+ ];
291
+
292
+ // Other languages agents commonly answer in. Kept simple: subject + pass verb.
293
+ const L = String.raw`(?:\s+\p{L}+){0,3}\s+`;
294
+ const OTHER_LANGS = [
295
+ new RegExp(String.raw`(?<!\p{L})(?:τεστ|tests?|δοκιμές|suite)${L}(?:περνούν|περνάνε|πέρασαν|περνάει|πέρασε|πέρασε\s+πλήρως|είναι\s+πράσινα)(?!\p{L})`, "iu"),
296
+ new RegExp(String.raw`\b(?:pruebas|tests?)${L}(?:pasan|pasaron|están\s+en\s+verde)(?!\p{L})`, "iu"),
297
+ new RegExp(String.raw`\b(?:testes|tests?)${L}(?:passam|passaram|estão\s+(?:verdes|passando))(?!\p{L})`, "iu"),
298
+ new RegExp(String.raw`\b(?:tests?)${L}(?:sind\s+grün|bestanden|laufen\s+(?:durch|grün)|sind\s+erfolgreich)(?!\p{L})`, "iu"),
299
+ new RegExp(String.raw`\b(?:tests?)${L}(?:passent|sont\s+(?:au\s+)?verts?|ont\s+réussi|réussissent)(?!\p{L})`, "iu"),
300
+ new RegExp(String.raw`\b(?:test)${L}(?:passano|sono\s+(?:tutti\s+)?verdi|superati)(?!\p{L})`, "iu"),
301
+ ];
302
+ const OTHER_HEDGES = /(?<!\p{L})(?:δεν|θα|αν|όταν|no|si|cuando|nicht|kein\w*|wenn|ne|n'|quand|não|se|quando|non)(?!\p{L})|\?/iu;
303
+
304
+ const HEDGES =
305
+ /\b(?:claim(?:s|ed|ing)?|agents?|issues?\s+\[?#|I['’]ll|I\s+will|I['’]m\s+going|going\s+to|confirm|plan(?:s|ning)?\s+(?:to|is)|next|then|not|no\s+longer|fail(?:s|ed|ing|ures?)?|should|would|could|will|might|may|to\s+pass|make\s+sure|ensure|until|unless|if|whether|once|after\s+(?:you|we)|verify|check\s+(?:that|whether)|expect(?:ed)?|need(?:s|ed)?\s+to|want|so\s+that|in\s+order|let['’]s|let\s+me|running|run\s+(?:the|it|them)|before|previously|earlier|originally|(?:against|on|with|for)\s+the\s+original|pre-?existing|baseline|unrelated|except|flaky|mostly|partly|partially|in\s+isolation|despite|continuing|still\s+running|recorded|pre-?fix|so\s+far|mixed|otherwise|blocked|(?:on|in)\s+CI|GitHub\s+Actions|by\s+(?:value|reference)|on\s+(?:the\s+)?(?:device|hardware|phone|emulator|simulator|Pixel|iPhone)|over\s+USB|manual(?:ly)?|the\s+other\s+[\d,]+)\b|\b(?!all\b)\w+\s+[\d,]+\s+of\s+[\d,]+\b|n['’]t\b|\?/i;
306
+
307
+ /** Return the sentence in which the agent claims tests pass, or null. */
308
+ export function findTestClaim(text) {
309
+ return findTestClaims(text, 1)[0] ?? null;
310
+ }
311
+
312
+ /** Every sentence in which the agent claims tests pass, in order (at most `limit`). */
313
+ export function findTestClaims(text, limit = Infinity) {
314
+ const out = [];
315
+ if (!text || text.length > 20000) return out;
316
+ // Inline code and short quotes are masked for the checks below and restored in the
317
+ // sentence that is returned, so a claim is quoted as the agent wrote it.
318
+ const kept = [];
319
+ const mask = (orig, shown) => {
320
+ kept.push(orig);
321
+ return `\u0000${kept.length - 1}:${shown}\u0000`;
322
+ };
323
+ const restore = (s) => s.replace(/\u0000(\d+):[^\u0000]*\u0000/g, (_, i) => kept[Number(i)]);
324
+ const masked = text
325
+ .replace(/```[\s\S]*?```/g, " ") // pasted runner output is evidence, not a claim
326
+ .replace(/`([^`\n]*)`/g, (m, code) => (/\d[\d,]*\s+passed\b/.test(code) ? code : mask(m, "CODE")))
327
+ .replace(/"[^"\n]{0,80}"|“[^”\n]{0,80}”/g, (m) => mask(m, "QUOTE")); // talking about the phrase "tests pass"
328
+ for (const raw of masked.split(/(?<=[.!;\n])\s+|\n+/)) {
329
+ const shown = raw.trim();
330
+ const sentence = shown.replace(/\u0000\d+:([^\u0000]*)\u0000/g, "$1");
331
+ if (!sentence || sentence.length > 400) continue;
332
+ if ((sentence.match(/\|/g) || []).length >= 2) continue; // table rows
333
+ // "0 failed" and "3 skipped" don't contradict a pass
334
+ const checked = sentence.replace(/\b(?:0|zero|no)\s+(?:[\w-]+\s+)?(?:failed|failures?|errors?)\b|\b[\d,]+\s+skipped\b/gi, "");
335
+ // "6/7 suites pass" is a partial result; "48/48" is not
336
+ if ([...checked.matchAll(/\b(\d+)\s*\/\s*(\d+)\b/g)].some((m) => m[1] !== m[2])) continue;
337
+ const english =
338
+ CLAIM_PATTERNS.some((rx) => rx.test(sentence)) ||
339
+ (NEAR.some((rx) => rx.test(sentence)) && !/^\W*pass\b/i.test(sentence));
340
+ const claim = (english && !HEDGES.test(checked)) || (!english && OTHER_LANGS.some((rx) => rx.test(sentence)) && !OTHER_HEDGES.test(checked));
341
+ if (claim) {
342
+ out.push(restore(shown));
343
+ if (out.length >= limit) break;
344
+ }
345
+ }
346
+ return out;
347
+ }
348
+
349
+ /** A claim about the whole suite: "the full suite passes", "entire test suite green". */
350
+ export function claimsFullSuite(sentence) {
351
+ return /\b(?:full|whole|entire|complete)\s+(?:test\s+)?(?:suite|test\s+run|run)\b|\bevery\s+test\b/i.test(sentence);
352
+ }
353
+
354
+ // CI results: judged only with --ci, against GitHub Actions. "CI is green", "all checks pass
355
+ // on the PR", "GitHub Actions passed".
356
+ const CI_CLAIM =
357
+ /\b(?:CI|GitHub\s+Actions|(?:the\s+)?(?:workflow|pipeline)s?|(?:PR|pull\s+request)\s+checks|(?:all\s+)?checks\s+(?:on|in|for)\s+(?:the\s+)?(?:PR|pull\s+request|branch|GitHub))\b(?:\W+\w+){0,4}?\W+(?:green|pass(?:es|ed|ing)?|succeed(?:s|ed)?|successful|succeeded)\b|\b(?:green|pass(?:es|ed)?|succeeded)\s+(?:on|in)\s+(?:CI|GitHub\s+Actions)\b|\bCI\s*(?:is\s+)?✅/i;
358
+ const CI_HEDGES = new RegExp(HEDGES.source.replace("|(?:on|in)\\s+CI|GitHub\\s+Actions", ""), "i");
359
+
360
+ /** Return the sentence in which the agent says CI passed, or null. */
361
+ export function findCiClaim(text) {
362
+ if (!text || text.length > 20000) return null;
363
+ const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, "CODE").replace(/"[^"\n]{0,80}"|“[^”\n]{0,80}”/g, "QUOTE");
364
+ for (const raw of prose.split(/(?<=[.!;\n])\s+|\n+/)) {
365
+ const sentence = raw.trim();
366
+ if (!sentence || sentence.length > 400 || (sentence.match(/\|/g) || []).length >= 2) continue;
367
+ const checked = sentence.replace(/\b(?:0|zero|no)\s+(?:[\w-]+\s+)?(?:failed|failures?|errors?)\b|\b[\d,]+\s+skipped\b/gi, "");
368
+ if (CI_CLAIM.test(sentence) && !CI_HEDGES.test(checked)) return sentence;
369
+ }
370
+ return null;
371
+ }
372
+
373
+ // --- files ----------------------------------------------------------------------------
374
+
375
+ const TEST_PATH =
376
+ /(?:^|\/)(?:tests?|__tests__|spec|specs)\/|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb|exs?)$|\.(?:test|spec)\.[cm]?[jt]sx?$|Tests?\.(?:java|kt|cs|swift)$|(?:^|\/)conftest\.py$/;
377
+
378
+ export function isTestPath(p) {
379
+ return TEST_PATH.test(p.replace(/\\/g, "/"));
380
+ }
381
+
382
+ // Only edits to source files make an earlier test run stale. Config, docs, data and
383
+ // generated output are ignored, as are hidden tool dirs such as agent worktrees.
384
+ const SOURCE = /\.(?:py|pyx|ipynb|[cm]?[jt]sx?|vue|svelte|go|rs|java|kt|kts|scala|groovy|rb|php|cs|fs|swift|m|mm|c|h|cc|cpp|hpp|cxx|zig|ex|exs|erl|hs|ml|clj|dart|lua|r|jl|sql)$/i;
385
+ const IGNORED_DIR = /(?:^|\/)(?:output|outputs|reports?|dist|build|coverage|node_modules|__pycache__|vendor|target)\/|(?:^|\/)\.[^/]+\//i;
386
+
387
+ export function isCodePath(p) {
388
+ return SOURCE.test(p) && !IGNORED_DIR.test(p);
389
+ }
390
+
391
+ // --- test weakening inside a single edit ------------------------------------------------
392
+
393
+ const ASSERT_LINE = /\bassert\w*\b|\bexpect\s*\(|\bself\.assert\w+|\bt\.(?:Error|Errorf|Fatal|Fatalf)\b|\b(?:require|assert)\.\w+\s*\(|\bshould\b\.|\.to(?:Be|Equal|Have|Throw|Match|Contain)\w*\(/;
394
+ const SKIP_MARK =
395
+ /@pytest\.mark\.(?:skip|skipif|xfail)\b|@unittest\.(?:skip\w*|expectedFailure)\b|\bpytest\.(?:skip|xfail)\s*\(|\bself\.skipTest\s*\(|\b(?:it|test|describe)\.(?:skip|todo)\b|\bx(?:it|test|describe)\s*\(|\bt\.Skip\w*\s*\(|#\[ignore\]|@Disabled\b|@Ignore\b/;
396
+ const FOCUS_MARK = /\b(?:it|test|describe)\.only\b|\bf(?:it|describe)\s*\(/;
397
+ const TRIVIAL = /\bassert\s+(?:True|1|not\s+False)\s*(?:$|#)|\bexpect\s*\(\s*(?:true|1)\s*\)\s*\.\s*toBe(?:Truthy)?\s*\(\s*(?:true|1)?\s*\)|\bassertTrue\s*\(\s*True\s*\)/;
398
+ const TEST_DEF = /^\s*(?:async\s+)?def\s+test\w*|\b(?:it|test)(?:\.(?:only|skip|todo|each|concurrent))?\s*\(\s*['"`]|^func\s+Test\w*|@Test\b|#\[test\]/;
399
+
400
+ const count = (lines, rx) => lines.filter((l) => rx.test(l)).length;
401
+
402
+ export const assertionLines = (lines) => (lines || []).filter((l) => ASSERT_LINE.test(l));
403
+
404
+ /**
405
+ * Compare removed vs added lines of one edit to a test file.
406
+ * Returns a list of short reasons (empty when nothing looks weakened).
407
+ */
408
+ export function weakening(removed, added) {
409
+ const reasons = [];
410
+ const strip = (ls) => ls.filter((l) => !/^\s*(?:#|\/\/)/.test(l));
411
+ const r = strip(removed);
412
+ const a = strip(added);
413
+ const lostAsserts = count(r, ASSERT_LINE) - count(a, ASSERT_LINE);
414
+ const lostTests = count(r, TEST_DEF) - count(a, TEST_DEF);
415
+ if (lostTests > 0) reasons.push(`removed ${lostTests} test case${lostTests > 1 ? "s" : ""}`);
416
+ if (lostAsserts > 0) reasons.push(`removed ${lostAsserts} assertion${lostAsserts > 1 ? "s" : ""}`);
417
+ const newTests = count(a, TEST_DEF) > count(r, TEST_DEF);
418
+ if (!newTests && count(a, SKIP_MARK) > count(r, SKIP_MARK)) reasons.push("added skip/xfail");
419
+ if (count(a, FOCUS_MARK) > count(r, FOCUS_MARK)) reasons.push("added .only");
420
+ if (count(a, TRIVIAL) > count(r, TRIVIAL)) reasons.push("added always-true assertion");
421
+ return reasons;
422
+ }
423
+
424
+ // --- risky commands -------------------------------------------------------------------
425
+
426
+ // Only git operations, matched at command position: these destroy work or skip checks,
427
+ // and unlike `rm -rf` on a temp dir they are almost never routine.
428
+ export const RISKY = [
429
+ { id: "no-verify", label: "skipped git hooks with --no-verify", rx: /^git\b.*\s--no-verify\b/ },
430
+ { id: "force-push", label: "force-pushed", rx: /^git\s+push\b.*\s(?:--force|-f)(?:\s|$)/ },
431
+ { id: "reset-hard", label: "ran git reset --hard", rx: /^git\s+reset\b.*\s--hard\b/ },
432
+ { id: "discard", label: "discarded uncommitted changes", rx: /^git\s+(?:checkout\s+(?:--\s+)?\.$|restore\s+(?:--\S+\s+)*\.$|clean\s+-\w*f)/ },
433
+ ];
434
+
435
+ export function riskyIn(cmd) {
436
+ // quoted text is data (commit messages, test strings, `node -e` snippets), not commands
437
+ const unquoted = stripHeredocs(cmd).replace(/'[^'\n]*'|"(?:[^"\\\n]|\\.)*"/g, "Q");
438
+ const parts = splitCommands(unquoted).map((p) => p.replace(/^(?:\w+=\S*\s+)*(?:sudo\s+)?/, ""));
439
+ return RISKY.filter((r) => parts.some((p) => r.rx.test(p)));
440
+ }
441
+
442
+ // --- user corrections ------------------------------------------------------------------
443
+
444
+ const CORRECTION =
445
+ /^(?:no\b|nope\b|stop\b|don'?t\b|that'?s\s+(?:wrong|not)|wrong\b|why\s+(?:did|are|would)\s+you|revert|undo)|\bi\s+(?:told|asked)\s+you\b|\b(?:i\s+said|again\?|you\s+(?:broke|deleted|ignored|forgot|didn'?t)|not\s+what\s+i\s+(?:asked|wanted)|that'?s\s+not\s+what)\b/i;
446
+
447
+ export function isCorrection(text) {
448
+ if (!text) return false;
449
+ const t = text.trim();
450
+ if (t.length > 1500) return false; // long messages are usually new instructions or pastes
451
+ return CORRECTION.test(t);
452
+ }
453
+
454
+ // --- which run a claim refers to ---------------------------------------------------------
455
+
456
+ const num = (s) => Number(String(s).replace(/,/g, ""));
457
+
458
+ /** How many tests a runner says passed ("12 passed", "ℹ pass 12", "Ran 12 tests … OK"). */
459
+ export function passedCount(output) {
460
+ if (!output) return null;
461
+ const m =
462
+ /\b(\d[\d,]*) passed\b/.exec(output) ||
463
+ /(?:^|\s)(?:ℹ|#) pass (\d+)(?:\s+(?:ℹ|#) fail \d+)?\s*$/m.exec(output) ||
464
+ /\bTests?:\s+(\d+) passed/.exec(output) ||
465
+ /\btest result: ok\. (\d+) passed/.exec(output) ||
466
+ /^Ran (\d+) tests? in [\d.]+s\s+OK\b/m.exec(output);
467
+ return m ? num(m[1]) : null;
468
+ }
469
+
470
+ /** Counts a claim names: "31/31", "all 17 atom tests", "62 passed". */
471
+ export function claimedCounts(text) {
472
+ const out = new Set();
473
+ for (const m of text.matchAll(/\b(\d[\d,]*)\s*\/\s*(\d[\d,]*)\b/g)) if (num(m[1]) === num(m[2])) out.add(num(m[1]));
474
+ // only counts tied to passing: "62 passed", "17 atom tests pass", "passes 2071 tests";
475
+ // not "there are 11 regression tests, and the suite passed"
476
+ for (const rx of [
477
+ /\b(\d[\d,]*)\s+(?:tests?\s+)?(?:passed|passing)\b/gi,
478
+ /\b(\d[\d,]*)\s+(?:[\w-]+\s+){0,3}?tests?\s+(?:all\s+|now\s+|still\s+)?(?:pass(?:es|ed)?|passing)\b/gi,
479
+ /\bpass(?:es|ed)?\s+(?:all\s+)?(\d[\d,]*)\b/gi,
480
+ ]) for (const m of text.matchAll(rx)) out.add(num(m[1]));
481
+ return out;
482
+ }
483
+
484
+ /** Test files a claim names: test_api.py, cart.test.ts, server_test.go. */
485
+ export function claimedTestFiles(text) {
486
+ return [...text.matchAll(/\b[\w.-]*(?:test_[\w-]+|[\w-]+[._]test|[\w-]+\.spec)\.[a-z]{1,4}\b/gi)].map((m) => m[0]);
487
+ }
488
+
489
+ /**
490
+ * Expand variables assigned earlier in the same command, so `S=/tmp/x; cat > $S/f.py` and
491
+ * `cd "$W"` resolve. Values built from command output (`$(mktemp)`) stay unexpanded.
492
+ * `env` supplies known values such as HOME.
493
+ */
494
+ export function expandVars(cmd, env = {}) {
495
+ const vars = { ...env };
496
+ const sub = (v) => v.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (m, k) => (k in vars ? vars[k] : m));
497
+ // sh: NAME=value, export NAME=value; PowerShell: $name = "value"
498
+ const rx = /(?:^|[;&\n]\s*|\s)(?:export\s+)?([A-Za-z_]\w*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|"'`]+))|\$([A-Za-z_]\w*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
499
+ for (const m of cmd.matchAll(rx)) {
500
+ const name = m[1] ?? m[5];
501
+ const raw = m[2] ?? m[3] ?? m[4] ?? m[6] ?? m[7] ?? "";
502
+ if (/\$\(|`/.test(raw)) continue; // computed at run time
503
+ vars[name] = m[3] !== undefined || m[7] !== undefined ? raw : sub(raw);
504
+ }
505
+ return sub(cmd);
506
+ }
package/src/demo.js ADDED
@@ -0,0 +1,120 @@
1
+ // `--demo`: write synthetic Claude Code and Codex sessions to a temp dir and report on
2
+ // them, so people can see the output (and screenshots never contain anyone's real data).
3
+ // The numbers are invented; they say nothing about how real agents behave.
4
+
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ export function makeDemo(seed = 11) {
10
+ const rand = mulberry32(seed);
11
+ const pick = (xs) => xs[Math.floor(rand() * xs.length)];
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "polygraph-demo-"));
13
+ const claudeDir = path.join(root, "claude");
14
+ const codexDir = path.join(root, "codex");
15
+ const start = Date.UTC(2026, 7, 10, 9);
16
+
17
+ const projects = ["shop-api", "billing-service", "web-dashboard", "cli-tools", "data-pipeline"];
18
+ const runners = [
19
+ { full: "pytest -q", focused: "pytest -q tests/test_orders.py", pass: (n) => `${n} passed in 3.2s`, fail: (n) => `2 failed, ${n} passed in 3.4s`, src: "src/orders.py", test: "tests/test_orders.py" },
20
+ { full: "npm test", focused: "npx vitest run src/cart.test.ts", pass: (n) => `Tests: ${n} passed, ${n} total`, fail: (n) => `Tests: 1 failed, ${n} passed, ${n + 1} total`, src: "src/cart.ts", test: "src/cart.test.ts" },
21
+ { full: "go test ./...", focused: "go test ./pkg/billing", pass: () => "ok \tapp/pkg/billing\t0.41s", fail: () => "--- FAIL: TestInvoice (0.00s)\nFAIL", src: "pkg/billing/invoice.go", test: "pkg/billing/invoice_test.go" },
22
+ ];
23
+
24
+ let t = start;
25
+ const tick = (s) => (t += s * 1000);
26
+ const iso = () => new Date(t).toISOString();
27
+
28
+ for (let i = 0; i < 120; i++) {
29
+ const agent = i % 2 ? "codex" : "claude";
30
+ const project = pick(projects);
31
+ const cwd = `/work/${project}`;
32
+ const r = pick(runners);
33
+ t = start + i * 12 * 3600 * 1000 + Math.floor(rand() * 4 * 3600 * 1000);
34
+ const ev = []; // abstract events, rendered per agent below
35
+
36
+ ev.push(["user", pick(["fix the failing checkout flow", "add retries to the invoice job", "the cart total is off by one", "refactor the order service"])]);
37
+ const runs = 2 + Math.floor(rand() * 5);
38
+ let n = 40 + Math.floor(rand() * 300);
39
+ for (let k = 0; k < runs; k++) {
40
+ const full = rand() < 0.3;
41
+ const fails = rand() < 0.22;
42
+ ev.push(["edit", r.src, [" total = subtotal"], [" total = subtotal + tax"]]);
43
+ ev.push(["test", full ? r.full : r.focused, fails ? r.fail(n) : r.pass(n), fails ? 1 : 0, full ? 60 + rand() * 300 : 4 + rand() * 30]);
44
+ if (fails && rand() < 0.05) {
45
+ // rewrite the assertion instead of the code, then rerun
46
+ ev.push(["edit", r.test, [" assert total == 108", " assert len(lines) == 3"], [" assert total"]]);
47
+ ev.push(["test", r.focused, r.pass(n), 0, 6]);
48
+ }
49
+ }
50
+ const roll = rand();
51
+ // the same odds for both agents (the demo must not suggest one is better), in the
52
+ // range real logs show: most claims backed, a few stale, rarely after a failure
53
+ const ok = 0.91;
54
+ if (roll < ok) ev.push(["test", r.focused, r.pass(n), 0, 8], ["say", `Done. All ${n} tests pass.`]);
55
+ else if (roll < ok + 0.06) ev.push(["test", r.focused, r.pass(n), 0, 8], ["edit", r.src, ["x"], ["y"]], ["say", "Fixed, and the tests pass."]);
56
+ else ev.push(["test", r.focused, r.fail(n), 1, 8], ["say", `The suite passed: ${n} tests.`]);
57
+ if (rand() < 0.25) ev.push(["user", pick(["no, don't change the public API", "I told you not to touch the migrations", "revert that last change"])]);
58
+
59
+ if (agent === "claude") writeClaude(claudeDir, project, `demo-${i}`, cwd, ev, tick, iso, pick(["claude-opus-5-5", "claude-sonnet-5-5"]));
60
+ else writeCodex(codexDir, `demo-${i}`, cwd, ev, tick, iso, pick(["gpt-6-sol", "gpt-6-astra"]));
61
+ }
62
+ return { root, claudeDir, codexDir };
63
+ }
64
+
65
+ function writeClaude(dir, project, id, cwd, ev, tick, iso, model) {
66
+ const out = [];
67
+ const base = { cwd, sessionId: id, isSidechain: false };
68
+ let n = 0;
69
+ for (const e of ev) {
70
+ tick(5);
71
+ if (e[0] === "user") out.push({ ...base, type: "user", timestamp: iso(), origin: { kind: "human" }, message: { content: e[1] } });
72
+ else if (e[0] === "say") out.push({ ...base, type: "assistant", timestamp: iso(), message: { model, content: [{ type: "text", text: e[1] }] } });
73
+ else if (e[0] === "edit") {
74
+ out.push({ ...base, type: "assistant", timestamp: iso(), message: { model, content: [{ type: "tool_use", id: `t${++n}`, name: "Edit", input: { file_path: `${cwd}/${e[1]}`, old_string: e[2].join("\n"), new_string: e[3].join("\n") } }] } });
75
+ tick(1);
76
+ out.push({ ...base, type: "user", timestamp: iso(), message: { content: [{ type: "tool_result", tool_use_id: `t${n}`, content: "ok" }] } });
77
+ } else if (e[0] === "test") {
78
+ out.push({ ...base, type: "assistant", timestamp: iso(), message: { model, content: [{ type: "tool_use", id: `t${++n}`, name: "Bash", input: { command: e[1] } }] } });
79
+ tick(e[4]);
80
+ const text = (e[3] ? `Exit code ${e[3]}\n` : "") + e[2];
81
+ out.push({ ...base, type: "user", timestamp: iso(), message: { content: [{ type: "tool_result", tool_use_id: `t${n}`, content: text, is_error: !!e[3] }] } });
82
+ }
83
+ }
84
+ const p = path.join(dir, `-work-${project}`);
85
+ fs.mkdirSync(p, { recursive: true });
86
+ fs.writeFileSync(path.join(p, `${id}.jsonl`), out.map((o) => JSON.stringify(o)).join("\n") + "\n");
87
+ }
88
+
89
+ function writeCodex(dir, id, cwd, ev, tick, iso, model) {
90
+ const out = [{ timestamp: iso(), type: "session_meta", payload: { id, cwd } }, { timestamp: iso(), type: "turn_context", payload: { model, cwd } }];
91
+ const item = (payload) => out.push({ timestamp: iso(), type: "response_item", payload });
92
+ let n = 0;
93
+ for (const e of ev) {
94
+ tick(5);
95
+ if (e[0] === "user") item({ type: "message", role: "user", content: [{ type: "input_text", text: e[1] }] });
96
+ else if (e[0] === "say") item({ type: "message", role: "assistant", content: [{ type: "output_text", text: e[1] }] });
97
+ else if (e[0] === "edit") {
98
+ const patch = ["*** Begin Patch", `*** Update File: ${e[1]}`, "@@", ...e[2].map((l) => "-" + l), ...e[3].map((l) => "+" + l), "*** End Patch"].join("\n");
99
+ item({ type: "custom_tool_call", name: "apply_patch", call_id: `c${++n}`, input: patch });
100
+ item({ type: "custom_tool_call_output", call_id: `c${n}`, output: [{ type: "input_text", text: "Success" }] });
101
+ } else if (e[0] === "test") {
102
+ item({ type: "custom_tool_call", name: "exec", call_id: `c${++n}`, input: `const r = await tools.exec_command(${JSON.stringify({ cmd: e[1] })}); text(r.output);` });
103
+ tick(e[4]);
104
+ item({ type: "custom_tool_call_output", call_id: `c${n}`, output: [{ type: "input_text", text: `Script completed\nWall time ${e[4].toFixed(1)} seconds\n${JSON.stringify({ exit_code: e[3], output: e[2] })}` }] });
105
+ }
106
+ }
107
+ const p = path.join(dir, "2026", "09", "01");
108
+ fs.mkdirSync(p, { recursive: true });
109
+ fs.writeFileSync(path.join(p, `rollout-${id}.jsonl`), out.map((o) => JSON.stringify(o)).join("\n") + "\n");
110
+ }
111
+
112
+ function mulberry32(a) {
113
+ return () => {
114
+ a |= 0;
115
+ a = (a + 0x6d2b79f5) | 0;
116
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
117
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
118
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
119
+ };
120
+ }