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 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
- function exitCodeFromText(text) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.47",
3
+ "version": "4.0.48",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
package/skalpel-setup.mjs CHANGED
@@ -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` owns its own flags (--last / --id <id> / --md / --plain) and forwards them to
129
- // card.mjs. They are NOT setup options, so setup must not reject them as unknown pass any token
130
- // after the `card` subcommand straight through (card.mjs validates its own flags). Global -v/-h
131
- // above still win for a bare `skalpel -v`. Scoped to `card` so no other subcommand's contract shifts.
132
- if (i > 0 && argv[0] === "card") continue;
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