skalpel 4.0.27 → 4.0.28

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 CHANGED
@@ -24,6 +24,12 @@ export const INSIGHTS_PATH = join(DIR, "insights.ndjson");
24
24
  // user-visible bar). Both the flag semantics and the file path live HERE — the one light module both the
25
25
  // writer and the reader (and the hook's spawn gate) already import — so they can never drift apart.
26
26
  export const REVEAL_PATH = join(DIR, "verify-reveal.json");
27
+ // verify-verdict: the POSITIVE half of the same surface — the quiet green "✓ verified" stamp written on
28
+ // a PASS (the agent claimed done and its OWN proof, re-run, really passed). Same writer (verify-shadow.mjs)
29
+ // and reader (skalpel-statusline.mjs), same opt-in flag as the red reveal. Ambient greens are what let the
30
+ // rare red catch land like a thunderclap — without a stream of honest verified stamps a lone red reads as
31
+ // noise, not a verdict. Path single-sourced HERE alongside REVEAL_PATH so writer + reader never drift.
32
+ export const VERDICT_PATH = join(DIR, "verify-verdict.json");
27
33
  // SKALPEL_VERIFY_REVEAL is OPT-IN and OFF BY DEFAULT. Unset / "" / "0" / "off" / "false" / "no" → DARK
28
34
  // (byte-identical to the shadow-only behavior). Only an explicit truthy value arms the visible reveal.
29
35
  export function revealEnabled(env) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
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
@@ -60,6 +60,7 @@ const KNOWN_SUBS = new Set([
60
60
  "logout",
61
61
  "uninstall",
62
62
  "autopsy",
63
+ "xray",
63
64
  "__build",
64
65
  "__verify-report",
65
66
  ]);
@@ -73,6 +74,7 @@ usage:
73
74
  skalpel login (re-)run the Google sign-in
74
75
  skalpel logout clear the saved session
75
76
  skalpel autopsy local, read-only receipt of your verified patterns
77
+ skalpel xray re-run your last session's own proof — did "done" hold up?
76
78
  skalpel uninstall [--purge] remove the hooks + local data from this machine
77
79
 
78
80
  options:
@@ -432,6 +434,13 @@ function promptLaunch(targets) {
432
434
 
433
435
  async function reportLiveAndLaunch(message) {
434
436
  console.log(message);
437
+ // FIRST-RUN X-RAY pointer: offer the user their own first catch — an independent re-run of the proof
438
+ // from the session they just had. A pointer, not an auto-run (an interactive install must never spawn a
439
+ // 60s test suite unasked); the command itself does the honest work when they choose to run it.
440
+ console.log(
441
+ ` ${D}Want to check the session you just had? Run ${X}${B}skalpel xray${X}${D} — I'll re-run your` +
442
+ ` agent's own proof and tell you if "done" really held up.${X}\n`,
443
+ );
435
444
  await promptLaunch(detectLaunchers());
436
445
  }
437
446
 
@@ -867,6 +876,15 @@ async function main() {
867
876
  spawnSync("node", [join(__dir, "verify-shadow.mjs"), "--report"], { stdio: "inherit" });
868
877
  return;
869
878
  }
879
+ // `skalpel xray` — the FIRST-RUN X-RAY: independently re-run the session you JUST had. Finds your most
880
+ // recent Claude Code transcript, reconstructs your agent's OWN last proof (test/build/typecheck/lint),
881
+ // re-runs it once, and stamps it verified or caught. Local, read-only against ~/.claude/projects; the
882
+ // only thing it executes is your session's own allowlisted proof. Reports a real verdict from a real
883
+ // re-run — never a minutes-lost tally, never a fabricated number.
884
+ if (sub === "xray") {
885
+ spawnSync("node", [join(__dir, "verify-shadow.mjs"), "--xray"], { stdio: "inherit" });
886
+ return;
887
+ }
870
888
  // Persist --api/--user to ~/.skalpel/client.json so the hooks can read them without shell-env
871
889
  // plumbing (env vars SKALPEL_API / SKALPEL_USER still win at hook runtime).
872
890
  if (apiFlag || userFlag) {
@@ -11,9 +11,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, REVEAL_PATH, revealEnabled } from "./insights.mjs";
14
+ import { cleanText, REVEAL_PATH, VERDICT_PATH, revealEnabled } from "./insights.mjs";
15
15
 
16
16
  const CYAN = "\x1b[36m";
17
+ const GREEN = "\x1b[32m";
17
18
  const BOLD = "\x1b[1m";
18
19
  const DIM = "\x1b[2m";
19
20
  const RESET = "\x1b[0m";
@@ -22,6 +23,11 @@ const RESET = "\x1b[0m";
22
23
  // wallpapers). verify-shadow.mjs writes the reveal the moment a GENUINE_FAIL mismatch is re-run; it also
23
24
  // retracts it if a later proof re-run PASSES. This TTL covers the abandoned case (user moved on).
24
25
  const REVEAL_TTL_MS = 90_000;
26
+ // LIVE VERDICT green stamp — the earned "✓ verified" written on a clean PASS. DELIBERATELY short-lived:
27
+ // it flashes right after the claim, then recedes so a stream of greens stays ambient (quiet trust) and
28
+ // never competes with the loud red catch. Short TTL is the whole point — a green that lingers is clutter;
29
+ // a green that blinks by, turn after turn, is what makes the rare red land like a thunderclap.
30
+ const VERDICT_TTL_MS = 20_000;
25
31
 
26
32
  // Claude Code pipes a JSON status payload on stdin (transcript_path, session_id, …). Read it ONLY when
27
33
  // stdin is actually piped — never block on a TTY (manual invocation) where reading fd 0 would hang.
@@ -116,6 +122,30 @@ function revealNote() {
116
122
  }
117
123
  }
