glassbox-trace 0.11.1 → 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/README.md +75 -7
- package/dist/glassbox.html +158 -15
- package/package.json +3 -2
- package/src/claims.mjs +207 -39
- package/src/cli.mjs +181 -30
- package/src/fence.mjs +179 -26
- package/src/guard.mjs +5 -3
- package/src/jsonl.mjs +104 -0
- package/src/managed-paths.mjs +32 -0
- package/src/managed.mjs +76 -0
- package/src/memory-audit.mjs +14 -4
- package/src/memory.mjs +158 -7
- package/src/plan.mjs +253 -0
- package/src/statusline.mjs +154 -0
- package/src/trace-core.js +158 -15
package/src/claims.mjs
CHANGED
|
@@ -93,13 +93,18 @@ const inputText = (c) => { try { return JSON.stringify(c.input || {}); } catch (
|
|
|
93
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;
|
|
94
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;
|
|
95
95
|
const CI_FAIL = /conclusion["']?\s*[:=]\s*["']?(?:failure|cancelled)|completed[:\s]+failure|\bfailed\b[^\n]{0,20}\bci\b/i;
|
|
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|Cannot find module|MODULE_NOT_FOUND|\bERROR\s+(?:collecting\b|[\w./-]+\.py\b)|Interrupted:\s*\d+\s+errors?/;
|
|
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;
|
|
97
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"
|
|
98
101
|
// said about the unit run beside it.
|
|
99
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)); };
|
|
100
103
|
export function runFailed(c) {
|
|
101
|
-
const r = c.resultText || ''; const
|
|
102
|
-
|
|
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));
|
|
103
108
|
}
|
|
104
109
|
const SHELL_EDIT_RE = /\bsed\s+-i\b|python3?\s+-\s*<<|\bcat\s*>>?\s|\btee\b|\bmv\b|\bcp\b|>\s*[\w./-]+\s*(?:&&|;|$)/;
|
|
105
110
|
const SHIP_CMD = /\bgit\s+(?:push|commit)\b|\bnpm\s+publish\b|\bgh\s+(?:pr|release)\b/;
|
|
@@ -114,26 +119,96 @@ const SHIP_TOOLS = new Set(['mcp__remote-devices__device_commit_files', 'Artifac
|
|
|
114
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;
|
|
115
120
|
|
|
116
121
|
// A result that reads like a test run whatever the command was: a summary line, PASS/FAIL rows, a lint's verdict.
|
|
117
|
-
const looksLikeRun = (r) => { r = String(r || ''); return
|
|
118
|
-
|
|
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) {
|
|
119
147
|
const t = String(text || '');
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 }));
|
|
127
158
|
}
|
|
128
|
-
|
|
129
|
-
|
|
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 ----
|
|
130
202
|
// A sentence that speaks for the whole suite: with any failure in the run it is wrong, whatever else it says.
|
|
131
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;
|
|
132
204
|
const SMALL = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
|
|
133
205
|
// Does the text name exactly `n` failures? "The 3 failures are…", "three failing", "fails: 3".
|
|
134
206
|
// The count may carry thousands separators ("1,200"); only test words may sit between it and the failure word, and
|
|
135
207
|
// a sentence that says the failures are fixed or gone is not owning up to them.
|
|
136
|
-
const
|
|
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, ' ')) };
|
|
137
212
|
function namesFailures(text, n) {
|
|
138
213
|
const forms = [String(n), n.toLocaleString('en-US')].concat(SMALL[n] ? [SMALL[n]] : []);
|
|
139
214
|
const num = `(?:${Array.from(new Set(forms)).map((f) => f.replace(/,/g, ',?')).join('|')})`;
|
|
@@ -163,15 +238,57 @@ function windowFor(trace, item, byReq, cap = 80) {
|
|
|
163
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) || [])); } }
|
|
164
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)); }
|
|
165
240
|
// The sentence's own response: calls before its block came before it; calls after it (the usual "Done. Running the
|
|
166
|
-
// tests to confirm." then a tool call) come after it.
|
|
241
|
+
// tests to confirm." then a tool call) come after it (GitHub 0.11.1).
|
|
167
242
|
const own = byReq.get(item.request.id) || [];
|
|
168
243
|
const at = new Map((item.request.blocks || []).map((b) => [b.id, b.index]));
|
|
169
244
|
const later = (c) => item.blockIndex != null && at.has(c.id) && at.get(c.id) > item.blockIndex;
|
|
170
245
|
before.push(...own.filter((c) => !later(c)));
|
|
171
246
|
after.push(...own.filter(later));
|
|
172
247
|
const ownAfter = after.length;
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
175
292
|
}
|
|
176
293
|
// Calls that can never be a receipt: the agent talking to the human (the sentence itself often travels in one),
|
|
177
294
|
// bookkeeping, skill and tool lookups, memory reads, and subagent results (a subagent saying "pushed" is hearsay;
|
|
@@ -201,7 +318,13 @@ function judgeOne(trace, claim, win) {
|
|
|
201
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).
|
|
202
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)));
|
|
203
320
|
const confirming = []; for (const c of win.after) { if (codeEdit(c)) break; confirming.push(c); }
|
|
204
|
-
|
|
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))); }
|
|
205
328
|
if (!runs.length) {
|
|
206
329
|
// A count quoted from a file or note the agent read is sourced, not measured — listed, not flagged.
|
|
207
330
|
const want = claimedCount(s);
|
|
@@ -220,29 +343,67 @@ function judgeOne(trace, claim, win) {
|
|
|
220
343
|
const want = claimedCount(s);
|
|
221
344
|
const vocab = (s.match(/\b(e2e|audit|lint|build|typecheck|type-check|validat|ci)\b/i) || [])[1];
|
|
222
345
|
let pool = runs;
|
|
223
|
-
|
|
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; }
|
|
224
348
|
let last = pool[pool.length - 1];
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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(); } }
|
|
228
359
|
}
|
|
229
360
|
const ci = runs.filter((c) => CI_RESULT.test(c.resultText || ''));
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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');
|
|
237
395
|
}
|
|
238
|
-
const counts = testCounts(last.resultText);
|
|
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');
|
|
239
400
|
if (want != null && counts && counts.pass !== want && counts.total !== want) {
|
|
240
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');
|
|
241
402
|
return ev([last], `the sentence says ${want}, the last run says ${counts.pass} of ${counts.total}`, 'partial');
|
|
242
403
|
}
|
|
243
404
|
const lw = lastWriteAt(win.before.filter((c) => c.start != null && last.start != null && c.start > last.start));
|
|
244
405
|
if (lw > -Infinity && !ci.some((c) => c.start != null && c.start > lw)) return ev([last], 'the run predates a later edit to source or tests, and nothing re-ran', 'stale');
|
|
245
|
-
return ev([last], counts ? `${counts.pass} passed, ${counts.fail} failed
|
|
406
|
+
return ev([last], counts ? `${counts.pass} passed, ${counts.fail} failed` : (CI_RESULT.test(last.resultText || '') ? 'CI success' : 'run ok'), 'verified');
|
|
246
407
|
}
|
|
247
408
|
if (claim.kind === 'ship') {
|
|
248
409
|
const shas = (s.match(SHA_RE) || []).filter((h) => /\d/.test(h) && /[a-f]/.test(h));
|
|
@@ -327,11 +488,13 @@ export function extractClaims(trace) {
|
|
|
327
488
|
const texts = userFacingTexts(trace);
|
|
328
489
|
const claims = [];
|
|
329
490
|
for (const t of texts) {
|
|
330
|
-
|
|
491
|
+
const sents = sentences(t.text);
|
|
492
|
+
sents.forEach((sent, i) => {
|
|
331
493
|
const kind = classify(sent);
|
|
332
|
-
if (!kind)
|
|
333
|
-
|
|
334
|
-
|
|
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
|
+
});
|
|
335
498
|
}
|
|
336
499
|
return { texts, claims };
|
|
337
500
|
}
|
|
@@ -345,9 +508,14 @@ function restateKey(cl) {
|
|
|
345
508
|
}
|
|
346
509
|
export function judgeClaims(trace, opts = {}) {
|
|
347
510
|
const { texts, claims } = extractClaims(trace);
|
|
348
|
-
|
|
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); } };
|
|
349
516
|
const byReq = new Map();
|
|
350
|
-
|
|
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); }
|
|
351
519
|
for (const cl of claims) {
|
|
352
520
|
if (cl.kind === 'declared') { cl.verdict = 'declared'; cl.evidence = []; cl.note = 'the agent said so'; continue; }
|
|
353
521
|
const win = windowFor(trace, cl._item, byReq, opts.window || 80);
|
|
@@ -364,7 +532,7 @@ export function judgeClaims(trace, opts = {}) {
|
|
|
364
532
|
// A contradicted claim the agent corrected later gets the note, and drops out of the error rule.
|
|
365
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; }
|
|
366
534
|
}
|
|
367
|
-
for (const cl of claims) delete cl._item;
|
|
535
|
+
for (const cl of claims) { delete cl._item; delete cl._next; delete cl._prev; }
|
|
368
536
|
const count = (v) => claims.filter((c) => c.verdict === v).length;
|
|
369
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: {} };
|
|
370
538
|
for (const cl of claims) summary.byKind[cl.kind] = (summary.byKind[cl.kind] || 0) + 1;
|