skalpel 4.0.51 → 4.0.53

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/analytics.mjs CHANGED
@@ -264,7 +264,7 @@ export function renderStatic(data) {
264
264
  L.push("");
265
265
  L.push(' nothing caught yet — keep coding, I\'ll log every "done" your agent claims.');
266
266
  L.push("");
267
- L.push(" Enable the shadow with SKALPEL_VERIFY_SHADOW=1; every completion claim gets its own");
267
+ L.push(" Arm the live catch with `skalpel verify on`; every completion claim gets its own");
268
268
  L.push(" proof re-run and appended to the local ledger. Nothing here is ever estimated.");
269
269
  L.push("");
270
270
  return L.join("\n");
@@ -377,7 +377,7 @@ function listLines(data, cols, rows, state) {
377
377
  );
378
378
  L.push("");
379
379
  L.push(
380
- ` ${DIM}Enable the shadow with ${RESET}${BOLD}SKALPEL_VERIFY_SHADOW=1${RESET}${DIM} — every completion claim gets its${RESET}`,
380
+ ` ${DIM}Arm the live catch with ${RESET}${BOLD}skalpel verify on${RESET}${DIM} — every completion claim gets its${RESET}`,
381
381
  );
382
382
  L.push(
383
383
  ` ${DIM}own proof re-run and appended locally. Nothing here is ever estimated.${RESET}`,
package/card.mjs CHANGED
@@ -296,9 +296,7 @@ function renderNoCatch({ id }) {
296
296
  ` ${D}and skalpel re-ran its OWN proof and it genuinely FAILED. Nothing is invented.${X}`,
297
297
  );
298
298
  L.push("");
299
- L.push(
300
- ` ${D}Turn the live shadow on with ${X}${B}SKALPEL_VERIFY_SHADOW=1${X}${D}, keep coding, and${X}`,
301
- );
299
+ L.push(` ${D}Arm the live catch with ${X}${B}skalpel verify on${X}${D}, keep coding, and${X}`);
302
300
  L.push(` ${D}run ${X}${B}skalpel card${X}${D} again the next time a claim doesn't hold up.${X}`);
303
301
  L.push("");
304
302
  return L.join("\n");
package/install.mjs CHANGED
@@ -195,9 +195,32 @@ function readJson(p) {
195
195
  return null; // signal "do not write" to callers
196
196
  }
197
197
  }
198
+ // Abbreviate a home-rooted path to `~/…` for the user-facing repair hint (so it reads `~/.claude`,
199
+ // not a long absolute path).
200
+ function homeTilde(p) {
201
+ const h = homedir();
202
+ return p === h || p.startsWith(h + "/") || p.startsWith(h + "\\") ? "~" + p.slice(h.length) : p;
203
+ }
204
+ // Write a config file DEFENSIVELY. A read-only / permission-denied target — a root-owned ~/.claude
205
+ // left behind by an earlier `sudo` install, an MDM-locked home — must NOT throw and abort the whole
206
+ // installer (that would leave ZERO working hooks). Instead surface ONE clear repair line and return
207
+ // the OS error code so the caller can report `failed:<code>` and STILL attempt every other target.
208
+ // Returns null on success. mkdir is inside the try so a read-only PARENT dir is caught too, never
209
+ // left as an uncaught throw.
210
+ function safeWrite(p, data) {
211
+ try {
212
+ mkdirSync(dirname(p), { recursive: true });
213
+ writeFileSync(p, data);
214
+ return null;
215
+ } catch (e) {
216
+ console.error(
217
+ `skalpel: could not write ${homeTilde(p)}: ${e.message} — run: sudo chown -R $(whoami) ${homeTilde(dirname(p))}`,
218
+ );
219
+ return e.code || "write-failed";
220
+ }
221
+ }
198
222
  function writeJson(p, o) {
199
- mkdirSync(dirname(p), { recursive: true });
200
- writeFileSync(p, JSON.stringify(o, null, 2));
223
+ return safeWrite(p, JSON.stringify(o, null, 2));
201
224
  }
202
225
  function isOurs(c, marker) {
203
226
  if (typeof c !== "string") return false;
@@ -284,7 +307,10 @@ function claude() {
284
307
  // — when there was nothing to do: an `uninstall` on a machine that never had our hooks leaves the
285
308
  // file exactly as it was (or absent). Install always changes something here, so this only suppresses
286
309
  // the no-op write.
287
- if (installed || refreshed || removed) writeJson(CLAUDE, d);
310
+ if (installed || refreshed || removed) {
311
+ const err = writeJson(CLAUDE, d);
312
+ if (err) return `failed:${err}`; // read-only ~/.claude — reported, never thrown
313
+ }
288
314
  if (uninstall) return removed ? "removed" : "absent";
289
315
  return installed ? "installed" : refreshed ? "refreshed" : "absent";
290
316
  }
@@ -404,14 +430,18 @@ function codexToml() {
404
430
  // Only rewrite a config.toml that already existed — never CREATE one during an uninstall. A user
405
431
  // with ~/.codex but no config.toml has nothing of ours to strip, so writing `next + "\n"` here
406
432
  // would leave a stray 1-byte file behind an operation that's supposed to clean up.
407
- if (hadToml) writeFileSync(CODEX_TOML, next + "\n");
433
+ if (hadToml) {
434
+ const err = safeWrite(CODEX_TOML, next + "\n");
435
+ if (err) return `failed:${err}`;
436
+ }
408
437
  return stripped.removed ? "removed" : "absent";
409
438
  }
410
439
  mkdirSync(CODEX_DIR, { recursive: true });
411
440
  for (const [event, marker, command] of HOOKS) {
412
441
  next += "\n" + tomlBlock(event, marker, command);
413
442
  }
414
- writeFileSync(CODEX_TOML, next + "\n");
443
+ const err = safeWrite(CODEX_TOML, next + "\n");
444
+ if (err) return `failed:${err}`;
415
445
  return stripped.removed ? "refreshed" : "installed";
416
446
  }
417
447
 
@@ -445,10 +475,14 @@ function codexJson() {
445
475
  // one of our blocks from an existing file (mirrors codexToml's hadToml guard). A machine whose
446
476
  // ~/.codex has no hooks.json (or none of ours) has nothing to clean up, so writing here would drop
447
477
  // a stray file behind an operation that's supposed to leave the machine clean.
448
- if (removed) writeJson(CODEX, d);
478
+ if (removed) {
479
+ const err = writeJson(CODEX, d);
480
+ if (err) return `failed:${err}`;
481
+ }
449
482
  return removed ? "removed" : "absent";
450
483
  }
451
- writeJson(CODEX, d);
484
+ const err = writeJson(CODEX, d);
485
+ if (err) return `failed:${err}`;
452
486
  return installed ? "installed" : refreshed ? "refreshed" : "absent";
453
487
  }