118
124
 
125
+ // The LIVE VERDICT green stamp: read the verdict state verify-shadow.mjs wrote on a clean PASS. Returns
126
+ // the plain "✓ verified" line (fresh + honest — the statusline never invents it, it only renders what the
127
+ // re-run recorded) or null. Auto-clears past its short TTL so a stale green never lingers. GATED entirely
128
+ // on SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the original.
129
+ function verdictNote() {
130
+ try {
131
+ const r = JSON.parse(readFileSync(VERDICT_PATH, "utf8"));
132
+ if (!r || typeof r.line !== "string" || !r.line.trim()) return null;
133
+ const age = Date.now() - Date.parse(r.ts);
134
+ if (!Number.isFinite(age) || age > VERDICT_TTL_MS) {
135
+ try {
136
+ rmSync(VERDICT_PATH, { force: true }); // stale → retract so it never lingers
137
+ } catch {
138
+ /* best-effort; a leftover verdict still self-expires by TTL on the next read */
139
+ }
140
+ return null;
141
+ }
142
+ const clean = cleanText(r.line).trim(); // last-line defense: this goes RAW to the live terminal
143
+ return clean || null;
144
+ } catch {
145
+ return null; // no verdict pending, or unreadable → print nothing (fail-open)
146
+ }
147
+ }
148
+
119
149
  async function main() {
120
150
  const payload = readPayload();
121
151
 
@@ -128,6 +158,14 @@ async function main() {
128
158
  process.stdout.write(`${BOLD}${reveal}${RESET}`);
129
159
  return;
130
160
  }
161
+ // No red catch pending → the quiet green half. A fresh PASS stamp shows dim (ambient, not shouting)
162
+ // for a short beat. Lower priority than the red catch, higher than the re-ask count: it's the most
163
+ // recent verdict on the last claim, and it's what earns the trust the red catch spends.
164
+ const verdict = verdictNote();
165
+ if (verdict) {
166
+ process.stdout.write(`${DIM}${GREEN}${verdict}${RESET}`);
167
+ return;
168
+ }
131
169
  }
132
170
 
133
171
  // Acute + reproducible: the user has re-asked / interrupted repeatedly this stretch. Surface the count
