skalpel 4.0.40 → 4.0.42

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/_test-home.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // _test-home.mjs — TEST-ONLY isolation shim. Imported FIRST (before any client module) by every test
2
+ // that touches ~/.skalpel, so os.homedir() resolves to a throwaway temp dir and the suite can never read
3
+ // or clobber the developer's real ~/.skalpel. ESM evaluates imports depth-first in source order, so a test
4
+ // that does `import "./_test-home.mjs"` before `import "./insights.mjs"` guarantees HOME is redirected
5
+ // BEFORE insights.mjs binds its DIR = join(homedir(), ".skalpel") at module load. Not a test file itself.
6
+ import { mkdtempSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const home = mkdtempSync(join(tmpdir(), "skalpel-verify-test-"));
11
+ process.env.HOME = home;
12
+ process.env.USERPROFILE = home; // Windows
13
+ // Never let a stray dev env var arm/disarm the resolver mid-test — each test sets these explicitly.
14
+ delete process.env.SKALPEL_VERIFY_REVEAL;
15
+ delete process.env.SKALPEL_VERIFY_SHADOW;
16
+
17
+ export const TEST_HOME = home;
18
+ export const SKALPEL_DIR = join(home, ".skalpel");
package/insights.mjs CHANGED
@@ -17,6 +17,9 @@ import { join } from "node:path";
17
17
 
18
18
  const DIR = join(homedir(), ".skalpel");
19
19
  export const INSIGHTS_PATH = join(DIR, "insights.ndjson");
20
+ // The persisted client config `skalpel setup` writes (api/user/verify). Read HERE too so the ONE arming
21
+ // resolver can consult the consented `verify` key without any writer/reader plumbing.
22
+ export const CLIENT_JSON_PATH = join(DIR, "client.json");
20
23
 
21
24
  // ---- verify-reveal: the opt-in "aha catch" surface — single source for the flag + the state-file path.
22
25
  // The reveal is WRITTEN by verify-shadow.mjs (the detached proof re-runner, when the agent claimed
@@ -30,10 +33,188 @@ export const REVEAL_PATH = join(DIR, "verify-reveal.json");
30
33
  // rare red catch land like a thunderclap — without a stream of honest verified stamps a lone red reads as
31
34
  // noise, not a verdict. Path single-sourced HERE alongside REVEAL_PATH so writer + reader never drift.
32
35
  export const VERDICT_PATH = join(DIR, "verify-verdict.json");
33
- // SKALPEL_VERIFY_REVEAL is OPT-IN and OFF BY DEFAULT. Unset / "" / "0" / "off" / "false" / "no" → DARK
34
- // (byte-identical to the shadow-only behavior). Only an explicit truthy value arms the visible reveal.
36
+ // ---- THE ONE ARMING RESOLVER (single source of truth) --------------------------------------------
37
+ // The "aha catch" is OPT-IN and OFF BY DEFAULT. Arming is resolved by ONE function everyone reads
38
+ // the hook spawn gate, the statusline reveal, verify-shadow's surfacing, and reveal.mjs's agent note —
39
+ // so writer, reader, and gate can never drift. PRECEDENCE:
40
+ // 1. ENV OVERRIDE (dev/CI): SKALPEL_VERIFY_REVEAL, explicit on OR off, always wins.
41
+ // 2. CONSENTED CONFIG: ~/.skalpel/client.json "verify" key ("on"/"off"), written by `skalpel setup`.
42
+ // 3. DEFAULT OFF — an absent config + unset env is DARK, byte-identical to the shadow-only baseline.
43
+ // Only an explicit consented "on" (or a dev env override) arms it; existing installs stay dark.
44
+ const ARM_TRUTHY = /^(1|true|on|yes)$/i;
45
+ const ARM_FALSY = /^(0|false|off|no)$/i;
46
+ // parseArmToken(v) → true (armed) | false (disarmed) | null (no opinion — unset/blank/unrecognized).
47
+ export function parseArmToken(v) {
48
+ const s = String(v ?? "").trim();
49
+ if (ARM_TRUTHY.test(s)) return true;
50
+ if (ARM_FALSY.test(s)) return false;
51
+ return null;
52
+ }
53
+ // verifyConfigArmed() → the persisted consent from client.json's "verify" key, or null if unset/unreadable.
54
+ // Fail-open: any read/parse error → null (no opinion → falls through to DEFAULT OFF). Never throws.
55
+ export function verifyConfigArmed() {
56
+ try {
57
+ return parseArmToken(JSON.parse(readFileSync(CLIENT_JSON_PATH, "utf8"))?.verify);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ // revealEnabled(env) — is the USER-VISIBLE reveal armed? env override > consented config > default off.
63
+ // SKALPEL_VERIFY_SHADOW is deliberately NOT consulted here: it arms only the DARK log-only shadow (no
64
+ // reveal); the spawn gate ORs it in separately (verifySpawnArmed). Wrapped so it can never throw.
35
65
  export function revealEnabled(env) {
36
- return /^(1|true|on|yes)$/i.test(String((env || {}).SKALPEL_VERIFY_REVEAL || "").trim());
66
+ try {
67
+ const envOverride = parseArmToken((env || {}).SKALPEL_VERIFY_REVEAL);
68
+ if (envOverride !== null) return envOverride; // 1) dev/CI env override wins
69
+ const cfg = verifyConfigArmed();
70
+ if (cfg !== null) return cfg; // 2) consented config
71
+ } catch {
72
+ /* fall through to default */
73
+ }
74
+ return false; // 3) DEFAULT OFF
75
+ }
76
+ // verifySpawnArmed(env) — should the per-turn hook SPAWN the detached shadow worker at all? True when the
77
+ // reveal is armed OR the dev log-only shadow (SKALPEL_VERIFY_SHADOW=1) is set. This is the single gate the
78
+ // hook reads; it stays byte-identical to the prior inline `SHADOW==="1" || revealEnabled(env)` when the
79
+ // config is absent, so no existing install starts spawning.
80
+ export function verifySpawnArmed(env) {
81
+ try {
82
+ if (String((env || {}).SKALPEL_VERIFY_SHADOW || "").trim() === "1") return true;
83
+ return revealEnabled(env);
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ // ---- FALSE-ACCUSATION SAFEGUARDS: adjudication ledger + derived auto-darken -----------------------
90
+ // A "was this right?" flight recorder. `skalpel yes|no` appends ONE row here keyed to the most-recent
91
+ // reveal; precision = right/(right+wrong) is derived from these rows, never a stored number that can lie.
92
+ export const ADJUDICATIONS_PATH = join(DIR, "verify-adjudications.ndjson");
93
+ // Every reveal actually SURFACED to the user is logged here (append-only) so `skalpel yes|no` can key an
94
+ // adjudication to the real reveal it saw, and the field-proof report can count real fires.
95
+ export const REVEALS_LOG_PATH = join(DIR, "verify-reveals.ndjson");
96
+ // Session-scoped suppression: a `skalpel no` writes the (session, proof) here so that proof never
97
+ // re-accuses for the rest of THAT session (matched by session id → a new session is never suppressed).
98
+ export const SUPPRESS_PATH = join(DIR, "verify-suppressed.ndjson");
99
+
100
+ // Auto-darken thresholds. If adjudicated precision < 90% over >= 10 adjudications, the LIVE red reveal
101
+ // self-darkens (derived from these rows every read — NOT a stored flag) and falls back to `skalpel catches`.
102
+ export const AUTO_DARKEN_MIN_ADJ = 10;
103
+ export const AUTO_DARKEN_MIN_PRECISION = 0.9;
104
+
105
+ // proofSig(s) — normalize a proof command for stable suppression matching (collapse whitespace, bound).
106
+ export function proofSig(s) {
107
+ return String(s ?? "")
108
+ .replace(/\s+/g, " ")
109
+ .trim()
110
+ .slice(0, 200);
111
+ }
112
+
113
+ function readNdjson(path, cap = 5000) {
114
+ try {
115
+ const rows = readFileSync(path, "utf8").trim().split("\n").filter(Boolean);
116
+ const out = [];
117
+ for (const l of rows.slice(-cap)) {
118
+ try {
119
+ out.push(JSON.parse(l));
120
+ } catch {
121
+ /* skip a partial/garbage line */
122
+ }
123
+ }
124
+ return out;
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ // adjudicationStats() → { right, wrong, total, precision } from the REAL adjudication rows. precision is
131
+ // right/(right+wrong) or null when there are none. Only "yes"/"no" verdicts count. Never throws.
132
+ export function adjudicationStats() {
133
+ let right = 0;
134
+ let wrong = 0;
135
+ for (const r of readNdjson(ADJUDICATIONS_PATH)) {
136
+ if (r && r.verdict === "yes") right++;
137
+ else if (r && r.verdict === "no") wrong++;
138
+ }
139
+ const total = right + wrong;
140
+ return { right, wrong, total, precision: total > 0 ? right / total : null };
141
+ }
142
+
143
+ // autoDarkened() → is the live red reveal auto-paused? DERIVED from the adjudication rows every call (never
144
+ // a stored boolean): true only once there are >= AUTO_DARKEN_MIN_ADJ adjudications AND precision < 90%.
145
+ // Fail-open: any error → false (stay live — a broken read must never silently darken the catch).
146
+ export function autoDarkened() {
147
+ try {
148
+ const { total, precision } = adjudicationStats();
149
+ return (
150
+ total >= AUTO_DARKEN_MIN_ADJ && precision !== null && precision < AUTO_DARKEN_MIN_PRECISION
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function appendNdjson(path, row) {
158
+ try {
159
+ mkdirSync(DIR, { recursive: true });
160
+ appendFileSync(path, JSON.stringify(row) + "\n");
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ // appendAdjudication(row) — ONE flight-recorder row (verdict yes/no, keyed to a reveal). Never throws.
168
+ export function appendAdjudication(row) {
169
+ return appendNdjson(ADJUDICATIONS_PATH, { ts: new Date().toISOString(), ...row });
170
+ }
171
+
172
+ // appendRevealLog(row) — record a reveal that was actually SHOWN to the user (append-only, bounded).
173
+ export function appendRevealLog(row) {
174
+ return appendNdjson(REVEALS_LOG_PATH, { ts: new Date().toISOString(), ...row });
175
+ }
176
+
177
+ // lastReveal() — the most-recent reveal actually shown (the row `skalpel yes|no` adjudicates). Null if none.
178
+ export function lastReveal() {
179
+ const rows = readNdjson(REVEALS_LOG_PATH);
180
+ return rows.length ? rows[rows.length - 1] : null;
181
+ }
182
+
183
+ // suppressProof(session, proof) — after a `skalpel no`, stop this proof re-accusing for the rest of the
184
+ // session. Requires a real session id (a null-session suppression would over-suppress). Never throws.
185
+ export function suppressProof(session, proof) {
186
+ const sid = session ? String(session) : "";
187
+ const sig = proofSig(proof);
188
+ if (!sid || !sig) return false;
189
+ return appendNdjson(SUPPRESS_PATH, { ts: new Date().toISOString(), session: sid, proof: sig });
190
+ }
191
+
192
+ // isProofSuppressed(session, proof) — has this (session, proof) been marked "no" this session? Both sides
193
+ // must have a real session id and they must match, plus the normalized proof must match. Fail-open → false.
194
+ export function isProofSuppressed(session, proof) {
195
+ try {
196
+ const sid = session ? String(session) : "";
197
+ const sig = proofSig(proof);
198
+ if (!sid || !sig) return false;
199
+ for (const r of readNdjson(SUPPRESS_PATH)) {
200
+ if (r && String(r.session || "") === sid && proofSig(r.proof) === sig) return true;
201
+ }
202
+ return false;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // verifyArmedAt() — the real ISO timestamp `verify` was last turned ON (recorded in client.json at consent
209
+ // time), as a Date, or null. Used only for the honest "armed N days" line — a recorded fact, not a metric.
210
+ export function verifyArmedAt() {
211
+ try {
212
+ const t = JSON.parse(readFileSync(CLIENT_JSON_PATH, "utf8"))?.verify_armed_at;
213
+ const d = t ? new Date(t) : null;
214
+ return d && Number.isFinite(d.getTime()) ? d : null;
215
+ } catch {
216
+ return null;
217
+ }
37
218
  }
38
219
 
39
220
  // Unlike metrics.ndjson (write-only telemetry, uncapped), this file is READ by a TUI every render —
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
 
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // mutation-check.mjs — mutation testing for the CRITICAL LOGIC: detectCompletionClaim (incl. the negation
3
+ // guard), classifyOutcome, classifyConfirm, plus the false-accusation safeguards shipUrlPass (URL
4
+ // reachability) and hasMutatingFlag (non-idempotent re-run guard). For each mutation we flip ONE key
5
+ // predicate/branch in verify-shadow.mjs and re-run the pure-function suite (verify-core.test.mjs) against
6
+ // the mutated copy. A mutation the suite still PASSES is a SURVIVOR (a test gap). Gate: survivors must be
7
+ // 0. Run: `node mutation-check.mjs`.
8
+ import { cpSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { spawnSync } from "node:child_process";
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const TARGET = "verify-shadow.mjs";
16
+ const SUITE = "verify-core.test.mjs";
17
+
18
+ // Each mutation: a UNIQUE substring in verify-shadow.mjs and the flipped version. Every one MUST be caught
19
+ // by at least one assertion in verify-core.test.mjs.
20
+ const MUTATIONS = [
21
+ // ---- detectCompletionClaim (incl. the negation/hedge guard, Fix 2) ----
22
+ {
23
+ fn: "detectCompletionClaim",
24
+ find: 'if (!CLAIM_RE.test(s)) return { claim: false, text: "" };',
25
+ replace: 'if (CLAIM_RE.test(s)) return { claim: false, text: "" };',
26
+ },
27
+ {
28
+ fn: "detectCompletionClaim",
29
+ find: "if (!l || !CLAIM_RE.test(l)) continue;",
30
+ replace: "if (!l || CLAIM_RE.test(l)) continue;",
31
+ },
32
+ {
33
+ fn: "detectCompletionClaim",
34
+ find: "if (NEGATION_RE.test(l)) continue; // negated/hedged → not a completion claim",
35
+ replace: "if (!NEGATION_RE.test(l)) continue; // negated/hedged → not a completion claim",
36
+ },
37
+ {
38
+ fn: "detectCompletionClaim",
39
+ find: 'if (NEGATION_RE.test(s)) return { claim: false, text: "" };',
40
+ replace: 'if (!NEGATION_RE.test(s)) return { claim: false, text: "" };',
41
+ },
42
+ // ---- shipUrlPass (Fix 1: URL false-red safeguard) ----
43
+ {
44
+ fn: "shipUrlPass",
45
+ find: "if (urlResponded(ev1)) return true;",
46
+ replace: "if (urlResponded(ev1)) return false;",
47
+ },
48
+ {
49
+ fn: "shipUrlPass",
50
+ find: " if (urlResponded(ev2)) return true;\n return false;",
51
+ replace: " if (urlResponded(ev2)) return true;\n return true;",
52
+ },
53
+ // ---- hasMutatingFlag (Fix 3: non-idempotent re-run guard) ----
54
+ {
55
+ fn: "hasMutatingFlag",
56
+ find: "if (a.some((x) => MUTATING_FLAG.test(String(x)))) return true;",
57
+ replace: "if (a.some((x) => MUTATING_FLAG.test(String(x)))) return false;",
58
+ },
59
+ // ---- classifyOutcome ----
60
+ {
61
+ fn: "classifyOutcome",
62
+ find: 'if (!err) return "PASS";',
63
+ replace: 'if (err) return "PASS";',
64
+ },
65
+ {
66
+ fn: "classifyOutcome",
67
+ find: 'if (err.killed || err.signal === "SIGTERM") return "HARNESS_ERROR"; // hit the timeout',
68
+ replace: 'if (err.killed && err.signal === "SIGTERM") return "PASS"; // hit the timeout',
69
+ },
70
+ {
71
+ fn: "classifyOutcome",
72
+ find: 'if (exit === 127 || exit === 126) return "HARNESS_ERROR"; // command not found / not executable',
73
+ replace:
74
+ 'if (exit === 127 && exit === 126) return "HARNESS_ERROR"; // command not found / not executable',
75
+ },
76
+ {
77
+ fn: "classifyOutcome",
78
+ find: 'return "GENUINE_FAIL"; // a real non-zero exit from the test/build/lint itself',
79
+ replace: 'return "HARNESS_ERROR"; // a real non-zero exit from the test/build/lint itself',
80
+ },
81
+ // ---- classifyConfirm (the flake-confirm state machine) ----
82
+ {
83
+ fn: "classifyConfirm",
84
+ find: 'const needsConfirm = firstOutcome === "GENUINE_FAIL";',
85
+ replace: 'const needsConfirm = firstOutcome !== "GENUINE_FAIL";',
86
+ },
87
+ {
88
+ fn: "classifyConfirm",
89
+ find: 'const confirmedFail = needsConfirm && confirmOutcome === "GENUINE_FAIL";',
90
+ replace: 'const confirmedFail = needsConfirm || confirmOutcome === "GENUINE_FAIL";',
91
+ },
92
+ {
93
+ fn: "classifyConfirm",
94
+ find: 'const confirmedFail = needsConfirm && confirmOutcome === "GENUINE_FAIL";',
95
+ replace: 'const confirmedFail = needsConfirm && confirmOutcome !== "GENUINE_FAIL";',
96
+ },
97
+ {
98
+ fn: "classifyConfirm",
99
+ find: "const unconfirmedFail = needsConfirm && !confirmedFail;",
100
+ replace: "const unconfirmedFail = needsConfirm && confirmedFail;",
101
+ },
102
+ ];
103
+
104
+ const workRoot = mkdtempSync(join(tmpdir(), "skalpel-mutants-"));
105
+ cpSync(HERE, join(workRoot, "src"), {
106
+ recursive: true,
107
+ filter: (s) => !s.includes("node_modules"),
108
+ });
109
+ const dir = join(workRoot, "src");
110
+ const targetPath = join(dir, TARGET);
111
+ const pristine = readFileSync(targetPath, "utf8");
112
+
113
+ function runSuite() {
114
+ const r = spawnSync("node", ["--test", SUITE], { cwd: dir, encoding: "utf8" });
115
+ return r.status; // 0 = suite passed (mutant SURVIVED); non-zero = suite failed (mutant KILLED)
116
+ }
117
+
118
+ // Baseline: the pristine copy must pass, else the harness itself is broken.
119
+ if (runSuite() !== 0) {
120
+ console.error(
121
+ "BASELINE FAIL: verify-core.test.mjs does not pass on the pristine copy — aborting.",
122
+ );
123
+ process.exit(2);
124
+ }
125
+
126
+ let survivors = 0;
127
+ let missing = 0;
128
+ const results = [];
129
+ for (const m of MUTATIONS) {
130
+ const count = pristine.split(m.find).length - 1;
131
+ if (count !== 1) {
132
+ missing++;
133
+ results.push(` ✗ MUTATION TARGET NOT UNIQUE (${count}×) [${m.fn}] ${m.find.slice(0, 50)}…`);
134
+ continue;
135
+ }
136
+ writeFileSync(targetPath, pristine.replace(m.find, m.replace));
137
+ const killed = runSuite() !== 0;
138
+ writeFileSync(targetPath, pristine); // restore
139
+ if (killed) {
140
+ results.push(` ✓ killed [${m.fn}] ${m.find.slice(0, 52)}…`);
141
+ } else {
142
+ survivors++;
143
+ results.push(` ☠ SURVIVED [${m.fn}] ${m.find.slice(0, 52)}…`);
144
+ }
145
+ }
146
+
147
+ rmSync(workRoot, { recursive: true, force: true });
148
+ console.log(results.join("\n"));
149
+ console.log(
150
+ `\nmutants: ${MUTATIONS.length} killed: ${MUTATIONS.length - survivors - missing} survivors: ${survivors} target-errors: ${missing}`,
151
+ );
152
+ if (survivors === 0 && missing === 0) {
153
+ console.log(
154
+ "MUTATION GATE: survivors = 0 on the critical + false-accusation-safeguard functions ✓",
155
+ );
156
+ process.exit(0);
157
+ }
158
+ process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.40",
3
+ "version": "4.0.42",
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
  );
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, revealEnabled } from "./insights.mjs";
16
+ import { recordInsight, cleanText, verifySpawnArmed } from "./insights.mjs";
17
17
  import { graphReadyNote, verifyCatchNote } from "./reveal.mjs";
18
18
  import { tailLines } from "./transcript.mjs";
19
19
 
@@ -510,13 +510,11 @@ async function main() {
510
510
  // recent test/build/typecheck/lint command and RE-RUNs it out-of-band to log the real PASS/FAIL the
511
511
  // agent can't forge. LOG ONLY — it writes nothing to this hook's stdout and never blocks the turn (the
512
512
  // re-run, up to 60s, lives in a separate unref'd process). Wrapped so it can never affect the injection.
513
- // SKALPEL_VERIFY_REVEAL (the opt-in "aha catch" surface) ALSO arms the re-run — one flag turns on both
514
- // the detection and its user-visible reveal. When BOTH are off (the default) this branch is skipped and
515
- // the hook's stdout is byte-identical to before; the spawn is detached with stdio ignored regardless.
516
- if (
517
- (process.env.SKALPEL_VERIFY_SHADOW === "1" || revealEnabled(process.env)) &&
518
- payload.transcript_path
519
- ) {
513
+ // ARMING is resolved by the ONE shared resolver (verifySpawnArmed): the consented `verify:"on"` in
514
+ // client.json OR a dev env override (SKALPEL_VERIFY_SHADOW=1 / SKALPEL_VERIFY_REVEAL). When arming
515
+ // resolves OFF (the default for every existing install env unset + config off/absent) this branch is
516
+ // skipped and the hook's stdout is byte-identical to before; the spawn is detached with stdio ignored.
517
+ if (verifySpawnArmed(process.env) && payload.transcript_path) {
520
518
  try {
521
519
  const { spawn } = await import("node:child_process");
522
520
  const { fileURLToPath } = await import("node:url");