454
488
 
@@ -505,14 +539,17 @@ function claudeMd() {
505
539
  }
506
540
  if (uninstall) {
507
541
  if (!had) return "absent";
508
- writeFileSync(CLAUDE_MD, stripped.trimEnd() + "\n");
542
+ const err = safeWrite(CLAUDE_MD, stripped.trimEnd() + "\n");
543
+ if (err) return `failed:${err}`;
509
544
  return "removed";
510
545
  }
511
546
  const next = stripped.trimEnd()
512
547
  ? `${stripped.trimEnd()}\n\n${CLAUDE_MD_BLOCK}\n`
513
548
  : `${CLAUDE_MD_BLOCK}\n`;
514
- mkdirSync(dirname(CLAUDE_MD), { recursive: true });
515
- writeFileSync(CLAUDE_MD, next);
549
+ // safeWrite mkdirs dirname(CLAUDE_MD) inside its try, so a read-only ~/.claude is caught here too
550
+ // (a bare mkdirSync would throw uncaught before the write).
551
+ const err = safeWrite(CLAUDE_MD, next);
552
+ if (err) return `failed:${err}`;
516
553
  return had ? "refreshed" : "installed";
517
554
  }
518
555
 
@@ -590,17 +627,33 @@ function cleanupLocalData() {
590
627
  return cleaned;
591
628
  }
592
629
 
630
+ // A target that could not be written returns `failed:<code>` (see safeWrite). Detect it so we never
631
+ // print a clean success over a partial wire.
632
+ const isFailure = (s) => typeof s === "string" && s.startsWith("failed:");
593
633
  const claudeResult = claude();
