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 +413 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +10 -0
- package/verify-shadow.mjs +20 -7
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
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
|
-
//
|
|
429
|
-
// The MOST RECENT test/build/typecheck/lint command the session actually ran
|
|
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
|
|
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";
|
|
481
|
+
return "HARNESS_ERROR";
|
|
469
482
|
return "GENUINE_FAIL"; // a real non-zero exit from the test/build/lint itself
|
|
470
483
|
}
|
|
471
484
|
|