skalpel 4.0.24 → 4.0.25
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/insights.mjs +12 -0
- package/install.mjs +1 -0
- package/package.json +1 -1
- package/skalpel-hook.mjs +8 -2
- package/skalpel-statusline.mjs +42 -2
- package/verify-shadow.mjs +105 -3
package/insights.mjs
CHANGED
|
@@ -18,6 +18,18 @@ import { join } from "node:path";
|
|
|
18
18
|
const DIR = join(homedir(), ".skalpel");
|
|
19
19
|
export const INSIGHTS_PATH = join(DIR, "insights.ndjson");
|
|
20
20
|
|
|
21
|
+
// ---- verify-reveal: the opt-in "aha catch" surface — single source for the flag + the state-file path.
|
|
22
|
+
// The reveal is WRITTEN by verify-shadow.mjs (the detached proof re-runner, when the agent claimed
|
|
23
|
+
// success but the session's OWN proof re-run GENUINELY failed) and READ by skalpel-statusline.mjs (the
|
|
24
|
+
// user-visible bar). Both the flag semantics and the file path live HERE — the one light module both the
|
|
25
|
+
// writer and the reader (and the hook's spawn gate) already import — so they can never drift apart.
|
|
26
|
+
export const REVEAL_PATH = join(DIR, "verify-reveal.json");
|
|
27
|
+
// SKALPEL_VERIFY_REVEAL is OPT-IN and OFF BY DEFAULT. Unset / "" / "0" / "off" / "false" / "no" → DARK
|
|
28
|
+
// (byte-identical to the shadow-only behavior). Only an explicit truthy value arms the visible reveal.
|
|
29
|
+
export function revealEnabled(env) {
|
|
30
|
+
return /^(1|true|on|yes)$/i.test(String((env || {}).SKALPEL_VERIFY_REVEAL || "").trim());
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
// Unlike metrics.ndjson (write-only telemetry, uncapped), this file is READ by a TUI every render —
|
|
22
34
|
// cap it so it never grows unbounded. Over the byte cap, keep only the newest lines via tmp-file +
|
|
23
35
|
// atomic rename (same idiom as writeSteer), so a concurrent reader never sees a half-written file.
|
package/install.mjs
CHANGED
package/package.json
CHANGED
package/skalpel-hook.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { identity } from "./auth.mjs";
|
|
15
15
|
import { record } from "./metrics.mjs";
|
|
16
|
-
import { recordInsight, cleanText } from "./insights.mjs";
|
|
16
|
+
import { recordInsight, cleanText, revealEnabled } from "./insights.mjs";
|
|
17
17
|
import { graphReadyNote } from "./reveal.mjs";
|
|
18
18
|
import { tailLines } from "./transcript.mjs";
|
|
19
19
|
|
|
@@ -473,7 +473,13 @@ async function main() {
|
|
|
473
473
|
// recent test/build/typecheck/lint command and RE-RUNs it out-of-band to log the real PASS/FAIL the
|
|
474
474
|
// agent can't forge. LOG ONLY — it writes nothing to this hook's stdout and never blocks the turn (the
|
|
475
475
|
// re-run, up to 60s, lives in a separate unref'd process). Wrapped so it can never affect the injection.
|
|
476
|
-
|
|
476
|
+
// SKALPEL_VERIFY_REVEAL (the opt-in "aha catch" surface) ALSO arms the re-run — one flag turns on both
|
|
477
|
+
// the detection and its user-visible reveal. When BOTH are off (the default) this branch is skipped and
|
|
478
|
+
// the hook's stdout is byte-identical to before; the spawn is detached with stdio ignored regardless.
|
|
479
|
+
if (
|
|
480
|
+
(process.env.SKALPEL_VERIFY_SHADOW === "1" || revealEnabled(process.env)) &&
|
|
481
|
+
payload.transcript_path
|
|
482
|
+
) {
|
|
477
483
|
try {
|
|
478
484
|
const { spawn } = await import("node:child_process");
|
|
479
485
|
const { fileURLToPath } = await import("node:url");
|
package/skalpel-statusline.mjs
CHANGED
|
@@ -7,17 +7,22 @@
|
|
|
7
7
|
// that verifiably happened, or
|
|
8
8
|
// • the current steer's plain-English label (what skalpel is doing now) — a description, not a number.
|
|
9
9
|
// A clean session prints NOTHING. Fail-open: any error → print nothing, exit 0. Never blocks the bar.
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
10
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { tailLines } from "./transcript.mjs";
|
|
14
|
-
import { cleanText } from "./insights.mjs";
|
|
14
|
+
import { cleanText, REVEAL_PATH, revealEnabled } from "./insights.mjs";
|
|
15
15
|
|
|
16
16
|
const CYAN = "\x1b[36m";
|
|
17
17
|
const BOLD = "\x1b[1m";
|
|
18
18
|
const DIM = "\x1b[2m";
|
|
19
19
|
const RESET = "\x1b[0m";
|
|
20
20
|
|
|
21
|
+
// AHA CATCH REVEAL — how long a fresh catch stays on the bar before it auto-clears (so it never
|
|
22
|
+
// wallpapers). verify-shadow.mjs writes the reveal the moment a GENUINE_FAIL mismatch is re-run; it also
|
|
23
|
+
// retracts it if a later proof re-run PASSES. This TTL covers the abandoned case (user moved on).
|
|
24
|
+
const REVEAL_TTL_MS = 90_000;
|
|
25
|
+
|
|
21
26
|
// Claude Code pipes a JSON status payload on stdin (transcript_path, session_id, …). Read it ONLY when
|
|
22
27
|
// stdin is actually piped — never block on a TTY (manual invocation) where reading fd 0 would hang.
|
|
23
28
|
function readPayload() {
|
|
@@ -87,9 +92,44 @@ function steerLabel() {
|
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
// The opt-in AHA CATCH: read the reveal state file verify-shadow.mjs wrote. Returns the plain reveal
|
|
96
|
+
// line (fresh + honest — the statusline never invents it, it only renders what the re-run recorded) or
|
|
97
|
+
// null. Auto-clears when stale (past the TTL) so an old catch can't wallpaper the bar. GATED entirely on
|
|
98
|
+
// SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the original.
|
|
99
|
+
function revealNote() {
|
|
100
|
+
try {
|
|
101
|
+
const r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
|
|
102
|
+
if (!r || typeof r.line !== "string" || !r.line.trim()) return null;
|
|
103
|
+
const age = Date.now() - Date.parse(r.ts);
|
|
104
|
+
if (!Number.isFinite(age) || age > REVEAL_TTL_MS) {
|
|
105
|
+
try {
|
|
106
|
+
rmSync(REVEAL_PATH, { force: true }); // stale → retract so it never lingers
|
|
107
|
+
} catch {
|
|
108
|
+
/* best-effort; a leftover reveal still self-expires by TTL on the next read */
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const clean = cleanText(r.line).trim(); // last-line defense: this goes RAW to the live terminal
|
|
113
|
+
return clean || null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null; // no reveal pending, or unreadable → print nothing (fail-open)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
function main() {
|
|
91
120
|
const payload = readPayload();
|
|
92
121
|
|
|
122
|
+
// AHA CATCH REVEAL (opt-in). The single highest-priority line: the agent claimed done but its OWN proof,
|
|
123
|
+
// re-run, GENUINELY failed. Fully gated on SKALPEL_VERIFY_REVEAL — when off, this branch never runs and
|
|
124
|
+
// the statusline output is byte-identical to the count-or-nothing original.
|
|
125
|
+
if (revealEnabled(process.env)) {
|
|
126
|
+
const reveal = revealNote();
|
|
127
|
+
if (reveal) {
|
|
128
|
+
process.stdout.write(`${BOLD}${reveal}${RESET}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
93
133
|
// Acute + reproducible: the user has re-asked / interrupted repeatedly this stretch. Surface the count
|
|
94
134
|
// and point at the receipts — every figure in `skalpel autopsy` re-derives from these same local logs.
|
|
95
135
|
const n = reaskCount(payload.transcript_path);
|
package/verify-shadow.mjs
CHANGED
|
@@ -40,6 +40,9 @@ import { execFile } from "node:child_process";
|
|
|
40
40
|
import { fileURLToPath } from "node:url";
|
|
41
41
|
import { realpathSync } from "node:fs";
|
|
42
42
|
import { tailLines } from "./transcript.mjs";
|
|
43
|
+
// Local sibling modules only (no sockets — RED LINE #4 holds): recordInsight logs the reveal fire for
|
|
44
|
+
// measurement; REVEAL_PATH/revealEnabled are the single-sourced state-file path + opt-in flag.
|
|
45
|
+
import { recordInsight, REVEAL_PATH, revealEnabled } from "./insights.mjs";
|
|
43
46
|
|
|
44
47
|
const DIR = join(homedir(), ".skalpel");
|
|
45
48
|
export const VERIFY_LOG_PATH = join(DIR, "verify-shadow.log");
|
|
@@ -50,6 +53,12 @@ const LOCK_PATH = join(DIR, "verify-shadow.lock");
|
|
|
50
53
|
// but it still gets a hard ceiling so a hung test process can never live forever.
|
|
51
54
|
const RERUN_TIMEOUT_MS = 60_000;
|
|
52
55
|
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
56
|
+
// The STORED evidence tail is a tight 200 chars (a human-readable receipt). But CLASSIFICATION must see
|
|
57
|
+
// more: a harness sentinel like `npm error Missing script: "test"` is followed by npm's multi-line
|
|
58
|
+
// "To see a list of scripts…" + debug-log-path epilogue, which on modern npm pushes the sentinel ~250
|
|
59
|
+
// chars back — out of a 200-char tail — so a HARNESS_ERROR would be misread as GENUINE_FAIL (crying wolf
|
|
60
|
+
// on a missing script, the exact case we must never surface). Classify on a wider window; store 200.
|
|
61
|
+
const CLASSIFY_TAIL_BYTES = 4000;
|
|
53
62
|
// The proof command lives near the end of the transcript (the agent runs tests, then claims done). A
|
|
54
63
|
// generous tail is fine here (off the hot path); still bounded so a multi-GB transcript can't OOM us.
|
|
55
64
|
const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
@@ -501,7 +510,9 @@ export function rerunProof({ cmd, args, cwd }) {
|
|
|
501
510
|
if (!err) return finish({ pass: true, outcome: "PASS", evidence });
|
|
502
511
|
if (err.killed || err.signal === "SIGTERM")
|
|
503
512
|
evidence = `timeout after ${RERUN_TIMEOUT_MS}ms; ${evidence}`.slice(-200);
|
|
504
|
-
|
|
513
|
+
// Classify on a WIDER window than we store, so a truncated harness sentinel (e.g. npm's
|
|
514
|
+
// "Missing script") is still caught as HARNESS_ERROR and never mislabeled GENUINE_FAIL.
|
|
515
|
+
const outcome = classifyOutcome(err, tail.slice(-CLASSIFY_TAIL_BYTES) || evidence);
|
|
505
516
|
return finish({
|
|
506
517
|
pass: false,
|
|
507
518
|
outcome,
|
|
@@ -632,6 +643,94 @@ function appendShadowLog(row) {
|
|
|
632
643
|
}
|
|
633
644
|
}
|
|
634
645
|
|
|
646
|
+
// ======================= (4b) the AHA CATCH REVEAL (opt-in, SKALPEL_VERIFY_REVEAL) =======================
|
|
647
|
+
// When the shadow re-run is a GENUINE_FAIL mismatch (agent claimed done, the session's OWN proof REALLY
|
|
648
|
+
// failed) AND the user opted in, we surface a striking, HONEST one-liner the statusline renders. HONESTY
|
|
649
|
+
// RED LINE: every word of the line traces to the real row — the real claim text, the real reconstructed
|
|
650
|
+
// proof command, the real evidence tail. NO fabricated numbers: a failing COUNT is shown ONLY when the
|
|
651
|
+
// real test-runner output printed one. HARNESS_ERROR never reaches here (only GENUINE_FAIL mismatch does),
|
|
652
|
+
// so we never cry wolf on a timeout / missing script / wrong cwd.
|
|
653
|
+
|
|
654
|
+
function clipText(s, n) {
|
|
655
|
+
const t = String(s || "")
|
|
656
|
+
.replace(/\s+/g, " ")
|
|
657
|
+
.trim();
|
|
658
|
+
return t.length > n ? t.slice(0, n - 1) + "…" : t;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// extractFailCount(evidence) → a positive integer ONLY if the real captured output printed a failure
|
|
662
|
+
// count (jest/vitest/mocha/pytest/cargo/tsc "<n> failed|failing|failures|errors|problems"); else null.
|
|
663
|
+
// A "0 failed" summary yields null (we never render a count we can't stand behind).
|
|
664
|
+
export function extractFailCount(evidence) {
|
|
665
|
+
const m = String(evidence || "").match(
|
|
666
|
+
/(\d+)\s+(?:failed|failing|failures?|errors?|problems?)\b/i,
|
|
667
|
+
);
|
|
668
|
+
if (!m) return null;
|
|
669
|
+
const n = parseInt(m[1], 10);
|
|
670
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// buildRevealLine(row) → the plain-text reveal (no ANSI — the statusline applies its own styling and
|
|
674
|
+
// re-sanitizes). Uses ONLY real fields from the shadow row.
|
|
675
|
+
export function buildRevealLine(row) {
|
|
676
|
+
const claim = clipText(row.claim_text, 64) || "it's done";
|
|
677
|
+
const proof = clipText(row.proof_command, 48) || "its own proof";
|
|
678
|
+
const failN = extractFailCount(row.evidence);
|
|
679
|
+
const count = failN != null ? ` (${failN} failing)` : "";
|
|
680
|
+
return `🔬 skalpel caught it — your agent said "${claim}" but \`${proof}\` just failed${count}. It's not done.`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// writeReveal(row) — atomic write of the reveal state file the statusline reads. Best-effort; never throws.
|
|
684
|
+
function writeReveal(row) {
|
|
685
|
+
try {
|
|
686
|
+
mkdirSync(DIR, { recursive: true });
|
|
687
|
+
const rec = {
|
|
688
|
+
ts: row.ts,
|
|
689
|
+
session: row.session || null,
|
|
690
|
+
claim_text: row.claim_text,
|
|
691
|
+
proof_command: row.proof_command,
|
|
692
|
+
fail_count: extractFailCount(row.evidence),
|
|
693
|
+
evidence: row.evidence,
|
|
694
|
+
line: buildRevealLine(row),
|
|
695
|
+
};
|
|
696
|
+
const tmp = `${REVEAL_PATH}.tmp`;
|
|
697
|
+
writeFileSync(tmp, JSON.stringify(rec));
|
|
698
|
+
renameSync(tmp, REVEAL_PATH); // atomic swap — a concurrent statusline never sees a half-written file
|
|
699
|
+
} catch {
|
|
700
|
+
/* the reveal is a bonus; a write failure must never affect the shadow */
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// clearReveal() — retract a prior catch (the agent actually fixed it: a later proof re-run PASSED).
|
|
705
|
+
function clearReveal() {
|
|
706
|
+
try {
|
|
707
|
+
rmSync(REVEAL_PATH, { force: true });
|
|
708
|
+
} catch {
|
|
709
|
+
/* stale reveal self-expires via the statusline's TTL anyway */
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// maybeSurfaceReveal(row) — the single opt-in branch. OFF by default → does nothing (fully dark).
|
|
714
|
+
function maybeSurfaceReveal(row) {
|
|
715
|
+
try {
|
|
716
|
+
if (!revealEnabled(process.env)) return; // SKALPEL_VERIFY_REVEAL unset/off → dark, no-op
|
|
717
|
+
if (row.outcome === "GENUINE_FAIL" && row.mismatch === true) {
|
|
718
|
+
writeReveal(row);
|
|
719
|
+
// MEASURABLE: one reveal_shown insight per fire → fire-rate + (later) retention, no fabricated data.
|
|
720
|
+
recordInsight({
|
|
721
|
+
kind: "reveal_shown",
|
|
722
|
+
display: buildRevealLine(row),
|
|
723
|
+
proof: row.proof_command,
|
|
724
|
+
session: row.session || null,
|
|
725
|
+
});
|
|
726
|
+
} else if (row.outcome === "PASS") {
|
|
727
|
+
clearReveal(); // resolved — don't wallpaper a catch the agent already fixed
|
|
728
|
+
}
|
|
729
|
+
} catch {
|
|
730
|
+
/* reveal surfacing is a bonus; never affect the shadow path */
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
635
734
|
// recordVerifyShadow({transcriptPath, session, cwd}) — the wired entry. On (claim + reconstructable
|
|
636
735
|
// proof) it re-runs the proof and appends one shadow row. LOG ONLY. Never injects, never throws.
|
|
637
736
|
export async function recordVerifyShadow({ transcriptPath, session, cwd }) {
|
|
@@ -676,7 +775,7 @@ export async function recordVerifyShadow({ transcriptPath, session, cwd }) {
|
|
|
676
775
|
// the work) so it is NOT counted in the mismatch metric — only a GENUINE_FAIL is "the agent lied".
|
|
677
776
|
const pass_fail =
|
|
678
777
|
outcome === "PASS" ? "PASS" : outcome === "GENUINE_FAIL" ? "FAIL" : "HARNESS_ERROR";
|
|
679
|
-
|
|
778
|
+
const row = {
|
|
680
779
|
ts: new Date().toISOString(),
|
|
681
780
|
session: session || null,
|
|
682
781
|
claim_text: text,
|
|
@@ -685,7 +784,10 @@ export async function recordVerifyShadow({ transcriptPath, session, cwd }) {
|
|
|
685
784
|
outcome,
|
|
686
785
|
mismatch: claim && outcome === "GENUINE_FAIL", // THE money metric: claimed done, own test REALLY failed
|
|
687
786
|
evidence: String(result.evidence || "").slice(0, 200),
|
|
688
|
-
}
|
|
787
|
+
};
|
|
788
|
+
appendShadowLog(row);
|
|
789
|
+
// SHADOW stays dark by default; this is the ONLY user-visible branch, gated on SKALPEL_VERIFY_REVEAL.
|
|
790
|
+
maybeSurfaceReveal(row);
|
|
689
791
|
} catch {
|
|
690
792
|
/* SHADOW: any failure is swallowed — this must never affect the hook or the turn */
|
|
691
793
|
}
|