skalpel 4.0.23 → 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 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
@@ -299,13 +299,17 @@ function stripManagedTomlBlocks(txt) {
299
299
 
300
300
  function codexToml() {
301
301
  if (!existsSync(CODEX_DIR)) return "no-codex";
302
- const txt = existsSync(CODEX_TOML) ? readFileSync(CODEX_TOML, "utf8") : "";
302
+ const hadToml = existsSync(CODEX_TOML);
303
+ const txt = hadToml ? readFileSync(CODEX_TOML, "utf8") : "";
303
304
  // Strip current managed blocks and older unmanaged skalpel blocks, so setup heals stale Codex
304
305
  // configs that still point at bare PATH commands from pre-staged installs.
305
306
  const stripped = stripManagedTomlBlocks(txt);
306
307
  let next = stripped.text.replace(/\n{3,}/g, "\n\n").trimEnd();
307
308
  if (uninstall) {
308
- writeFileSync(CODEX_TOML, next + "\n");
309
+ // Only rewrite a config.toml that already existed — never CREATE one during an uninstall. A user
310
+ // with ~/.codex but no config.toml has nothing of ours to strip, so writing `next + "\n"` here
311
+ // would leave a stray 1-byte file behind an operation that's supposed to clean up.
312
+ if (hadToml) writeFileSync(CODEX_TOML, next + "\n");
309
313
  return stripped.removed ? "removed" : "absent";
310
314
  }
311
315
  mkdirSync(CODEX_DIR, { recursive: true });
@@ -450,6 +454,7 @@ function cleanupLocalData() {
450
454
  rm(join(SKALPEL_DIR, "hooks"), "staged hooks");
451
455
  rm(join(SKALPEL_DIR, "ingest-outbox"), "raw transcript snapshots (ingest-outbox)");
452
456
  for (const f of [
457
+ "stats.json", // legacy "time saved" accumulator — nothing writes it now, but old installs left one
453
458
  "session.json",
454
459
  "steer.json",
455
460
  "traj.json",
@@ -466,6 +471,7 @@ function cleanupLocalData() {
466
471
  "verify-shadow.log",
467
472
  "verify-last.json",
468
473
  "verify-shadow.lock",
474
+ "verify-reveal.json",
469
475
  ]) {
470
476
  rm(join(SKALPEL_DIR, f), f);
471
477
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.23",
3
+ "version": "4.0.25",
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-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
- if (process.env.SKALPEL_VERIFY_SHADOW === "1" && payload.transcript_path) {
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-setup.mjs CHANGED
@@ -30,7 +30,9 @@ const argv = process.argv.slice(2);
30
30
  const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : null;
31
31
  const flagOf = (name) => {
32
32
  const i = argv.indexOf(name);
33
- return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
33
+ // A flag-looking next token is a missing value, not the value never swallow it (would poison
34
+ // client.json and let `--api --version` fall through to a full install).
35
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[i + 1] : null;
34
36
  };
35
37
  const apiFlag = flagOf("--api");
36
38
  const userFlag = flagOf("--user");
@@ -92,7 +94,9 @@ function validateArgv() {
92
94
  for (let i = 0; i < argv.length; i++) {
93
95
  const a = argv[i];
94
96
  if (VALUE_FLAGS.has(a)) {
95
- i++; // its value is not a token to validate
97
+ // Only skip a real value; a following flag (-v/--version/--help/unknown) is a missing value
98
+ // and must still be validated/handled, not eaten.
99
+ if (argv[i + 1] && !argv[i + 1].startsWith("-")) i++;
96
100
  continue;
97
101
  }
98
102
  if (a === "-v" || a === "--version") {
@@ -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
- const outcome = classifyOutcome(err, evidence);
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
- appendShadowLog({
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
  }