skalpel 4.0.28 → 4.0.30
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/anchor-shadow.mjs +627 -0
- package/catches.mjs +413 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +10 -0
- package/verify-shadow.mjs +346 -41
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).
|