package/verify-shadow.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  import {
28
28
  appendFileSync,
29
29
  mkdirSync,
30
+ readdirSync,
30
31
  readFileSync,
31
32
  writeFileSync,
32
33
  renameSync,
@@ -42,7 +43,7 @@ import { realpathSync } from "node:fs";
42
43
  import { tailLines } from "./transcript.mjs";
43
44
  // Local sibling modules only (no sockets — RED LINE #4 holds): recordInsight logs the reveal fire for
44
45
  // measurement; REVEAL_PATH/revealEnabled are the single-sourced state-file path + opt-in flag.
45
- import { recordInsight, REVEAL_PATH, revealEnabled } from "./insights.mjs";
46
+ import { recordInsight, REVEAL_PATH, VERDICT_PATH, revealEnabled } from "./insights.mjs";
46
47
 
47
48
  const DIR = join(homedir(), ".skalpel");
48
49
  export const VERIFY_LOG_PATH = join(DIR, "verify-shadow.log");
@@ -710,12 +711,52 @@ function clearReveal() {
710
711
  }
711
712
  }
712
713
 
714
+ // ---- the POSITIVE half: the quiet green VERDICT STAMP (LIVE VERDICT). On a PASS (claim + the session's
715
+ // OWN proof re-run really exited 0) we write a dim-green "✓ verified" stamp the statusline renders for a
716
+ // short beat. HONESTY: every token traces to the real run — the real reconstructed proof command and the
717
+ // literal fact that its process exited 0. NO number is shown here (a pass has no count to invent). The
718
+ // ambient stream of these earned greens is what makes the rare red catch feel like a verdict, not noise.
719
+
720
+ // buildVerdictLine(row) → the plain-text green stamp (no ANSI — the statusline styles + re-sanitizes).
721
+ export function buildVerdictLine(row) {
722
+ const proof = clipText(row.proof_command, 48) || "its own proof";
723
+ return `✓ skalpel verified — re-ran \`${proof}\`, exit 0. It's real.`;
724
+ }
725
+
726
+ // writeVerdict(row) — atomic write of the green-stamp state file the statusline reads. Best-effort.
727
+ function writeVerdict(row) {
728
+ try {
729
+ mkdirSync(DIR, { recursive: true });
730
+ const rec = {
731
+ ts: row.ts,
732
+ session: row.session || null,
733
+ proof_command: row.proof_command,
734
+ line: buildVerdictLine(row),
735
+ };
736
+ const tmp = `${VERDICT_PATH}.tmp`;
737
+ writeFileSync(tmp, JSON.stringify(rec));
738
+ renameSync(tmp, VERDICT_PATH); // atomic swap — a concurrent statusline never sees a half write
739
+ } catch {
740
+ /* the verdict stamp is a bonus; a write failure must never affect the shadow */
741
+ }
742
+ }
743
+
744
+ // clearVerdict() — a fresh RED catch supersedes any lingering green (never show both at once).
745
+ function clearVerdict() {
746
+ try {
747
+ rmSync(VERDICT_PATH, { force: true });
748
+ } catch {
749
+ /* stale verdict self-expires via the statusline's short TTL anyway */
750
+ }
751
+ }
752
+
713
753
  // maybeSurfaceReveal(row) — the single opt-in branch. OFF by default → does nothing (fully dark).
