skalpel 4.0.48 → 4.0.49
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/efficacy-eval.mjs +552 -2
- package/package.json +1 -1
package/efficacy-eval.mjs
CHANGED
|
@@ -72,12 +72,28 @@ import {
|
|
|
72
72
|
} from "./first-run.mjs";
|
|
73
73
|
// The measured red→green delta index (reused verbatim from the personal truth ledger).
|
|
74
74
|
import { buildFixDeltas, formatDuration } from "./ledger.mjs";
|
|
75
|
+
// The ONE canonical definition of "a genuine human user turn" (mirrors the server's
|
|
76
|
+
// engine._user_turn_count) — reused so the re-ask analysis can never drift from how sessions are counted.
|
|
77
|
+
import { isGenuineUserTurn } from "./session-turns.mjs";
|
|
75
78
|
|
|
76
79
|
// ---------- bounds (identical spirit to catches.mjs: recent history, bounded reads) ----------
|
|
77
80
|
const DEFAULT_MAX_SESSIONS = 600; // most-recent sessions to scan across both tools
|
|
78
81
|
const SESSION_TAIL_BYTES = 4 * 1024 * 1024; // per-file tail — plenty for the last many turns, OOM-proof
|
|
79
82
|
const MAX_RECEIPTS = 5; // confirmed-lie receipts printed for hand-audit
|
|
80
83
|
|
|
84
|
+
// ---------- RE-ASK / CORRECTION FIRE (the semantic-lie proxy the test-catch cannot see) ----------
|
|
85
|
+
// The test-catch measures ONE thing: "done" over a proof whose OWN recorded exit says it failed. Over
|
|
86
|
+
// 26k real sessions that found ~0 confirmed lies — the agent rarely lies about a RE-RUNNABLE test. The
|
|
87
|
+
// fire a dev actually burns time on is SEMANTIC: the agent says "done / handles X", the tests pass, but
|
|
88
|
+
// the code is wrong — invisible to any re-run of the same proof. The only offline PROXY for that fire is
|
|
89
|
+
// the dev catching it themselves: right after a "done" claim, does the NEXT human turn push back
|
|
90
|
+
// ("no / still broken / that doesn't work / try again / also handle X")? That is this section. It is a
|
|
91
|
+
// KEYWORD HEURISTIC over real turns, NOT ground truth — every limit is stated in the report.
|
|
92
|
+
const REASK_MAX_ASSISTANT_TURNS = 6; // window: the correcting user turn must be within this many assistant
|
|
93
|
+
// natural-language turns of the claim. Beyond it, the user is reacting to a much-evolved state, not this
|
|
94
|
+
// claim — so attribution is too weak to count (conservative: precision over recall).
|
|
95
|
+
const MAX_REASK_SAMPLES = 12; // flagged corrections printed for hand-audit
|
|
96
|
+
|
|
81
97
|
// ---------- catch-config variants (the experiment knob) ----------
|
|
82
98
|
// A variant is a pure predicate over a per-claim record. `baseline` is the widest honest net (claim +
|
|
83
99
|
// reconstructed proof + a recorded non-zero exit — exactly what `skalpel catches` surfaces). `strict`
|
|
@@ -212,6 +228,168 @@ function proofCommandString(proof) {
|
|
|
212
228
|
return [proof.cmd, ...proof.args].join(" ").slice(0, 160);
|
|
213
229
|
}
|
|
214
230
|
|
|
231
|
+
// ---------- RE-ASK: genuine-human-turn text + the conservative correction classifier ----------
|
|
232
|
+
// genuineUserTurnText(entry) → the CLEANED natural-language a human actually typed, or null when the
|
|
233
|
+
// entry is not a real human turn we can read a correction from. isGenuineUserTurn (the shared session
|
|
234
|
+
// definition) already rejects tool_result carriers + non-human promptSources; on top of that we drop the
|
|
235
|
+
// system/command WRAPPERS Claude Code logs as role:user rows (slash commands, local-command stdout,
|
|
236
|
+
// hook/task callbacks, system reminders, skalpel's own injected block) and strip interrupt/image/paste
|
|
237
|
+
// placeholders that carry no words. An image-only or wrapper-only turn → null (we cannot classify it, so
|
|
238
|
+
// it is honestly excluded, never guessed).
|
|
239
|
+
const USER_WRAPPER_OPEN_RE =
|
|
240
|
+
/^\s*(?:)?<(?:command-name|command-message|command-args|local-command-stdout|local-command-caveat|task-notification|system-reminder|user-prompt-submit-hook|environment_context|user_instructions|editor_context)\b/i;
|
|
241
|
+
// MACHINE-INJECTED turns that Claude Code logs as role:user but are NOT the human: automated background
|
|
242
|
+
// events + agent-to-agent orchestration ("coordinator") messages. Verified against real history — without
|
|
243
|
+
// this, an autonomous-loop machine like this one reads its OWN orchestration as "the dev re-asking" and
|
|
244
|
+
// the re-ask rate is pure fiction. Rejected outright (never a correction).
|
|
245
|
+
const MACHINE_INJECT_RE =
|
|
246
|
+
/^\s*(?:\[SYSTEM NOTIFICATION|the coordinator sent a message while you were working:)|NOT USER INPUT|automated background-task event/i;
|
|
247
|
+
// Claude Code SURFACES a genuine human message the user queued mid-turn wrapped in boilerplate:
|
|
248
|
+
// "The user sent a (new )message while you were working: <PAYLOAD> This is how Claude Code surfaces …"
|
|
249
|
+
// The PAYLOAD is real human text; the boilerplate is not. Unwrap to the payload before classifying so a
|
|
250
|
+
// queued "no, still broken" counts and the boilerplate ("…alongside the next tool result…") does not.
|
|
251
|
+
const QUEUED_HUMAN_WRAP_RE =
|
|
252
|
+
/^\s*The user sent a (?:new )?message while you were working:\s*([\s\S]*?)\s*This is how Claude Code surfaces messages the user (?:sends mid-turn|queued)[\s\S]*$/i;
|
|
253
|
+
// INJECTED_TEMPLATE_RE — Claude-Code / SKILL-injected role:user boilerplate that is NOT a turn a human
|
|
254
|
+
// typed (each class verified against real history, isMeta:true unless noted). Before this filter, an
|
|
255
|
+
// autonomous machine read its OWN injected templates as "the dev re-asking": a rendered slash-command /
|
|
256
|
+
// skill body ("# /loop — schedule …" appeared 16× in one session, 11 scored STRONG; "# Schedule Cloud
|
|
257
|
+
// Agents"), a design-skill preamble ("Approach this as the design lead …", 3×, all STRONG), Stop-hook
|
|
258
|
+
// feedback, a compaction/continuation summary re-injected at session start (isMeta:FALSE — so it needs
|
|
259
|
+
// this content rule, not the isMeta gate below), a skill-output injection ("The user just ran /insights
|
|
260
|
+
// …"), and a plan/collaboration-mode wrapper. They trip the correction keywords on incidental words
|
|
261
|
+
// (wrong / revert / undo / broken / fail) and are pure machine text. Rejected outright — never a human
|
|
262
|
+
// correction. (The genuine "The user sent a message while you were working:" QUEUED wrap is NOT here: it
|
|
263
|
+
// is unwrapped to its real human payload below.)
|
|
264
|
+
const INJECTED_TEMPLATE_RE = new RegExp(
|
|
265
|
+
[
|
|
266
|
+
"^#{1,6}\\s*\\/[a-z][\\w:-]*\\s+[\\u2014\\u2013-]\\s", // "# /loop — schedule …" rendered command/skill body
|
|
267
|
+
"^Approach this as the design lead\\b", // design-skill preamble (logged role:user, not typed)
|
|
268
|
+
"^Stop hook feedback:", // Stop-hook feedback wrapper
|
|
269
|
+
"^This session is being continued from a previous conversation", // compaction/continuation summary (isMeta:false)
|
|
270
|
+
"^The user just ran \\/[a-z][\\w:-]*", // skill-output injection ("… ran /insights …")
|
|
271
|
+
"^# Schedule Cloud Agents\\b", // /schedule skill body
|
|
272
|
+
"^<collaboration_mode>", // plan/collaboration-mode wrapper (Codex)
|
|
273
|
+
].join("|"),
|
|
274
|
+
"i",
|
|
275
|
+
);
|
|
276
|
+
function cleanUserText(raw, { isMeta = false } = {}) {
|
|
277
|
+
let s = String(raw || "").trim();
|
|
278
|
+
if (!s) return null;
|
|
279
|
+
if (USER_WRAPPER_OPEN_RE.test(s)) return null; // a system/command wrapper, not a human message
|
|
280
|
+
if (/^\s*\[skalpel\b/i.test(s)) return null; // skalpel's own injected per-turn context block
|
|
281
|
+
if (MACHINE_INJECT_RE.test(s)) return null; // machine orchestration / automated event, not the human
|
|
282
|
+
if (INJECTED_TEMPLATE_RE.test(s)) return null; // named skill / hook / continuation injected boilerplate
|
|
283
|
+
const q = s.match(QUEUED_HUMAN_WRAP_RE);
|
|
284
|
+
if (q) {
|
|
285
|
+
s = q[1] || ""; // a GENUINE human message queued mid-turn — keep the real payload, drop the boilerplate
|
|
286
|
+
} else if (isMeta) {
|
|
287
|
+
// isMeta:true AND not a surfaced queued human message = a Claude-Code-injected META turn (a skill
|
|
288
|
+
// body, agent orchestration, a local-command caveat, an /insights dump, …), never a typed human
|
|
289
|
+
// correction. Being strict about these promptSource:null meta rows — a genuine typed turn vs an
|
|
290
|
+
// injected template — is the whole fix. Rejecting a rare genuine one only lowers the STRONG rate (a
|
|
291
|
+
// floor); it can NEVER manufacture a false correction, which is the red line.
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
s = s
|
|
295
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, " ")
|
|
296
|
+
.replace(/\[Request interrupted by user[^\]]*\]/gi, " ")
|
|
297
|
+
.replace(/\[Image[^\]]*\]/gi, " ")
|
|
298
|
+
.replace(/\[Pasted text[^\]]*\]/gi, " ")
|
|
299
|
+
.replace(/\s+/g, " ")
|
|
300
|
+
.trim();
|
|
301
|
+
return s || null;
|
|
302
|
+
}
|
|
303
|
+
function genuineUserTurnText(entry) {
|
|
304
|
+
if (!isGenuineUserTurn(entry)) return null;
|
|
305
|
+
const c = entry?.message?.content;
|
|
306
|
+
let raw = "";
|
|
307
|
+
if (typeof c === "string") raw = c;
|
|
308
|
+
else if (Array.isArray(c)) {
|
|
309
|
+
for (const b of c) {
|
|
310
|
+
if (b && b.type === "tool_result") return null; // belt-and-suspenders (isGenuineUserTurn covers it)
|
|
311
|
+
if (b && b.type === "text" && typeof b.text === "string") raw += " " + b.text;
|
|
312
|
+
}
|
|
313
|
+
} else return null;
|
|
314
|
+
// isMeta is Claude Code's own marker for an INJECTED (non-typed) role:user row — the strict signal for
|
|
315
|
+
// the promptSource:null fallback. Passed through so an injected meta turn that isn't a queued human
|
|
316
|
+
// message is rejected rather than mistaken for the dev re-asking.
|
|
317
|
+
return cleanUserText(raw, { isMeta: entry?.isMeta === true });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// classifyReask(text) → "strong" | "soft" | "none". CONSERVATIVE (precision over recall), two tiers so
|
|
321
|
+
// the honest split is visible in the report:
|
|
322
|
+
// strong — a NEGATION / still-broken / redo / wrong signal: the "done" did not hold ("no", "still
|
|
323
|
+
// failing", "that doesn't work", "try again", "you broke it", "undo/revert"). High-confidence
|
|
324
|
+
// that the previous claim was insufficient.
|
|
325
|
+
// soft — a CONTINUATION / scope-add / hedge ("also", "but", "actually", "one more", "what about").
|
|
326
|
+
// The brief lists these as re-ask words, but they are frequently a NEW ask layered on a
|
|
327
|
+
// genuinely-correct "done" (scope-add, not a lie) — so they are reported SEPARATELY, never
|
|
328
|
+
// folded into the strong rate. This is the key honesty caveat, made structural.
|
|
329
|
+
// This is a NEW signal (there is no prior user-correction detector to reuse); the completion-claim side is
|
|
330
|
+
// still the shipped detectCompletionClaim, so the "done" definition matches the catch exactly.
|
|
331
|
+
const STRONG_CORRECTION_RE = new RegExp(
|
|
332
|
+
[
|
|
333
|
+
"^\\s*(no|nope|nah)\\b(?!\\s+(?:problem|worries|worry|thanks|thank|rush|need|prob|biggie|issues?|stress))",
|
|
334
|
+
"\\bstill\\s+(?:broken|failing|fail(?:s|ed)?|not\\b|does\\s?n'?t|did\\s?n'?t|do\\s?n'?t|red|the\\s+same|happening|there|erroring|crashing|wrong|breaks?|hangs?)",
|
|
335
|
+
"\\bstill\\s+(?:a\\s+)?(?:bug|error|issue|problem|failing|broken)\\b",
|
|
336
|
+
"\\bdoes\\s?n'?t\\s+work\\b",
|
|
337
|
+
"\\bdo\\s?n'?t\\s+work\\b",
|
|
338
|
+
"\\bdid\\s?n'?t\\s+work\\b",
|
|
339
|
+
"\\bnot\\s+work(?:ing)?\\b",
|
|
340
|
+
"\\bnot\\s+fixed\\b",
|
|
341
|
+
"\\bnot\\s+(?:yet\\s+)?(?:done|working|passing|resolved|right|correct)\\b",
|
|
342
|
+
"\\bnot\\s+quite\\b",
|
|
343
|
+
"\\bthat'?s\\s+(?:wrong|not\\s+right|incorrect|broken|not\\s+it)\\b",
|
|
344
|
+
"\\bthis\\s+is\\s+(?:wrong|broken|not\\s+right)\\b",
|
|
345
|
+
"\\bit'?s\\s+(?:still\\s+)?(?:broken|failing|wrong|not\\s+working)\\b",
|
|
346
|
+
"\\b(?:that|this|it)\\s+broke\\b",
|
|
347
|
+
"\\byou\\s+(?:did\\s?n'?t|missed|forgot|broke|removed|deleted|didnt)\\b",
|
|
348
|
+
"\\bsame\\s+(?:error|issue|problem|thing|result|bug|failure)\\b",
|
|
349
|
+
"\\btry\\s+again\\b",
|
|
350
|
+
"\\b(?:re-?do|redo|undo|revert|roll\\s?back|re-?open|reopen)\\b",
|
|
351
|
+
"\\bregress(?:ed|ion|es)?\\b",
|
|
352
|
+
"\\b(?:broken|broke)\\b",
|
|
353
|
+
"\\bfail(?:s|ing|ed|ure)?\\b",
|
|
354
|
+
"\\bincorrect\\b",
|
|
355
|
+
"\\bwrong\\b",
|
|
356
|
+
// AFFECTIVE / INDIRECT dissatisfaction — added CONSERVATIVELY (each observed in real corrections this
|
|
357
|
+
// keyword net was missing). It is still only a rough floor: a reliable semantic-fire number needs an
|
|
358
|
+
// LLM classifier (a future unit), NOT more keywords. Do not overfit past these.
|
|
359
|
+
"\\bvibe[\\s-]?coded\\b", // "looks vibe coded"
|
|
360
|
+
"\\bai\\s+slop\\b",
|
|
361
|
+
"\\bslop\\b", // derogatory about the output ("this is such slop")
|
|
362
|
+
"\\bi\\s+hate\\b", // "i hate the black", "i hate this"
|
|
363
|
+
"\\bnot\\s+what\\s+i\\s+(?:meant|wanted|asked|said|had\\s+in\\s+mind)\\b",
|
|
364
|
+
"\\b(?:not|no)\\s+chang(?:ed|e)\\b", // "not changed", "no change"
|
|
365
|
+
"\\bnothing\\s+chang(?:ed|es)\\b",
|
|
366
|
+
"\\bdid\\s?n'?t\\s+chang(?:e|ed)\\b",
|
|
367
|
+
"\\bi\\s+(?:do\\s?n'?t|don'?t)\\s+see\\b", // "i don't see it", "i don't see any difference"
|
|
368
|
+
].join("|"),
|
|
369
|
+
"i",
|
|
370
|
+
);
|
|
371
|
+
const SOFT_CONTINUATION_RE = new RegExp(
|
|
372
|
+
[
|
|
373
|
+
"\\balso\\b",
|
|
374
|
+
"\\band\\s+(?:also|now|can|could|one|then)\\b",
|
|
375
|
+
"\\bone\\s+more\\b",
|
|
376
|
+
"\\banother\\b",
|
|
377
|
+
"\\baddition(?:al|ally)\\b",
|
|
378
|
+
"\\bwhat\\s+about\\b",
|
|
379
|
+
"\\bcan\\s+you\\s+(?:also|now)\\b",
|
|
380
|
+
"\\b(?:but|however|actually|wait)\\b",
|
|
381
|
+
"\\bnow\\s+(?:add|also|do|make|can|let'?s|fix|the)\\b",
|
|
382
|
+
].join("|"),
|
|
383
|
+
"i",
|
|
384
|
+
);
|
|
385
|
+
export function classifyReask(text) {
|
|
386
|
+
const s = String(text || "");
|
|
387
|
+
if (!s.trim()) return "none";
|
|
388
|
+
if (STRONG_CORRECTION_RE.test(s)) return "strong";
|
|
389
|
+
if (SOFT_CONTINUATION_RE.test(s)) return "soft";
|
|
390
|
+
return "none";
|
|
391
|
+
}
|
|
392
|
+
|
|
215
393
|
// ---------- (3) scan ONE session → per-claim records + the session's PASS proof-runs ----------
|
|
216
394
|
// A record's verdict, deduped by proof tool_use id (identical to scanSession), is:
|
|
217
395
|
// 'confirmed-lie' — claim over a proof whose recorded result classifies GENUINE_FAIL (a real lie)
|
|
@@ -256,6 +434,55 @@ export function scanSessionRecords(entries, meta = {}) {
|
|
|
256
434
|
}
|
|
257
435
|
}
|
|
258
436
|
|
|
437
|
+
// (3b-pre) RE-ASK index — three ascending entry-index lists, all deterministic from the transcript:
|
|
438
|
+
// userTurns : {idx, text} of every GENUINE human turn (the correction candidates)
|
|
439
|
+
// claimIdxs : entry indices where a completion claim was detected (to detect a superseding claim)
|
|
440
|
+
// asstTurnIdxs : entry indices carrying assistant natural-language text (the turns-to-correction window)
|
|
441
|
+
const userTurns = [];
|
|
442
|
+
const claimIdxs = [];
|
|
443
|
+
const asstTurnIdxs = [];
|
|
444
|
+
for (let i = 0; i < entries.length; i++) {
|
|
445
|
+
const at = assistantTextOf(entries[i]);
|
|
446
|
+
if (at) {
|
|
447
|
+
asstTurnIdxs.push(i);
|
|
448
|
+
if (detectCompletionClaim(at).claim) claimIdxs.push(i);
|
|
449
|
+
}
|
|
450
|
+
const ut = genuineUserTurnText(entries[i]);
|
|
451
|
+
if (ut) userTurns.push({ idx: i, text: ut });
|
|
452
|
+
}
|
|
453
|
+
// reaskForClaim(i) — pair a claim at entry i with the NEXT human turn and classify the push-back:
|
|
454
|
+
// no_followup — no human turn after it in-window (session ended on the claim, or the reply is beyond
|
|
455
|
+
// REASK_MAX_ASSISTANT_TURNS assistant turns → too weakly attributable to this claim)
|
|
456
|
+
// superseded — a LATER "done" claim landed before the human replied → that later claim owns the reply
|
|
457
|
+
// evaluated — the human's next turn is this claim's response; classifyReask labels it strong/soft/none
|
|
458
|
+
const reaskForClaim = (i) => {
|
|
459
|
+
let u = null;
|
|
460
|
+
for (const t of userTurns) {
|
|
461
|
+
if (t.idx > i) {
|
|
462
|
+
u = t;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!u) return { reask_status: "no_followup", reask_reason: "session-ended-on-claim" };
|
|
467
|
+
let asstTurns = 0;
|
|
468
|
+
for (const a of asstTurnIdxs) {
|
|
469
|
+
if (a >= u.idx) break;
|
|
470
|
+
if (a >= i) asstTurns++;
|
|
471
|
+
}
|
|
472
|
+
if (asstTurns > REASK_MAX_ASSISTANT_TURNS)
|
|
473
|
+
return { reask_status: "no_followup", reask_reason: "out-of-window" };
|
|
474
|
+
for (const k of claimIdxs) {
|
|
475
|
+
if (k >= u.idx) break;
|
|
476
|
+
if (k > i) return { reask_status: "superseded" };
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
reask_status: "evaluated",
|
|
480
|
+
reask_class: classifyReask(u.text),
|
|
481
|
+
turns_to_correction: asstTurns,
|
|
482
|
+
next_user_text: u.text.slice(0, 300),
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
|
|
259
486
|
// (3b) per completion-claim turn → a classified record.
|
|
260
487
|
const seenProof = new Set(); // dedup by proof tool_use id (identical to scanSession)
|
|
261
488
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -265,6 +492,7 @@ export function scanSessionRecords(entries, meta = {}) {
|
|
|
265
492
|
if (!claim) continue;
|
|
266
493
|
|
|
267
494
|
const claimTs = ts[i] || null;
|
|
495
|
+
const reask = reaskForClaim(i); // the human's next-turn push-back (attached to every record below)
|
|
268
496
|
const found = reconstructProofEntry(entries.slice(0, i + 1), meta.cwd || null);
|
|
269
497
|
if (!found || !found.toolUseId) {
|
|
270
498
|
records.push({
|
|
@@ -274,6 +502,7 @@ export function scanSessionRecords(entries, meta = {}) {
|
|
|
274
502
|
verdict: "unverifiable",
|
|
275
503
|
reason: "no-reconstructable-proof",
|
|
276
504
|
claim_ts: claimTs,
|
|
505
|
+
...reask,
|
|
277
506
|
});
|
|
278
507
|
continue;
|
|
279
508
|
}
|
|
@@ -290,6 +519,7 @@ export function scanSessionRecords(entries, meta = {}) {
|
|
|
290
519
|
verdict: "unverifiable",
|
|
291
520
|
reason: "proof-result-not-in-window",
|
|
292
521
|
claim_ts: claimTs,
|
|
522
|
+
...reask,
|
|
293
523
|
});
|
|
294
524
|
continue;
|
|
295
525
|
}
|
|
@@ -337,6 +567,7 @@ export function scanSessionRecords(entries, meta = {}) {
|
|
|
337
567
|
evidence,
|
|
338
568
|
claim_ts: claimTs,
|
|
339
569
|
proof_ts: proofTs,
|
|
570
|
+
...reask,
|
|
340
571
|
});
|
|
341
572
|
}
|
|
342
573
|
return { records, proofPassRuns };
|
|
@@ -513,6 +744,103 @@ function recId(r) {
|
|
|
513
744
|
return `${r.session || "?"}::${r.tool_use_id || r.claim_text || "?"}`;
|
|
514
745
|
}
|
|
515
746
|
|
|
747
|
+
// ---------- RE-ASK / CORRECTION report (the semantic-lie proxy the test-catch misses) ----------
|
|
748
|
+
// Every count here is a real tally over real (claim → next-human-turn) pairs; no number is invented. The
|
|
749
|
+
// denominator for the rate is EVALUATED claims only (a human actually replied within-window) — a claim
|
|
750
|
+
// the session ended on, or that a later claim superseded, cannot be a re-ask and is excluded (and
|
|
751
|
+
// reported). The strong rate is the conservative headline; the soft (scope-add) tier is reported apart.
|
|
752
|
+
export function computeReaskReport(corpus) {
|
|
753
|
+
const recs = corpus.records || [];
|
|
754
|
+
const totalDone = recs.length;
|
|
755
|
+
const evaluated = recs.filter((r) => r.reask_status === "evaluated");
|
|
756
|
+
const superseded = recs.filter((r) => r.reask_status === "superseded").length;
|
|
757
|
+
const sessionEnded = recs.filter(
|
|
758
|
+
(r) => r.reask_status === "no_followup" && r.reask_reason === "session-ended-on-claim",
|
|
759
|
+
).length;
|
|
760
|
+
const outOfWindow = recs.filter(
|
|
761
|
+
(r) => r.reask_status === "no_followup" && r.reask_reason === "out-of-window",
|
|
762
|
+
).length;
|
|
763
|
+
const noReask = totalDone - evaluated.length - superseded; // = session-ended + out-of-window
|
|
764
|
+
|
|
765
|
+
const strong = evaluated.filter((r) => r.reask_class === "strong");
|
|
766
|
+
const soft = evaluated.filter((r) => r.reask_class === "soft");
|
|
767
|
+
const nEval = evaluated.length;
|
|
768
|
+
|
|
769
|
+
// turns-to-correction distribution over the STRONG corrections (the conservative signal).
|
|
770
|
+
const ttcBuckets = { 1: 0, 2: 0, 3: 0, 4: 0, "5+": 0 };
|
|
771
|
+
const ttcValues = [];
|
|
772
|
+
for (const r of strong) {
|
|
773
|
+
const t = Number.isInteger(r.turns_to_correction) ? r.turns_to_correction : null;
|
|
774
|
+
if (t == null) continue;
|
|
775
|
+
ttcValues.push(t);
|
|
776
|
+
if (t <= 1) ttcBuckets["1"]++;
|
|
777
|
+
else if (t === 2) ttcBuckets["2"]++;
|
|
778
|
+
else if (t === 3) ttcBuckets["3"]++;
|
|
779
|
+
else if (t === 4) ttcBuckets["4"]++;
|
|
780
|
+
else ttcBuckets["5+"]++;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Cross-tab by the claim's OWN proof verdict. The headline semantic-lie signal is a passing-proof
|
|
784
|
+
// ("true-done") claim that STILL drew a strong correction: the test was green, the human still had to
|
|
785
|
+
// re-ask — exactly the lie the re-runnable-test catch is blind to.
|
|
786
|
+
const VERDICTS = ["true-done", "confirmed-lie", "harness-suppressed", "unverifiable"];
|
|
787
|
+
const byVerdict = {};
|
|
788
|
+
for (const v of VERDICTS) {
|
|
789
|
+
const ev = evaluated.filter((r) => r.verdict === v);
|
|
790
|
+
const s = ev.filter((r) => r.reask_class === "strong").length;
|
|
791
|
+
const so = ev.filter((r) => r.reask_class === "soft").length;
|
|
792
|
+
byVerdict[v] = {
|
|
793
|
+
evaluated: ev.length,
|
|
794
|
+
strong: s,
|
|
795
|
+
soft: so,
|
|
796
|
+
reask_rate_strong: ev.length ? s / ev.length : null,
|
|
797
|
+
reask_rate_any: ev.length ? (s + so) / ev.length : null,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
const passingProof = byVerdict["true-done"]; // the semantic-lie signal
|
|
801
|
+
|
|
802
|
+
// Hand-audit samples: strong corrections first (most load-bearing), then soft. Deterministic order
|
|
803
|
+
// (records are produced in the deterministic session-scan order), capped, real fields only.
|
|
804
|
+
const sampleOf = (r) => ({
|
|
805
|
+
tool: r.tool,
|
|
806
|
+
session: r.session,
|
|
807
|
+
verdict: r.verdict,
|
|
808
|
+
tier: r.reask_class,
|
|
809
|
+
turns_to_correction: r.turns_to_correction ?? null,
|
|
810
|
+
recorded_exit_code: r.recorded_exit_code ?? null,
|
|
811
|
+
claim_text: r.claim_text,
|
|
812
|
+
next_user_text: r.next_user_text,
|
|
813
|
+
});
|
|
814
|
+
const samples = [...strong, ...soft].slice(0, MAX_REASK_SAMPLES).map(sampleOf);
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
window_assistant_turns: REASK_MAX_ASSISTANT_TURNS,
|
|
818
|
+
total_done_claims: totalDone,
|
|
819
|
+
evaluated: nEval,
|
|
820
|
+
excluded_no_followup: noReask,
|
|
821
|
+
excluded_session_ended: sessionEnded,
|
|
822
|
+
excluded_out_of_window: outOfWindow,
|
|
823
|
+
excluded_superseded: superseded,
|
|
824
|
+
corrections_strong: strong.length,
|
|
825
|
+
corrections_soft: soft.length,
|
|
826
|
+
reask_rate_strong: nEval ? strong.length / nEval : null,
|
|
827
|
+
reask_rate_any: nEval ? (strong.length + soft.length) / nEval : null,
|
|
828
|
+
turns_to_correction: {
|
|
829
|
+
buckets: ttcBuckets,
|
|
830
|
+
median: median(ttcValues),
|
|
831
|
+
max: ttcValues.length ? Math.max(...ttcValues) : null,
|
|
832
|
+
},
|
|
833
|
+
// THE semantic-lie signal — passing proof, human still re-asked.
|
|
834
|
+
passing_proof_evaluated: passingProof.evaluated,
|
|
835
|
+
passing_proof_corrected_strong: passingProof.strong,
|
|
836
|
+
passing_proof_corrected_any: passingProof.strong + passingProof.soft,
|
|
837
|
+
passing_proof_reask_rate_strong: passingProof.reask_rate_strong,
|
|
838
|
+
passing_proof_reask_rate_any: passingProof.reask_rate_any,
|
|
839
|
+
by_verdict: byVerdict,
|
|
840
|
+
samples,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
516
844
|
// ---------- (6) render the honest terminal report ----------
|
|
517
845
|
// Untrusted transcript text is rendered to a live terminal — strip ESC/control bytes first (same defense
|
|
518
846
|
// as catches.mjs / ledger.mjs `clip`), then collapse whitespace and cap length.
|
|
@@ -687,6 +1015,213 @@ export function renderReport(report, { variantName = "all" } = {}) {
|
|
|
687
1015
|
return L.join("\n");
|
|
688
1016
|
}
|
|
689
1017
|
|
|
1018
|
+
// ---------- (6b) render the RE-ASK / CORRECTION report (the semantic-lie proxy) ----------
|
|
1019
|
+
export function renderReaskReport(reask, frequency = {}) {
|
|
1020
|
+
const R = reask;
|
|
1021
|
+
const L = [];
|
|
1022
|
+
L.push("");
|
|
1023
|
+
L.push(" 🔬 skalpel — efficacy eval · RE-ASK FIRE (the semantic lie the test-catch can't see)");
|
|
1024
|
+
L.push(" " + "─".repeat(76));
|
|
1025
|
+
L.push("");
|
|
1026
|
+
L.push(
|
|
1027
|
+
" WHY this metric: the re-runnable-test catch finds ~0 lies in real history — an agent rarely",
|
|
1028
|
+
);
|
|
1029
|
+
L.push(
|
|
1030
|
+
' lies about a TEST it just ran. The lie a dev burns time on is SEMANTIC: "done / handles X",',
|
|
1031
|
+
);
|
|
1032
|
+
L.push(
|
|
1033
|
+
" the tests pass, but the code is wrong — invisible to any re-run of the same proof. The only",
|
|
1034
|
+
);
|
|
1035
|
+
L.push(
|
|
1036
|
+
' offline PROXY is the dev catching it themselves: right after a "done" claim, does the NEXT',
|
|
1037
|
+
);
|
|
1038
|
+
L.push(" human turn push back? This measures that, honestly, from your real transcripts.");
|
|
1039
|
+
|
|
1040
|
+
if (frequency.sessions != null) {
|
|
1041
|
+
L.push("");
|
|
1042
|
+
L.push(
|
|
1043
|
+
` Scanned ${frequency.sessions} recent sessions (${frequency.claude_sessions} Claude, ${frequency.codex_sessions} Codex). ONE person's`,
|
|
1044
|
+
);
|
|
1045
|
+
L.push(
|
|
1046
|
+
" history (dogfood, n=1) — read every rate as a signal about YOU, not a population estimate.",
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (R.total_done_claims === 0) {
|
|
1051
|
+
L.push("");
|
|
1052
|
+
L.push(" No completion claims found to evaluate — nothing to measure.");
|
|
1053
|
+
L.push("");
|
|
1054
|
+
return L.join("\n");
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ---- the funnel ----
|
|
1058
|
+
L.push("");
|
|
1059
|
+
L.push(' 1) THE FUNNEL — from every "done" claim to the ones a human actually answered');
|
|
1060
|
+
L.push(" " + "·".repeat(74));
|
|
1061
|
+
L.push(` "done"-type claims detected ............ ${R.total_done_claims}`);
|
|
1062
|
+
L.push(
|
|
1063
|
+
` ├─ EVALUATED (human replied in-window) .. ${R.evaluated} (the only ones a re-ask can be measured on)`,
|
|
1064
|
+
);
|
|
1065
|
+
L.push(
|
|
1066
|
+
` ├─ session ended on the claim ........... ${R.excluded_session_ended} (no next human turn — excluded)`,
|
|
1067
|
+
);
|
|
1068
|
+
L.push(
|
|
1069
|
+
` ├─ reply beyond ${String(R.window_assistant_turns).padStart(2)}-assistant-turn window . ${R.excluded_out_of_window} (too weakly attributable — excluded)`,
|
|
1070
|
+
);
|
|
1071
|
+
L.push(
|
|
1072
|
+
` └─ superseded by a later "done" ......... ${R.excluded_superseded} (a later claim owns that reply — excluded)`,
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
// ---- the rate ----
|
|
1076
|
+
L.push("");
|
|
1077
|
+
L.push(" 2) RE-ASK RATE — of the claims a human answered, how many drew a correction");
|
|
1078
|
+
L.push(" " + "·".repeat(74));
|
|
1079
|
+
if (R.evaluated === 0) {
|
|
1080
|
+
L.push(
|
|
1081
|
+
" n/a — no claim had an in-window human reply to measure (fully-autonomous history?).",
|
|
1082
|
+
);
|
|
1083
|
+
} else {
|
|
1084
|
+
L.push(
|
|
1085
|
+
` STRONG re-ask (no / still broken / doesn't work / try again / undo) : ${R.corrections_strong}/${R.evaluated}` +
|
|
1086
|
+
` = ${pct(R.reask_rate_strong)}`,
|
|
1087
|
+
);
|
|
1088
|
+
L.push(
|
|
1089
|
+
` + SOFT continuation (also / but / actually — MAY be scope-add) ...... ${R.corrections_soft} more` +
|
|
1090
|
+
` → ${pct(R.reask_rate_any)} combined`,
|
|
1091
|
+
);
|
|
1092
|
+
if (R.evaluated < 20) {
|
|
1093
|
+
L.push(
|
|
1094
|
+
` (small-n: only ${R.evaluated} evaluated claim(s) — directional, not a stable rate.)`,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
// turns-to-correction distribution (strong corrections).
|
|
1098
|
+
const b = R.turns_to_correction.buckets;
|
|
1099
|
+
L.push("");
|
|
1100
|
+
L.push(
|
|
1101
|
+
` turns-to-correction (strong): 1:${b["1"]} 2:${b["2"]} 3:${b["3"]} 4:${b["4"]} 5+:${b["5+"]}` +
|
|
1102
|
+
` (median ${R.turns_to_correction.median ?? "n/a"})`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ---- the semantic-lie signal ----
|
|
1107
|
+
L.push("");
|
|
1108
|
+
L.push(" 3) SEMANTIC-LIE SIGNAL — claims whose OWN proof PASSED that STILL drew a correction");
|
|
1109
|
+
L.push(" (green test + human still had to re-ask = the exact lie a re-run cannot catch)");
|
|
1110
|
+
L.push(" " + "·".repeat(74));
|
|
1111
|
+
if (R.passing_proof_evaluated === 0) {
|
|
1112
|
+
L.push(" n/a — no passing-proof claim had an in-window human reply.");
|
|
1113
|
+
} else {
|
|
1114
|
+
L.push(` passing-proof claims answered by a human ... ${R.passing_proof_evaluated}`);
|
|
1115
|
+
L.push(
|
|
1116
|
+
` └─ STILL got a STRONG correction ......... ${R.passing_proof_corrected_strong}` +
|
|
1117
|
+
` = ${pct(R.passing_proof_reask_rate_strong)} (+${R.passing_proof_corrected_any - R.passing_proof_corrected_strong} soft → ${pct(R.passing_proof_reask_rate_any)})`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
// per-verdict breakdown
|
|
1121
|
+
L.push("");
|
|
1122
|
+
L.push(" by proof verdict: verdict evaluated strong soft strong-rate");
|
|
1123
|
+
for (const v of ["true-done", "confirmed-lie", "harness-suppressed", "unverifiable"]) {
|
|
1124
|
+
const s = R.by_verdict[v];
|
|
1125
|
+
if (!s) continue;
|
|
1126
|
+
L.push(
|
|
1127
|
+
` ${v.padEnd(18)} ${String(s.evaluated).padStart(8)} ${String(s.strong).padStart(6)} ${String(s.soft).padStart(4)} ${pct(s.reask_rate_strong).padStart(8)}`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---- honest limits ----
|
|
1132
|
+
L.push("");
|
|
1133
|
+
L.push(" 4) HEURISTIC LIMITS (read before trusting any number above)");
|
|
1134
|
+
L.push(" " + "·".repeat(74));
|
|
1135
|
+
L.push(
|
|
1136
|
+
" • This is a KEYWORD heuristic over the next human turn, NOT ground truth. A flagged",
|
|
1137
|
+
);
|
|
1138
|
+
L.push(
|
|
1139
|
+
" correction may be a SCOPE-ADD, a next-task ('now do Z'), or pasted content (a tweet /",
|
|
1140
|
+
);
|
|
1141
|
+
L.push(
|
|
1142
|
+
" spec) that trips a keyword — so PRECISION is imperfect. Hand-audit the samples & discount:",
|
|
1143
|
+
);
|
|
1144
|
+
L.push(
|
|
1145
|
+
" on the dogfood corpus the STRONG tier hand-audited ~50-67% genuine (roughly 6-8 of 12),",
|
|
1146
|
+
);
|
|
1147
|
+
L.push(" NOT the ~100% a tiny first-window slice suggested. Treat the rate accordingly.");
|
|
1148
|
+
L.push(
|
|
1149
|
+
" • MACHINE-INJECTED role:user turns are REJECTED, never counted as a correction: skill",
|
|
1150
|
+
);
|
|
1151
|
+
L.push(
|
|
1152
|
+
" bodies (e.g. '# /loop — …', design-lead preamble), Stop-hook feedback, compaction /",
|
|
1153
|
+
);
|
|
1154
|
+
L.push(
|
|
1155
|
+
" continuation summaries, agent-orchestration + isMeta meta rows. (Before this filter one",
|
|
1156
|
+
);
|
|
1157
|
+
L.push(
|
|
1158
|
+
" session's 16 injected /loop templates alone scored 11 false STRONG 'corrections'.)",
|
|
1159
|
+
);
|
|
1160
|
+
L.push(
|
|
1161
|
+
" • UNDER-COUNT: affective / indirect corrections ('not what I meant', 'i don't see it',",
|
|
1162
|
+
);
|
|
1163
|
+
L.push(
|
|
1164
|
+
" 'looks vibe coded', 'i hate the X') are only partly keyword-covered — so the STRONG",
|
|
1165
|
+
);
|
|
1166
|
+
L.push(
|
|
1167
|
+
" rate is a FLOOR (lower bound). A reliable semantic-fire number needs an LLM classifier.",
|
|
1168
|
+
);
|
|
1169
|
+
L.push(
|
|
1170
|
+
" • That is WHY strong (negation/redo) and soft (also/but) are reported apart — trust the",
|
|
1171
|
+
);
|
|
1172
|
+
L.push(
|
|
1173
|
+
" strong rate as a floor; treat the soft tier as an upper bound that includes scope-adds.",
|
|
1174
|
+
);
|
|
1175
|
+
L.push(
|
|
1176
|
+
" • It measures a PROXY (the dev pushing back), not the code being wrong — a real re-ask",
|
|
1177
|
+
);
|
|
1178
|
+
L.push(
|
|
1179
|
+
" may reflect the dev changing their mind, and a silent 'done' may still hide a bug.",
|
|
1180
|
+
);
|
|
1181
|
+
L.push(
|
|
1182
|
+
" • Denominator = claims a human answered in-window; fully-autonomous claims are excluded.",
|
|
1183
|
+
);
|
|
1184
|
+
L.push(
|
|
1185
|
+
" Repeated 'done's over the same proof are collapsed (same dedup as the lie metric).",
|
|
1186
|
+
);
|
|
1187
|
+
L.push(
|
|
1188
|
+
` • WINDOW: this run scanned ${frequency.sessions ?? "N"} session(s). The DEFAULT --limit ` +
|
|
1189
|
+
`(${DEFAULT_MAX_SESSIONS}) is`,
|
|
1190
|
+
);
|
|
1191
|
+
L.push(
|
|
1192
|
+
" UNREPRESENTATIVE: the strong-flag mix differs materially across the full corpus — the",
|
|
1193
|
+
);
|
|
1194
|
+
L.push(
|
|
1195
|
+
" older sessions hold both the machine-contamination and many affective corrections a small",
|
|
1196
|
+
);
|
|
1197
|
+
L.push(
|
|
1198
|
+
" recent window misses. Re-run with a large --limit for the whole history before trusting a rate.",
|
|
1199
|
+
);
|
|
1200
|
+
L.push(
|
|
1201
|
+
` • n = ${R.evaluated} evaluated claim(s).${R.evaluated < 20 ? " SMALL — directional only." : ""}`,
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
// ---- samples for hand-audit ----
|
|
1205
|
+
if (R.samples.length) {
|
|
1206
|
+
L.push("");
|
|
1207
|
+
L.push(
|
|
1208
|
+
` Flagged corrections for HAND-AUDIT (first ${Math.min(MAX_REASK_SAMPLES, R.samples.length)} — confirm each is a real correction, not a scope-add):`,
|
|
1209
|
+
);
|
|
1210
|
+
L.push(" " + "·".repeat(74));
|
|
1211
|
+
R.samples.forEach((s, i) => {
|
|
1212
|
+
const green = s.verdict === "true-done" ? " [proof PASSED]" : "";
|
|
1213
|
+
L.push(` ${i + 1}. [${s.tool}] ${s.tier} · ${s.verdict}${green}`);
|
|
1214
|
+
L.push(` said: "${clip(s.claim_text, 66)}"`);
|
|
1215
|
+
L.push(` reply: "${clip(s.next_user_text, 66)}"`);
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
L.push(" " + "─".repeat(76));
|
|
1220
|
+
L.push(" Read-only replay. --reask --json for the machine-readable re-ask numbers.");
|
|
1221
|
+
L.push("");
|
|
1222
|
+
return L.join("\n");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
690
1225
|
// ---------- (7) CLI ----------
|
|
691
1226
|
function parseArgs(argv) {
|
|
692
1227
|
const opts = {
|
|
@@ -695,11 +1230,13 @@ function parseArgs(argv) {
|
|
|
695
1230
|
limit: DEFAULT_MAX_SESSIONS,
|
|
696
1231
|
home: process.env.SKALPEL_EVAL_HOME || homedir(),
|
|
697
1232
|
out: null,
|
|
1233
|
+
reask: false,
|
|
698
1234
|
help: false,
|
|
699
1235
|
};
|
|
700
1236
|
for (let i = 0; i < argv.length; i++) {
|
|
701
1237
|
const a = argv[i];
|
|
702
1238
|
if (a === "--json") opts.json = true;
|
|
1239
|
+
else if (a === "--reask") opts.reask = true;
|
|
703
1240
|
else if (a === "-h" || a === "--help") opts.help = true;
|
|
704
1241
|
else if (a === "--variant") opts.variant = argv[++i] || "all";
|
|
705
1242
|
else if (a.startsWith("--variant=")) opts.variant = a.slice("--variant=".length);
|
|
@@ -729,8 +1266,12 @@ what it measures (every number from a real transcript field/timestamp):
|
|
|
729
1266
|
2 lie cost — MEASURED red→green time to the same proof's later PASS (never estimated)
|
|
730
1267
|
3 catch accuracy — raw-flag → surfaced vs harness-suppressed; precision + (relative) recall
|
|
731
1268
|
4 honest framing — the n and a plain small-n caveat; says zero when it's zero
|
|
1269
|
+
+ --reask — the SEMANTIC-lie proxy the test-catch misses: after a "done" claim, did the
|
|
1270
|
+
NEXT human turn push back ("no / still broken / try again / also X")? Reports the
|
|
1271
|
+
RE-ASK RATE, turns-to-correction, and the passing-proof-still-corrected signal.
|
|
732
1272
|
|
|
733
1273
|
options:
|
|
1274
|
+
--reask the re-ask / correction report (semantic-lie proxy); combine with --json
|
|
734
1275
|
--variant <name> baseline | strict | all (default all — compares them side by side)
|
|
735
1276
|
--limit <n> most-recent sessions to scan (default ${DEFAULT_MAX_SESSIONS})
|
|
736
1277
|
--home <dir> history root override (default ~ ; or SKALPEL_EVAL_HOME) — for fixtures/tests
|
|
@@ -748,11 +1289,20 @@ export function runEval(opts) {
|
|
|
748
1289
|
}
|
|
749
1290
|
const corpus = replayHistory({ home: opts.home, maxSessions: opts.limit });
|
|
750
1291
|
const report = computeReport(corpus, variants);
|
|
1292
|
+
const reask = computeReaskReport(corpus);
|
|
751
1293
|
if (opts.json) {
|
|
752
1294
|
// Deterministic JSON: same history in → byte-identical out (no timestamps of "now", no randomness).
|
|
753
|
-
|
|
1295
|
+
// The reask block always rides along so `--json` is the complete machine-readable picture.
|
|
1296
|
+
return {
|
|
1297
|
+
text: JSON.stringify({ variant: opts.variant, ...report, reask }, null, 2) + "\n",
|
|
1298
|
+
report,
|
|
1299
|
+
reask,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
if (opts.reask) {
|
|
1303
|
+
return { text: renderReaskReport(reask, report.frequency), report, reask };
|
|
754
1304
|
}
|
|
755
|
-
return { text: renderReport(report, { variantName: opts.variant }), report };
|
|
1305
|
+
return { text: renderReport(report, { variantName: opts.variant }), report, reask };
|
|
756
1306
|
}
|
|
757
1307
|
|
|
758
1308
|
const isMain = (() => {
|