594
634
  if (statuslineOnly) {
595
635
  console.log(`skalpel statusline (${STATUSLINE_CMD}) → claude: ${claudeResult}`);
596
- console.log(
597
- claudeResult === "preserved"
598
- ? "custom Claude status line preserved."
599
- : "installed. Behavior hooks activate automatically after `skalpel login`.",
600
- );
636
+ if (isFailure(claudeResult)) {
637
+ // Read-only ~/.claude (root-owned after a sudo install, MDM lock). Don't claim the status line
638
+ // installed; exit non-zero so postinstall's staging check surfaces its WARNING + repair path.
639
+ console.log(
640
+ "status line NOT installed — could not write ~/.claude/settings.json (see the error above). " +
641
+ "Fix the permissions shown, then run `skalpel setup`.",
642
+ );
643
+ process.exitCode = 1;
644
+ } else {
645
+ console.log(
646
+ claudeResult === "preserved"
647
+ ? "custom Claude status line preserved."
648
+ : "installed. Behavior hooks activate automatically after `skalpel login`.",
649
+ );
650
+ }
601
651
  } else {
652
+ const claudeMdResult = claudeMd();
653
+ const codexTomlResult = codexToml();
654
+ const codexJsonResult = codexJson();
602
655
  console.log(
603
- `skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claudeResult} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
656
+ `skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claudeResult} · claude.md: ${claudeMdResult} · codex(config.toml): ${codexTomlResult} · codex(hooks.json): ${codexJsonResult}`,
604
657
  );
605
658
  if (uninstall) {
606
659
  const cleaned = cleanupLocalData();
@@ -614,8 +667,22 @@ if (statuslineOnly) {
614
667
  }
615
668
  console.log("uninstalled.");
616
669
  } else {
617
- console.log(
618
- `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
670
+ // Downgrade the success summary if ANY target could not be written (read-only/permission-denied
671
+ // ~/.claude). One failed target never aborts the others but never print a clean "installed."
672
+ // over a partial wire. When nothing failed, the success line below is byte-identical to before.
673
+ const failed = [claudeResult, claudeMdResult, codexTomlResult, codexJsonResult].filter(
674
+ isFailure,
619
675
  );
676
+ if (failed.length) {
677
+ console.log(
678
+ `install incomplete — ${failed.length} target(s) could not be written (see the error line(s) above). ` +
679
+ "Fix the permissions shown, then run `skalpel setup`.",
680
+ );
681
+ process.exitCode = 1;
682
+ } else {
683
+ console.log(
684
+ `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
685
+ );
686
+ }
620
687
  }
621
688
  }
package/ledger.mjs CHANGED
@@ -246,7 +246,7 @@ export function renderLedger(ledger, { logPath = VERIFY_LOG_PATH } = {}) {
246
246
  L.push("");
247
247
  L.push(" No claim-verification history yet on this machine.");
248
248
  L.push(
249
- " Enable the shadow with SKALPEL_VERIFY_SHADOW=1, then keep coding — every turn your agent",
249
+ " Arm the live catch with `skalpel verify on`, then keep coding — every turn your agent",
250
250
  );
251
251
  L.push(' claims "done / tests pass" gets its own proof re-run and appended to the ledger.');
252
252
  L.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.51",
3
+ "version": "4.0.53",
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
@@ -6,7 +6,7 @@
6
6
  // the user runs `skalpel` (login → full install), so a bare `npm i -g` never silently starts
7
7
  // reading prompts.
8
8
  import { spawnSync } from "node:child_process";
9
- import { rmSync } from "node:fs";
9
+ import { rmSync, existsSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join, dirname } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
@@ -93,7 +93,18 @@ function fixSudoOwnership() {
93
93
  const uid = process.env.SUDO_UID;
94
94
  if (!uid) return; // genuinely root (not via sudo) — don't touch their files
95
95
  const gid = process.env.SUDO_GID || uid;
96
- spawnSync("chown", ["-R", `${uid}:${gid}`, join(homedir(), ".skalpel")], q);
96
+ // Chown back EVERYTHING the sudo install may have created/written as root in the invoking user's
97
+ // HOME — not just ~/.skalpel. The `install --statusline-only` run above writes ~/.claude
98
+ // (settings.json / CLAUDE.md) and can touch ~/.codex; if those stay root-owned, the next non-root
99
+ // `skalpel` (or Claude Code itself) dies EACCES on them. Guard each with existsSync so we only
100
+ // chown what actually exists.
101
+ for (const dir of [
102
+ join(homedir(), ".skalpel"),
103
+ join(homedir(), ".claude"),
104
+ join(homedir(), ".codex"),
105
+ ]) {
106
+ if (existsSync(dir)) spawnSync("chown", ["-R", `${uid}:${gid}`, dir], q);
107
+ }
97
108
  } catch {
98
109
  /* best-effort — worst case the user runs `sudo chown -R $(whoami) ~/.skalpel` themselves */
99
110
  }
package/skalpel-hook.mjs CHANGED
@@ -93,8 +93,18 @@ function isRealPrompt(q) {
93
93
  // interrupt markers + rapid corrections ("no", "still wrong", "that's not it"). The felt "you've cut
94
94
  // me off twice, let's reset" — the one steer you can't self-catch, and it's purely local + cheap.
95
95
  const _CORRECTION =
96
- /^(no\b|nope|that'?s not|not what|still\b|again\b|wrong\b|i said|i meant|wtf|tf\b|why (is|are|does|isn|do)|ugh|c'?mon|cmon|bruh)/i;
97
- function detectCascade(payload) {
96
+ /^(no\b|nope|that'?s not|not what|still\b|again\b|wrong\b|i said|i meant|wtf|tf\b|ugh|c'?mon|cmon|bruh)/i;
97
+ // A ^-anchored _CORRECTION match is only a REAL interrupt signal when the turn is a SHORT standalone
98
+ // reaction ("no", "nope wrong", "still broken") — NOT an ordinary debugging prompt whose prose merely
99
+ // OPENS with one of those words ("still seeing the error after the refresh because…", "why is the auth
100
+ // middleware rejecting valid tokens?"). Gating on shape (<= 4 words OR < 30 chars) is what stops the
101
+ // cascade interrupt from false-firing on healthy debugging turns. Explicit "[Request interrupted by
102
+ // user]" markers stay counted unconditionally (a hard signal) — this gate is only for prose corrections.
103
+ // NOTE: kept BYTE-IDENTICAL with skalpel-statusline.mjs's CORRECTION gate so the two never drift.
104
+ function isShortReaction(trimmed) {
105
+ return trimmed.length < 30 || trimmed.split(/\s+/).length <= 4;
106
+ }
107
+ export function detectCascade(payload) {
98
108
  try {
99
109
  if (!payload.transcript_path) return 0;
100
110
  // Bounded tail read (256 KiB) — this runs EVERY turn, BEFORE the fetch deadline is armed, so an
@@ -123,7 +133,10 @@ function detectCascade(payload) {
123
133
  if (/\[Request interrupted by user\]/i.test(text)) signals++; // you cut it off
124
134
  if (e.type === "user" || e.role === "user") {
125
135
  userTurns++;
126
- if (_CORRECTION.test(text.trim())) signals++; // you re-corrected
136
+ const trimmed = text.trim();
137
+ // A correction counts only as a SHORT standalone reaction — not a long prompt that merely opens
138
+ // with "still"/"again"/"no"/… (ordinary debugging phrasing). See isShortReaction above.
139
+ if (isShortReaction(trimmed) && _CORRECTION.test(trimmed)) signals++; // you re-corrected
127
140
  }
128
141
  }
129
142
  return signals;
@@ -376,7 +389,7 @@ export function updatePrefs(mutate) {
376
389
  // captured natively through the prompt (no button UI needed; this IS the harness wrapping). Returns
377
390
  // {action} or null. Deliberately narrow so normal prose never trips it: the word "skalpel", or a
378
391
  // short standalone reaction right after a steer fired.
379
- function parseControl(q) {
392
+ export function parseControl(q) {
380
393
  const t = (q || "").trim().toLowerCase();
381
394
  if (t.length > 60) return null; // a real coding prompt, not a reaction
382
395
  const mentionsUs = /\bskalpel\b/.test(t);
@@ -407,12 +420,19 @@ function parseControl(q) {
407
420
  pure("(?:fewer|less|stop) (?:of )?(?:these|this|that|like this|steers?)|fewer like this"))
408
421
  )
409
422
  return { action: "fewer" };
410
- if (/\b(tell me more|more detail|expand|go deeper|more on this|explain the trap)\b/.test(t))
423
+ if (
424
+ /\b(tell me more|more detail|expand|go deeper|more on this|explain the trap)\b/.test(t) &&
425
+ (mentionsUs || pure("tell me more|more detail|expand|go deeper|more on this|explain the trap"))
426
+ )
411
427
  return { action: "more" };
412
428
  if (
413
429
  /\b(that('?s| was)? (helpful|useful|good|great)|nice catch|good (call|catch)|helped|love (this|that)|👍)\b/.test(
414
430
  t,
415
- )
431
+ ) &&
432
+ (mentionsUs ||
433
+ pure(
434
+ "that(?:'?s| was)? (?:helpful|useful|good|great)|nice catch|good (?:call|catch)|helped|love (?:this|that)|👍",
435
+ ))
416
436
  )
417
437
  return { action: "up" };
418
438
  if (/\b(not (helpful|useful)|wrong|useless|👎|off base|missed)\b/.test(t) && mentionsUs)
package/skalpel-setup.mjs CHANGED
@@ -77,10 +77,10 @@ const KNOWN_SUBS = new Set([
77
77
  const VALUE_FLAGS = new Set(["--api", "--user"]);
78
78
  // Boolean flags that take no value. `--purge` on `uninstall` also removes auth + all local data.
79
79
  const BOOL_FLAGS = new Set(["--purge"]);
80
- const USAGE = `skalpel — connect your coding history to your behavioral graph
80
+ const USAGE = `skalpel — your AI says "done." skalpel re-runs its own tests and shows you the truth.
81
81
 
82
82
  usage:
83
- skalpel [setup] [--api <url>] [--user <id>] sign in, learn your history, wire the hooks
83
+ skalpel [setup] [--api <url>] [--user <id>] set up the catch: scan your history, wire the live check
84
84
  skalpel login (re-)run the Google sign-in
85
85
  skalpel logout clear the saved session
86
86
  skalpel autopsy local, read-only receipt of your verified patterns
@@ -273,7 +273,9 @@ ${R} ███████╗██╗ ██╗ █████╗ ██╗
273
273
  ╚════██║██╔═██╗ ██╔══██║██║ ██╔═══╝ ██╔══╝ ██║
274
274
  ███████║██║ ██╗██║ ██║███████╗██║ ███████╗███████╗
275
275
  ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══════╝╚══════╝${X}`);
276
- console.log(`\n ${D}your AI coding sessions, learned — and steered in real time${X}\n`);
276
+ console.log(
277
+ `\n ${D}when your AI says "done," skalpel re-runs its own tests — and tells you the truth${X}\n`,
278
+ );
277
279
  }
278
280
 
279
281
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -85,7 +85,17 @@ function readPayload() {
85
85
  // "still wrong", "that's not it"). Mirrors the per-turn hook's own cascade detector so the number the
86
86
  // bar shows is the same one the hook acts on — and it is a COUNT of real events, not a time estimate.
87
87
  const CORRECTION =
88
- /^(no\b|nope|that'?s not|not what|still\b|again\b|wrong\b|i said|i meant|wtf|tf\b|why (is|are|does|isn|do)|ugh|c'?mon|cmon|bruh)/i;
88
+ /^(no\b|nope|that'?s not|not what|still\b|again\b|wrong\b|i said|i meant|wtf|tf\b|ugh|c'?mon|cmon|bruh)/i;
89
+ // A ^-anchored CORRECTION match is only a REAL interrupt signal when the turn is a SHORT standalone
90
+ // reaction ("no", "nope wrong", "still broken") — NOT an ordinary debugging prompt whose prose merely
91
+ // OPENS with one of those words ("still seeing the error after the refresh because…", "why is the auth
92
+ // middleware rejecting valid tokens?"). Gating on shape (<= 4 words OR < 30 chars) is what stops the
93
+ // re-ask count from false-firing on healthy debugging turns. Explicit "[Request interrupted by
94
+ // user]" markers stay counted unconditionally (a hard signal) — this gate is only for prose corrections.
95
+ // NOTE: kept BYTE-IDENTICAL with skalpel-hook.mjs's _CORRECTION gate so the two never drift.
96
+ function isShortReaction(trimmed) {
97
+ return trimmed.length < 30 || trimmed.split(/\s+/).length <= 4;
98
+ }
89
99
  function reaskCount(transcriptPath) {
90
100
  if (!transcriptPath) return 0;
91
101
  const lines = tailLines(transcriptPath, 256 * 1024);
@@ -112,7 +122,10 @@ function reaskCount(transcriptPath) {
112
122
  if (/\[Request interrupted by user\]/i.test(text)) signals++;
113
123
  if (e.type === "user" || e.role === "user") {
114
124
  userTurns++;
115
- if (CORRECTION.test(text.trim())) signals++;
125
+ const trimmed = text.trim();
126
+ // A correction counts only as a SHORT standalone reaction — not a long prompt that merely opens
127
+ // with "still"/"again"/"no"/… (ordinary debugging phrasing). See isShortReaction above.
128
+ if (isShortReaction(trimmed) && CORRECTION.test(trimmed)) signals++;
116
129
  }
117
130
  }
118
131
  return signals;