skalpel 4.0.40 → 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/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.40",
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
  );