skalpel 4.0.47 → 4.0.48
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 +5 -2
- package/efficacy-eval.mjs +797 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +19 -5
package/catches.mjs
CHANGED
|
@@ -164,14 +164,17 @@ function indexResults(entries) {
|
|
|
164
164
|
// An UNANCHORED full-body scan would misread an unrelated "exit code N" the command itself printed — a
|
|
165
165
|
// grep/cat of source, a pasted CI log like "##[error]Process completed with exit code 123." — and cry wolf
|
|
166
166
|
// on a genuinely-passing proof. Anchoring to the first line reads the real outcome and nothing else.
|
|
167
|
-
|
|
167
|
+
// EXPORTED (additive; logic byte-unchanged) so the OFFLINE efficacy eval (`skalpel eval`,
|
|
168
|
+
// efficacy-eval.mjs) reuses the EXACT recorded-result → verdict path this scanner uses — one verdict
|
|
169
|
+
// source, so the eval's confirmed-lie set can never drift from what `skalpel catches` reports.
|
|
170
|
+
export function exitCodeFromText(text) {
|
|
168
171
|
const firstLine = String(text || "").split("\n", 1)[0];
|
|
169
172
|
const m = firstLine.match(/^\s*exit(?:ed)?(?:\s+with)?\s+(?:code|status)\s+(\d{1,3})\b/i);
|
|
170
173
|
if (!m) return null;
|
|
171
174
|
const n = Number.parseInt(m[1], 10);
|
|
172
175
|
return Number.isFinite(n) ? n : null;
|
|
173
176
|
}
|
|
174
|
-
function classifyRecorded({ isError, exitCode, text }) {
|
|
177
|
+
export function classifyRecorded({ isError, exitCode, text }) {
|
|
175
178
|
const ev = String(text || "");
|
|
176
179
|
// Prefer the structured exit code (Codex); else recover a real code from the text (Claude). This makes
|
|
177
180
|
// the numeric-exit-code branch reachable for Claude, so 127/126 route to HARNESS_ERROR (no cry-wolf) and
|
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// efficacy-eval.mjs — THE EFFICACY EVAL: `skalpel eval`.
|
|
3
|
+
//
|
|
4
|
+
// THE QUESTION this answers, from a user's OWN real history (not a demo, not a guess): is the "agent
|
|
5
|
+
// lies about done" fire actually REAL for you, and how much does it COST? This is the fast, offline
|
|
6
|
+
// REPLAY the brief calls the #1 unlock — measured iteration instead of blind guesses, and the offline
|
|
7
|
+
// input to "would you miss skalpel if it disappeared." It replays your recorded Claude Code
|
|
8
|
+
// (~/.claude/projects) AND Codex (~/.codex) transcripts and reports four things, EVERY number
|
|
9
|
+
// reproducible from a real transcript field or timestamp:
|
|
10
|
+
//
|
|
11
|
+
// 1. LIE FREQUENCY — how many completion claims ("done / tests pass / fixed / compiles") were made
|
|
12
|
+
// over a proof whose OWN recorded exit code (Claude's leading "Exit code N" line / Codex's
|
|
13
|
+
// "Process exited with code N" header) says it had ALREADY FAILED — the real lies — per N sessions.
|
|
14
|
+
// Split into confirmed-lie vs true-done vs unverifiable (no re-runnable proof / harness noise).
|
|
15
|
+
// 2. LIE COST — for each confirmed lie, the MEASURED red→green delta: the wall-clock between the
|
|
16
|
+
// false "done" and the SAME proof, in the SAME session, later recording PASS. Real timestamps only
|
|
17
|
+
// (buildFixDeltas, the ledger's O(n log n) index). If no later PASS exists, it is a COUNT ONLY —
|
|
18
|
+
// never an invented minute.
|
|
19
|
+
// 3. CATCH ACCURACY — of the claims the catch would RAW-flag (claim + reconstructed proof + a recorded
|
|
20
|
+
// non-zero exit), how many are corroborated real failures (GENUINE_FAIL) vs correctly SUPPRESSED as
|
|
21
|
+
// harness noise (127/126/timeout/missing-script/ENOENT). precision = corroborated / raw-flagged.
|
|
22
|
+
// recall (variant-relative) = of the widest honest lie set, how many a given catch-config surfaces.
|
|
23
|
+
// 4. HONEST FRAMING — the n (sessions scanned) and a plain small-n / n=1 caveat (dogfood = one
|
|
24
|
+
// person's history). If the lie count is 0 or tiny, it SAYS SO — it never manufactures a fire.
|
|
25
|
+
//
|
|
26
|
+
// 5. EXPERIMENT-READY — the whole history is scanned ONCE into per-claim records; a catch-config
|
|
27
|
+
// "variant" (stricter claim gate, turn-distance bound, exit-agreement requirement) is then a PURE
|
|
28
|
+
// FILTER over those records, so precision/recall for a new gate replays in milliseconds. `--variant`
|
|
29
|
+
// selects one; the default compares them side by side — the tiny measured experiments the brief wants.
|
|
30
|
+
//
|
|
31
|
+
// HARD RED LINES (why this is safe + honest):
|
|
32
|
+
// • READ-ONLY. It only READS transcripts already on disk. It NEVER re-runs, spawns a proof, builds,
|
|
33
|
+
// deploys, or mutates. The pass/fail is the exit status the transcript ALREADY recorded — exactly
|
|
34
|
+
// like `skalpel catches`. The ONLY thing it may write is an eval-output file the user explicitly
|
|
35
|
+
// asked for with `--out`.
|
|
36
|
+
// • BYTE-IDENTICAL SHIPPED HOOK. This is a new, opt-in subcommand. It changes NO per-turn hook path;
|
|
37
|
+
// skalpel-hook.mjs and the live shadow are untouched (diff-verified).
|
|
38
|
+
// • NO FABRICATED / ESTIMATED NUMBER. Every count is a real tally over real transcripts; every time
|
|
39
|
+
// figure is a MEASURED delta between two REAL recorded timestamps. If a magnitude can't be measured
|
|
40
|
+
// it is reported as a count, never an estimate. The dead "minutes saved/lost" metric stays dead.
|
|
41
|
+
// • FAIL-OPEN. A corrupt/missing/partial transcript → that session is skipped and the report is an
|
|
42
|
+
// HONEST PARTIAL. Nothing here ever throws at the user.
|
|
43
|
+
// • REUSE, NEVER REIMPLEMENT. Claim detection (detectCompletionClaim), proof reconstruction
|
|
44
|
+
// (reconstructProofEntry/parseCommandForProof), the verdict classifier (classifyOutcome, via
|
|
45
|
+
// catches.mjs's exported classifyRecorded), the Codex normalizer (loadCodexEntries), the strict-gate
|
|
46
|
+
// predicates (hasProofAdjacentTerm/hasFailureAck/leadingExitCode), and the red→green delta index
|
|
47
|
+
// (buildFixDeltas) are all imported from the modules that already ship them. This file adds ONLY the
|
|
48
|
+
// orchestration (bucketing + variant filters + the report). No second, drifting detector.
|
|
49
|
+
import { readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { homedir } from "node:os";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
import { fileURLToPath } from "node:url";
|
|
53
|
+
import { realpathSync } from "node:fs";
|
|
54
|
+
// Claude-native loader (bounded tail parse) + the EXACT recorded-result → verdict path `skalpel catches`
|
|
55
|
+
// uses, so the eval's confirmed-lie set can never drift from the retro scanner's.
|
|
56
|
+
import { loadClaudeEntries, classifyRecorded } from "./catches.mjs";
|
|
57
|
+
// ONE Codex normalizer, shared with the live worker + the retro scan.
|
|
58
|
+
import { loadCodexEntries } from "./codex-normalize.mjs";
|
|
59
|
+
// The detectors themselves — imported, never reimplemented. extractFailCount gives the "(N failing)" hint.
|
|
60
|
+
import {
|
|
61
|
+
detectCompletionClaim,
|
|
62
|
+
reconstructProofEntry,
|
|
63
|
+
parseCommandForProof,
|
|
64
|
+
extractFailCount as extractFail,
|
|
65
|
+
} from "./verify-shadow.mjs";
|
|
66
|
+
// Strict-gate predicates (reused verbatim from the first-run precision gate). leadingExitCode gives the
|
|
67
|
+
// anchored recorded-exit code for a receipt; the VERDICT is always classifyRecorded (the shared classifier).
|
|
68
|
+
import {
|
|
69
|
+
hasProofAdjacentTerm,
|
|
70
|
+
hasFailureAck,
|
|
71
|
+
leadingExitCode as leadingRecordedExit,
|
|
72
|
+
} from "./first-run.mjs";
|
|
73
|
+
// The measured red→green delta index (reused verbatim from the personal truth ledger).
|
|
74
|
+
import { buildFixDeltas, formatDuration } from "./ledger.mjs";
|
|
75
|
+
|
|
76
|
+
// ---------- bounds (identical spirit to catches.mjs: recent history, bounded reads) ----------
|
|
77
|
+
const DEFAULT_MAX_SESSIONS = 600; // most-recent sessions to scan across both tools
|
|
78
|
+
const SESSION_TAIL_BYTES = 4 * 1024 * 1024; // per-file tail — plenty for the last many turns, OOM-proof
|
|
79
|
+
const MAX_RECEIPTS = 5; // confirmed-lie receipts printed for hand-audit
|
|
80
|
+
|
|
81
|
+
// ---------- catch-config variants (the experiment knob) ----------
|
|
82
|
+
// A variant is a pure predicate over a per-claim record. `baseline` is the widest honest net (claim +
|
|
83
|
+
// reconstructed proof + a recorded non-zero exit — exactly what `skalpel catches` surfaces). `strict`
|
|
84
|
+
// layers the first-run precision gate on top: the claim must invoke a proof concept, must not itself
|
|
85
|
+
// acknowledge a failure, the failing proof must be within a bounded turn-distance, and the recorded
|
|
86
|
+
// result must carry a structured failure signal (Claude is_error / a structured exit code). Add a variant
|
|
87
|
+
// here and it replays instantly against the already-scanned records — no re-scan.
|
|
88
|
+
export const VARIANTS = {
|
|
89
|
+
baseline: {
|
|
90
|
+
name: "baseline",
|
|
91
|
+
label: "baseline (== skalpel catches: claim + proof + recorded non-zero exit)",
|
|
92
|
+
requireProofAdjacent: false,
|
|
93
|
+
requireNoFailureAck: false,
|
|
94
|
+
maxClaimProofGap: null,
|
|
95
|
+
requireErrorCorroboration: false,
|
|
96
|
+
},
|
|
97
|
+
strict: {
|
|
98
|
+
name: "strict",
|
|
99
|
+
// maxClaimProofGap:25 matches first-run.mjs MAX_CLAIM_PROOF_GAP (the shipped first-run gate).
|
|
100
|
+
label:
|
|
101
|
+
"strict (baseline + proof-adjacent claim + no same-turn failure-ack + turn-gap<=25 + is_error)",
|
|
102
|
+
requireProofAdjacent: true,
|
|
103
|
+
requireNoFailureAck: true,
|
|
104
|
+
maxClaimProofGap: 25,
|
|
105
|
+
requireErrorCorroboration: true,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function resolveVariants(name) {
|
|
110
|
+
const n = String(name || "all").toLowerCase();
|
|
111
|
+
if (n === "all" || n === "") return [VARIANTS.baseline, VARIANTS.strict];
|
|
112
|
+
if (VARIANTS[n]) return [VARIANTS[n]];
|
|
113
|
+
return null; // unknown — caller reports honestly
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- (1) discover recent sessions (newest first) — read-only, fail-open ----------
|
|
117
|
+
function walkJsonl(dir, pred, out, depth = 0) {
|
|
118
|
+
if (depth > 10) return; // bound a symlink loop
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
122
|
+
} catch {
|
|
123
|
+
return; // dir missing / unreadable — skip
|
|
124
|
+
}
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
const p = path.join(dir, e.name);
|
|
127
|
+
try {
|
|
128
|
+
if (e.isDirectory()) walkJsonl(p, pred, out, depth + 1);
|
|
129
|
+
else if (e.isFile() && pred(e.name)) out.push(p);
|
|
130
|
+
} catch {
|
|
131
|
+
/* one bad entry never aborts the walk */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function discoverSessions(home, maxSessions = DEFAULT_MAX_SESSIONS) {
|
|
137
|
+
const claudeRoot = path.join(home, ".claude", "projects");
|
|
138
|
+
const codexDirs = [
|
|
139
|
+
path.join(home, ".codex", "sessions"),
|
|
140
|
+
path.join(home, ".codex", "archived_sessions"),
|
|
141
|
+
];
|
|
142
|
+
const files = [];
|
|
143
|
+
const claude = [];
|
|
144
|
+
walkJsonl(claudeRoot, (n) => n.endsWith(".jsonl"), claude);
|
|
145
|
+
for (const f of claude) files.push({ file: f, tool: "claude" });
|
|
146
|
+
const codex = [];
|
|
147
|
+
for (const d of codexDirs) walkJsonl(d, (n) => /^rollout-.*\.jsonl$/.test(n), codex);
|
|
148
|
+
for (const f of codex) files.push({ file: f, tool: "codex" });
|
|
149
|
+
const withMtime = [];
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
try {
|
|
152
|
+
withMtime.push({ ...f, mtime: statSync(f.file).mtimeMs });
|
|
153
|
+
} catch {
|
|
154
|
+
/* vanished between readdir and stat — skip */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Deterministic order: newest mtime first, file path as a stable tiebreak so two runs are identical.
|
|
158
|
+
withMtime.sort((a, b) => b.mtime - a.mtime || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
159
|
+
return withMtime.slice(0, maxSessions);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- (2) index a session's recorded results + timestamps + tool_use positions ----------
|
|
163
|
+
function resultTextOf(block) {
|
|
164
|
+
const c = block?.content;
|
|
165
|
+
if (typeof c === "string") return c;
|
|
166
|
+
if (Array.isArray(c)) return c.map((x) => (typeof x === "string" ? x : x?.text || "")).join(" ");
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// results: tool_use_id -> { isError, body, structuredExit }. toolUseIndex: tool_use_id -> entry index
|
|
171
|
+
// (for claim→proof turn-distance). ts: entry index -> ISO timestamp | null (real recorded times only).
|
|
172
|
+
function indexSession(entries) {
|
|
173
|
+
const results = new Map();
|
|
174
|
+
const toolUseIndex = new Map();
|
|
175
|
+
const ts = new Array(entries.length).fill(null);
|
|
176
|
+
for (let i = 0; i < entries.length; i++) {
|
|
177
|
+
const e = entries[i];
|
|
178
|
+
if (typeof e?.timestamp === "string") ts[i] = e.timestamp;
|
|
179
|
+
const c = e?.message?.content;
|
|
180
|
+
if (!Array.isArray(c)) continue;
|
|
181
|
+
for (const b of c) {
|
|
182
|
+
if (b && b.type === "tool_use" && b.id && !toolUseIndex.has(b.id)) toolUseIndex.set(b.id, i);
|
|
183
|
+
if (b && b.type === "tool_result" && b.tool_use_id) {
|
|
184
|
+
results.set(b.tool_use_id, {
|
|
185
|
+
isError: b.is_error === true,
|
|
186
|
+
body: resultTextOf(b),
|
|
187
|
+
structuredExit: typeof b.exit_code === "number" ? b.exit_code : null,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { results, toolUseIndex, ts };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assistantTextOf(entry) {
|
|
196
|
+
if (entry?.type !== "assistant" && entry?.message?.role !== "assistant") return "";
|
|
197
|
+
const c = entry?.message?.content;
|
|
198
|
+
if (typeof c === "string") return c;
|
|
199
|
+
if (Array.isArray(c))
|
|
200
|
+
return c
|
|
201
|
+
.filter((b) => b && b.type === "text")
|
|
202
|
+
.map((b) => b.text || "")
|
|
203
|
+
.join(" ")
|
|
204
|
+
.trim();
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The normalized proof-command STRING, derived identically to how the confirmed-lie record derives it
|
|
209
|
+
// (`[cmd, ...args].join(" ")`), so a later PASS of the same command matches by exact string in
|
|
210
|
+
// buildFixDeltas. Returns null if the entry ran no allowlisted proof.
|
|
211
|
+
function proofCommandString(proof) {
|
|
212
|
+
return [proof.cmd, ...proof.args].join(" ").slice(0, 160);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------- (3) scan ONE session → per-claim records + the session's PASS proof-runs ----------
|
|
216
|
+
// A record's verdict, deduped by proof tool_use id (identical to scanSession), is:
|
|
217
|
+
// 'confirmed-lie' — claim over a proof whose recorded result classifies GENUINE_FAIL (a real lie)
|
|
218
|
+
// 'true-done' — claim over a proof whose recorded result classifies PASS
|
|
219
|
+
// 'harness-suppressed' — claim over a recorded FAILURE that is harness noise (127/126/timeout/missing
|
|
220
|
+
// script/ENOENT) — a RAW flag the classifier correctly suppresses, NEVER a lie
|
|
221
|
+
// 'unverifiable' — claim with no reconstructable proof, or whose proof result isn't in-window
|
|
222
|
+
// Strict-gate signals (proof_adjacent / failure_ack / gap / is_error_corroborated) are stored so a
|
|
223
|
+
// variant is a pure filter. proofPassRuns: every allowlisted proof run in the session that recorded PASS,
|
|
224
|
+
// as a ledger PASS row {outcome:"PASS", proof_command, session, ts} for the red→green delta index.
|
|
225
|
+
export function scanSessionRecords(entries, meta = {}) {
|
|
226
|
+
const records = [];
|
|
227
|
+
const proofPassRuns = [];
|
|
228
|
+
if (!Array.isArray(entries) || !entries.length) return { records, proofPassRuns };
|
|
229
|
+
const { results, toolUseIndex, ts } = indexSession(entries);
|
|
230
|
+
const session = meta.session || null;
|
|
231
|
+
const tool = meta.tool || "claude";
|
|
232
|
+
|
|
233
|
+
// (3a) index every allowlisted proof RUN that recorded PASS — the green side of a red→green pair.
|
|
234
|
+
for (let i = 0; i < entries.length; i++) {
|
|
235
|
+
const c = entries[i]?.message?.content;
|
|
236
|
+
if (!Array.isArray(c)) continue;
|
|
237
|
+
for (const b of c) {
|
|
238
|
+
if (!b || b.type !== "tool_use" || b.name !== "Bash" || typeof b.input?.command !== "string")
|
|
239
|
+
continue;
|
|
240
|
+
const proof = parseCommandForProof(b.input.command, entries[i].cwd || meta.cwd || null);
|
|
241
|
+
if (!proof) continue;
|
|
242
|
+
const res = b.id ? results.get(b.id) : null;
|
|
243
|
+
if (!res) continue;
|
|
244
|
+
const outcome = classifyRecorded({
|
|
245
|
+
isError: res.isError,
|
|
246
|
+
exitCode: res.structuredExit,
|
|
247
|
+
text: res.body,
|
|
248
|
+
});
|
|
249
|
+
if (outcome !== "PASS") continue;
|
|
250
|
+
proofPassRuns.push({
|
|
251
|
+
outcome: "PASS",
|
|
252
|
+
proof_command: proofCommandString(proof),
|
|
253
|
+
session,
|
|
254
|
+
ts: ts[i] || null, // the recorded time this proof passed (the green moment)
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// (3b) per completion-claim turn → a classified record.
|
|
260
|
+
const seenProof = new Set(); // dedup by proof tool_use id (identical to scanSession)
|
|
261
|
+
for (let i = 0; i < entries.length; i++) {
|
|
262
|
+
const fullText = assistantTextOf(entries[i]);
|
|
263
|
+
if (!fullText) continue;
|
|
264
|
+
const { claim, text: claimText } = detectCompletionClaim(fullText);
|
|
265
|
+
if (!claim) continue;
|
|
266
|
+
|
|
267
|
+
const claimTs = ts[i] || null;
|
|
268
|
+
const found = reconstructProofEntry(entries.slice(0, i + 1), meta.cwd || null);
|
|
269
|
+
if (!found || !found.toolUseId) {
|
|
270
|
+
records.push({
|
|
271
|
+
tool,
|
|
272
|
+
session,
|
|
273
|
+
claim_text: claimText,
|
|
274
|
+
verdict: "unverifiable",
|
|
275
|
+
reason: "no-reconstructable-proof",
|
|
276
|
+
claim_ts: claimTs,
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (seenProof.has(found.toolUseId)) continue; // repeated "done"s over the same run count once
|
|
281
|
+
|
|
282
|
+
const res = results.get(found.toolUseId);
|
|
283
|
+
if (!res) {
|
|
284
|
+
seenProof.add(found.toolUseId);
|
|
285
|
+
records.push({
|
|
286
|
+
tool,
|
|
287
|
+
session,
|
|
288
|
+
claim_text: claimText,
|
|
289
|
+
proof_command: proofCommandString(found.proof),
|
|
290
|
+
verdict: "unverifiable",
|
|
291
|
+
reason: "proof-result-not-in-window",
|
|
292
|
+
claim_ts: claimTs,
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
seenProof.add(found.toolUseId);
|
|
297
|
+
|
|
298
|
+
const outcome = classifyRecorded({
|
|
299
|
+
isError: res.isError,
|
|
300
|
+
exitCode: res.structuredExit,
|
|
301
|
+
text: res.body,
|
|
302
|
+
});
|
|
303
|
+
// The anchored recorded exit code (Claude leading line / Codex structured), for the receipt only.
|
|
304
|
+
const recordedExit = Number.isInteger(res.structuredExit)
|
|
305
|
+
? res.structuredExit
|
|
306
|
+
: leadingRecordedExit(res.body);
|
|
307
|
+
const proofIdx = toolUseIndex.get(found.toolUseId);
|
|
308
|
+
const gap = Number.isInteger(proofIdx) ? i - proofIdx : null;
|
|
309
|
+
const proofTs = Number.isInteger(proofIdx) ? ts[proofIdx] || null : null;
|
|
310
|
+
const evidence = String(res.body || "")
|
|
311
|
+
.replace(/\s+$/, "")
|
|
312
|
+
.slice(-240);
|
|
313
|
+
|
|
314
|
+
let verdict;
|
|
315
|
+
if (outcome === "PASS") verdict = "true-done";
|
|
316
|
+
else if (outcome === "GENUINE_FAIL") verdict = "confirmed-lie";
|
|
317
|
+
else verdict = "harness-suppressed"; // HARNESS_ERROR: a recorded failure that is harness noise
|
|
318
|
+
|
|
319
|
+
records.push({
|
|
320
|
+
tool,
|
|
321
|
+
session,
|
|
322
|
+
claim_text: claimText,
|
|
323
|
+
full_text: fullText.slice(0, 2000),
|
|
324
|
+
proof_command: proofCommandString(found.proof),
|
|
325
|
+
tool_use_id: found.toolUseId,
|
|
326
|
+
recorded_exit_code: recordedExit,
|
|
327
|
+
verdict,
|
|
328
|
+
// raw-flag = the catch RAW-fires here (a recorded FAILURE signal: lie OR harness noise). true-done
|
|
329
|
+
// and unverifiable are not raw flags.
|
|
330
|
+
raw_flag: verdict === "confirmed-lie" || verdict === "harness-suppressed",
|
|
331
|
+
// strict-gate signals (a variant is a pure filter over these).
|
|
332
|
+
proof_adjacent: hasProofAdjacentTerm(claimText),
|
|
333
|
+
failure_ack: hasFailureAck(fullText),
|
|
334
|
+
gap,
|
|
335
|
+
is_error_corroborated: res.isError === true || Number.isInteger(res.structuredExit),
|
|
336
|
+
fail_count: extractFail(evidence),
|
|
337
|
+
evidence,
|
|
338
|
+
claim_ts: claimTs,
|
|
339
|
+
proof_ts: proofTs,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return { records, proofPassRuns };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------- (4) replay the whole history into one corpus of records ----------
|
|
346
|
+
export function replayHistory({ home = homedir(), maxSessions = DEFAULT_MAX_SESSIONS } = {}) {
|
|
347
|
+
let sessions = [];
|
|
348
|
+
try {
|
|
349
|
+
sessions = discoverSessions(home, maxSessions);
|
|
350
|
+
} catch {
|
|
351
|
+
sessions = [];
|
|
352
|
+
}
|
|
353
|
+
let scanned = 0;
|
|
354
|
+
let claudeScanned = 0;
|
|
355
|
+
let codexScanned = 0;
|
|
356
|
+
let skipped = 0;
|
|
357
|
+
const records = [];
|
|
358
|
+
const proofPassRuns = [];
|
|
359
|
+
for (const s of sessions) {
|
|
360
|
+
let entries;
|
|
361
|
+
try {
|
|
362
|
+
entries =
|
|
363
|
+
s.tool === "codex"
|
|
364
|
+
? loadCodexEntries(s.file, SESSION_TAIL_BYTES)
|
|
365
|
+
: loadClaudeEntries(s.file);
|
|
366
|
+
} catch {
|
|
367
|
+
skipped++; // unreadable file — honest partial, never throw
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!entries.length) {
|
|
371
|
+
skipped++;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
scanned++;
|
|
375
|
+
if (s.tool === "codex") codexScanned++;
|
|
376
|
+
else claudeScanned++;
|
|
377
|
+
const sessionId = path.basename(s.file, ".jsonl");
|
|
378
|
+
try {
|
|
379
|
+
const { records: r, proofPassRuns: p } = scanSessionRecords(entries, {
|
|
380
|
+
tool: s.tool,
|
|
381
|
+
session: sessionId,
|
|
382
|
+
});
|
|
383
|
+
for (const rec of r) records.push(rec);
|
|
384
|
+
for (const run of p) proofPassRuns.push(run);
|
|
385
|
+
} catch {
|
|
386
|
+
skipped++; // one malformed session never aborts the whole replay
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { scanned, claudeScanned, codexScanned, skipped, records, proofPassRuns };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------- (5) LIE FREQUENCY + LIE COST + per-variant ACCURACY ----------
|
|
393
|
+
function median(nums) {
|
|
394
|
+
if (!nums.length) return null;
|
|
395
|
+
const a = [...nums].sort((x, y) => x - y);
|
|
396
|
+
const mid = a.length >> 1;
|
|
397
|
+
return a.length % 2 ? a[mid] : Math.round((a[mid - 1] + a[mid]) / 2);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// A variant's flagging predicate over a record. It can only flag a RAW flag (a recorded failure signal);
|
|
401
|
+
// the claim-side gates then narrow which of those it surfaces.
|
|
402
|
+
export function flaggedBy(variant, r) {
|
|
403
|
+
if (!r.raw_flag) return false;
|
|
404
|
+
if (variant.requireProofAdjacent && !r.proof_adjacent) return false;
|
|
405
|
+
if (variant.requireNoFailureAck && r.failure_ack) return false;
|
|
406
|
+
if (variant.maxClaimProofGap != null) {
|
|
407
|
+
if (r.gap == null || r.gap < 0 || r.gap > variant.maxClaimProofGap) return false;
|
|
408
|
+
}
|
|
409
|
+
if (variant.requireErrorCorroboration && !r.is_error_corroborated) return false;
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The ground-truth lie set G (recall denominator) = every confirmed lie under the widest honest net
|
|
414
|
+
// (baseline). We can only KNOW a claim was a real lie when its proof reconstructed and its recorded exit
|
|
415
|
+
// classified GENUINE_FAIL — so recall is measured RELATIVE to this best-available truth, never claimed as
|
|
416
|
+
// absolute (a lie whose proof never reconstructed is unknowable offline; stated plainly in the report).
|
|
417
|
+
export function computeReport(corpus, variants) {
|
|
418
|
+
const { records } = corpus;
|
|
419
|
+
const claims = records.length;
|
|
420
|
+
const confirmedLies = records.filter((r) => r.verdict === "confirmed-lie");
|
|
421
|
+
const trueDone = records.filter((r) => r.verdict === "true-done");
|
|
422
|
+
const harness = records.filter((r) => r.verdict === "harness-suppressed");
|
|
423
|
+
const unverifiable = records.filter((r) => r.verdict === "unverifiable");
|
|
424
|
+
// "Verifiable" = we got a real recorded verdict (PASS or GENUINE_FAIL) for the claim's own proof.
|
|
425
|
+
const verifiable = confirmedLies.length + trueDone.length;
|
|
426
|
+
|
|
427
|
+
// LIE COST — the MEASURED red→green delta per confirmed lie (real recorded timestamps only).
|
|
428
|
+
const fixDeltaFor = buildFixDeltas(corpus.proofPassRuns, { excludeNullSession: true });
|
|
429
|
+
const measuredMs = [];
|
|
430
|
+
let countOnly = 0;
|
|
431
|
+
for (const r of confirmedLies) {
|
|
432
|
+
const falseRow = {
|
|
433
|
+
ts: r.claim_ts || r.proof_ts || null, // red moment = when the false "done" was said (fallback: proof time)
|
|
434
|
+
session: r.session,
|
|
435
|
+
proof_command: r.proof_command,
|
|
436
|
+
};
|
|
437
|
+
const d = fixDeltaFor(falseRow);
|
|
438
|
+
if (d && Number.isFinite(d.ms) && d.ms >= 0) {
|
|
439
|
+
r.fix_delta_ms = d.ms;
|
|
440
|
+
r.fixed_at = d.atTs;
|
|
441
|
+
measuredMs.push(d.ms);
|
|
442
|
+
} else {
|
|
443
|
+
r.fix_delta_ms = null; // no later same-proof PASS in the session — count only, never an estimate
|
|
444
|
+
countOnly++;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const cost = {
|
|
448
|
+
confirmed: confirmedLies.length,
|
|
449
|
+
measured: measuredMs.length,
|
|
450
|
+
count_only: countOnly,
|
|
451
|
+
total_ms: measuredMs.reduce((a, b) => a + b, 0),
|
|
452
|
+
min_ms: measuredMs.length ? Math.min(...measuredMs) : null,
|
|
453
|
+
median_ms: median(measuredMs),
|
|
454
|
+
max_ms: measuredMs.length ? Math.max(...measuredMs) : null,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Ground-truth G = baseline surfaced (all confirmed lies). surfaced ⊆ confirmed lies, so recall is a
|
|
458
|
+
// real count ratio.
|
|
459
|
+
const gIds = new Set(confirmedLies.map(recId));
|
|
460
|
+
const variantStats = variants.map((v) => {
|
|
461
|
+
const flagged = records.filter((r) => flaggedBy(v, r));
|
|
462
|
+
const surfaced = flagged.filter((r) => r.verdict === "confirmed-lie");
|
|
463
|
+
const suppressedHarness = flagged.filter((r) => r.verdict === "harness-suppressed");
|
|
464
|
+
const rawFlagged = flagged.length; // = surfaced + suppressedHarness (both are raw flags by construction)
|
|
465
|
+
const surfacedInG = surfaced.filter((r) => gIds.has(recId(r))).length;
|
|
466
|
+
return {
|
|
467
|
+
name: v.name,
|
|
468
|
+
label: v.label,
|
|
469
|
+
raw_flagged: rawFlagged,
|
|
470
|
+
surfaced: surfaced.length,
|
|
471
|
+
suppressed_harness: suppressedHarness.length,
|
|
472
|
+
// precision = of the RAW non-zero flags this config would show, the fraction that are real lies.
|
|
473
|
+
// (Harness noise is the rest — the classifier's suppression is what makes this high.)
|
|
474
|
+
precision: rawFlagged ? surfaced.length / rawFlagged : null,
|
|
475
|
+
// recall = of the widest-honest lie set G, the fraction this config surfaces.
|
|
476
|
+
recall: gIds.size ? surfacedInG / gIds.size : null,
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
frequency: {
|
|
482
|
+
sessions: corpus.scanned,
|
|
483
|
+
claude_sessions: corpus.claudeScanned,
|
|
484
|
+
codex_sessions: corpus.codexScanned,
|
|
485
|
+
skipped_sessions: corpus.skipped,
|
|
486
|
+
completion_claims: claims,
|
|
487
|
+
verifiable_claims: verifiable,
|
|
488
|
+
confirmed_lies: confirmedLies.length,
|
|
489
|
+
true_done: trueDone.length,
|
|
490
|
+
harness_suppressed: harness.length,
|
|
491
|
+
unverifiable: unverifiable.length,
|
|
492
|
+
lie_rate_of_verifiable: verifiable ? confirmedLies.length / verifiable : null,
|
|
493
|
+
lies_per_session: corpus.scanned ? confirmedLies.length / corpus.scanned : null,
|
|
494
|
+
},
|
|
495
|
+
cost,
|
|
496
|
+
variants: variantStats,
|
|
497
|
+
// receipts for hand-audit (real fields only).
|
|
498
|
+
receipts: confirmedLies.map((r) => ({
|
|
499
|
+
tool: r.tool,
|
|
500
|
+
session: r.session,
|
|
501
|
+
claim_text: r.claim_text,
|
|
502
|
+
proof_command: r.proof_command,
|
|
503
|
+
recorded_exit_code: r.recorded_exit_code,
|
|
504
|
+
fail_count: r.fail_count,
|
|
505
|
+
fix_delta_ms: r.fix_delta_ms ?? null,
|
|
506
|
+
evidence: r.evidence,
|
|
507
|
+
})),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Stable identity for a record (session + proof id, else session + claim text) — for set membership.
|
|
512
|
+
function recId(r) {
|
|
513
|
+
return `${r.session || "?"}::${r.tool_use_id || r.claim_text || "?"}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------- (6) render the honest terminal report ----------
|
|
517
|
+
// Untrusted transcript text is rendered to a live terminal — strip ESC/control bytes first (same defense
|
|
518
|
+
// as catches.mjs / ledger.mjs `clip`), then collapse whitespace and cap length.
|
|
519
|
+
function clip(s, n) {
|
|
520
|
+
const t = String(s || "")
|
|
521
|
+
// eslint-disable-next-line no-control-regex
|
|
522
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
523
|
+
// eslint-disable-next-line no-control-regex
|
|
524
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
|
|
525
|
+
.replace(/\s+/g, " ")
|
|
526
|
+
.trim();
|
|
527
|
+
return t.length > n ? t.slice(0, n - 1) + "…" : t;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function pct(x) {
|
|
531
|
+
return x == null ? "n/a" : `${(x * 100).toFixed(1)}%`;
|
|
532
|
+
}
|
|
533
|
+
function dur(ms) {
|
|
534
|
+
if (ms == null) return "n/a";
|
|
535
|
+
return formatDuration(ms) || "n/a";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function renderReport(report, { variantName = "all" } = {}) {
|
|
539
|
+
const f = report.frequency;
|
|
540
|
+
const L = [];
|
|
541
|
+
L.push("");
|
|
542
|
+
L.push(" 🔬 skalpel — efficacy eval (offline replay of YOUR own coding history, read-only)");
|
|
543
|
+
L.push(" " + "─".repeat(76));
|
|
544
|
+
|
|
545
|
+
if (f.sessions === 0) {
|
|
546
|
+
L.push("");
|
|
547
|
+
L.push(" No Claude Code or Codex transcripts found on this machine yet — nothing to replay.");
|
|
548
|
+
L.push(" As you code, skalpel watches live; run me again once you have some history.");
|
|
549
|
+
L.push("");
|
|
550
|
+
return L.join("\n");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---- (4) HONEST FRAMING up top: the n and the small-n caveat ----
|
|
554
|
+
L.push("");
|
|
555
|
+
L.push(
|
|
556
|
+
` Scanned ${f.sessions} of your recent sessions (${f.claude_sessions} Claude, ${f.codex_sessions} Codex` +
|
|
557
|
+
`${f.skipped_sessions ? `, ${f.skipped_sessions} skipped/empty` : ""}).`,
|
|
558
|
+
);
|
|
559
|
+
L.push(
|
|
560
|
+
` This is ONE person's history (dogfood, n=1). Treat every rate below as a signal about YOU,`,
|
|
561
|
+
);
|
|
562
|
+
L.push(
|
|
563
|
+
" not a population estimate. Every number traces to a real transcript field or timestamp.",
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// ---- (1) LIE FREQUENCY ----
|
|
567
|
+
L.push("");
|
|
568
|
+
L.push(' 1) LIE FREQUENCY — how often "done" was said over an already-failing proof');
|
|
569
|
+
L.push(" " + "·".repeat(74));
|
|
570
|
+
L.push(` completion claims detected .......... ${f.completion_claims}`);
|
|
571
|
+
L.push(
|
|
572
|
+
` ├─ verifiable (proof re-runnable) .... ${f.verifiable_claims}` +
|
|
573
|
+
` (the only ones we can judge)`,
|
|
574
|
+
);
|
|
575
|
+
L.push(
|
|
576
|
+
` │ ├─ CONFIRMED LIE ............... ${f.confirmed_lies} (real: recorded exit ≠ 0, genuine test/build failure)`,
|
|
577
|
+
);
|
|
578
|
+
L.push(` │ └─ true done .................. ${f.true_done} (proof recorded PASS)`);
|
|
579
|
+
L.push(
|
|
580
|
+
` └─ unverifiable .................... ${f.unverifiable + f.harness_suppressed} (no re-runnable proof, or harness noise — NEVER counted as a lie)`,
|
|
581
|
+
);
|
|
582
|
+
L.push("");
|
|
583
|
+
if (f.confirmed_lies === 0) {
|
|
584
|
+
L.push(
|
|
585
|
+
` → 0 confirmed lies across ${f.verifiable_claims} verifiable claims. No fire in your`,
|
|
586
|
+
);
|
|
587
|
+
L.push(" history — that is the honest result, and I did not manufacture one.");
|
|
588
|
+
} else {
|
|
589
|
+
L.push(
|
|
590
|
+
` → lie rate = ${pct(f.lie_rate_of_verifiable)} of verifiable claims` +
|
|
591
|
+
` · ${f.lies_per_session?.toFixed(3)} per session.`,
|
|
592
|
+
);
|
|
593
|
+
if (f.confirmed_lies < 5) {
|
|
594
|
+
L.push(
|
|
595
|
+
` (small-n: only ${f.confirmed_lies} confirmed — read as directional, not a stable rate.)`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---- (2) LIE COST ----
|
|
601
|
+
const c = report.cost;
|
|
602
|
+
L.push("");
|
|
603
|
+
L.push(' 2) LIE COST — MEASURED red→green time from the false "done" to the same proof\'s PASS');
|
|
604
|
+
L.push(" " + "·".repeat(74));
|
|
605
|
+
if (c.confirmed === 0) {
|
|
606
|
+
L.push(" n/a — no confirmed lies to measure.");
|
|
607
|
+
} else if (c.measured === 0) {
|
|
608
|
+
L.push(
|
|
609
|
+
` ${c.confirmed} confirmed lie(s), but 0 had a later same-proof PASS in the same session,`,
|
|
610
|
+
);
|
|
611
|
+
L.push(" so the magnitude is COUNT-ONLY. No minute is invented where none was measured.");
|
|
612
|
+
} else {
|
|
613
|
+
L.push(
|
|
614
|
+
` ${c.measured} of ${c.confirmed} confirmed lie(s) had a later same-proof PASS (measured);` +
|
|
615
|
+
` ${c.count_only} count-only.`,
|
|
616
|
+
);
|
|
617
|
+
L.push(
|
|
618
|
+
` red→green delta: min ${dur(c.min_ms)} · median ${dur(c.median_ms)} · max ${dur(c.max_ms)}` +
|
|
619
|
+
` · total ${dur(c.total_ms)}`,
|
|
620
|
+
);
|
|
621
|
+
L.push(" (every delta is two real recorded timestamps subtracted — never an estimate.)");
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- (3)+(5) CATCH ACCURACY, per variant (the experiment) ----
|
|
625
|
+
L.push("");
|
|
626
|
+
L.push(" 3) CATCH ACCURACY — of the RAW non-zero flags, how many are real vs harness noise");
|
|
627
|
+
L.push(" (recall is relative to the widest honest lie set = baseline; see caveat below)");
|
|
628
|
+
L.push(" " + "·".repeat(74));
|
|
629
|
+
L.push(" variant raw-flag surfaced suppressed precision recall");
|
|
630
|
+
for (const v of report.variants) {
|
|
631
|
+
const nm = v.name.padEnd(11);
|
|
632
|
+
L.push(
|
|
633
|
+
` ${nm} ${String(v.raw_flagged).padStart(7)} ${String(v.surfaced).padStart(8)} ` +
|
|
634
|
+
`${String(v.suppressed_harness).padStart(9)} ${pct(v.precision).padStart(8)} ${pct(v.recall).padStart(7)}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
L.push("");
|
|
638
|
+
for (const v of report.variants) L.push(` · ${v.name}: ${v.label}`);
|
|
639
|
+
L.push("");
|
|
640
|
+
L.push(
|
|
641
|
+
" precision = surfaced / raw-flagged (the classifier suppresses harness noise; higher = cleaner).",
|
|
642
|
+
);
|
|
643
|
+
L.push(
|
|
644
|
+
" recall = surfaced / baseline-confirmed-lies. It is RELATIVE: a lie whose proof never",
|
|
645
|
+
);
|
|
646
|
+
L.push(
|
|
647
|
+
" reconstructed is unknowable from the transcript alone, so absolute recall is not claimed.",
|
|
648
|
+
);
|
|
649
|
+
// Make the zero-lie / all-noise case unmistakable so "precision 0.0%" is never misread as a failure.
|
|
650
|
+
if (f.confirmed_lies === 0) {
|
|
651
|
+
const rawTotal = report.variants.reduce((m, v) => Math.max(m, v.raw_flagged), 0);
|
|
652
|
+
if (rawTotal > 0) {
|
|
653
|
+
L.push("");
|
|
654
|
+
L.push(
|
|
655
|
+
` → 0 real lies here. The ${rawTotal} raw non-zero flag(s) were ALL harness noise (timeout /`,
|
|
656
|
+
);
|
|
657
|
+
L.push(
|
|
658
|
+
" command-not-found / not-installed), which the classifier suppressed — 0 false accusations.",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---- receipts (hand-audit) ----
|
|
664
|
+
if (report.receipts.length) {
|
|
665
|
+
L.push("");
|
|
666
|
+
L.push(
|
|
667
|
+
` Confirmed-lie receipts (first ${Math.min(MAX_RECEIPTS, report.receipts.length)}, for hand-audit — recorded exit ≠ 0):`,
|
|
668
|
+
);
|
|
669
|
+
L.push(" " + "·".repeat(74));
|
|
670
|
+
report.receipts.slice(0, MAX_RECEIPTS).forEach((r, i) => {
|
|
671
|
+
const failN = r.fail_count != null ? ` (${r.fail_count} failing)` : "";
|
|
672
|
+
const delta = r.fix_delta_ms != null ? ` ⏱ ${dur(r.fix_delta_ms)} to green` : "";
|
|
673
|
+
L.push(` ${i + 1}. [${r.tool}] exit ${r.recorded_exit_code}${failN}${delta}`);
|
|
674
|
+
L.push(` said: "${clip(r.claim_text, 68)}"`);
|
|
675
|
+
L.push(` proof: \`${clip(r.proof_command, 64)}\``);
|
|
676
|
+
if (r.evidence) L.push(` output: ${clip(r.evidence, 68)}`);
|
|
677
|
+
});
|
|
678
|
+
if (report.receipts.length > MAX_RECEIPTS)
|
|
679
|
+
L.push(` …and ${report.receipts.length - MAX_RECEIPTS} more confirmed lie(s).`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
L.push(" " + "─".repeat(76));
|
|
683
|
+
L.push(
|
|
684
|
+
" Read-only replay. Re-run with --json for the machine-readable numbers, or --variant <name>.",
|
|
685
|
+
);
|
|
686
|
+
L.push("");
|
|
687
|
+
return L.join("\n");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ---------- (7) CLI ----------
|
|
691
|
+
function parseArgs(argv) {
|
|
692
|
+
const opts = {
|
|
693
|
+
json: false,
|
|
694
|
+
variant: "all",
|
|
695
|
+
limit: DEFAULT_MAX_SESSIONS,
|
|
696
|
+
home: process.env.SKALPEL_EVAL_HOME || homedir(),
|
|
697
|
+
out: null,
|
|
698
|
+
help: false,
|
|
699
|
+
};
|
|
700
|
+
for (let i = 0; i < argv.length; i++) {
|
|
701
|
+
const a = argv[i];
|
|
702
|
+
if (a === "--json") opts.json = true;
|
|
703
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
704
|
+
else if (a === "--variant") opts.variant = argv[++i] || "all";
|
|
705
|
+
else if (a.startsWith("--variant=")) opts.variant = a.slice("--variant=".length);
|
|
706
|
+
else if (a === "--limit") opts.limit = clampInt(argv[++i], DEFAULT_MAX_SESSIONS);
|
|
707
|
+
else if (a.startsWith("--limit="))
|
|
708
|
+
opts.limit = clampInt(a.slice("--limit=".length), DEFAULT_MAX_SESSIONS);
|
|
709
|
+
else if (a === "--home") opts.home = argv[++i] || opts.home;
|
|
710
|
+
else if (a.startsWith("--home=")) opts.home = a.slice("--home=".length);
|
|
711
|
+
else if (a === "--out") opts.out = argv[++i] || null;
|
|
712
|
+
else if (a.startsWith("--out=")) opts.out = a.slice("--out=".length);
|
|
713
|
+
// unknown flags are ignored (fail-open, never a crash on a stray arg)
|
|
714
|
+
}
|
|
715
|
+
return opts;
|
|
716
|
+
}
|
|
717
|
+
function clampInt(v, dflt) {
|
|
718
|
+
const n = Number.parseInt(v, 10);
|
|
719
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const HELP = `skalpel eval — offline efficacy replay of your own coding history (read-only)
|
|
723
|
+
|
|
724
|
+
usage:
|
|
725
|
+
skalpel eval [--variant <name>] [--limit <n>] [--json] [--out <file>]
|
|
726
|
+
|
|
727
|
+
what it measures (every number from a real transcript field/timestamp):
|
|
728
|
+
1 lie frequency — "done" claims over an already-failing proof, per N sessions
|
|
729
|
+
2 lie cost — MEASURED red→green time to the same proof's later PASS (never estimated)
|
|
730
|
+
3 catch accuracy — raw-flag → surfaced vs harness-suppressed; precision + (relative) recall
|
|
731
|
+
4 honest framing — the n and a plain small-n caveat; says zero when it's zero
|
|
732
|
+
|
|
733
|
+
options:
|
|
734
|
+
--variant <name> baseline | strict | all (default all — compares them side by side)
|
|
735
|
+
--limit <n> most-recent sessions to scan (default ${DEFAULT_MAX_SESSIONS})
|
|
736
|
+
--home <dir> history root override (default ~ ; or SKALPEL_EVAL_HOME) — for fixtures/tests
|
|
737
|
+
--json emit the machine-readable numbers (deterministic; for experiments + diffing)
|
|
738
|
+
--out <file> also write the rendered report to a file (the ONLY thing this ever writes)
|
|
739
|
+
-h, --help print this help`;
|
|
740
|
+
|
|
741
|
+
export function runEval(opts) {
|
|
742
|
+
const variants = resolveVariants(opts.variant);
|
|
743
|
+
if (!variants) {
|
|
744
|
+
return {
|
|
745
|
+
text: `\n skalpel eval: unknown --variant \`${opts.variant}\` (use: baseline | strict | all)\n`,
|
|
746
|
+
report: null,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const corpus = replayHistory({ home: opts.home, maxSessions: opts.limit });
|
|
750
|
+
const report = computeReport(corpus, variants);
|
|
751
|
+
if (opts.json) {
|
|
752
|
+
// Deterministic JSON: same history in → byte-identical out (no timestamps of "now", no randomness).
|
|
753
|
+
return { text: JSON.stringify({ variant: opts.variant, ...report }, null, 2) + "\n", report };
|
|
754
|
+
}
|
|
755
|
+
return { text: renderReport(report, { variantName: opts.variant }), report };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const isMain = (() => {
|
|
759
|
+
try {
|
|
760
|
+
return (
|
|
761
|
+
Boolean(process.argv[1]) &&
|
|
762
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
763
|
+
);
|
|
764
|
+
} catch {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
})();
|
|
768
|
+
|
|
769
|
+
if (isMain) {
|
|
770
|
+
try {
|
|
771
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
772
|
+
if (opts.help) {
|
|
773
|
+
process.stdout.write(HELP + "\n");
|
|
774
|
+
process.exit(0);
|
|
775
|
+
}
|
|
776
|
+
const { text } = runEval(opts);
|
|
777
|
+
process.stdout.write(text.endsWith("\n") ? text : text + "\n");
|
|
778
|
+
if (opts.out) {
|
|
779
|
+
// The ONLY write — opt-in, user asked for it. Fail-open: a bad path never crashes the report.
|
|
780
|
+
try {
|
|
781
|
+
mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
782
|
+
writeFileSync(path.resolve(opts.out), text.endsWith("\n") ? text : text + "\n");
|
|
783
|
+
process.stdout.write(`\n wrote ${path.resolve(opts.out)}\n`);
|
|
784
|
+
} catch (e) {
|
|
785
|
+
process.stdout.write(
|
|
786
|
+
`\n (could not write --out file: ${String(e && e.message).slice(0, 120)})\n`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
} catch {
|
|
791
|
+
// READ-ONLY + fail-open: never throw at the user.
|
|
792
|
+
process.stdout.write(
|
|
793
|
+
"\n 🔬 skalpel — could not replay your history on this machine (read-only, no changes made).\n\n",
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
process.exit(0);
|
|
797
|
+
}
|
package/package.json
CHANGED
package/skalpel-setup.mjs
CHANGED
|
@@ -66,6 +66,7 @@ const KNOWN_SUBS = new Set([
|
|
|
66
66
|
"catches",
|
|
67
67
|
"card",
|
|
68
68
|
"ledger",
|
|
69
|
+
"eval",
|
|
69
70
|
"analytics",
|
|
70
71
|
"verify",
|
|
71
72
|
"yes",
|
|
@@ -87,6 +88,7 @@ usage:
|
|
|
87
88
|
skalpel catches scan your own history for "done" claims over failing tests
|
|
88
89
|
skalpel card [--last|--id <id>] [--md] share one verified catch: the claim + its own failing proof
|
|
89
90
|
skalpel ledger your running tally: N "done"s checked, M verified FALSE
|
|
91
|
+
skalpel eval [--variant <v>] [--json] offline replay of your history: lie frequency, measured cost, catch accuracy
|
|
90
92
|
skalpel analytics interactive local analytics — caught lies, measured cost, steers, re-asks
|
|
91
93
|
skalpel verify on|off|status arm/disarm the live catch (re-runs your own proof on a "done" claim)
|
|
92
94
|
skalpel yes | skalpel no was the last catch right? (feeds precision; "no" stops that proof this session)
|
|
@@ -125,11 +127,12 @@ function validateArgv() {
|
|
|
125
127
|
process.exit(0);
|
|
126
128
|
}
|
|
127
129
|
if (BOOL_FLAGS.has(a)) continue; // valueless flag (e.g. `uninstall --purge`)
|
|
128
|
-
// `skalpel card`
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
130
|
+
// `skalpel card` (--last / --id <id> / --md / --plain) and `skalpel eval` (--variant / --limit /
|
|
131
|
+
// --json / --home / --out) own their own flags and forward them to card.mjs / efficacy-eval.mjs.
|
|
132
|
+
// They are NOT setup options, so setup must not reject them as unknown — pass any token after those
|
|
133
|
+
// subcommands straight through (each module validates its own flags). Global -v/-h above still win
|
|
134
|
+
// for a bare `skalpel -v`. Scoped so no other subcommand's contract shifts.
|
|
135
|
+
if (i > 0 && (argv[0] === "card" || argv[0] === "eval")) continue;
|
|
133
136
|
if (a.startsWith("-")) {
|
|
134
137
|
console.error(`skalpel: unknown option \`${a}\`\n\n${USAGE}`);
|
|
135
138
|
process.exit(2);
|
|
@@ -993,6 +996,17 @@ async function main() {
|
|
|
993
996
|
spawnSync("node", [join(__dir, "ledger.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
994
997
|
return;
|
|
995
998
|
}
|
|
999
|
+
// `skalpel eval` — THE EFFICACY EVAL: a LOCAL, READ-ONLY, ZERO-NETWORK offline REPLAY of the user's own
|
|
1000
|
+
// Claude Code + Codex history that measures whether the "agent lies about done" fire is real FOR THEM and
|
|
1001
|
+
// how much it costs — lie frequency (confirmed-lie vs true-done vs unverifiable), MEASURED red→green cost
|
|
1002
|
+
// (real timestamps only, never estimated), and catch accuracy (raw-flag → surfaced vs harness-suppressed,
|
|
1003
|
+
// precision + relative recall) with a pluggable catch-config `--variant` so a stricter gate replays in
|
|
1004
|
+
// seconds. Reuses the SAME detectors as the live catch (no divergent claim logic); never re-runs a proof,
|
|
1005
|
+
// never touches the network, never fabricates a number; writes nothing unless the user asks with `--out`.
|
|
1006
|
+
if (sub === "eval") {
|
|
1007
|
+
spawnSync("node", [join(__dir, "efficacy-eval.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
996
1010
|
// `skalpel analytics` — INTERACTIVE LOCAL ANALYTICS: a keyboard-navigable, READ-ONLY terminal view
|
|
997
1011
|
// derived from the same honest local data as `skalpel ledger` (verify-shadow.log + steers.ndjson +
|
|
998
1012
|
// insights.ndjson). Zero network, zero writes, zero estimates. Degrades to a plain static snapshot
|