skalpel 4.0.39 → 4.0.41

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/eval-harness.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
 
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
+ import { fileURLToPath } from "node:url";
28
29
 
29
30
  // ───────────────────────────── tunables (UNCALIBRATED defaults) ─────────────────────────────
30
31
  // These thresholds are NOT tuned against a human gold set unless one is supplied via --gold.
@@ -1117,9 +1118,22 @@ function main() {
1117
1118
  console.log("═".repeat(90));
1118
1119
  }
1119
1120
 
1120
- const isMain =
1121
- process.argv[1] &&
1122
- path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
1121
+ // Robust main detection (mirrors verify-shadow.mjs / skalpel-setup.mjs): compare REAL paths via
1122
+ // realpathSync, not a hand-built path.resolve(url.pathname). On Windows, `new URL(import.meta.url)
1123
+ // .pathname` yields "/C:/Users/..." (a leading slash BEFORE the drive letter); path.resolve() on that
1124
+ // string produces "\C:\Users\..." — a different string than path.resolve(process.argv[1])'s
1125
+ // "C:\Users\...". isMain then silently evaluates false, so `node eval-harness.mjs <dir>` (the CLI's
1126
+ // entire purpose) does nothing at all on Windows: no usage, no report, no error, exit 0.
1127
+ const isMain = (() => {
1128
+ try {
1129
+ return (
1130
+ Boolean(process.argv[1]) &&
1131
+ fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))
1132
+ );
1133
+ } catch {
1134
+ return false;
1135
+ }
1136
+ })();
1123
1137
  if (isMain) main();
1124
1138
 
1125
1139
  export { V1_onset, V2_cue, V3_repeat, FIRE_ALWAYS, FIRE_NEVER, calibrate };
package/install.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  mkdirSync,
9
9
  existsSync,
10
10
  copyFileSync,
11
+ renameSync,
11
12
  rmSync,
12
13
  readdirSync,
13
14
  } from "node:fs";
@@ -42,40 +43,95 @@ const SESSION_FILE = join(HOOKS_DIR, "skalpel-hook-session.mjs");
42
43
  const SESSION_END_FILE = join(HOOKS_DIR, "skalpel-hook-session-end.mjs");
43
44
  const STATUSLINE_FILE = join(HOOKS_DIR, "skalpel-statusline.mjs");
44
45
 