714
754
  function maybeSurfaceReveal(row) {
715
755
  try {
716
756
  if (!revealEnabled(process.env)) return; // SKALPEL_VERIFY_REVEAL unset/off → dark, no-op
717
757
  if (row.outcome === "GENUINE_FAIL" && row.mismatch === true) {
718
758
  writeReveal(row);
759
+ clearVerdict(); // a real catch overrides any stale green — never show a ✓ next to a ✗
719
760
  // MEASURABLE: one reveal_shown insight per fire → fire-rate + (later) retention, no fabricated data.
720
761
  recordInsight({
721
762
  kind: "reveal_shown",
@@ -725,7 +766,15 @@ function maybeSurfaceReveal(row) {
725
766
  });
726
767
  } else if (row.outcome === "PASS") {
727
768
  clearReveal(); // resolved — don't wallpaper a catch the agent already fixed
769
+ writeVerdict(row); // …and stamp the earned green so the ambient trust-line keeps building
770
+ recordInsight({
771
+ kind: "verdict_shown",
772
+ display: buildVerdictLine(row),
773
+ proof: row.proof_command,
774
+ session: row.session || null,
775
+ });
728
776
  }
777
+ // HARNESS_ERROR / REFUSED: neither a catch nor a clean pass — surface nothing, touch no state file.
729
778
  } catch {
730
779
  /* reveal surfacing is a bonus; never affect the shadow path */
731
780
  }
@@ -883,8 +932,130 @@ export function renderVerifyReport() {
883
932
  return L.join("\n");
884
933
  }
885
934
 
935
+ // ======================= FIRST-RUN X-RAY: `skalpel xray` =======================
936
+ // A one-shot, user-INVOKED replay over the session you JUST had: find your most recent Claude Code
937
+ // transcript, read the agent's last completion claim, reconstruct the session's OWN proof, re-run it
938
+ // once, and stamp it verified or caught. Same engine as the live shadow — the only new thing is finding
939
+ // the newest transcript and printing a human verdict. Explicit invocation IS the opt-in; it re-runs only
940
+ // the session's own allowlisted proof (never anything from prompt/claim CONTENT). It reports ONLY a real
941
+ // verdict from a real re-run — NEVER a minutes-lost tally, never a fabricated number (the banned
942
+ // confession). Local, read-only against ~/.claude/projects; opens no socket. Never throws.
943
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
944
+
945
+ // newestTranscript() → absolute path of the most-recently-modified *.jsonl under ~/.claude/projects, or
946
+ // null. Bounded: one level of project dirs (Claude's layout), newest mtime wins. Fail-open on any error.
947
+ export function newestTranscript(root = CLAUDE_PROJECTS_DIR) {
948
+ let best = null;
949
+ let bestMtime = -Infinity;
950
+ let dirs;
951
+ try {
952
+ dirs = readdirSync(root, { withFileTypes: true });
953
+ } catch {
954
+ return null; // no ~/.claude/projects → nothing to x-ray
955
+ }
956
+ for (const d of dirs) {
957
+ if (!d.isDirectory()) continue;
958
+ const sub = join(root, d.name);
959
+ let files;
960
+ try {
961
+ files = readdirSync(sub);
962
+ } catch {
963
+ continue;
964
+ }
965
+ for (const f of files) {
966
+ if (!f.endsWith(".jsonl")) continue;
967
+ const p = join(sub, f);
968
+ try {
969
+ const m = statSync(p).mtimeMs;
970
+ if (m > bestMtime) {
971
+ bestMtime = m;
972
+ best = p;
973
+ }
974
+ } catch {
975
+ /* vanished mid-scan — skip */
976
+ }
977
+ }
978
+ }
979
+ return best;
980
+ }
981
+
982
+ // xrayLastSession() → runs the replay and PRINTS the verdict. Returns a small result object (also used by
983
+ // tests). Ansi via the same palette the statusline uses; honest by construction — no derived numbers.
984
+ export async function xrayLastSession({ transcriptPath } = {}) {
985
+ const RED = "\x1b[38;2;233;38;31m";
986
+ const GREEN = "\x1b[38;2;124;184;108m";
987
+ const DIM = "\x1b[2m";
988
+ const BOLD = "\x1b[1m";
989
+ const X = "\x1b[0m";
990
+ const say = (s) => process.stdout.write(s + "\n");
991
+
992
+ const tp = transcriptPath || newestTranscript();
993
+ if (!tp) {
994
+ say(
995
+ `\n ${DIM}No recent Claude Code session found to check (looked in ~/.claude/projects).${X}\n`,
996
+ );
997
+ return { status: "no_session" };
998
+ }
999
+ const entries = readEntries(tp);
1000
+ if (!entries.length) {
1001
+ say(`\n ${DIM}Your most recent session had nothing readable to check.${X}\n`);
1002
+ return { status: "empty" };
1003
+ }
1004
+
1005
+ const { claim, text } = detectCompletionClaim(lastAssistantText(entries));
1006
+ say(`\n ${BOLD}skalpel x-ray${X} ${DIM}· replaying the session you just had${X}`);
1007
+ if (!claim) {
1008
+ // No completion claim in the last turn — nothing was asserted done, so there's nothing to catch.
1009
+ say(
1010
+ `\n ${DIM}Your agent's last turn didn't claim it was done — nothing to independently verify.${X}\n`,
1011
+ );
1012
+ return { status: "no_claim" };
1013
+ }
1014
+ say(` ${DIM}claim:${X} "${clipText(text, 72)}"`);
1015
+
1016
+ const proof = reconstructProof(entries, null);
1017
+ if (!proof) {
1018
+ // Honest limit: it claimed done but ran no test/build/typecheck we could re-run out-of-band.
1019
+ say(
1020
+ `\n ${DIM}It said done, but ran no test/build/typecheck this session — nothing skalpel can` +
1021
+ ` independently re-run. No verdict (and no guess).${X}\n`,
1022
+ );
1023
+ return { status: "no_proof" };
1024
+ }
1025
+ const proofStr = [proof.cmd, ...proof.args].join(" ").slice(0, 120);
1026
+ say(` ${DIM}re-running its own proof:${X} ${proofStr}${DIM} …${X}`);
1027
+
1028
+ const result = await rerunProof(proof);
1029
+ const outcome = result.outcome || (result.pass ? "PASS" : "GENUINE_FAIL");
1030
+ if (outcome === "PASS") {
1031
+ say(
1032
+ `\n ${GREEN}✓ verified${X} — re-ran \`${proofStr}\`, exit 0. Your agent's "done" holds up.\n`,
1033
+ );
1034
+ return { status: "pass", proof: proofStr };
1035
+ }
1036
+ if (outcome === "GENUINE_FAIL") {
1037
+ const failN = extractFailCount(result.evidence);
1038
+ const count = failN != null ? ` (${failN} failing)` : "";
1039
+ say(
1040
+ `\n ${RED}${BOLD}✗ caught${X} — your agent said "${clipText(text, 60)}" but \`${proofStr}\`` +
1041
+ ` just failed${count}. It's not done.`,
1042
+ );
1043
+ if (result.evidence) say(` ${DIM}real output:${X} ${clipText(result.evidence, 180)}`);
1044
+ say("");
1045
+ return { status: "caught", proof: proofStr, evidence: result.evidence };
1046
+ }
1047
+ // HARNESS_ERROR / REFUSED — the proof machinery broke, NOT the agent's work. Never render this as a
1048
+ // catch (crying wolf on a timeout / missing script / wrong cwd is exactly what kills the trust).
1049
+ say(
1050
+ `\n ${DIM}Couldn't independently verify — the proof \`${proofStr}\` errored` +
1051
+ `${result.evidence ? ` (${clipText(result.evidence, 120)})` : ""}. Not a catch.${X}\n`,
1052
+ );
1053
+ return { status: "harness_error", proof: proofStr };
1054
+ }
1055
+
886
1056
  // CLI: `node verify-shadow.mjs --run` → detached worker launched by the per-turn hook.
887
1057
  // `node verify-shadow.mjs --report`→ print the instrument (also reachable via `skalpel __verify-report`).
1058
+ // `node verify-shadow.mjs --xray` → the one-shot FIRST-RUN X-RAY (reachable via `skalpel xray`).
888
1059
  // Robust main detection (mirrors skalpel-setup.mjs): compare REAL paths, not a hand-built file:// URL —
889
1060
  // the staged path lives under the home dir, which on macOS/Windows can contain a space that would break
890
1061
  // `new URL("file://"+path)` and wrongly disable the CLI. Importing this module (tests) never trips it.
@@ -903,6 +1074,10 @@ if (isMain) {
903
1074
  if (arg === "--report") {
904
1075
  process.stdout.write(renderVerifyReport() + "\n");
905
1076
  process.exit(0);
1077
+ } else if (arg === "--xray") {
1078
+ xrayLastSession({ transcriptPath: process.env.SKALPEL_VERIFY_TRANSCRIPT || null })
1079
+ .then(() => process.exit(0))
1080
+ .catch(() => process.exit(0));
906
1081
  } else if (arg === "--run") {
907
1082
  recordVerifyShadow({
908
1083
  transcriptPath: process.env.SKALPEL_VERIFY_TRANSCRIPT || null,