skalpel 4.0.28 → 4.0.29

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/catches.mjs ADDED
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ // catches.mjs — the RETROACTIVE first-run "wow": `skalpel catches`.
3
+ //
4
+ // THE PROBLEM (same as verify-shadow): your agent is both the executor AND the reporter of its own
5
+ // work. When it says "done / tests pass / fixed" nothing independent re-checks. verify-shadow closes
6
+ // that loop LIVE, but a brand-new user has no live catches yet — so there is no instant aha on install.
7
+ //
8
+ // THIS closes the aha gap by looking BACKWARD: it scans the user's OWN recent coding transcripts
9
+ // (Claude Code ~/.claude/projects/**/*.jsonl and Codex ~/.codex/{sessions,archived_sessions}) and finds
10
+ // REAL past turns where the agent CLAIMED success while its OWN in-transcript proof — the tool_result of
11
+ // the last test/build/lint it ran before the claim — had ALREADY FAILED at claim time. That is a genuine
12
+ // claim-vs-own-evidence mismatch that is verifiable FROM THE TRANSCRIPT ALONE.
13
+ //
14
+ // HARD RED LINES (why this is safe + instant):
15
+ // • READ-ONLY. It only READS transcripts already on disk. It NEVER re-runs, spawns, builds, writes,
16
+ // deploys, or mutates anything. Unlike verify-shadow it does NOT execFile a proof — the pass/fail is
17
+ // the exit status the transcript ALREADY recorded (Claude's tool_result.is_error / Codex's
18
+ // "Process exited with code N"). So it is instant and cannot break or slow anything.
19
+ // • LOCAL ONLY. Imports only node: core + local sibling modules. Opens no socket.
20
+ // • OPT-IN. User-invoked subcommand; prints and exits. The per-turn hook is untouched (byte-identical).
21
+ // • NO FABRICATED NUMBERS. Every count is a real tally over real transcripts; every example quotes the
22
+ // real claim text, the real reconstructed proof command, and the real recorded failure output. If it
23
+ // finds zero, it SAYS zero honestly — it never invents a catch.
24
+ //
25
+ // REUSE: the detection + classification logic is verify-shadow's, applied to historical turns —
26
+ // detectCompletionClaim (is this a "done" claim?), reconstructProofEntry (the most-recent proof the
27
+ // session actually ran before the claim, + the tool_use id to match its result), and classifyOutcome
28
+ // (PASS / GENUINE_FAIL / HARNESS_ERROR — only a GENUINE_FAIL counts as a lie; a timeout / missing-script
29
+ // / wrong-cwd is harness noise and is NEVER surfaced, so we never cry wolf).
30
+ import { readdirSync, statSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { realpathSync } from "node:fs";
35
+ import { tailLines } from "./transcript.mjs";
36
+ import {
37
+ detectCompletionClaim,
38
+ reconstructProofEntry,
39
+ classifyOutcome,
40
+ extractFailCount,
41
+ } from "./verify-shadow.mjs";
42
+ import { recordInsight } from "./insights.mjs";
43
+
44
+ const HOME = homedir();
45
+ const CLAUDE_PROJECTS = path.join(HOME, ".claude", "projects");
46
+ const CODEX_DIRS = [
47
+ path.join(HOME, ".codex", "sessions"),
48
+ path.join(HOME, ".codex", "archived_sessions"),
49
+ ];
50
+
51
+ // Bounds. We look at RECENT sessions (the aha is about YOUR history, and reading every one of tens of
52
+ // thousands of transcripts would be neither instant nor "recent"). Newest-first by mtime, capped, each
53
+ // read as a bounded tail so a multi-GB transcript can never stall or OOM us.
54
+ const MAX_SESSIONS = 600; // most-recent sessions to scan (across both tools)
55
+ const SESSION_TAIL_BYTES = 4 * 1024 * 1024; // per-file tail — plenty for the last many turns
56
+ const MAX_EXAMPLES = 3; // concrete receipts to print
57
+
58
+ // ---------- (1) discover recent session files (newest first) ----------
59
+ function walkJsonl(dir, pred, out) {
60
+ let entries;
61
+ try {
62
+ entries = readdirSync(dir, { withFileTypes: true });
63
+ } catch {
64
+ return; // dir missing / unreadable — skip (read-only, fail-open)
65
+ }
66
+ for (const e of entries) {
67
+ const p = path.join(dir, e.name);
68
+ try {
69
+ if (e.isDirectory()) walkJsonl(p, pred, out);
70
+ else if (e.isFile() && pred(e.name)) out.push(p);
71
+ } catch {
72
+ /* stat/permission hiccup on one entry — skip it */
73
+ }
74
+ }
75
+ }
76
+
77
+ // Return [{ file, tool, mtime }] for the MAX_SESSIONS most-recently-modified transcripts across tools.
78
+ function discoverSessions() {
79
+ const files = [];
80
+ const claude = [];
81
+ walkJsonl(CLAUDE_PROJECTS, (n) => n.endsWith(".jsonl"), claude);
82
+ for (const f of claude) files.push({ file: f, tool: "claude" });
83
+ const codex = [];
84
+ for (const d of CODEX_DIRS) walkJsonl(d, (n) => /^rollout-.*\.jsonl$/.test(n), codex);
85
+ for (const f of codex) files.push({ file: f, tool: "codex" });
86
+
87
+ const withMtime = [];
88
+ for (const f of files) {
89
+ try {
90
+ withMtime.push({ ...f, mtime: statSync(f.file).mtimeMs });
91
+ } catch {
92
+ /* vanished between readdir and stat — skip */
93
+ }
94
+ }
95
+ withMtime.sort((a, b) => b.mtime - a.mtime);
96
+ return withMtime.slice(0, MAX_SESSIONS);
97
+ }
98
+
99
+ // ---------- (2) load a session as Claude-shaped entries ----------
100
+ // verify-shadow's reconstructProofEntry + claim detection speak the Claude transcript shape
101
+ // (entry.message.content = [{type:'text'|'tool_use'|'tool_result', ...}], entry.cwd). We parse Claude
102
+ // natively and NORMALIZE Codex rollout events into that same shape so ONE code path handles both.
103
+
104
+ export function loadClaudeEntries(file) {
105
+ const entries = [];
106
+ for (const line of tailLines(file, SESSION_TAIL_BYTES)) {
107
+ try {
108
+ entries.push(JSON.parse(line));
109
+ } catch {
110
+ /* partial/garbage line — skip */
111
+ }
112
+ }
113
+ return entries;
114
+ }
115
+
116
+ // Codex output wrapper always carries "Process exited with code N" — the recorded exit status, which is
117
+ // exactly the ground truth we need (no re-run). Returns { exitCode:number|null, body:string }.
118
+ function parseCodexOutput(output) {
119
+ const s = typeof output === "string" ? output : "";
120
+ const m = s.match(/Process exited with code\s+(-?\d+)/i);
121
+ const exitCode = m ? parseInt(m[1], 10) : null;
122
+ // Prefer the text AFTER "Output:" as the evidence body (drops the Command/Chunk/Wall-time header).
123
+ const oi = s.indexOf("\nOutput:\n");
124
+ const body = oi >= 0 ? s.slice(oi + "\nOutput:\n".length) : s;
125
+ return { exitCode, body };
126
+ }
127
+
128
+ // Normalize one Codex rollout file into Claude-shaped entries. exec_command → a Bash tool_use; its
129
+ // function_call_output → a tool_result carrying the recorded exit code; assistant messages → text.
130
+ export function loadCodexEntries(file) {
131
+ const entries = [];
132
+ for (const line of tailLines(file, SESSION_TAIL_BYTES)) {
133
+ let e;
134
+ try {
135
+ e = JSON.parse(line);
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (e.type !== "response_item") continue;
140
+ const p = e.payload || {};
141
+ if (p.type === "message") {
142
+ const role = p.role === "assistant" ? "assistant" : "user";
143
+ const text = Array.isArray(p.content)
144
+ ? p.content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("")
145
+ : "";
146
+ if (text.trim())
147
+ entries.push({ type: role, message: { role, content: [{ type: "text", text }] } });
148
+ } else if (p.type === "function_call" && p.name === "exec_command" && p.call_id) {
149
+ let cmd = "";
150
+ let workdir = null;
151
+ try {
152
+ const a = JSON.parse(p.arguments || "{}");
153
+ cmd = typeof a.cmd === "string" ? a.cmd : Array.isArray(a.cmd) ? a.cmd.join(" ") : "";
154
+ workdir = typeof a.workdir === "string" ? a.workdir : null;
155
+ } catch {
156
+ /* unparseable args — leave cmd empty (no proof) */
157
+ }
158
+ if (cmd)
159
+ entries.push({
160
+ cwd: workdir,
161
+ message: {
162
+ role: "assistant",
163
+ content: [{ type: "tool_use", name: "Bash", id: p.call_id, input: { command: cmd } }],
164
+ },
165
+ });
166
+ } else if (p.type === "function_call_output" && p.call_id) {
167
+ const { exitCode, body } = parseCodexOutput(p.output);
168
+ entries.push({
169
+ message: {
170
+ role: "user",
171
+ content: [
172
+ {
173
+ type: "tool_result",
174
+ tool_use_id: p.call_id,
175
+ is_error: typeof exitCode === "number" ? exitCode !== 0 : false,
176
+ exit_code: exitCode,
177
+ content: body,
178
+ },
179
+ ],
180
+ },
181
+ });
182
+ }
183
+ }
184
+ return entries;
185
+ }
186
+
187
+ // ---------- (3) index the recorded proof results (no re-run) ----------
188
+ function resultTextOf(block) {
189
+ const c = block?.content;
190
+ if (typeof c === "string") return c;
191
+ if (Array.isArray(c)) return c.map((x) => (typeof x === "string" ? x : x?.text || "")).join(" ");
192
+ return "";
193
+ }
194
+
195
+ // tool_use_id -> { isError, exitCode, text } for every tool_result in the session.
196
+ function indexResults(entries) {
197
+ const idx = new Map();
198
+ for (const e of entries) {
199
+ const c = e?.message?.content;
200
+ if (!Array.isArray(c)) continue;
201
+ for (const b of c) {
202
+ if (b && b.type === "tool_result" && b.tool_use_id) {
203
+ idx.set(b.tool_use_id, {
204
+ isError: b.is_error === true,
205
+ exitCode: typeof b.exit_code === "number" ? b.exit_code : null,
206
+ text: resultTextOf(b),
207
+ });
208
+ }
209
+ }
210
+ }
211
+ return idx;
212
+ }
213
+
214
+ // Reuse verify-shadow's classifyOutcome by synthesizing the `err` object it expects from the RECORDED
215
+ // result (we never ran the process, so there is no live err). This keeps HARNESS_ERROR vs GENUINE_FAIL
216
+ // identical to the live shadow: a timeout / command-not-found / missing-script is harness noise and is
217
+ // NEVER counted as a lie.
218
+ function classifyRecorded({ isError, exitCode, text }) {
219
+ if (exitCode === 0) return "PASS";
220
+ if (exitCode == null && !isError) return "PASS"; // no failure signal recorded → treat as pass (never invent)
221
+ const ev = String(text || "");
222
+ let err;
223
+ if (/\btimed?\s*out\b|timeout after|\bSIGKILL\b|\bSIGTERM\b/i.test(ev)) {
224
+ err = { killed: true, signal: "SIGTERM" }; // classifyOutcome → HARNESS_ERROR
225
+ } else if (typeof exitCode === "number" && exitCode !== 0) {
226
+ err = { code: exitCode }; // real recorded non-zero exit (127/126 → HARNESS_ERROR in classifyOutcome)
227
+ } else {
228
+ err = { code: 1 }; // Claude is_error with no numeric code → generic non-zero
229
+ }
230
+ return classifyOutcome(err, ev);
231
+ }
232
+
233
+ // ---------- (4) scan one session for genuine claim-vs-proof mismatches ----------
234
+ // For each assistant turn carrying a completion claim, reconstruct the most-recent proof the session ran
235
+ // BEFORE that claim, look up that proof's RECORDED result, and classify it. A GENUINE_FAIL = a caught
236
+ // "done" over its own already-failing test. Dedup by proof tool_use id so repeated "done"s about the
237
+ // same failing run count once.
238
+ function assistantTextOf(entry) {
239
+ if (entry?.type !== "assistant" && entry?.message?.role !== "assistant") return "";
240
+ const c = entry?.message?.content;
241
+ if (typeof c === "string") return c;
242
+ if (Array.isArray(c))
243
+ return c
244
+ .filter((b) => b && b.type === "text")
245
+ .map((b) => b.text || "")
246
+ .join(" ")
247
+ .trim();
248
+ return "";
249
+ }
250
+
251
+ export function scanSession(entries, meta = {}) {
252
+ const catches = [];
253
+ if (!Array.isArray(entries) || !entries.length) return catches;
254
+ const results = indexResults(entries);
255
+ const seenProof = new Set(); // proof tool_use ids already reported in THIS session
256
+
257
+ for (let i = 0; i < entries.length; i++) {
258
+ const text = assistantTextOf(entries[i]);
259
+ if (!text) continue;
260
+ const { claim, text: claimText } = detectCompletionClaim(text);
261
+ if (!claim) continue;
262
+
263
+ // The most-recent proof the session actually ran up to and including this claim turn.
264
+ const found = reconstructProofEntry(entries.slice(0, i + 1), meta.cwd || null);
265
+ if (!found || !found.toolUseId) continue;
266
+ if (seenProof.has(found.toolUseId)) continue;
267
+
268
+ const res = results.get(found.toolUseId);
269
+ if (!res) continue; // proof's result not recorded in the scanned window — can't verify, skip honestly
270
+
271
+ const outcome = classifyRecorded(res);
272
+ if (outcome !== "GENUINE_FAIL") continue; // PASS / HARNESS_ERROR are never a caught lie
273
+
274
+ seenProof.add(found.toolUseId);
275
+ const evidence = String(res.text || "")
276
+ .replace(/\s+$/, "")
277
+ .slice(-240);
278
+ catches.push({
279
+ tool: meta.tool || "claude",
280
+ session: meta.session || null,
281
+ claim_text: claimText,
282
+ proof_command: [found.proof.cmd, ...found.proof.args].join(" ").slice(0, 160),
283
+ exit_code: res.exitCode,
284
+ fail_count: extractFailCount(evidence),
285
+ evidence,
286
+ });
287
+ }
288
+ return catches;
289
+ }
290
+
291
+ // ---------- (5) run the scan across recent sessions ----------
292
+ export function runScan() {
293
+ const sessions = discoverSessions();
294
+ let scanned = 0;
295
+ const allCatches = [];
296
+ for (const s of sessions) {
297
+ let entries;
298
+ try {
299
+ entries = s.tool === "codex" ? loadCodexEntries(s.file) : loadClaudeEntries(s.file);
300
+ } catch {
301
+ continue; // unreadable file — skip (read-only, fail-open)
302
+ }
303
+ if (!entries.length) continue;
304
+ scanned++;
305
+ const sessionId = path.basename(s.file, ".jsonl");
306
+ let found;
307
+ try {
308
+ found = scanSession(entries, { tool: s.tool, session: sessionId });
309
+ } catch {
310
+ continue; // a single malformed session must never abort the whole scan
311
+ }
312
+ for (const c of found) allCatches.push(c);
313
+ }
314
+ return { scanned, catches: allCatches };
315
+ }
316
+
317
+ // ---------- (6) render the honest report ----------
318
+ function clip(s, n) {
319
+ const t = String(s || "")
320
+ .replace(/\s+/g, " ")
321
+ .trim();
322
+ return t.length > n ? t.slice(0, n - 1) + "…" : t;
323
+ }
324
+
325
+ export function renderReport({ scanned, catches }) {
326
+ const N = scanned;
327
+ const K = catches.length;
328
+ const L = [];
329
+ L.push("");
330
+ L.push(" 🔬 skalpel — retroactive claim check (read-only scan of your own coding history)");
331
+ L.push(" " + "─".repeat(72));
332
+ if (N === 0) {
333
+ L.push("");
334
+ L.push(" No Claude Code or Codex transcripts found on this machine yet.");
335
+ L.push(" As you code, skalpel watches live — run me again anytime.");
336
+ L.push("");
337
+ return L.join("\n");
338
+ }
339
+ if (K === 0) {
340
+ // HONEST zero-state — we do NOT invent a catch.
341
+ L.push("");
342
+ L.push(` skalpel scanned ${N} of your recent sessions and found`);
343
+ L.push(` 0 times your agent said "done" while its own test/build was already failing.`);
344
+ L.push("");
345
+ L.push(" No caught lies in your history — clean. I'll watch live from here:");
346
+ L.push(" every turn your agent claims done, I check its own proof against the claim.");
347
+ L.push("");
348
+ return L.join("\n");
349
+ }
350
+
351
+ const cmds = [...new Set(catches.map((c) => c.proof_command.split(/\s+/).slice(0, 3).join(" ")))];
352
+ const cmdPhrase = cmds.length === 1 ? `\`${cmds[0]}\`` : "its own test/build";
353
+ L.push("");
354
+ L.push(` skalpel scanned ${N} of your recent sessions and found`);
355
+ L.push(
356
+ ` ${K} time${K === 1 ? "" : "s"} your agent said "done" while ${cmdPhrase} was already failing.`,
357
+ );
358
+ L.push("");
359
+ L.push(" Every one is real — pulled straight from your transcript, no re-run needed:");
360
+ L.push("");
361
+
362
+ const top = catches.slice(0, MAX_EXAMPLES);
363
+ top.forEach((c, i) => {
364
+ const failN = c.fail_count != null ? ` (${c.fail_count} failing)` : "";
365
+ L.push(` ${i + 1}. [${c.tool}] your agent said:`);
366
+ L.push(` "${clip(c.claim_text, 72)}"`);
367
+ L.push(` but its own \`${clip(c.proof_command, 64)}\` had just FAILED${failN}:`);
368
+ L.push(` ${clip(c.evidence, 72)}`);
369
+ L.push("");
370
+ });
371
+ if (K > top.length) L.push(` …and ${K - top.length} more.`);
372
+ L.push(" " + "─".repeat(72));
373
+ L.push(" That's the gap skalpel closes. From here I check every claim live, as you code.");
374
+ L.push("");
375
+ return L.join("\n");
376
+ }
377
+
378
+ // ---------- CLI ----------
379
+ const isMain = (() => {
380
+ try {
381
+ return (
382
+ Boolean(process.argv[1]) &&
383
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
384
+ );
385
+ } catch {
386
+ return false;
387
+ }
388
+ })();
389
+
390
+ if (isMain) {
391
+ try {
392
+ const result = runScan();
393
+ process.stdout.write(renderReport(result) + "\n");
394
+ // MEASURABLE (local only, no network): record the honest fire-frequency for THIS user — how many
395
+ // recent sessions were scanned and how many genuine catches were found. Never fabricated.
396
+ try {
397
+ recordInsight({
398
+ kind: "catches_scan",
399
+ display: `retroactive scan: ${result.catches.length} catch(es) across ${result.scanned} sessions`,
400
+ catches: result.catches.length,
401
+ sessions: result.scanned,
402
+ });
403
+ } catch {
404
+ /* insight is a bonus */
405
+ }
406
+ } catch {
407
+ // READ-ONLY + fail-open: never throw at the user.
408
+ process.stdout.write(
409
+ "\n 🔬 skalpel — could not read your transcript history on this machine.\n\n",
410
+ );
411
+ }
412
+ process.exit(0);
413
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.28",
3
+ "version": "4.0.29",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
package/skalpel-setup.mjs CHANGED
@@ -61,6 +61,7 @@ const KNOWN_SUBS = new Set([
61
61
  "uninstall",
62
62
  "autopsy",
63
63
  "xray",
64
+ "catches",
64
65
  "__build",
65
66
  "__verify-report",
66
67
  ]);
@@ -75,6 +76,7 @@ usage:
75
76
  skalpel logout clear the saved session
76
77
  skalpel autopsy local, read-only receipt of your verified patterns
77
78
  skalpel xray re-run your last session's own proof — did "done" hold up?
79
+ skalpel catches scan your own history for "done" claims over failing tests
78
80
  skalpel uninstall [--purge] remove the hooks + local data from this machine
79
81
 
80
82
  options:
@@ -868,6 +870,14 @@ async function main() {
868
870
  spawnSync("node", [join(__dir, "autopsy.mjs"), ...argv.slice(1)], { stdio: "inherit" });
869
871
  return;
870
872
  }
873
+ // `skalpel catches` — the RETROACTIVE first-run "wow": a LOCAL, READ-ONLY, ZERO-NETWORK scan of the
874
+ // user's OWN recent Claude Code + Codex transcripts for real past turns where the agent claimed "done"
875
+ // while its OWN test/build had already failed in-transcript. Never re-runs anything (the pass/fail is
876
+ // the exit status already recorded), so it is instant + safe; prints an honest summary (0 if none).
877
+ if (sub === "catches") {
878
+ spawnSync("node", [join(__dir, "catches.mjs"), ...argv.slice(1)], { stdio: "inherit" });
879
+ return;
880
+ }
871
881
  // `skalpel __verify-report` — the claim-verification SHADOW instrument: reads the local
872
882
  // verify-shadow.log and prints the three numbers (claim fire-rate, proof-reconstruction rate, and the
873
883
  // headline MISMATCH rate = how often the agent claimed done but its OWN proof, re-run, failed).
package/verify-shadow.mjs CHANGED
@@ -425,11 +425,13 @@ function deniedToolUseIds(entries) {
425
425
  return denied;
426
426
  }
427
427
 
428
- // reconstructProof(entries, cwd) -> { cmd, args, cwd } | null
429
- // The MOST RECENT test/build/typecheck/lint command the session actually ran. Scans newest→oldest; the
428
+ // reconstructProofEntry(entries, cwd) -> { proof:{cmd,args,cwd}, toolUseId, command } | null
429
+ // The MOST RECENT test/build/typecheck/lint command the session actually ran, WITH the id of the
430
+ // tool_use it came from (so a caller can match it back to its recorded tool_result — the retroactive
431
+ // `skalpel catches` scanner reads that stored result INSTEAD of re-running). Scans newest→oldest; the
430
432
  // entry's own recorded cwd is the base (a `cd` inside the command still overrides it). SHARP-3: a
431
433
  // command the user DENIED (never ran) is skipped — we only re-run what the session actually executed.
432
- export function reconstructProof(entries, cwd) {
434
+ export function reconstructProofEntry(entries, cwd) {
433
435
  const list = Array.isArray(entries) ? entries : [];
434
436
  const denied = deniedToolUseIds(list);
435
437
  for (let i = list.length - 1; i >= 0; i--) {
@@ -442,30 +444,41 @@ export function reconstructProof(entries, cwd) {
442
444
  const id = cmds[k].id;
443
445
  if (id ? denied.has(id) : denied.size > 0) continue;
444
446
  const proof = parseCommandForProof(cmds[k].command, cmds[k].cwd || cwd);
445
- if (proof) return proof;
447
+ if (proof) return { proof, toolUseId: id || null, command: cmds[k].command };
446
448
  }
447
449
  }
448
450
  return null;
449
451
  }
450
452
 
453
+ // reconstructProof(entries, cwd) -> { cmd, args, cwd } | null — thin wrapper over reconstructProofEntry
454
+ // (the live shadow re-runner only needs the proof itself, not the originating tool_use id).
455
+ export function reconstructProof(entries, cwd) {
456
+ const found = reconstructProofEntry(entries, cwd);
457
+ return found ? found.proof : null;
458
+ }
459
+
451
460
  // Classify a re-run into PASS / GENUINE_FAIL / HARNESS_ERROR. This is what keeps the money metric HONEST:
452
461
  // only a GENUINE_FAIL (the test/build itself ran and reported failure) may count as "the agent lied".
453
462
  // A HARNESS_ERROR (timeout, missing binary/script, wrong cwd — the PROOF machinery broke, not the code)
454
463
  // must NEVER be shown as a lie. Real-data measurement showed 6/6 raw "mismatches" were harness noise
455
464
  // (a 3-min lint timeout, a missing lint script, a wrong-cwd eslint) — counting those would cry wolf and
456
465
  // burn the whole point of an honesty product.
457
- function classifyOutcome(err, evidence) {
466
+ export function classifyOutcome(err, evidence) {
458
467
  if (!err) return "PASS";
459
468
  if (err.killed || err.signal === "SIGTERM") return "HARNESS_ERROR"; // hit the timeout
460
469
  if (err.code === "ENOENT") return "HARNESS_ERROR"; // binary/cwd does not exist
461
470
  const exit = typeof err.code === "number" ? err.code : null;
462
471
  if (exit === 127 || exit === 126) return "HARNESS_ERROR"; // command not found / not executable
472
+ // The proof harness/environment is broken, not the work: a missing script, a `command not found`, or a
473
+ // tool binary/runtime that simply isn't installed (a Playwright browser not downloaded — "Executable
474
+ // doesn't exist" / "download new browsers"). Surfacing any of these would cry wolf on setup state, so
475
+ // they are HARNESS_ERROR, never counted as a lie.
463
476
  if (
464
- /\bno such file or directory\b|\bENOENT\b|\bcommand not found\b|missing script|no [a-z:-]{1,24} script|npm err!.*missing script/i.test(
477
+ /\bno such file or directory\b|\bENOENT\b|\bcommand not found\b|missing script|no [a-z:-]{1,24} script|npm err!.*missing script|executable doesn'?t exist|download new browsers/i.test(
465
478
  String(evidence || ""),
466
479
  )
467
480
  )
468
- return "HARNESS_ERROR"; // the proof harness itself is broken, not the work
481
+ return "HARNESS_ERROR";
469
482
  return "GENUINE_FAIL"; // a real non-zero exit from the test/build/lint itself
470
483
  }
471
484