45
- // Copy the hook runtime (the two entrypoints + their shared auth.mjs) into ~/.skalpel/hooks so the
46
- // wired absolute path is always valid. Overwrites on every install so upgrades refresh the code.
46
+ // The COMPLETE import closure that must live in ~/.skalpel/hooks: the CLI entrypoints Claude Code /
47
+ // Codex are wired to, plus every module those entrypoints (transitively) import. A hook wired to an
48
+ // absolute path whose imported module is absent crashes on EVERY invocation with
49
+ // `SyntaxError: ... does not provide an export named ...` / ERR_MODULE_NOT_FOUND — the exact
50
+ // 0.3.12–0.3.16 bug shape. Keep this list a superset of the closure of skalpel-hook.mjs /
51
+ // skalpel-hook-session.mjs / skalpel-hook-session-end.mjs / skalpel-statusline.mjs (see the trailing
52
+ // note on each line for who imports it). Order is irrelevant now — staging is atomic (all files land
53
+ // in a temp dir; the temp dir is only swapped into place after every copy succeeds).
54
+ const RUNTIME_FILES = [
55
+ "insights.mjs", // hooks + statusline + reveal + verify-shadow import ./insights.mjs
56
+ "reveal.mjs", // both prompt/session hooks import ./reveal.mjs
57
+ "transcript.mjs", // skalpel-hook + skalpel-statusline + verify-shadow import ./transcript.mjs (tailLines)
58
+ "skalpel-hook.mjs", // UserPromptSubmit entrypoint (wired)
59
+ "skalpel-hook-session.mjs", // SessionStart entrypoint (wired)
60
+ "incremental-ingest.mjs", // skalpel-hook-session-end.mjs imports ./incremental-ingest.mjs
61
+ "skalpel-hook-session-end.mjs", // SessionEnd entrypoint (wired)
62
+ "skalpel-statusline.mjs", // statusLine entrypoint (wired)
63
+ "anchor-shadow.mjs", // verify-shadow.mjs (staged worker) imports ./anchor-shadow.mjs — probe primitives
64
+ "verify-shadow.mjs", // claim-verification SHADOW worker (spawned by skalpel-hook.mjs)
65
+ "auth.mjs", // hooks import ./auth.mjs
66
+ "metrics.mjs", // hooks import ./metrics.mjs
67
+ "stats.mjs", // `node ~/.skalpel/hooks/stats.mjs` — local read-only accumulator
68
+ "autopsy.mjs", // `node ~/.skalpel/hooks/autopsy.mjs` — local read-only receipt
69
+ ];
70
+
71
+ // Stage the hook runtime into ~/.skalpel/hooks so the wired absolute path is always valid, ATOMICALLY:
72
+ // copy the whole closure into a fresh temp dir and only swap it into place after EVERY copy succeeds.
73
+ // This is the fix for the partial-upgrade fail-open: the old in-place overwrite copied the new
74
+ // skalpel-hook.mjs BEFORE its auth.mjs/metrics.mjs, so a mid-copy I/O failure on an UPGRADE (hooks
75
+ // already wired to the stable path) left the NEW hook next to the OLD auth at the live path — every
76
+ // turn then crashed with a missing-export SyntaxError, and the all-or-nothing guard below only
77
+ // refused to WIRE, never rolled back the already-clobbered files. With atomic staging a failed copy
78
+ // leaves the EXISTING hooks dir 100% intact.
47
79
  function stageHookRuntime() {
48
80
  // Login and setup repair paths invoke the already-staged copy. Avoid copying a file onto itself.
49
81
  if (PKG_DIR === HOOKS_DIR) return;
50
- mkdirSync(HOOKS_DIR, { recursive: true });
51
- // insights.mjs is staged BEFORE the entrypoints that import it — a mid-copy ENOENT
52
- // must not leave a wired hook missing its import (auth/metrics staging order predates
53
- // this rule and is guarded by the all-or-nothing check below instead).
54
- // a hook entrypoint staged without a module it imports the exact 0.3.12–0.3.16 bug shape.
55
- copyFileSync(join(PKG_DIR, "insights.mjs"), join(HOOKS_DIR, "insights.mjs")); // hooks import ./insights.mjs
56
- copyFileSync(join(PKG_DIR, "reveal.mjs"), join(HOOKS_DIR, "reveal.mjs")); // both hooks import ./reveal.mjs
57
- copyFileSync(join(PKG_DIR, "transcript.mjs"), join(HOOKS_DIR, "transcript.mjs")); // skalpel-hook + skalpel-statusline + verify-shadow + anchor-shadow import ./transcript.mjs (tailLines)
58
- copyFileSync(join(PKG_DIR, "skalpel-hook.mjs"), HOOK_FILE);
59
- copyFileSync(join(PKG_DIR, "skalpel-hook-session.mjs"), SESSION_FILE);
60
- copyFileSync(join(PKG_DIR, "incremental-ingest.mjs"), join(HOOKS_DIR, "incremental-ingest.mjs"));
61
- copyFileSync(join(PKG_DIR, "skalpel-hook-session-end.mjs"), SESSION_END_FILE);
62
- copyFileSync(join(PKG_DIR, "skalpel-statusline.mjs"), STATUSLINE_FILE);
63
- copyFileSync(join(PKG_DIR, "anchor-shadow.mjs"), join(HOOKS_DIR, "anchor-shadow.mjs")); // verify-shadow.mjs (staged worker) imports ./anchor-shadow.mjs probe primitives; staged BEFORE verify-shadow.mjs
64
- copyFileSync(join(PKG_DIR, "verify-shadow.mjs"), join(HOOKS_DIR, "verify-shadow.mjs")); // claim-verification SHADOW worker (spawned by skalpel-hook.mjs)
65
- copyFileSync(join(PKG_DIR, "auth.mjs"), join(HOOKS_DIR, "auth.mjs")); // hooks import ./auth.mjs
66
- copyFileSync(join(PKG_DIR, "metrics.mjs"), join(HOOKS_DIR, "metrics.mjs")); // hooks import ./metrics.mjs
67
- copyFileSync(join(PKG_DIR, "stats.mjs"), join(HOOKS_DIR, "stats.mjs")); // `node ~/.skalpel/hooks/stats.mjs`
68
- copyFileSync(join(PKG_DIR, "autopsy.mjs"), join(HOOKS_DIR, "autopsy.mjs")); // `node ~/.skalpel/hooks/autopsy.mjs` — local read-only receipt
82
+ const SKALPEL_DIR = dirname(HOOKS_DIR); // ~/.skalpel staging/backup are siblings of hooks so every
83
+ const stagingDir = join(SKALPEL_DIR, `hooks.staging-${process.pid}`); // rename below is same-filesystem
84
+ const backupDir = join(SKALPEL_DIR, `hooks.bak-${process.pid}`); // (atomic) and never crosses devices.
85
+
86
+ // 1) Copy the WHOLE closure into a fresh temp dir. Scoped to THIS pid so a concurrent installer's
87
+ // staging-<otherpid> is never disturbed; drop any leftover from a crashed prior run of this pid.
88
+ rmSync(stagingDir, { recursive: true, force: true });
89
+ mkdirSync(stagingDir, { recursive: true });
90
+ try {
91
+ for (const name of RUNTIME_FILES) {
92
+ copyFileSync(join(PKG_DIR, name), join(stagingDir, name));
93
+ }
94
+ } catch (e) {
95
+ // A copy failed part-way (incomplete tarball / disk error). The LIVE hooks dir was never touched —
96
+ // clean up the partial staging dir and rethrow so the caller refuses to wire.
97
+ rmSync(stagingDir, { recursive: true, force: true });
98
+ throw e;
99
+ }
100
+
101
+ // 2) All copies succeeded — swap staging into place. rename can't overwrite a non-empty dir, so move
102
+ // the old dir aside first, move the new one in, then delete the old. Both renames are same-device
103
+ // (siblings under ~/.skalpel), so each is atomic. On ANY failure — including the old dir being
104
+ // locked (Windows) so it can't even be moved aside — clean up the staging dir, and if we already
105
+ // moved the old dir aside, roll it back so the wired path is never left without a hooks dir.
106
+ rmSync(backupDir, { recursive: true, force: true }); // stale bak from a crashed prior same-pid run
107
+ const hadLive = existsSync(HOOKS_DIR);
108
+ try {
109
+ if (hadLive) renameSync(HOOKS_DIR, backupDir);
110
+ renameSync(stagingDir, HOOKS_DIR);
111
+ } catch (e) {
112
+ if (hadLive && !existsSync(HOOKS_DIR) && existsSync(backupDir)) {
113
+ try {
114
+ renameSync(backupDir, HOOKS_DIR); // restore the previous working install
115
+ } catch {
116
+ /* rollback best-effort; the original is still recoverable at backupDir */
117
+ }
118
+ }
119
+ rmSync(stagingDir, { recursive: true, force: true });
120
+ throw e;
121
+ }
122
+ // Swap done. Dropping the old dir is best-effort: a failure here only leaves a stale hooks.bak-<pid>,
123
+ // never a broken wired path.
124
+ rmSync(backupDir, { recursive: true, force: true });
69
125
  }
