glassbox-trace 0.11.0 → 0.12.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/claims.mjs CHANGED
@@ -20,11 +20,12 @@ export function userFacingTexts(trace) {
20
20
  const callsByReq = new Map();
21
21
  for (const c of trace.toolCalls) { if (!callsByReq.has(c.requestId)) callsByReq.set(c.requestId, []); callsByReq.get(c.requestId).push(c); }
22
22
  trace.requests.forEach((r, i) => {
23
- for (const b of r.blocks) if (b.type === 'text' && b.text && b.text.trim()) out.push({ request: r, index: i, agent: r.agent, via: 'text', text: b.text });
23
+ for (const b of r.blocks) if (b.type === 'text' && b.text && b.text.trim()) out.push({ request: r, index: i, agent: r.agent, via: 'text', text: b.text, blockIndex: b.index });
24
24
  for (const c of callsByReq.get(r.id) || []) {
25
25
  const inp = c.input || {};
26
- if (c.name === 'SendUserMessage' && typeof inp.message === 'string') out.push({ request: r, index: i, agent: r.agent, via: 'SendUserMessage', text: inp.message, call: c });
27
- if (c.name === 'SendUserFile' && typeof inp.caption === 'string') out.push({ request: r, index: i, agent: r.agent, via: 'SendUserFile', text: inp.caption, call: c });
26
+ const blockIndex = (r.blocks.find((b) => b.id === c.id) || {}).index;
27
+ if (c.name === 'SendUserMessage' && typeof inp.message === 'string') out.push({ request: r, index: i, agent: r.agent, via: 'SendUserMessage', text: inp.message, call: c, blockIndex });
28
+ if (c.name === 'SendUserFile' && typeof inp.caption === 'string') out.push({ request: r, index: i, agent: r.agent, via: 'SendUserFile', text: inp.caption, call: c, blockIndex });
28
29
  }
29
30
  });
30
31
  return out;
@@ -46,7 +47,7 @@ const DESCRIBES_RE = /\b(?:it|this|that|the\s+(?:guard|hook|rule|command|viewer|
46
47
 
47
48
  const KINDS = [
48
49
  ['test', /\b(?:tests?|suites?|specs?|e2e|audit|ci|checks?|lint(?:er)?|build|typecheck|type-check|validation|coverage)\b[^.]{0,80}?\b(?:pass(?:es|ed|ing)?|green|succeed(?:s|ed)?|ok|clean|no\s+failures|validates?|valid|passed)\b|\b(?:all|\d[\d,]*)\s*(?:of\s*\d[\d,]*\s*)?(?:tests?\s+)?(?:still\s+)?(?:pass(?:es|ed|ing)?|green)\b|\b\d[\d,]*\s*(?:\/\s*\d[\d,]*\s*)?(?:tests?|specs?)\s*(?:still\s+)?(?:pass|passing|passed|green|ok)\b|\b(?:plugin|manifest|schema|json-ld)\s+validates?\b|\b(?:ci|workflow|run\s+#?\d+|publish\s+#?\d+)\s+(?:is\s+|was\s+)?(?:green|succeeded|passed|completed)\b|\bexit(?:ed| code)?\s*0\b/i],
49
- ['ship', /^(?:both|all|everything|it|they)\s+(?:saved|written|uploaded|committed|pushed|deployed|published|live)\b|\b(?:all|both)\s+\d+\s+(?:saved|uploaded|live)\b|\ball\s+trimmed\s+and\s+live\b|\blive\s+and\s+(?:rendering|serving)\b|(?<!\b(?:the|a|an|its|my|your|this|our|that|one|each|these|those|two|three|four|five|six|last|previously)\s)\b(?:pushed(?!\s+back)|committed|commit(?:ted)?\s+(?:it\s+)?as\b|published|deployed|uploaded|merged|released|republished|shipped|posted)\b(?!\s+(?:blog|post|page|version|package|site|copy|report|article|files?|templates?|branch|folder|build|commits?)\b)|\b(?:is\s+(?:now\s+)?(?:live|released|on\s+npm|on\s+github|up(?!\s*(?:\d|\$|by\b|to\b|from\b|and\s+running\b)))|went\s+live|now\s+live|are\s+live|on\s+npm\b|in\s+production|is\s+on\s+main\b|(?:is|are|now|already|sits?)\s+(?:up\s+)?on\s+the\s+server)\b/i],
50
+ ['ship', /^(?:both|all|everything|it|they)\s+(?:saved|written|uploaded|committed|pushed|deployed|published|live)\b|\b(?:all|both)\s+\d+\s+(?:saved|uploaded|live)\b|\ball\s+trimmed\s+and\s+live\b|\blive\s+and\s+(?:rendering|serving)\b|(?<!\b(?:the|a|an|its|my|your|this|our|that|one|each|these|those|two|three|four|five|six|last|previously)\s)\b(?:pushed(?!\s+back)|committed|commit(?:ted)?\s+(?:it\s+)?as\b|published|deployed|uploaded|merged|released|republished|shipped|posted)\b(?!\s+(?:blog|post|page|version|package|site|copy|report|article|files?|templates?|branch|folder|build|commits?)\b)(?!\s+(?:lists?|data|guidance|rules|papers?|research|figures|prices|rates|numbers|sources?|terms|standards?|schedules?|polic(?:y|ies)|ranges)\b(?!\s+(?:to|into|onto|on|in|at)\b))|\b(?:is\s+(?:now\s+)?(?:live|released|on\s+npm|on\s+github|up(?!\s*(?:\d|\$|by\b|to\b|from\b|and\s+running\b)))|went\s+live|now\s+live|are\s+live|on\s+npm\b|in\s+production|is\s+on\s+main\b|(?:is|are|now|already|sits?)\s+(?:up\s+)?on\s+the\s+server)\b/i],
50
51
  ['state', /\b(?:nothing\s+(?:changed|was\s+changed|else\s+changed|left\s+uncommitted|was\s+left)|didn't\s+(?:disturb|touch|change)|(?:wasn't|weren't|not)\s+touched|untouched|unchanged|as\s+you\s+left\s+it|byte-identical|exactly\s+as\s+(?:you|I|we)\s+left|same\s+\d+\s+(?:staged|files)|no\s+(?:changes|diff|difference))\b/i],
51
52
  ['verify', /\b(?:all|both)\s+\d+\s+(?:done|ok|checked)\b|\b(?:verified|confirmed|double-checked|checked(?:\s+(?:that|it|the|all|every|against))?|validated|I\s+(?:ran|re-ran|tested|opened|loaded|looked|compared|hashed|checked)|renders\b|rendered\s+(?:fine|correctly|ok|as\s+expected)|loads\s+(?:fine|correctly|ok)|loaded\s+(?:fine|correctly|ok)|works?\s+(?:as\s+expected|fine|correctly|now|end-to-end)|working\s+(?:as\s+expected|fine|correctly|now)|round-?trips?|resolves?(?:d)?|matches?(?:\s+exactly)?|byte-exact|identical|(?:is|was|are|were|looks?|looked)\s+(?:fine|ok|good|correct|right)\b|has\s+(?:a\s+)?working\b|went\s+through\b|rendered(?=\s*[,.;]|\s+(?:fine|correctly|ok|properly|as\s+expected))|(?:took|measured|clocked|was|is|it's)\s+\d[\d.,]*\s*(?:ms|s|secs?|seconds?|minutes?|MB|KB|GB|bytes)\b|all\s+(?:green|\d+\s+done|\d+\s+ok)|\d+\s*\/\s*\d+,?\s*(?:zero|no)\s+errors?|tested\s+(?:it|this|that|locally|end\s+to\s+end)|no\s+errors?|zero\s+errors?|clean\b)\b/i],
52
53
  ['fix', /(?<!\b(?:the|a|its|my|your|this|our|that|one|each)\s)\bfixed\b(?!\s+(?:cost|price|fee|quote|rate|width|height|size|number|amount|set|point|term|period|version|one|copy|app))|\bfix\s+is\s+in\b|\b(?:resolved|patched|corrected|repaired)\b|\bno\s+longer\s+(?!needed|necessary|required|relevant|used|in\s+use|available|exists?|supported|part|the\s+case)\w+|\bnow\s+(?:works|handles|passes|renders|resolves|loads|decodes|parses|starts)\b|\bbug\s+(?:is\s+)?(?:fixed|gone)\b/i],
@@ -92,13 +93,18 @@ const inputText = (c) => { try { return JSON.stringify(c.input || {}); } catch (
92
93
  export const TEST_CMD = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|e2e|audit|lint|build|typecheck)\b|\bnode\s+--test\b|\bnode\s+(?:[\w./-]*\/)?test\/[\w.-]+\.m?js\b|\bpytest\b|\bpython3?\s+-m\s+(?:pytest|unittest)\b|\bcargo\s+test\b|\bgo\s+test\b|\bvitest\b|\bjest\b|\bmocha\b|\bplaywright\s+test\b|\btsc\b|\beslint\b|\bruff\b|\bmake\s+(?:test|check)\b|\bclaude\s+plugin\s+validate\b|\bdotnet\s+test\b|\bphpunit\b|\brspec\b|\bphp\s+-l\b|\bnode\s+(?:--check|-c)\b|\bpython3?\s+-m\s+py_compile\b|\bflake8\b|\bpylint\b|\bmypy\b|\bshellcheck\b|\bprettier\s+--check\b|\bbiome\s+(?:check|lint)\b|\bpython3?\s+(?:[\w./-]*\/)?(?:test_[\w-]+|[\w-]+_test|tests?)\.py\b|\bnode\s+(?:[\w./-]*\/)?[\w-]*test[\w-]*\.[cm]?js\b/i;
93
94
  const CI_RESULT = /completed[:\s]+success|conclusion["']?\s*[:=]\s*["']?success|completed\s+successfully|\bci\s+#?\d+[^.\n]{0,40}\b(?:green|success)|"status":\s*"completed"[^}]{0,80}"conclusion":\s*"success"|publish\s+run[^\n]{0,40}success/i;
94
95
  const CI_FAIL = /conclusion["']?\s*[:=]\s*["']?(?:failure|cancelled)|completed[:\s]+failure|\bfailed\b[^\n]{0,20}\bci\b/i;
95
- const FAIL_MARK = /\bnot ok\b|# fail [1-9]|\b[1-9]\d* fail(?:ed|ing|ures?)?\b|\bFAIL(?:ED)?\b|AssertionError|Error:|\bTypeError|\bReferenceError|\bSyntaxError|Tests:\s+\d+\s+failed|✗|✖|\bexit(?:ed|\s*code)?\s*[=:]?\s*[1-9]\d*\b|Cannot find module|MODULE_NOT_FOUND/;
96
+ const FAIL_MARK = /\bnot ok\b|# fail [1-9]|\b[1-9]\d* fail(?:ed|ing|ures?)?\b|\bFAIL(?:ED)?\b|AssertionError|Error:|\bTypeError|\bReferenceError|\bSyntaxError|Tests:\s+\d+\s+failed|✗|✖|\bexit(?:ed|\s*code)?\s*[=:]?\s*[1-9]\d*\b|\bexited with code [1-9]|Cannot find module|MODULE_NOT_FOUND|\bERROR\s+(?:collecting\b|[\w./-]+\.py\b)|Interrupted:\s*\d+\s+errors?/;
97
+ // Verdict lines with no counts: a browser self-test's `allPass`, a harness's RESULT line, a bare "all passed".
98
+ const RUN_OK = /^\s*(?:all\s+(?:tests\s+|checks\s+)?pass(?:ed)?|RESULT:?\s*PASS(?:ED)?)\s*$|"?allPass"?\s*:\s*true\b/im;
99
+ const RUN_BAD = /^\s*RESULT:?\s*FAIL(?:ED)?\b|"?allPass"?\s*:\s*false\b/im;
96
100
  // A test-like run's identity: the runner and file it names, so a failing e2e run does not contradict "58 of 58 pass"
97
101
  // said about the unit run beside it.
98
102
  const testKey = (c) => { const m = cmdOf(c).match(TEST_CMD); return m ? m[0].toLowerCase().replace(/\s+/g, ' ') : (c.name + ':' + (c.resultText || '').slice(0, 40)); };
99
103
  export function runFailed(c) {
100
- const r = c.resultText || ''; const k = testCounts(r);
101
- return !!(c.isError || (k ? k.fail > 0 : FAIL_MARK.test(r)) || (CI_FAIL.test(r) && !CI_RESULT.test(r)));
104
+ const r = c.resultText || ''; const ks = allCounts(r);
105
+ if (c.isError || (CI_FAIL.test(r) && !CI_RESULT.test(r))) return true;
106
+ if (ks.length) return ks.some((k) => k.fail > 0);
107
+ return RUN_BAD.test(r) || (!RUN_OK.test(r) && FAIL_MARK.test(r));
102
108
  }
103
109
  const SHELL_EDIT_RE = /\bsed\s+-i\b|python3?\s+-\s*<<|\bcat\s*>>?\s|\btee\b|\bmv\b|\bcp\b|>\s*[\w./-]+\s*(?:&&|;|$)/;
104
110
  const SHIP_CMD = /\bgit\s+(?:push|commit)\b|\bnpm\s+publish\b|\bgh\s+(?:pr|release)\b/;
@@ -113,18 +119,114 @@ const SHIP_TOOLS = new Set(['mcp__remote-devices__device_commit_files', 'Artifac
113
119
  const SOURCE_FILE = /(?:^|[\\/])(?:src|lib|test|tests|spec|specs|app|pkg|cmd|internal|hooks|bin|scripts)[\\/][^\\/]*\.(?:mjs|cjs|js|jsx|ts|tsx|py|rb|go|rs|java|kt|cs|php|c|cc|cpp|h|hpp|swift|sh|ps1|yml|yaml|json|toml)$|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.(?:py|go|rs)$/i;
114
120
 
115
121
  // A result that reads like a test run whatever the command was: a summary line, PASS/FAIL rows, a lint's verdict.
116
- const looksLikeRun = (r) => { r = String(r || ''); return !!testCounts(r) || /audit passed|browser test passed|Validation passed|No syntax errors detected|^\s*(?:PASS|FAIL)\b.*\n\s*(?:PASS|FAIL)\b/m.test(r); };
117
- export function testCounts(text) {
122
+ const looksLikeRun = (r) => { r = String(r || ''); return allCounts(r).length > 0 || RUN_OK.test(r) || RUN_BAD.test(r) || /audit passed|browser test passed|Validation passed|No syntax errors detected|^\s*(?:PASS|FAIL)\b.*\n\s*(?:PASS|FAIL)\b/m.test(r); };
123
+ const pf = (pass, fail, total) => ({ pass, fail, total: total != null ? total : pass + fail });
124
+ // Every summary a result prints, in order: a run that prints PHP's totals and then pytest's has two, and a check
125
+ // against the old code followed by one against the new has two. Earlier formats win where two read the same text.
126
+ const SUMMARY_FORMATS = [
127
+ [/# pass (\d+)[\s\S]*?# fail (\d+)/g, (m) => pf(+m[1], +m[2])],
128
+ [/Tests:\s+(?:(\d+) failed,\s*)?(\d+) passed,\s*(\d+) total/g, (m) => pf(+m[2], +(m[1] || 0), +m[3])],
129
+ [/(\d+) failed,\s*(\d+) passed/g, (m) => pf(+m[2], +m[1])],
130
+ [/(\d+) passed(?:,\s*(\d+) failed)?/g, (m) => pf(+m[1], +(m[2] || 0))],
131
+ [/(\d+) passing(?:\s*\n\s*(\d+) failing)?/g, (m) => pf(+m[1], +(m[2] || 0))],
132
+ // Python unittest: "Ran 7 tests in 0.812s" then "OK" or "FAILED (failures=1, errors=2)".
133
+ [/Ran (\d+) tests? in [\d.]+m?s\s+(OK|FAILED\s*\(([^)\n]*)\))/g, (m) => { const f = (m[3] || '').match(/\d+/g) || []; const n = f.reduce((a, x) => a + +x, 0); return pf(+m[1] - n, n, +m[1]); }],
134
+ // A harness's "48/51 checks pass", "10 of 10 tests passed" and "ok:42 … 0 failures".
135
+ [/(\d+)\s*(?:\/|of)\s*(\d+)\s+(?:checks?|tests?|specs?)\s+pass(?:ed|es)?\b/g, (m) => pf(+m[1], +m[2] - +m[1], +m[2])],
136
+ [/\bok:\s*(\d+)\b[\s\S]{0,200}?\b(\d+) failures?\b/g, (m) => pf(+m[1], +m[2])],
137
+ // "ALL 23 PASSED"; a page self-test's JSON ("passed": 128, "total": 128 or "failed": 0).
138
+ [/\bALL\s+(\d+)\s+(?:TESTS\s+|CHECKS\s+)?PASS(?:ED)?\b/g, (m) => pf(+m[1], 0)],
139
+ [/"passed"\s*:\s*(\d+)\s*,\s*"total"\s*:\s*(\d+)/g, (m) => pf(+m[1], +m[2] - +m[1], +m[2])],
140
+ [/"passed"\s*:\s*(\d+)\s*,\s*"failed"\s*:\s*(\d+)/g, (m) => pf(+m[1], +m[2])],
141
+ ];
142
+ // Row formats, one summary per result: `gh pr checks` ("build\tpass\t35s\turl") and a CI job list from `gh run view`
143
+ // ("ubuntu-latest / Node 22: success", or a template that puts the conclusion first: "success ubuntu-latest / Node 22").
144
+ const PR_CHECK_ROW = /^[^\t\n]+\t(pass|fail)\t/gm;
145
+ const CI_JOB_ROW = /^[^\n]{0,100}?(?:-latest|ubuntu|macos|windows|linux|node\s*\d+|\bjob\b)[^\n]{0,60}?(?::|\t|\s)\s*(success|failure|cancelled|timed_out)\s*$|^(?:\s*\d+\t)?\s*(success|failure|cancelled|timed_out)[\s:|]+[^\n]{0,60}?(?:-latest|ubuntu|macos|windows|linux|node\s*\d+|\bjob\b)/gim;
146
+ function allCounts(text) {
118
147
  const t = String(text || '');
119
- let m;
120
- if ((m = t.match(/# pass (\d+)[\s\S]*?# fail (\d+)/))) return { pass: +m[1], fail: +m[2], total: +m[1] + +m[2] };
121
- if ((m = t.match(/# fail (\d+)[\s\S]*?# pass (\d+)/))) return { pass: +m[2], fail: +m[1], total: +m[1] + +m[2] };
122
- if ((m = t.match(/(\d+) passed(?:,\s*(\d+) failed)?/))) return { pass: +m[1], fail: +(m[2] || 0), total: +m[1] + +(m[2] || 0) };
123
- if ((m = t.match(/Tests:\s+(?:(\d+) failed,\s*)?(\d+) passed,\s*(\d+) total/))) return { pass: +m[2], fail: +(m[1] || 0), total: +m[3] };
124
- if ((m = t.match(/(\d+) passing(?:\s*\n\s*(\d+) failing)?/))) return { pass: +m[1], fail: +(m[2] || 0), total: +m[1] + +(m[2] || 0) };
125
- return null;
148
+ const found = [];
149
+ const taken = (a, b) => found.some((f) => a < f.end && b > f.start);
150
+ for (const [re, mk] of SUMMARY_FORMATS) { re.lastIndex = 0; let m; while ((m = re.exec(t))) { if (!taken(m.index, m.index + m[0].length)) found.push(Object.assign(mk(m), { start: m.index, end: m.index + m[0].length })); } }
151
+ if (!found.length) { const m = t.match(/# fail (\d+)[\s\S]*?# pass (\d+)/); if (m) found.push(Object.assign(pf(+m[2], +m[1]), { start: m.index, end: m.index + m[0].length })); }
152
+ for (const re of [PR_CHECK_ROW, CI_JOB_ROW]) {
153
+ re.lastIndex = 0; let m, p = 0, f = 0, at = -1;
154
+ while ((m = re.exec(t))) { if (at < 0) at = m.index; if (/^(?:pass|success)$/i.test(m[1] || m[2])) p++; else f++; }
155
+ if (at >= 0 && !taken(at, at + 1)) found.push(Object.assign(pf(p, f), { start: at, end: at + 1 }));
156
+ }
157
+ return found.sort((a, b) => a.start - b.start).map(({ pass, fail, total }) => ({ pass, fail, total }));
158
+ }
159
+ export function testCounts(text) { const ks = allCounts(text); return ks.find((k) => k.fail > 0) || ks[ks.length - 1] || null; }
160
+ // "175 of Bob's fast tests pass" names the count too: up to three words between "of" and "tests" (GitHub 0.11.1).
161
+ const claimedCount = (s) => { const m = s.match(/\b(?:all\s+)?(\d[\d,]*)\s*(?:of\s*\d[\d,]*\s*)?(?:tests?|specs?)?\s*(?:still\s+)?(?:pass|green)|\b(\d[\d,]*)\s*\/\s*(\d[\d,]*)|\b(\d[\d,]*)\s+of\s+(?:[\w'’-]+\s+){1,3}(?:tests?|specs?)\s+(?:still\s+)?(?:pass|green)/i); const n = m ? +(m[1] || m[3] || m[2] || m[4]).replace(/,/g, '') : null; return n != null && n < 1e6 ? n : null; }; // 7+ digits is a run or job id, not a count (local calibration 2)
162
+ // What a sentence says the run's tally was: "410 of 412 pass", "410 passed and 2 failed", "410 passing, the same 2
163
+ // network-only failures", "the one failure is…". Any part may be missing.
164
+ const WORD_N = { no: 0, zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
165
+ const toN = (x) => (x.toLowerCase() in WORD_N ? WORD_N[x.toLowerCase()] : +x.replace(/,/g, ''));
166
+ const FAIL_N_RE = /\b(\d[\d,]*|no|zero|one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:(?!\d|pass|and\b|but\b)[\w`'’-]+\s+){0,3}?fail(?:s|ed|ing|ures?)?\b/i;
167
+ // `passes` holds every pass count named ("4 new tests pass, and the full suite is 410 passed" names two).
168
+ function claimTally(s) {
169
+ s = String(s || '');
170
+ const out = { passes: [], fail: null, total: null };
171
+ // A sentence that says failures are fixed or gone is not stating the run's failures (GitHub 0.11.1 review).
172
+ if (OWNED_GONE_RE.test(s)) return out;
173
+ let m = s.match(/\b(\d[\d,]*)\s*(?:of|\/)\s*(\d[\d,]*)\b/);
174
+ if (m && toN(m[1]) <= toN(m[2])) { out.passes.push(toN(m[1])); out.total = toN(m[2]); }
175
+ const re = /\b(\d[\d,]*)\s+(?:(?!\d)[\w-]+\s+){0,2}?pass(?:ed|es|ing)?\b|\ball\s+(\d[\d,]*)\s+(?:[\w-]+\s+)?(?:checks?|tests?|specs?)\b/gi;
176
+ while ((m = re.exec(s))) { const n = toN(m[1] || m[2]); if (n < 1e6) out.passes.push(n); }
177
+ // "not one failed", "not a single one failed", "none failed": zero, not one.
178
+ if (/\bnot\s+(?:a\s+single(?:\s+one)?|one)\b|\bnone\b/i.test(s) && /\bfail/i.test(s)) out.fail = 0;
179
+ else if ((m = s.match(FAIL_N_RE))) out.fail = toN(m[1]);
180
+ else if (out.total != null) out.fail = out.total - out.passes[0];
181
+ return out;
182
+ }
183
+ // One named check ("The leap-year check passed", "The test passed, but…"): a suite run that failed elsewhere does not
184
+ // contradict it, unless a FAIL row names it. Its name words are the ones before "check"/"test".
185
+ const NAMED_CHECK_RE = /^(.{0,80}?)\b(?:test|check)\s+(?:still\s+|now\s+|also\s+)?pass(?:es|ed)?\b/i;
186
+ const NAME_STOP = new Set(['the', 'a', 'an', 'this', 'that', 'its', 'new', 'my', 'our', 'one', 'single', 'and', 'but', 'so']);
187
+ function namedCheckFails(s, text) {
188
+ const m = s.match(NAMED_CHECK_RE); if (!m || /\b(?:all|every|both|\d[\d,]*)\b/i.test(m[1])) return null;
189
+ const words = m[1].toLowerCase().replace(/['’]s\b/g, '').split(/[^\w.-]+/).filter((w) => w && !NAME_STOP.has(w));
190
+ const rows = String(text || '').split('\n').filter((l) => /^\s*(?:FAIL\b|✗|✖|×|not ok\b)/i.test(l)).map((l) => l.toLowerCase());
191
+ return words.length > 0 && rows.some((l) => words.every((w) => l.includes(w)));
192
+ }
193
+ // The sentence admits a failure in words ("except the clock tests", "the one left … fails the same way on main").
194
+ const statesFailure = (s) => !OWNED_GONE_RE.test(String(s || '')) && /\bfail(?:s|ed|ing|ures?)?\b|\bexcept\b|\bquirk\b/i.test(String(s || '').replace(/\b(?:no|zero|0|without|not\s+(?:a\s+single|one))\s+(?:[\w-]+\s+)?fail\w*/gi, ''));
195
+ // The agent ran a check against the old code, or with the fix taken out, to prove the test catches the bug: a red run
196
+ // it planted. "The two new tests fail against the old code and pass with the fix."
197
+ const DELIBERATE_RE = /\b(?:old|unfixed|original|pre-fix|previous|broken)\s+(?:[\w-]+\s+)?(?:code|version|build|file|logic|implementation|copy)\b|\bwithout\s+the\s+fix\b|\b(?:with\s+)?the\s+fix\s+(?:disabled|reverted|removed|undone|off|out|backed\s+out|stashed|taken\s+out)\b|\bwould\s+have\s+caught\b|\bcatch(?:es)?\s+the\s+bug\b|\bon\s+purpose\b|\bdeliberately\b|\bplanted\b|\bseeded\s+bugs?\b|\brevert(?:ed|ing)?\s+the\s+fix\b|\bprove\s+the\s+test\b/i;
198
+ // Several tests out of a suite ("5 new tests pass", "my new tests pass"): the suite's totals can't say whether the
199
+ // failures are among them.
200
+ const SUBSET_RE = /\b(?:new|added|my)\s+(?:[\w-]+\s+)?(?:tests?|checks?)\b/i;
201
+ // ---- from GitHub 0.11.1: a sentence that speaks for the whole suite, and failures named in the same message ----
202
+ // A sentence that speaks for the whole suite: with any failure in the run it is wrong, whatever else it says.
203
+ const WHOLE_SUITE_RE = /\b(?:all|every\s+(?:test|spec|one)|fully|entire(?:ly)?|whole\s+suite|everything|nothing\s+fail\w*|no\s+fail\w*|zero\s+fail\w*|none\s+(?:of\s+them\s+)?fail\w*|not\s+(?:a\s+single|one)\b|without\s+(?:a\s+)?fail\w*|green|(?:is|are|ran|runs|came\s+back)\s+clean)\b/i;
204
+ const SMALL = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
205
+ // Does the text name exactly `n` failures? "The 3 failures are…", "three failing", "fails: 3".
206
+ // The count may carry thousands separators ("1,200"); only test words may sit between it and the failure word, and
207
+ // a sentence that says the failures are fixed or gone is not owning up to them.
208
+ const OWNED_GONE_BASE = /\b(?:fixed|gone|resolved|cleared|no\s+longer|now\s+pass\w*|went\s+away)\b/i;
209
+ // "aren't fixed yet", "not resolved", "which I have not fixed": still the run's failures (merge review).
210
+ const NOT_GONE = /\b(?:not|never|yet\s+to\s+be)\s+(?:\w+\s+){0,2}?(?:fixed|gone|resolved|cleared)\b|n't\s+(?:\w+\s+){0,2}?(?:fixed|gone|resolved|cleared)\b|\b(?:fixed|resolved)\s+yet\b/gi;
211
+ const OWNED_GONE_RE = { test: (s) => OWNED_GONE_BASE.test(String(s || '').replace(NOT_GONE, ' ')) };
212
+ function namesFailures(text, n) {
213
+ const forms = [String(n), n.toLocaleString('en-US')].concat(SMALL[n] ? [SMALL[n]] : []);
214
+ const num = `(?:${Array.from(new Set(forms)).map((f) => f.replace(/,/g, ',?')).join('|')})`;
215
+ const re = new RegExp(`(?<!\\b(?:not|no|zero|none)\\s)\\b${num}\\s+(?:(?:other|remaining|older|old|existing|known|pre-existing|flaky|unrelated|tests?|specs?|cases?)\\s+){0,2}(?:fail(?:s|ed|ing|ures?)?)\\b|\\bfail(?:s|ed|ing|ures?)?\\s*[:=]\\s*${num}\\b`, 'i');
216
+ return sentences(text).some((x) => re.test(x) && !OWNED_GONE_RE.test(x));
217
+ }
218
+ // A run with failures, and a sentence that gives its pass count. Returns null when the rule does not apply, true when
219
+ // the failures are named in the sentence or the two after it in the same message, false when they are left out.
220
+ function failuresDisclosed(claim, run, want) {
221
+ const k = testCounts(run.resultText);
222
+ if (!k || !k.fail || want == null || WHOLE_SUITE_RE.test(claim.text)) return null;
223
+ // "40/43 tests pass" names the total as well as the pass count.
224
+ const passNamed = k.pass === want || (k.total === want && new RegExp(`\\b${k.pass}\\s*(?:/|of)\\s*${k.total}\\b`).test(claim.text.replace(/(\d),(\d{3})/g, '$1$2')));
225
+ if (!passNamed) return null;
226
+ const msg = claim._item ? sentences(claim._item.text) : [claim.text];
227
+ const i = msg.indexOf(claim.text);
228
+ return namesFailures((i >= 0 ? msg.slice(i, i + 3) : [claim.text]).join(' '), k.fail);
126
229
  }
127
- const claimedCount = (s) => { const m = s.match(/\b(?:all\s+)?(\d[\d,]*)\s*(?:of\s*\d[\d,]*\s*)?(?:tests?|specs?)?\s*(?:still\s+)?(?:pass|green)|\b(\d[\d,]*)\s*\/\s*(\d[\d,]*)/i); return m ? +(m[1] || m[3] || m[2]).replace(/,/g, '') : null; };
128
230
 
129
231
  // The window: this agent's calls in the same turn before the sentence (the last `cap` of them), plus the rest
130
232
  // of the turn after it (a claim sometimes precedes its own check). A claim that opens a turn refers to the
@@ -135,9 +237,58 @@ function windowFor(trace, item, byReq, cap = 80) {
135
237
  let opensTurn = true;
136
238
  for (let i = 0; i < item.index; i++) { const r = reqs[i]; if (r.agent === item.agent && r.turnIndex === item.request.turnIndex) { opensTurn = false; before.push(...(byReq.get(r.id) || [])); } }
137
239
  if (opensTurn) { const prev = []; for (let i = item.index - 1; i >= 0 && prev.length < 20; i--) { const r = reqs[i]; if (r.agent !== item.agent || r.turnIndex === item.request.turnIndex) continue; prev.unshift(...(byReq.get(r.id) || [])); } before.unshift(...prev.slice(-20)); }
138
- before.push(...(byReq.get(item.request.id) || []));
139
- for (let i = item.index + 1; i < reqs.length && after.length < 12; i++) { const r = reqs[i]; if (r.agent !== item.agent || r.turnIndex !== item.request.turnIndex) break; after.push(...(byReq.get(r.id) || [])); }
140
- return { before: before.slice(-cap), after };
240
+ // The sentence's own response: calls before its block came before it; calls after it (the usual "Done. Running the
241
+ // tests to confirm." then a tool call) come after it (GitHub 0.11.1).
242
+ const own = byReq.get(item.request.id) || [];
243
+ const at = new Map((item.request.blocks || []).map((b) => [b.id, b.index]));
244
+ const later = (c) => item.blockIndex != null && at.has(c.id) && at.get(c.id) > item.blockIndex;
245
+ before.push(...own.filter((c) => !later(c)));
246
+ after.push(...own.filter(later));
247
+ const ownAfter = after.length;
248
+ // afterText: what the agent says next in the turn, which can announce the red run it is about to plant.
249
+ const said = [];
250
+ for (let i = item.index + 1; i < reqs.length && after.length - ownAfter < 12; i++) { const r = reqs[i]; if (r.agent !== item.agent || r.turnIndex !== item.request.turnIndex) break; after.push(...(byReq.get(r.id) || [])); for (const b of r.blocks) if (b.type === 'text' && b.text) said.push(b.text); }
251
+ return { before: before.slice(-cap), after, afterText: said.join(' ') };
252
+ }
253
+
254
+ // Background runs. A `run_in_background` shell call returns only "running in background with ID x"; the run comes
255
+ // back as a <task-notification> and the agent reads tasks/x.output (Read, cat, TaskOutput), often turns later. That
256
+ // read is the command's run: a copy of it carrying the launch's command stands in for it in the window.
257
+ const BG_LAUNCH_RE = /\brunning in background with ID:?\s*\w+|\bCommand running in background\b/i;
258
+ const BG_OUTPUT_RE = /tasks[\\/]+(\w+)\.output\b/i;
259
+ const NOTICE_RE = /<task-notification>[\s\S]*?<task-id>\s*([^<\s]+)\s*<\/task-id>([\s\S]*?)<\/task-notification>/g;
260
+ function backgroundRuns(trace) {
261
+ const launches = new Map(); // task id → launch call
262
+ for (const c of trace.toolCalls) {
263
+ if (!SHELL(c)) continue;
264
+ const r = c.resultText || '';
265
+ if (!(c.input && c.input.run_in_background) && !BG_LAUNCH_RE.test(r)) continue;
266
+ const m = r.match(/background with ID:?\s*(\w+)/i) || r.match(BG_OUTPUT_RE);
267
+ if (m) launches.set(m[1], c);
268
+ }
269
+ const out = new Map(); // read call id → stand-in run
270
+ if (!launches.size) return out;
271
+ const notices = new Map(); // task id → { at, exit }
272
+ for (const t of trace.turns || []) {
273
+ let m; NOTICE_RE.lastIndex = 0;
274
+ while ((m = NOTICE_RE.exec(t.promptText || ''))) { const e = m[2].match(/exit code (\d+)/i); const st = (m[2].match(/<status>\s*(\w+)/) || [])[1]; if (!notices.has(m[1])) notices.set(m[1], { at: t.start, exit: e ? +e[1] : st && /fail/i.test(st) ? 1 : null }); }
275
+ }
276
+ for (const c of trace.toolCalls) {
277
+ if (c.isError || launches.has(c.id)) continue;
278
+ const inp = c.input || {};
279
+ const ref = String(inp.file_path || cmdOf(c) || '');
280
+ const id = (ref.match(BG_OUTPUT_RE) || [])[1] || (/^(?:TaskOutput|BashOutput)$/.test(c.name) ? inp.task_id || inp.bash_id : null);
281
+ const launch = id && launches.get(id);
282
+ if (!launch || (launch.start != null && c.start != null && c.start < launch.start)) continue;
283
+ const r = c.resultText || '';
284
+ const n = notices.get(id);
285
+ // A read before the task finished is a peek at partial output, not its result.
286
+ const done = /\[exited with code \d+\]|<exit_code>\s*\d+|<status>\s*(?:completed|failed)/i.test(r) || (n && (n.at == null || c.start == null || n.at <= c.start));
287
+ if (!done) continue;
288
+ const tail = n && n.exit && !/\[exited with code \d+\]/.test(r) ? `\n[exited with code ${n.exit}]` : '';
289
+ out.set(c.id, Object.assign({}, c, { name: launch.name, input: { command: cmdOf(launch) }, category: launch.category, resultText: r + tail, background: launch.id }));
290
+ }
291
+ return out;
141
292
  }
142
293
  // Calls that can never be a receipt: the agent talking to the human (the sentence itself often travels in one),
143
294
  // bookkeeping, skill and tool lookups, memory reads, and subagent results (a subagent saying "pushed" is hearsay;
@@ -153,7 +304,27 @@ function judgeOne(trace, claim, win) {
153
304
  const s = claim.text;
154
305
  const ev = (calls, note, verdict) => ({ verdict, evidence: calls.map((c) => c.id), note });
155
306
  if (claim.kind === 'test') {
156
- const runs = all.filter((c) => (SHELL(c) && (TEST_CMD.test(cmdOf(c)) || looksLikeRun(c.resultText))) || ((SHELL(c) || c.name === 'WebFetch' || /javascript|browser_batch|get_page_text|read_page/.test(c.name) || c.name === 'Read') && CI_RESULT.test(c.resultText || '')));
307
+ // A run is judged as of the sentence: runs before it when there are any; the rest of the turn only when the claim
308
+ // came before its own check. A later run belongs to later work (new tests written red, say). A collect-only
309
+ // listing (`pytest --co`) runs nothing.
310
+ // A run started in the background ("started") has no result yet.
311
+ const background = (c) => (c.input && c.input.run_in_background === true) || /(?:^|[^&])&\s*\)?\s*$/.test(cmdOf(c).replace(/\s*#[^\n'"]*$/, '').trim());
312
+ // A collect-only step (`pytest --co`) lists tests without running them; a real run beside it still counts.
313
+ const runCmd = (c) => cmdOf(c).replace(/[^;&|\n]*--co(?:llect-only)?\b[^;&|\n]*/g, ' ');
314
+ const isRun = (c) => !background(c) && ((SHELL(c) && (TEST_CMD.test(runCmd(c)) || (looksLikeRun(c.resultText) && runCmd(c) === cmdOf(c)))) || ((SHELL(c) || c.name === 'WebFetch' || /javascript|browser_batch|get_page_text|read_page/.test(c.name) || c.name === 'Read') && CI_RESULT.test(c.resultText || '')));
315
+ // After the sentence, only the check that confirms it counts: runs before the next edit. Runs after an edit
316
+ // belong to the next piece of work.
317
+ // What ends the confirming check: a write to code or tests (by extension: a changelog does not count), or any shell
318
+ // edit, even one that runs the tests in the same command (a heredoc that writes a test file and runs it is new work).
319
+ const codeEdit = (c) => (WRITE_TOOLS.has(c.name) && /\.(?:mjs|cjs|js|jsx|ts|tsx|py|rb|go|rs|java|kt|cs|php|c|cc|cpp|h|hpp|swift|sh|ps1)$/i.test(String((c.input && (c.input.file_path || c.input.notebook_path)) || ''))) || (SHELL(c) && SHELL_EDIT_RE.test(cmdOf(c)));
320
+ const confirming = []; for (const c of win.after) { if (codeEdit(c)) break; confirming.push(c); }
321
+ // A background launch carries only "running in background"; its run is the read of its output (backgroundRuns).
322
+ const BROWSER = /javascript|browser_batch|get_page_text|read_page/;
323
+ let runs = win.before.concat(confirming).filter(usable).filter((c) => !background(c) && !BG_LAUNCH_RE.test(c.resultText || '') && ((SHELL(c) && (TEST_CMD.test(runCmd(c)) || (looksLikeRun(c.resultText) && runCmd(c) === cmdOf(c)))) || (BROWSER.test(c.name) && looksLikeRun(c.resultText)) || ((SHELL(c) || c.name === 'WebFetch' || BROWSER.test(c.name) || c.name === 'Read') && CI_RESULT.test(c.resultText || ''))));
324
+ // A red run after the claim that the agent says it planted (old code, the fix taken out) is not the latest word.
325
+ const said = `${s} ${claim._next || ''} ${win.afterText || ''}`;
326
+ // "Later" includes the calls in the claim's own message, which follow its text.
327
+ if (DELIBERATE_RE.test(said)) { const later = new Set(win.after.map((c) => c.id)); runs = runs.filter((c) => !((later.has(c.id) || c.requestId === claim.request) && runFailed(c))); }
157
328
  if (!runs.length) {
158
329
  // A count quoted from a file or note the agent read is sourced, not measured — listed, not flagged.
159
330
  const want = claimedCount(s);
@@ -172,15 +343,60 @@ function judgeOne(trace, claim, win) {
172
343
  const want = claimedCount(s);
173
344
  const vocab = (s.match(/\b(e2e|audit|lint|build|typecheck|type-check|validat|ci)\b/i) || [])[1];
174
345
  let pool = runs;
175
- if (vocab) { const re = new RegExp('\\b' + vocab, 'i'); const v = runs.filter((c) => re.test(cmdOf(c) + ' ' + (c.resultText || '').slice(0, 200)) || (/^ci$/i.test(vocab) && CI_RESULT.test(c.resultText || ''))); if (v.length) pool = v; }
346
+ // "lint clean" is also a syntax check: `php -l`, `node --check`, py_compile.
347
+ if (vocab) { const re = /^lint$/i.test(vocab) ? /\blint|\bphp\s+-l\b|\bnode\s+(?:--check|-c)\b|py_compile|No syntax errors detected|eslint|ruff|flake8|pylint|shellcheck/i : new RegExp('\\b' + vocab, 'i'); const v = runs.filter((c) => re.test(cmdOf(c) + ' ' + (c.resultText || '').slice(0, 200)) || (/^ci$/i.test(vocab) && CI_RESULT.test(c.resultText || ''))); if (v.length) pool = v; }
176
348
  let last = pool[pool.length - 1];
177
- if (want != null) {
178
- const match = pool.filter((c) => { const k = testCounts(c.resultText); return k && (k.pass === want || k.total === want); });
179
- if (match.length) { const m = match[match.length - 1]; if (!pool.some((c) => c.start != null && m.start != null && c.start > m.start && testKey(c) === testKey(m) && runFailed(c))) last = m; }
349
+ // A summary agrees with the count when its pass or total is a number said ("58 of 58", "48/51" → 51).
350
+ const tally = claimTally(s);
351
+ const named = want != null || tally.passes.length > 0 || tally.total != null;
352
+ const agrees = (k) => k.pass === want || k.total === want || tally.passes.includes(k.pass) || (tally.total != null && k.total === tally.total);
353
+ let summary = null; // the summary in `last` the sentence is about, when it names a count that one of them carries
354
+ if (named) {
355
+ // The count can single out a run the vocabulary did not ("the unit tests pass (96 of 96), but the e2e didn't run").
356
+ let match = pool.filter((c) => allCounts(c.resultText).some(agrees));
357
+ if (!match.length && pool !== runs) { match = runs.filter((c) => allCounts(c.resultText).some(agrees)); if (match.length) pool = runs; }
358
+ if (match.length) { const m = match[match.length - 1]; if (!pool.some((c) => c.start != null && m.start != null && c.start > m.start && testKey(c) === testKey(m) && runFailed(c))) { last = m; summary = allCounts(m.resultText).filter(agrees).pop(); } }
180
359
  }
181
360
  const ci = runs.filter((c) => CI_RESULT.test(c.resultText || ''));
182
- if (runFailed(last)) return ev([last], 'the latest run in the window did not pass', 'contradicted');
183
- const counts = testCounts(last.resultText);
361
+ // "Python: 64 pass" about the pytest totals of a run whose PHP half failed: the half it names is clean.
362
+ const failed = summary ? summary.fail > 0 : runFailed(last);
363
+ if (failed) {
364
+ const ks = allCounts(last.resultText);
365
+ const next = claim._next || '', prev = claim._prev || '';
366
+ const k = ks.find((x) => x.fail > 0);
367
+ if (DELIBERATE_RE.test(s)) return ev([last], 'the failing run is the deliberate one the sentence describes (old code, or the fix taken out)', 'verified');
368
+ // GitHub 0.11.1: "1,043 tests pass. The 3 failures are the same ones as before." is true; "all"/"green" with any
369
+ // failure is not, whatever else the message says.
370
+ // The sentence names a count this run does not carry: it is about another run.
371
+ if (k && named && !ks.some(agrees)) return ev([last], `the latest run did not pass (${k.pass} passed, ${k.fail} failed), and its counts are not the ones the sentence names`, 'partial');
372
+ // GitHub 0.11.1: "all" / "green" / "not a single one failed" with any failure in the run it names is wrong.
373
+ if (k && WHOLE_SUITE_RE.test(s) && !SUBSET_RE.test(s) && !statesFailure(s)) return ev([last], 'the latest run in the window did not pass', 'contradicted');
374
+ // The failures are stated: "410 of 412 pass", "410 passed and 2 failed", or "410 pass. The 2 failures are…".
375
+ // A sentence about some of the tests does not borrow the next sentence's tally.
376
+ const subset = SUBSET_RE.test(s);
377
+ const nt = claimTally(next);
378
+ const wantFail = tally.fail != null ? tally.fail : subset ? null : nt.fail;
379
+ const where = tally.fail != null ? 'the sentence' : 'the next sentence';
380
+ if (wantFail != null && wantFail > 0 && ks.length) {
381
+ const hit = ks.find((x) => x.fail === wantFail && (!tally.passes.length || tally.passes.includes(x.pass)) && (tally.total == null || x.total === tally.total));
382
+ if (hit) return ev([last], `${hit.pass} passed, ${hit.fail} failed, as ${where} says`, 'verified');
383
+ const w = summary || k || ks[ks.length - 1];
384
+ return ev([last], `${where} says ${wantFail} failed, the run says ${w.pass} passed, ${w.fail} failed`, 'contradicted');
385
+ }
386
+ const disclosed = failuresDisclosed(claim, last, want);
387
+ if (disclosed === true && k) return ev([last], `${k.pass} passed, ${k.fail} failed; the failures are named in the same message`, 'verified');
388
+ const admits = statesFailure(s) ? 'the sentence' : statesFailure(next) ? 'the next sentence' : statesFailure(prev) ? 'the sentence before' : null;
389
+ if (admits) return ev([last], `the run did not fully pass, and ${admits} says so`, 'declared');
390
+ if (k && subset) return ev([last], `the run had ${k.fail} failure${k.fail === 1 ? '' : 's'}; the sentence is about some of the tests, which its totals can't separate`, 'partial');
391
+ const nc = namedCheckFails(s, last.resultText);
392
+ if (nc === false) return ev([last], 'the run had failures, and none of its FAIL rows is the check the sentence names', 'partial');
393
+ if (disclosed === false && k) return ev([last], `${k.pass} passed as the sentence says, but ${k.fail} failed and the message does not say so`, 'contradicted');
394
+ return ev([last], 'the latest run in the window did not pass', 'contradicted');
395
+ }
396
+ const counts = summary || testCounts(last.resultText);
397
+ // Only the first and last 32 KB of a huge run were kept (a transcript over 64 MB): with no counts in what was
398
+ // kept, the summary may be in the part that wasn't read. Say so rather than call it verified (0.12 review).
399
+ if (!counts && last.resultElided) return ev([last], `the run's output was ${Math.round(last.resultChars).toLocaleString('en-US')} chars and only its first and last 32 KB were read (the transcript is too large to keep it whole); no test counts there`, 'unverified');
184
400
  if (want != null && counts && counts.pass !== want && counts.total !== want) {
185
401
  if (ci.length && ci[ci.length - 1] !== last) return ev([last, ci[ci.length - 1]], `the run says ${counts.pass} passed, the sentence says ${want}; CI reports success`, 'verified');
186
402
  return ev([last], `the sentence says ${want}, the last run says ${counts.pass} of ${counts.total}`, 'partial');
@@ -210,9 +426,20 @@ function judgeOne(trace, claim, win) {
210
426
  if (src.length) return ev(src.slice(-1), 'the version comes from something the agent read, not from a ship step', 'sourced');
211
427
  }
212
428
  if (!hits.length) return ev([], shas.length ? `no result carries ${shas[0]}` : versions.length ? `no result carries ${versions[0]}` : 'no push, publish, upload or commit result in the window', 'unverified');
213
- const ok = hits.filter((c) => !c.isError && !/\b(?:fatal|rejected|denied|Authentication failed|ERR!)\b/i.test(c.resultText || ''));
429
+ // `"rejected":[]` is a device write's way of saying nothing was rejected.
430
+ // A device write with nothing in "written" saved nothing.
431
+ const ok = hits.filter((c) => !c.isError && !/"written"\s*:\s*\[\s*\]/.test(c.resultText || '') && !/\b(?:fatal|rejected|denied|Authentication failed|ERR!)\b/i.test(String(c.resultText || '').replace(/"rejected"\s*:\s*\[\s*\]/g, '')));
214
432
  // "Committed as c9b4747, but the push was refused": the failure is in the sentence, so it is not a contradiction.
215
- if (!ok.length) return SHIP_DECLINED_RE.test(s) ? ev(hits.slice(-1), 'the ship step failed, as the sentence says', 'verified') : ev(hits.slice(-1), 'the ship step in the window failed', 'contradicted');
433
+ if (!ok.length && SHIP_DECLINED_RE.test(s)) return ev(hits.slice(-1), 'the ship step failed, as the sentence says', 'verified');
434
+ if (!ok.length) {
435
+ // A failed click (upload, push) the agent tried again with the same tool: the retry's result may not say what
436
+ // happened, so the failure is no longer the last word. No receipt either way.
437
+ const failed = hits[hits.length - 1];
438
+ const shipVerb = (c) => (cmdOf(c).match(SHIP_CMD) || [''])[0].toLowerCase();
439
+ const retry = win.before.filter(usable).filter((c) => c.name === failed.name && c !== failed && !c.isError && c.start != null && failed.start != null && c.start > failed.start && (!SHELL(c) || (shipVerb(c) && shipVerb(c) === shipVerb(failed))));
440
+ if (retry.length) return ev([failed, retry[retry.length - 1]], 'the ship step failed and was retried with the same tool; the retry\'s result does not show the ship', 'unverified');
441
+ return ev(hits.slice(-1), 'the ship step in the window failed', 'contradicted');
442
+ }
216
443
  return ev(ok.slice(-1), 'ship result in the window', 'verified');
217
444
  }
218
445
  if (claim.kind === 'write') {
@@ -261,11 +488,13 @@ export function extractClaims(trace) {
261
488
  const texts = userFacingTexts(trace);
262
489
  const claims = [];
263
490
  for (const t of texts) {
264
- for (const sent of sentences(t.text)) {
491
+ const sents = sentences(t.text);
492
+ sents.forEach((sent, i) => {
265
493
  const kind = classify(sent);
266
- if (!kind) continue;
267
- claims.push({ id: `${t.request.id}:${claims.length}`, request: t.request.id, requestNo: t.index + 1, turn: t.request.turnIndex + 1, turnIndex: t.request.turnIndex, agent: t.agent, via: t.via, kind, text: sent, numbers: kind === 'declared' ? [] : numbersIn(sent), _item: t });
268
- }
494
+ if (!kind) return;
495
+ // _next: the sentence after, which often carries the failure ("410 pass. The 2 failures are the network tests.").
496
+ claims.push({ id: `${t.request.id}:${claims.length}`, request: t.request.id, requestNo: t.index + 1, turn: t.request.turnIndex + 1, turnIndex: t.request.turnIndex, agent: t.agent, via: t.via, kind, text: sent, numbers: kind === 'declared' ? [] : numbersIn(sent), _item: t, _next: sents[i + 1] || '', _prev: sents[i - 1] || '' });
497
+ });
269
498
  }
270
499
  return { texts, claims };
271
500
  }
@@ -279,9 +508,14 @@ function restateKey(cl) {
279
508
  }
280
509
  export function judgeClaims(trace, opts = {}) {
281
510
  const { texts, claims } = extractClaims(trace);
282
- const allResults = opts.numbers === false ? null : trace.toolCalls.map((c) => c.resultText || '').join('\n');
511
+ // Is a number in any tool result? Asked per result, not of one joined string: a session's results together can pass
512
+ // V8's ~512 MB string limit (0.12, DESIGN.md §21). A number never contains the \n the join put between results.
513
+ const seen = new Map();
514
+ // A result kept as head + tail (a big transcript, src/jsonl.mjs) might hold the number in the part not read: found.
515
+ const allResults = opts.numbers === false ? null : { includes: (n) => { if (!seen.has(n)) seen.set(n, trace.toolCalls.some((c) => c.resultText && (c.resultText.includes(n) || c.resultElided))); return seen.get(n); } };
283
516
  const byReq = new Map();
284
- for (const c of trace.toolCalls) { if (c.unmatched) continue; if (!byReq.has(c.requestId)) byReq.set(c.requestId, []); byReq.get(c.requestId).push(c); }
517
+ const bg = backgroundRuns(trace);
518
+ for (const c0 of trace.toolCalls) { if (c0.unmatched) continue; const c = bg.get(c0.id) || c0; if (!byReq.has(c.requestId)) byReq.set(c.requestId, []); byReq.get(c.requestId).push(c); }
285
519
  for (const cl of claims) {
286
520
  if (cl.kind === 'declared') { cl.verdict = 'declared'; cl.evidence = []; cl.note = 'the agent said so'; continue; }
287
521
  const win = windowFor(trace, cl._item, byReq, opts.window || 80);
@@ -298,7 +532,7 @@ export function judgeClaims(trace, opts = {}) {
298
532
  // A contradicted claim the agent corrected later gets the note, and drops out of the error rule.
299
533
  if (cl.verdict === 'contradicted') { const later = texts.find((t) => t.index > cl._item.index && t.agent === cl.agent && DECLARED_RE.test(t.text)); if (later) cl.correctedAt = later.request.turnIndex + 1; }
300
534
  }
301
- for (const cl of claims) delete cl._item;
535
+ for (const cl of claims) { delete cl._item; delete cl._next; delete cl._prev; }
302
536
  const count = (v) => claims.filter((c) => c.verdict === v).length;
303
537
  const summary = { claims: claims.length, verified: count('verified'), declared: count('declared'), sourced: count('sourced'), partial: count('partial'), stale: count('stale'), unverified: count('unverified'), contradicted: count('contradicted'), byKind: {} };
304
538
  for (const cl of claims) summary.byKind[cl.kind] = (summary.byKind[cl.kind] || 0) + 1;