skalpel 4.0.12 → 4.0.14

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/auth.mjs CHANGED
@@ -15,7 +15,11 @@ const COGNITO_TOKEN_URL =
15
15
  const COGNITO_CLIENT_ID = process.env.SKALPEL_COGNITO_CLIENT_ID || "2gfil041fv9u58jemc7l5u2pht";
16
16
  const GRAPH_API = process.env.SKALPEL_API || "https://graph.skalpel.ai";
17
17
 
18
- function cliAuthPath() {
18
+ // Exported so `skalpel logout` / `skalpel uninstall --purge` remove the session from the SAME
19
+ // platform path this module reads it from (macOS Application Support, XDG on Linux, APPDATA on
20
+ // Windows) — never a divergent guess. This is only a path resolver: it touches no token-exchange
21
+ // or verification logic.
22
+ export function cliAuthPath() {
19
23
  if (process.env.SKALPEL_CONFIG_DIR) return join(process.env.SKALPEL_CONFIG_DIR, "auth.json");
20
24
  if (process.platform === "win32") {
21
25
  const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
package/bootstrap.mjs CHANGED
@@ -368,7 +368,9 @@ function recordProfile(profile, sessions) {
368
368
  if (headline) {
369
369
  recordInsight({
370
370
  kind: "profile",
371
- display: `learned ${headline.hours ?? 0}h across ${headline.sessions ?? sessions} sessions; ${headline.rework_pct ?? 0}% was rework`,
371
+ // RED LINE 1: hours/rework_pct are the server's LLM-derived ESTIMATES, not a stopwatch and not
372
+ // locally reproducible — label them as estimates and point to the `skalpel autopsy` receipt.
373
+ display: `estimated ~${headline.hours ?? 0}h active with AI across ${headline.sessions ?? sessions} sessions; est. ~${headline.rework_pct ?? 0}% rework (run 'skalpel autopsy' for the exact receipt)`,
372
374
  });
373
375
  } else {
374
376
  recordInsight({ kind: "profile", display: `standing profile built from ${sessions} sessions` });
package/install.mjs CHANGED
@@ -2,12 +2,25 @@
2
2
  // install.mjs — wire the hosted skalpel behavior hook into Claude Code AND Codex. Pure Node,
3
3
  // idempotent, and non-clobbering:
4
4
  // preserves any existing hooks/settings, refreshes ours in place. Run: `node install.mjs` (or --uninstall).
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
5
+ import {
6
+ readFileSync,
7
+ writeFileSync,
8
+ mkdirSync,
9
+ existsSync,
10
+ copyFileSync,
11
+ rmSync,
12
+ readdirSync,
13
+ } from "node:fs";
6
14
  import { homedir } from "node:os";
7
15
  import { join, dirname } from "node:path";
8
16
  import { fileURLToPath } from "node:url";
17
+ import { cliAuthPath } from "./auth.mjs";
9
18
 
10
19
  const uninstall = process.argv.includes("--uninstall");
20
+ // `--purge` (only meaningful with --uninstall): additionally delete the saved session + ALL local
21
+ // skalpel data so an uninstalled machine has nothing left — important because skalpel uploads
22
+ // transcripts and a user must be able to verify no residue remains.
23
+ const purge = process.argv.includes("--purge");
11
24
  // npm postinstall uses this mode to make the local, read-only status indicator visible without
12
25
  // opting the user into prompt/session hooks. A successful `skalpel login` runs the full installer;
13
26
  // `skalpel setup` remains an explicit repair path.
@@ -141,7 +154,11 @@ function isOurs(c, marker) {
141
154
  ];
142
155
  return markers.some((candidate) => {
143
156
  const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144
- return new RegExp(`(^|[/\\\\\\s])${escaped}(\\.mjs|\\.js|\\.exe)?($|\\s)`).test(c);
157
+ // Boundaries must accept a QUOTE too: the installer writes the Windows-safe quoted form
158
+ // `node "<path>/skalpel-hook.mjs"`, so the marker is bounded by `/` before and `"` after. The
159
+ // old class only allowed path-sep/whitespace/end, so it NEVER matched a quoted command — uninstall
160
+ // left the hooks wired and re-install appended duplicate groups. Allow `"` and `'` on both sides.
161
+ return new RegExp(`(^|[/\\\\"'\\s])${escaped}(\\.mjs|\\.js|\\.exe)?($|["'\\s])`).test(c);
145
162
  });
146
163
  }
147
164
 
@@ -380,6 +397,75 @@ function claudeMd() {
380
397
  return had ? "refreshed" : "installed";
381
398
  }
382
399
 
400
+ // After the hooks are UNWIRED (settings.json / CLAUDE.md / Codex — handled above), remove the local
401
+ // data skalpel staged and accumulated so an uninstall leaves the machine clean. Plain uninstall keeps
402
+ // the saved auth (so `--purge` is the deliberate "forget me entirely" step); --purge removes auth +
403
+ // the whole ~/.skalpel tree. Returns a short human summary of what was cleaned.
404
+ function cleanupLocalData() {
405
+ const SKALPEL_DIR = join(homedir(), ".skalpel");
406
+ const cleaned = [];
407
+ const rm = (target, label) => {
408
+ // Never delete a directory that contains the currently-running installer (defensive — install.mjs
409
+ // is never staged into ~/.skalpel, but guard anyway so a self-hosted layout can't self-destruct).
410
+ if (
411
+ PKG_DIR === target ||
412
+ PKG_DIR.startsWith(target + "/") ||
413
+ PKG_DIR.startsWith(target + "\\")
414
+ ) {
415
+ return;
416
+ }
417
+ if (!existsSync(target)) return;
418
+ try {
419
+ rmSync(target, { recursive: true, force: true });
420
+ cleaned.push(label);
421
+ } catch {
422
+ /* best-effort — a locked/again-absent path never fails the uninstall */
423
+ }
424
+ };
425
+
426
+ if (purge) {
427
+ // Forget-me-entirely: the saved session at the PLATFORM path (macOS Application Support / XDG /
428
+ // APPDATA — resolved by auth.mjs so it matches exactly where login wrote it) + the whole tree.
429
+ rm(cliAuthPath(), "auth (session token)");
430
+ rm(SKALPEL_DIR, "~/.skalpel (all local data)");
431
+ return cleaned;
432
+ }
433
+
434
+ // Plain uninstall: remove staged hooks + the raw transcript snapshots + all local state/logs, but
435
+ // KEEP the saved auth (that's what `--purge` is for). List every file skalpel writes under ~/.skalpel
436
+ // so nothing is silently left behind; auth.json (legacy local location) is deliberately preserved.
437
+ rm(join(SKALPEL_DIR, "hooks"), "staged hooks");
438
+ rm(join(SKALPEL_DIR, "ingest-outbox"), "raw transcript snapshots (ingest-outbox)");
439
+ for (const f of [
440
+ "session.json",
441
+ "steer.json",
442
+ "traj.json",
443
+ "pending.json",
444
+ "prefs.json",
445
+ "insights.ndjson",
446
+ "insights-ready.json",
447
+ "metrics.ndjson",
448
+ "bootstrap.json",
449
+ "bootstrap.lock",
450
+ "client.json",
451
+ "doctor.log",
452
+ "hook.log",
453
+ ]) {
454
+ rm(join(SKALPEL_DIR, f), f);
455
+ }
456
+ if (cleaned.length)
457
+ cleaned.push("(auth kept — run `skalpel uninstall --purge` to remove it too)");
458
+ // Tidy: if nothing but an empty shell remains, drop the directory. Preserve it if auth.json is still
459
+ // there (legacy local session) so the kept-auth promise holds.
460
+ try {
461
+ if (existsSync(SKALPEL_DIR) && readdirSync(SKALPEL_DIR).length === 0)
462
+ rmSync(SKALPEL_DIR, { force: true, recursive: true });
463
+ } catch {
464
+ /* leave the dir if we can't inspect it */
465
+ }
466
+ return cleaned;
467
+ }
468
+
383
469
  const claudeResult = claude();
384
470
  if (statuslineOnly) {
385
471
  console.log(`skalpel statusline (${STATUSLINE_CMD}) → claude: ${claudeResult}`);
@@ -392,9 +478,20 @@ if (statuslineOnly) {
392
478
  console.log(
393
479
  `skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claudeResult} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
394
480
  );
395
- console.log(
396
- uninstall
397
- ? "uninstalled."
398
- : `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
399
- );
481
+ if (uninstall) {
482
+ const cleaned = cleanupLocalData();
483
+ if (cleaned.length) console.log(`removed: ${cleaned.join(", ")}.`);
484
+ if (purge) {
485
+ // Honest note: there is no server-side data-deletion endpoint yet. Don't imply we called one.
486
+ console.log(
487
+ "note: local data removed. skalpel has no self-serve server-side deletion endpoint yet — " +
488
+ "to delete the transcripts already uploaded to the graph, email privacy@skalpel.ai.",
489
+ );
490
+ }
491
+ console.log("uninstalled.");
492
+ } else {
493
+ console.log(
494
+ `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
495
+ );
496
+ }
400
497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.12",
3
+ "version": "4.0.14",
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/reveal.mjs CHANGED
@@ -31,13 +31,25 @@ export function graphReadyNote() {
31
31
  const trap = m.report && (m.report.discriminative || [])[0] && m.report.discriminative[0].seq;
32
32
  const n = m.n_sessions || h.sessions || 0;
33
33
 
34
+ // PROVENANCE / HONESTY (RED LINE 1): both `hours` and `rework_pct` come from the hosted graph
35
+ // server's profile, which is built from an LLM judge's per-intent classification (intent_class +
36
+ // resolution) over the ingested transcripts. `hours` = summed, banded human read/dwell time on
37
+ // non-`other` intents (NOT raw session wall-clock); `rework_pct` = share of that time whose
38
+ // LLM-judged resolution is a "waste" class. Neither is a stopwatch measurement and neither
39
+ // re-derives locally the way `skalpel autopsy` does — so they must be surfaced as ESTIMATES, never
40
+ // as measured fact. The reproducible receipt is `skalpel autopsy`.
34
41
  const facts = [];
35
42
  if (h.hours)
36
- facts.push(`they've spent about ${h.hours} hours working with AI across these sessions`);
43
+ facts.push(
44
+ `skalpel ESTIMATES they've spent about ${h.hours} hours of active time working with AI across ` +
45
+ `these sessions (inferred from their history — an estimate, not an exact clock)`,
46
+ );
37
47
  if (h.rework_pct)
38
48
  facts.push(
39
- `roughly ${h.rework_pct}% of that time went into REWORK — going back to redo, re-explain, or ` +
40
- `re-fix things they'd already worked on (that's the time skalpel is built to give back)`,
49
+ `and it estimates roughly ${h.rework_pct}% of that went into REWORK-type patterns — going back ` +
50
+ `to redo, re-explain, or re-fix things they'd already worked on (this % is skalpel's ESTIMATE ` +
51
+ `from reading their sessions, NOT a stopwatch figure — it's the kind of loop skalpel is built ` +
52
+ `to help with)`,
41
53
  );
42
54
  if (trap)
43
55
  facts.push(
@@ -54,8 +66,12 @@ export function graphReadyNote() {
54
66
  `"🔬 skalpel · your graph's ready — here's what I noticed about how you work:" — then, in 2-3 ` +
55
67
  `friendly plain-English sentences, tell them what it found` +
56
68
  (facts.length ? `: ${facts.join("; ")}` : "") +
57
- `. Close by reassuring them this is exactly the loop skalpel now quietly watches for and steers them ` +
58
- `out of, live, while they code they don't have to track it themselves. Then continue with whatever ` +
59
- `they actually asked. Keep the whole thing short and human.]`
69
+ `. HONESTY RULE: present every number as skalpel's ESTIMATE inferred from their history hedge ` +
70
+ `naturally ("about", "roughly", "skalpel estimates") and NEVER state a figure as an exact ` +
71
+ `measurement. If they want the exact, locally-verified receipt that re-derives from the coding ` +
72
+ `logs already on their own machine, tell them they can run \`skalpel autopsy\` anytime. Close by ` +
73
+ `reassuring them this is exactly the loop skalpel now quietly watches for and steers them out of, ` +
74
+ `live, while they code — they don't have to track it themselves. Then continue with whatever they ` +
75
+ `actually asked. Keep the whole thing short and human.]`
60
76
  );
61
77
  }
package/skalpel-setup.mjs CHANGED
@@ -20,7 +20,7 @@ import { join, dirname, delimiter, sep as pathSep } from "node:path";
20
20
  import { createInterface, emitKeypressEvents } from "node:readline";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { spawn, spawnSync } from "node:child_process";
23
- import { identity as authIdentity } from "./auth.mjs";
23
+ import { identity as authIdentity, cliAuthPath } from "./auth.mjs";
24
24
 
25
25
  // CLI (the `skalpel-prosumer` bin): `skalpel-prosumer setup [--api https://graph.skalpel.ai]
26
26
  // [--user <id>]`, plus `skalpel-prosumer uninstall` and `skalpel-prosumer login`. A bare invocation
@@ -53,6 +53,8 @@ const isMain =
53
53
  // pulls in userTurnCount) can never exit the test runner.
54
54
  const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "autopsy", "__build"]);
55
55
  const VALUE_FLAGS = new Set(["--api", "--user"]);
56
+ // Boolean flags that take no value. `--purge` on `uninstall` also removes auth + all local data.
57
+ const BOOL_FLAGS = new Set(["--purge"]);
56
58
  const USAGE = `skalpel-prosumer — connect your coding history to your behavioral graph
57
59
 
58
60
  usage:
@@ -60,11 +62,12 @@ usage:
60
62
  skalpel-prosumer login (re-)run the Google sign-in
61
63
  skalpel-prosumer logout clear the saved session
62
64
  skalpel-prosumer autopsy local, read-only receipt of your verified patterns
63
- skalpel-prosumer uninstall remove the hooks from Claude Code + Codex
65
+ skalpel-prosumer uninstall [--purge] remove the hooks + local data from this machine
64
66
 
65
67
  options:
66
68
  --api <url> point at a different graph server
67
69
  --user <id> dev-only identity override
70
+ --purge on uninstall, also delete the saved session + ALL local skalpel data (~/.skalpel)
68
71
  -v, --version print the version and exit
69
72
  -h, --help print this help and exit`;
70
73
 
@@ -91,6 +94,7 @@ function validateArgv() {
91
94
  console.log(USAGE);
92
95
  process.exit(0);
93
96
  }
97
+ if (BOOL_FLAGS.has(a)) continue; // valueless flag (e.g. `uninstall --purge`)
94
98
  if (a.startsWith("-")) {
95
99
  console.error(`skalpel: unknown option \`${a}\`\n\n${USAGE}`);
96
100
  process.exit(2);
@@ -698,12 +702,15 @@ async function revealInsights(report) {
698
702
  console.log("");
699
703
  await line(` ${B}Here's what I learned about how you work:${X}\n`, 480);
700
704
 
705
+ // RED LINE 1: h.hours / h.rework_pct are the hosted graph server's ESTIMATES, built from an LLM
706
+ // judge's per-intent classification — not a stopwatch, and not locally reproducible. Surface them
707
+ // as estimates and point to `skalpel autopsy` (the zero-network receipt that re-derives from disk).
701
708
  const rw = h.rework_pct || 0;
702
709
  await line(
703
- ` ${R}▸${X} You've spent ${B}${h.hours}h${X} with AI across ${h.sessions} sessions — and about ${B}${R}${rw}%${X} of it went to ${B}rework${X}:`,
710
+ ` ${R}▸${X} skalpel estimates about ${B}${h.hours}h${X} of active time with AI across ${h.sessions} sessions — and that about ${B}${R}${rw}%${X} of it went to ${B}rework${X}:`,
704
711
  );
705
712
  await line(
706
- ` ${D}circling back, re-explaining, re-fixing the same thing. That's the time skalpel goes after.${X}\n`,
713
+ ` ${D}circling back, re-explaining, re-fixing the same thing the loop skalpel goes after.${X}\n`,
707
714
  520,
708
715
  );
709
716
 
@@ -738,6 +745,13 @@ async function revealInsights(report) {
738
745
  }
739
746
 
740
747
  console.log("");
748
+ // RED LINE 1: everything above (hours, rework %, archetype waste-hours, time-sink %, escape %) is
749
+ // the hosted graph's ESTIMATE from an LLM read of your sessions — not a stopwatch. State that once,
750
+ // plainly, and point to the zero-network receipt that re-derives from the logs on THIS machine.
751
+ await line(
752
+ ` ${D}These are skalpel's estimates from your recent sessions — run ${X}${B}skalpel autopsy${X}${D} for the exact receipt that re-derives from the logs on this machine.${X}`,
753
+ 260,
754
+ );
741
755
  await line(
742
756
  ` ${G}skalpel now watches for these every session and steers you off them in real time.${X}`,
743
757
  260,
@@ -792,7 +806,8 @@ function preflight() {
792
806
 
793
807
  async function main() {
794
808
  if (sub === "uninstall") {
795
- spawnSync("node", [join(__dir, "install.mjs"), "--uninstall"], { stdio: "inherit" });
809
+ const passthrough = argv.includes("--purge") ? ["--uninstall", "--purge"] : ["--uninstall"];
810
+ spawnSync("node", [join(__dir, "install.mjs"), ...passthrough], { stdio: "inherit" });
796
811
  return;
797
812
  }
798
813
  // `skalpel-prosumer login` — just run the sign-in flow (also invoked automatically by setup).
@@ -806,7 +821,30 @@ async function main() {
806
821
  if (sub === "logout") {
807
822
  try {
808
823
  const { rmSync } = await import("node:fs");
809
- rmSync(join(homedir(), ".skalpel", "auth.json"), { force: true });
824
+ // The real session written by `skalpel login` / read by auth.mjs lives at the PLATFORM path
825
+ // (macOS Application Support, XDG on Linux, APPDATA on Windows) — NOT ~/.skalpel/auth.json.
826
+ // Delete it via auth.mjs's own resolver so we hit exactly where the session lives; the old
827
+ // code only unlinked the legacy ~/.skalpel/auth.json, so the next run silently re-authed.
828
+ const platformAuth = cliAuthPath();
829
+ rmSync(platformAuth, { force: true });
830
+ rmSync(join(homedir(), ".skalpel", "auth.json"), { force: true }); // legacy location, if present
831
+ // Clear the live per-session steering state + first-run bootstrap state so a subsequent login
832
+ // (possibly a different account) starts clean. Historical telemetry (metrics/insights ndjson)
833
+ // and config/prefs are left alone — `skalpel uninstall` owns those.
834
+ const skalpelDir = join(homedir(), ".skalpel");
835
+ for (const f of [
836
+ "session.json",
837
+ "steer.json",
838
+ "traj.json",
839
+ "pending.json",
840
+ "bootstrap.json",
841
+ "bootstrap.lock",
842
+ "insights-ready.json",
843
+ ]) {
844
+ rmSync(join(skalpelDir, f), { force: true });
845
+ }
846
+ // No persistent local proxy/daemon ships with this client (the legacy Go `skalpeld` is retired
847
+ // by postinstall.mjs). Clearing bootstrap.lock above releases any stuck first-run build lock.
810
848
  console.log(` ${G}✓${X} Signed out. Next run will prompt for Google sign-in.\n`);
811
849
  } catch (e) {
812
850
  console.log(` Could not sign out: ${String(e.message || e)}\n`);