70
126
  if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
71
127
  try {
72
128
  stageHookRuntime();
73
129
  } catch (e) {
74
- // Staging is all-or-nothing. A missing source file means the tarball is incomplete (a file this
75
- // installer copies was left out of package.json "files"), and copyFileSync throws part-way
76
- // through so the *later* copies never run and the hooks are staged without the auth.mjs /
77
- // metrics.mjs they import. Wiring absolute paths at that point yields hooks that crash on every
78
- // prompt. Refuse to wire instead; a loud failure beats a silent no-op.
130
+ // Staging is atomic and all-or-nothing. A missing source file means the tarball is incomplete (a
131
+ // file this installer copies was left out of package.json "files"); any copy error aborts staging
132
+ // with the EXISTING hooks dir left fully intact (never a half-updated dir at the wired path).
133
+ // Wiring on top of a failed stage would risk hooks that crash on every prompt — so refuse to wire
134
+ // instead; a loud failure beats a silent no-op.
79
135
  console.error(`skalpel: could not stage hook runtime: ${e.message}`);
80
136
  console.error("skalpel: hooks were NOT wired.");
81
137
  process.exit(1);
@@ -222,7 +278,12 @@ function claude() {
222
278
  } else if (statuslineOnly) {
223
279
  return "preserved"; // a custom status line wins; do not rewrite unrelated Claude settings
224
280
  }
225
- writeJson(CLAUDE, d);
281
+ // Only write when we actually added, refreshed, or removed one of OUR entries (mirrors codexToml's
282
+ // `if (hadToml) write` guard). Never CREATE a settings.json — nor re-serialize/churn an existing one
283
+ // — when there was nothing to do: an `uninstall` on a machine that never had our hooks leaves the
284
+ // file exactly as it was (or absent). Install always changes something here, so this only suppresses
285
+ // the no-op write.
286
+ if (installed || refreshed || removed) writeJson(CLAUDE, d);
226
287
  if (uninstall) return removed ? "removed" : "absent";
227
288
  return installed ? "installed" : refreshed ? "refreshed" : "absent";
228
289
  }
@@ -378,8 +439,15 @@ function codexJson() {
378
439
  installed += 1;
379
440
  }
380
441
  }
442
+ if (uninstall) {
443
+ // Never CREATE ~/.codex/hooks.json during an uninstall — only rewrite it when we actually stripped
444
+ // one of our blocks from an existing file (mirrors codexToml's hadToml guard). A machine whose
445
+ // ~/.codex has no hooks.json (or none of ours) has nothing to clean up, so writing here would drop
446
+ // a stray file behind an operation that's supposed to leave the machine clean.
447
+ if (removed) writeJson(CODEX, d);
448
+ return removed ? "removed" : "absent";
449
+ }
381
450
  writeJson(CODEX, d);
382
- if (uninstall) return removed ? "removed" : "absent";
383
451
  return installed ? "installed" : refreshed ? "refreshed" : "absent";
384
452
  }
385
453
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.39",
3
+ "version": "4.0.41",
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/postinstall.mjs CHANGED
@@ -48,12 +48,37 @@ function retireLegacyDaemon() {
48
48
 
49
49
  function stageStatusline() {
50
50
  try {
51
- // --statusline-only: stage the read-only indicator, do NOT wire prompt/session hooks yet.
52
- spawnSync(process.execPath, [join(DIR, "install.mjs"), "--statusline-only"], {
51
+ // --statusline-only: stage the read-only indicator, do NOT wire prompt/session hooks yet. NOTE:
52
+ // this still runs the FULL hook-runtime staging inside install.mjs so if staging genuinely fails
53
+ // (an incomplete package, a mid-copy I/O error) install.mjs exits non-zero. We MUST read that
54
+ // status: an unattended `npm install/update -g` upgrade that hits a staging failure would otherwise
55
+ // leave a broken/stale runtime with postinstall exiting 0 — a failure masquerading as success.
56
+ const r = spawnSync(process.execPath, [join(DIR, "install.mjs"), "--statusline-only"], {
53
57
  stdio: "inherit",
54
58
  });
55
- } catch {
56
- /* fail-open */
59
+ if (r.error || r.status !== 0) {
60
+ // Surface it LOUDLY (stderr), but keep the fail-open posture: package.json wraps postinstall in
61
+ // `|| true`, so npm install itself is never bricked — the user still gets the package and can
62
+ // repair with `skalpel setup`. A first install where hooks aren't wired yet does NOT reach here:
63
+ // install.mjs only exits non-zero on a real staging failure, not on a clean statusline-only stage.
64
+ const detail = r.error
65
+ ? r.error.message
66
+ : r.signal
67
+ ? `killed by ${r.signal}`
68
+ : `exit code ${r.status}`;
69
+ process.stderr.write(
70
+ `\n skalpel: WARNING — hook staging failed (${detail}). Hooks/status line may be missing or ` +
71
+ `stale. Run \x1b[1mskalpel setup\x1b[22m to repair.\n\n`,
72
+ );
73
+ process.exitCode = 1; // record the failure; package.json's `|| true` keeps npm install green
74
+ return false;
75
+ }
76
+ return true;
77
+ } catch (e) {
78
+ // spawn itself failed (couldn't launch node at all) — still surface, still fail-open.
79
+ process.stderr.write(`\n skalpel: WARNING — could not run the installer (${e.message}).\n\n`);
80
+ process.exitCode = 1;
81
+ return false;
57
82
  }
58
83
  }
59
84
 
@@ -75,11 +100,13 @@ function fixSudoOwnership() {
75
100
  }
76
101
 
77
102
  retireLegacyDaemon();
78
- stageStatusline();
103
+ const stagingOk = stageStatusline();
79
104
  fixSudoOwnership();
80
105
 
81
106
  // A quiet nudge — the real onboarding (Google sign-in → build graph → insights) runs on `skalpel`.
82
- if (process.stdout.isTTY && !process.env.CI) {
107
+ // Suppressed when staging failed: the warning above is the honest signal, and a cheerful
108
+ // "installed" line under it would read as success.
109
+ if (stagingOk && process.stdout.isTTY && !process.env.CI) {
83
110
  process.stdout.write(
84
111
  "\n skalpel installed. Run \x1b[1mskalpel\x1b[22m to sign in and build your graph.\n\n",
85
112
  );
@@ -139,10 +139,17 @@ function steerLabel() {
139
139
  // line (fresh + honest — the statusline never invents it, it only renders what the re-run recorded) or
140
140
  // null. Auto-clears when stale (past the TTL) so an old catch can't wallpaper the bar. GATED entirely on
141
141
  // SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the baseline.
142
- function revealNote() {
142
+ function revealNote(sessionId) {
143
143
  try {
144
144
  const r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
145
145
  if (!r || typeof r.line !== "string" || !r.line.trim()) return null;
146
+ // SESSION SCOPING: verify-reveal.json is a single shared path across every concurrent Claude Code
147
+ // session (multiple tabs/worktrees). Without this check, this pane would show — and misattribute to
148
+ // "your agent" — a catch that actually came from a DIFFERENT session's transcript. Only skip when
149
+ // BOTH sides know their session and they disagree; an unknown session on either side stays permissive.
150
+ // NOT deleted here (unlike the stale-TTL branch below) — it may still be the right reveal for whichever
151
+ // session actually owns it.
152
+ if (r.session && sessionId && r.session !== sessionId) return null;
146
153
  const age = Date.now() - Date.parse(r.ts);
147
154
  if (!Number.isFinite(age) || age > REVEAL_TTL_MS) {
148
155
  try {
@@ -361,7 +368,7 @@ function main() {
361
368
  // SKALPEL_VERIFY_REVEAL — when off (the default) this branch never runs and the bar is byte-identical to
362
369
  // the always-on baseline below.
363
370
  if (revealEnabled(process.env)) {
364
- const reveal = revealNote();
371
+ const reveal = revealNote(payload.session_id);
365
372
  if (reveal) {
366
373
  process.stdout.write(`${BOLD}${reveal}${RESET}`);
367
374
  return;
package/verify-shadow.mjs CHANGED
@@ -877,9 +877,23 @@ function writeReveal(row) {
877
877
  }
878
878
  }
879
879
 
880
- // clearReveal() — retract a prior catch (the agent actually fixed it: a later proof re-run PASSED).
881
- function clearReveal() {
880
+ // clearReveal(session) — retract a prior catch (the agent actually fixed it: a later proof re-run
881
+ // PASSED). SESSION-SCOPED: verify-reveal.json is a single shared path under ~/.skalpel, read/written by
882
+ // every concurrent Claude Code session (multiple tabs/worktrees is a normal prosumer workflow). Without
883
+ // this guard, session B's PASSING proof would silently wipe out session A's still-unresolved GENUINE_FAIL
884
+ // reveal — the user in session A would never see the catch they hadn't gotten to yet, and session B gets
885
+ // credit for "fixing" something it never touched. Only refuse to clear when BOTH sides know their session
886
+ // and they disagree; an unknown session on either side stays permissive (matches prior single-session
887
+ // behavior, and a manual/CLI-driven re-run with no session context still self-heals as before).
888
+ function clearReveal(session) {
882
889
  try {
890
+ let existing = null;
891
+ try {
892
+ existing = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
893
+ } catch {
894
+ /* no reveal file, or unreadable — nothing to protect; fall through to the (idempotent) remove */
895
+ }
896
+ if (existing && existing.session && session && existing.session !== session) return; // not ours
883
897
  rmSync(REVEAL_PATH, { force: true });
884
898
  } catch {
885
899
  /* stale reveal self-expires via the statusline's TTL anyway */
@@ -946,7 +960,7 @@ function maybeSurfaceReveal(row) {
946
960
  session: row.session || null,
947
961
  });
948
962
  } else if (isPass) {
949
- clearReveal(); // resolved — don't wallpaper a catch the agent already fixed
963
+ clearReveal(row.session || null); // resolved — don't wallpaper a catch the agent already fixed
950
964
  // The green VERDICT stamp is #612's TEST-pass path only: its line ("re-ran `<proof>`, exit 0") is a
951
965
  // real test re-run receipt. A ship SHIP_OK (read-only probe came back positive) has no re-run and no
952
966
  // proof_command, so it must NOT borrow that wording — it only clears any prior catch (as #611 did).