skalpel 4.0.8 → 4.0.10

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.
@@ -6,6 +6,7 @@
6
6
  // process kill, or accepted-response loss cannot lose the completed session or duplicate paid work.
7
7
  import { createHash } from "node:crypto";
8
8
  import {
9
+ appendFileSync,
9
10
  closeSync,
10
11
  existsSync,
11
12
  mkdirSync,
@@ -23,7 +24,6 @@ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "nod
23
24
  import { gzipSync } from "node:zlib";
24
25
 
25
26
  import { identity } from "./auth.mjs";
26
- import { recordInsight } from "./insights.mjs";
27
27
 
28
28
  const OUTBOX_DIR = "ingest-outbox";
29
29
  const LEDGER_FILE = "delivered.json";
@@ -159,11 +159,31 @@ function saveItem(home, item) {
159
159
  writeAtomic(itemPath(home, item.key), item);
160
160
  }
161
161
 
162
- function notifyOnce(home, item, state, display, insightFn) {
162
+ // INFRA STATUS is NOT product output. Background context-graph maintenance (retry-queued, waiting on
163
+ // provider quota, snapshot-missing, membership-required) is an operational condition, not an insight the
164
+ // user asked for — so it goes to a DOCTOR channel (~/.skalpel/doctor.log + stderr), never to the
165
+ // user-facing insights feed. `skalpel doctor`/stderr is where an operator looks; the insights feed stays
166
+ // exclusively product signal. Fail-open: a doctor write must never break the fail-safe SessionEnd hook.
167
+ function doctorLog(home, message) {
168
+ const line = `${new Date().toISOString()} [ingest] ${message}`;
169
+ try {
170
+ process.stderr.write(line + "\n");
171
+ } catch {
172
+ /* stderr may be closed under the async hook — never throw */
173
+ }
174
+ try {
175
+ mkdirSync(skalpelDir(home), { recursive: true });
176
+ appendFileSync(join(skalpelDir(home), "doctor.log"), line + "\n");
177
+ } catch {
178
+ /* the doctor trail is best-effort; never block ingest on it */
179
+ }
180
+ }
181
+
182
+ function notifyOnce(home, item, state, message, doctorFn) {
163
183
  if (item.notified_state === state) return;
164
184
  item.notified_state = state;
165
185
  saveItem(home, item);
166
- insightFn({ kind: "profile", display });
186
+ doctorFn(home, message);
167
187
  }
168
188
 
169
189
  function listItems(home, preferredKey) {
@@ -200,7 +220,7 @@ function retryDelayMs(generation) {
200
220
  return Math.min(60 * 60 * 1000, 5 * 60 * 1000 * 2 ** Math.max(0, generation - 1));
201
221
  }
202
222
 
203
- function scheduleTerminalRetry(home, item, status, now, insightFn) {
223
+ function scheduleTerminalRetry(home, item, status, now, doctorFn) {
204
224
  const nextGeneration = (item.generation || 0) + 1;
205
225
  item.last_job_id = item.job_id || null;
206
226
  item.job_id = null;
@@ -218,7 +238,7 @@ function scheduleTerminalRetry(home, item, status, now, insightFn) {
218
238
  item,
219
239
  `retry-${nextGeneration}`,
220
240
  "context graph update hit an error; a safe retry is queued",
221
- insightFn,
241
+ doctorFn,
222
242
  );
223
243
  return;
224
244
  }
@@ -230,12 +250,12 @@ function scheduleTerminalRetry(home, item, status, now, insightFn) {
230
250
  item,
231
251
  "fallback-required",
232
252
  "context graph update needs a retry; reopen Skalpel to run the history catch-up",
233
- insightFn,
253
+ doctorFn,
234
254
  );
235
255
  }
236
256
 
237
257
  async function pollAcceptedJob(home, item, auth, options, deadline) {
238
- const { api, fetchFn, insightFn, now, pollIntervalMs, requestTimeoutMs, sleepFn } = options;
258
+ const { api, fetchFn, doctorFn, now, pollIntervalMs, requestTimeoutMs, sleepFn } = options;
239
259
  const statusURL = `${api}/ingest/status?job_id=${encodeURIComponent(item.job_id)}`;
240
260
  for (;;) {
241
261
  let response;
@@ -258,7 +278,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
258
278
  return "pending";
259
279
  }
260
280
  if (response.status === 404) {
261
- scheduleTerminalRetry(home, item, { state: "error", error: "job not found" }, now, insightFn);
281
+ scheduleTerminalRetry(home, item, { state: "error", error: "job not found" }, now, doctorFn);
262
282
  return "retry";
263
283
  }
264
284
  if (!response.ok) {
@@ -295,7 +315,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
295
315
  // carries retry_at, and resumes the SAME job. Pin both contracts; never infer one from the
296
316
  // state string alone.
297
317
  if (item.terminal && item.requires_replay) {
298
- scheduleTerminalRetry(home, item, status, now, insightFn);
318
+ scheduleTerminalRetry(home, item, status, now, doctorFn);
299
319
  return "retry";
300
320
  }
301
321
  notifyOnce(
@@ -303,16 +323,16 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
303
323
  item,
304
324
  "waiting-quota",
305
325
  "context graph update is safely queued while provider capacity recovers",
306
- insightFn,
326
+ doctorFn,
307
327
  );
308
328
  return "pending";
309
329
  }
310
330
  if (item.terminal && item.requires_replay) {
311
- scheduleTerminalRetry(home, item, status, now, insightFn);
331
+ scheduleTerminalRetry(home, item, status, now, doctorFn);
312
332
  return "retry";
313
333
  }
314
334
  if (item.state === "error" || item.state === "failed") {
315
- scheduleTerminalRetry(home, item, status, now, insightFn);
335
+ scheduleTerminalRetry(home, item, status, now, doctorFn);
316
336
  return "retry";
317
337
  }
318
338
  if (now() + pollIntervalMs >= deadline) return "pending";
@@ -321,7 +341,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
321
341
  }
322
342
 
323
343
  async function submitItem(home, item, auth, options, deadline) {
324
- const { api, fetchFn, insightFn, now, requestTimeoutMs } = options;
344
+ const { api, fetchFn, doctorFn, now, requestTimeoutMs } = options;
325
345
  if (!item.job_id) {
326
346
  let transcript;
327
347
  try {
@@ -334,7 +354,7 @@ async function submitItem(home, item, auth, options, deadline) {
334
354
  item,
335
355
  "snapshot-missing",
336
356
  "context graph update needs a retry; reopen Skalpel to run the history catch-up",
337
- insightFn,
357
+ doctorFn,
338
358
  );
339
359
  return "pending";
340
360
  }
@@ -378,7 +398,7 @@ async function submitItem(home, item, auth, options, deadline) {
378
398
  item,
379
399
  "membership-required",
380
400
  "context graph update is queued until membership access is available",
381
- insightFn,
401
+ doctorFn,
382
402
  );
383
403
  return "pending";
384
404
  }
@@ -469,7 +489,7 @@ export function enqueueCompletedSession(payload, options = {}) {
469
489
  export async function drainIncrementalOutbox(options = {}) {
470
490
  const home = options.home || homedir();
471
491
  const now = options.now || Date.now;
472
- const insightFn = options.insightFn || recordInsight;
492
+ const doctorFn = options.doctorFn || doctorLog;
473
493
  ensureOutbox(home);
474
494
  const lockPath = join(outboxDir(home), DRAIN_LOCK);
475
495
  if (!acquireLock(lockPath, now)) return { locked: true, pending: listItems(home).length };
@@ -490,7 +510,7 @@ export async function drainIncrementalOutbox(options = {}) {
490
510
  const common = {
491
511
  api: options.api || clientAPI(home),
492
512
  fetchFn: options.fetchFn || fetch,
493
- insightFn,
513
+ doctorFn,
494
514
  now,
495
515
  pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
496
516
  requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
package/install.mjs CHANGED
@@ -49,6 +49,7 @@ function stageHookRuntime() {
49
49
  copyFileSync(join(PKG_DIR, "auth.mjs"), join(HOOKS_DIR, "auth.mjs")); // hooks import ./auth.mjs
50
50
  copyFileSync(join(PKG_DIR, "metrics.mjs"), join(HOOKS_DIR, "metrics.mjs")); // hooks import ./metrics.mjs
51
51
  copyFileSync(join(PKG_DIR, "stats.mjs"), join(HOOKS_DIR, "stats.mjs")); // `node ~/.skalpel/hooks/stats.mjs`
52
+ copyFileSync(join(PKG_DIR, "autopsy.mjs"), join(HOOKS_DIR, "autopsy.mjs")); // `node ~/.skalpel/hooks/autopsy.mjs` — local read-only receipt
52
53
  }
53
54
  if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
54
55
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.8",
3
+ "version": "4.0.10",
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": {
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Wire it (Claude ~/.claude/settings.json):
8
8
  // {"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node …/skalpel-hook-session.mjs"}]}]}}
9
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
9
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { identity } from "./auth.mjs";
@@ -14,43 +14,44 @@ import { record } from "./metrics.mjs";
14
14
  import { recordInsight } from "./insights.mjs";
15
15
  import { graphReadyNote } from "./reveal.mjs";
16
16
 
17
- // The statusline counter is PER-SESSION: it shows the estimated rework skalpel flagged during THIS
18
- // session, not a forever-accumulating lifetime total (which reads as a bragging trophy and is
19
- // unprovable). SessionStart zeroes it so each new session starts the count fresh. Fail-open — a
20
- // stats reset must never block the profile injection.
21
- // RECAP — the felt wins, surfaced at a break point (peak-end rule). Two break points:
17
+ // RECAP surfaced at a break point (peak-end rule). It reports a COUNT of the steers skalpel actually
18
+ // showed (a reproducible number from session.json's steers array) and the recurring work-type — NEVER a
19
+ // "saved ~Xmin" figure (that was a server estimate rendered as measured time; it is gone). Two break
20
+ // points:
22
21
  // • "compact" → mid-session, right AFTER compaction (a natural chapter break, user present). The
23
22
  // recap is CUMULATIVE ("so far this session") and the tally is NOT reset — work goes on.
24
23
  // • else → a fresh session start; recap the PREVIOUS session's tally (re-engagement), then reset.
25
24
  // PreCompact/SessionEnd stdout are silent in Claude Code; the post-compact SessionStart CAN inject, so
26
25
  // that's where the compaction recap rides. Read BEFORE resetStats wipes it.
27
26
  const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
28
- // Returns { note, mins } — the injected directive AND the minutes it cites, so the local insight
29
- // record (below) can reuse the exact same number instead of re-deriving it.
27
+ // Returns { note, n } — the injected directive AND the steer count it cites, so the local insight record
28
+ // (below) can reuse the exact same number instead of re-deriving it.
30
29
  function buildRecap(source) {
31
30
  try {
32
31
  const s = JSON.parse(readFileSync(SESSION_PATH, "utf8"));
33
- const mins = Math.round(s.mins || 0);
34
32
  const steers = Array.isArray(s.steers) ? s.steers : [];
35
- // Recap only when there's real saved time to report — the honest, felt number. No "caught" count.
36
- if (mins < 1) return { note: null, mins: 0 };
33
+ const n = steers.length;
34
+ // Recap only when skalpel actually showed steers this session — a real, reproducible count.
35
+ if (n < 1) return { note: null, n: 0 };
37
36
  const byWt = {};
38
37
  for (const st of steers) if (st.wt) byWt[st.wt] = (byWt[st.wt] || 0) + 1;
39
38
  const top = Object.entries(byWt).sort((a, b) => b[1] - a[1])[0];
40
- const trapTail = top ? ` — ${top[0]} kept coming up` : "";
39
+ const trapTail = top ? ` — ${top[0]} kept coming up (${top[1]}×)` : "";
41
40
  const compact = source === "compact";
42
41
  const when = compact ? "in the stretch before this compaction" : "last session";
42
+ const count = `${n} recurring-pattern moment${n === 1 ? "" : "s"}`;
43
43
  const line = compact
44
- ? `“🔬 skalpel · in that last stretch I saved you ~${mins}min${trapTail} — fresh context now.”`
45
- : `“🔬 skalpel · last session I saved you ~${mins}min${trapTail}.”`;
44
+ ? `“🔬 skalpel · flagged ${count} in that last stretch${trapTail} — fresh context now. Run \`skalpel autopsy\` for the receipts.”`
45
+ : `“🔬 skalpel · last session I flagged ${count}${trapTail}. Run \`skalpel autopsy\` for the receipts.”`;
46
46
  const note =
47
- `[skalpel — RECAP (${when}): saved ~${mins}min` +
47
+ `[skalpel — RECAP (${when}): skalpel flagged ${count}` +
48
48
  (top ? `; the recurring trap was ${top[0]} (${top[1]}×)` : "") +
49
- `. Open THIS message with ONE natural line so they see it landed — ${line} ` +
50
- "Then continue normally. Skip it only if it doesn't fit.]";
51
- return { note, mins };
49
+ `. Open THIS message with ONE natural line so they see it — ${line} ` +
50
+ "Then continue normally. Do NOT invent any time-saved number — the only honest figures live in " +
51
+ "`skalpel autopsy`. Skip the recap if it doesn't fit.]";
52
+ return { note, n };
52
53
  } catch {
53
- return { note: null, mins: 0 }; // no prior session / fresh install
54
+ return { note: null, n: 0 }; // no prior session / fresh install
54
55
  }
55
56
  }
56
57
 
@@ -58,34 +59,14 @@ function resetStats() {
58
59
  try {
59
60
  const dir = join(homedir(), ".skalpel");
60
61
  mkdirSync(dir, { recursive: true });
61
- const p = join(dir, "stats.json");
62
- // Reset the SESSION counters to zero but PRESERVE the lifetime total across sessions so the
63
- // statusline can show both "saved this session" and "total saved". Read current first.
64
- let s = {
65
- session_hours: 0,
66
- session_cost: 0,
67
- session_events: 0,
68
- total_hours: 0,
69
- total_cost: 0,
70
- total_events: 0,
71
- };
72
- try {
73
- s = { ...s, ...JSON.parse(readFileSync(p, "utf8")) };
74
- } catch {
75
- /* fresh */
76
- }
77
- s.session_hours = 0;
78
- s.session_cost = 0;
79
- s.session_events = 0;
80
- const tmp = `${p}.tmp`;
81
- writeFileSync(tmp, JSON.stringify(s));
82
- renameSync(tmp, p);
83
- // also clear any pending n+1 prediction from the prior session — a stale prediction must never
84
- // false-credit an avoided detour across a session boundary.
62
+ // The old stats.json "time saved" accumulator is GONE — nothing writes or reads it anymore, so there
63
+ // is nothing to zero here. This just clears the per-session working state so each session starts fresh.
64
+ // Clear any pending n+1 prediction from the prior session a stale prediction must never
65
+ // false-attribute a break-out across a session boundary.
85
66
  writeFileSync(join(dir, "pending.json"), "{}");
86
67
  writeFileSync(join(dir, "steer.json"), "{}"); // no live steer until the first prompt this session
87
68
  writeFileSync(join(dir, "traj.json"), "[]"); // fresh work-type trajectory each session
88
- writeFileSync(join(dir, "session.json"), JSON.stringify({ steers: [], caught: 0, mins: 0 })); // recap tally
69
+ writeFileSync(join(dir, "session.json"), JSON.stringify({ steers: [] })); // recap tally (steer count)
89
70
  // clear the SESSION-scoped mute (a shush lasts one session), but PRESERVE persistent prefs
90
71
  // (per-trap "fewer"/"more", usefulness scores) — those are the learned personalization.
91
72
  try {
@@ -131,7 +112,7 @@ async function main() {
131
112
  }
132
113
  // source distinguishes a real new session (startup/resume/clear) from a mid-session compaction.
133
114
  const source = payload.source || payload.hookSpecificOutput?.source || "startup";
134
- const { note: recapNote, mins: recapMins } = buildRecap(source); // read the tally BEFORE resetStats wipes it
115
+ const { note: recapNote, n: recapN } = buildRecap(source); // read the tally BEFORE resetStats wipes it
135
116
  // Compaction CONTINUES the session — keep the counter + tally running; only a real start zeroes them.
136
117
  if (source !== "compact") resetStats();
137
118
  // identity from the Google sign-in (refreshed if near expiry); the token authenticates the read so
@@ -180,8 +161,7 @@ async function main() {
180
161
  if (recapNote)
181
162
  recordInsight({
182
163
  kind: "recap",
183
- display: `last session skalpel saved you ~${recapMins}min`,
184
- mins: recapMins,
164
+ display: `last session skalpel flagged ${recapN} recurring-pattern moment${recapN === 1 ? "" : "s"}`,
185
165
  });
186
166
  if (context)
187
167
  recordInsight({
package/skalpel-hook.mjs CHANGED
@@ -108,46 +108,10 @@ function detectCascade(payload) {
108
108
  }
109
109
  }
110
110
 
111
- // The running "rework flagged" counter one JSON file, incremented every time a real recurring
112
- // pattern fires (server already gated it on recurs>=3, so this only counts real trends). The number
113
- // is the ESTIMATED downstream waste on the path we flagged rework that USUALLY happens from here,
114
- // not time already saved (we can't prove the nudge changed anything). Per-session: SessionStart
115
- // zeroes it (see skalpel-hook-session.mjs). Read by the statusline, Brave-ad-block-counter style.
116
- const STATS_PATH = join(homedir(), ".skalpel", "stats.json");
117
- const EMPTY_STATS = {
118
- session_hours: 0,
119
- session_cost: 0,
120
- session_events: 0,
121
- total_hours: 0,
122
- total_cost: 0,
123
- total_events: 0,
124
- };
125
- function bumpStats(hours, cost) {
126
- try {
127
- let s = { ...EMPTY_STATS };
128
- try {
129
- s = { ...s, ...JSON.parse(readFileSync(STATS_PATH, "utf8")) };
130
- } catch {
131
- /* missing or corrupt -> start fresh, never block on it */
132
- }
133
- const r2 = (n) => Math.round(n * 100) / 100;
134
- const h = hours || 0;
135
- const c = cost || 0;
136
- // increment BOTH: session (resets each session) and total (lifetime, never resets)
137
- s.session_hours = r2(s.session_hours + h);
138
- s.session_cost = r2(s.session_cost + c);
139
- s.session_events += 1;
140
- s.total_hours = r2(s.total_hours + h);
141
- s.total_cost = r2(s.total_cost + c);
142
- s.total_events += 1;
143
- mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
144
- const tmp = `${STATS_PATH}.tmp`;
145
- writeFileSync(tmp, JSON.stringify(s));
146
- renameSync(tmp, STATS_PATH); // atomic swap — a concurrent read never sees a half-written file
147
- } catch {
148
- /* stats are a bonus, never block emitting the injection */
149
- }
150
- }
111
+ // NOTE: the old stats.json "time saved" accumulator (session_hours/total_hours/…/total_events) is GONE.
112
+ // It summed a SERVER estimate of expected-waste-ahead and the statusline rendered it as "~Xh saved" — an
113
+ // estimate shown as a measurement. Nothing writes or reads stats.json anymore; the only honest local
114
+ // receipts are what `skalpel autopsy` re-derives from the raw transcripts on disk.
151
115
 
152
116
  // n+1 CONFIRMATION store — the honest, Brave-style counter. At turn N we PREDICT a rework path but
153
117
  // credit NOTHING yet; we stash the prediction here. At turn N+1 we check whether you actually broke
@@ -182,13 +146,12 @@ function readSteer() {
182
146
  return {};
183
147
  }
184
148
  }
185
- function writeSteer(label, creditMins, type, wt, id) {
186
- // label = server's plain-English "what skalpel's doing" (or null on a silent turn). credit = the
187
- // +Xmin the n+1 check just credited THIS turn (or null) -> the statusline flashes "+Xmin ✓", the
188
- // felt "just delivered" tick, on the exact turn a break-out lands. type/wt = the raw steer identity
189
- // (e.g. "TELL"/"refine") so the NEXT turn's feedback phrase ("fewer like this") can target it.
190
- // id = the steer's join key (minted once per steer turn, also on that steer's insight row) so the
191
- // NEXT turn's adherence record can reference exactly this steer; null on a silent turn.
149
+ function writeSteer(label, type, wt, id) {
150
+ // label = server's plain-English "what skalpel's doing" (or null on a silent turn) a DESCRIPTION the
151
+ // statusline can show, never a number. The old `credit` field (a +Xmin estimate rendered as a measured
152
+ // tick) is REMOVED. type/wt = the raw steer identity (e.g. "TELL"/"refine") so the NEXT turn's feedback
153
+ // phrase ("fewer like this") can target it. id = the steer's join key (minted once per steer turn, also
154
+ // on that steer's insight row) so the NEXT turn's adherence record can reference exactly this steer.
192
155
  try {
193
156
  mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
194
157
  const tmp = `${STEER_PATH}.tmp`;
@@ -196,7 +159,6 @@ function writeSteer(label, creditMins, type, wt, id) {
196
159
  tmp,
197
160
  JSON.stringify({
198
161
  label: label || null,
199
- credit: creditMins || null,
200
162
  type: type || null,
201
163
  wt: wt || null,
202
164
  id: id || null,
@@ -228,8 +190,9 @@ function readPrefs() {
228
190
 
229
191
  // ADHERENCE — the implicit workhorse (Grammarly's accept/reject, not thumbs). Per steer TYPE we track,
230
192
  // from BEHAVIOR alone: shown (fired), acted (you moved off the work-type it pushed you off next turn),
231
- // paid (acting also produced an n+1 time-saved credit). This is revealed preference — which of your
232
- // patterns you actually act on — and it accretes across sessions into your personal steer model.
193
+ // paid (acting also coincided with an n+1 BREAK-OUT — expected-waste-ahead dropped). All categorical
194
+ // revealed preference — which of your patterns you actually act on — never a number shown to the user;
195
+ // it accretes across sessions into your personal steer model.
233
196
  function recordAdherence(prefs, prev, thisWt, paid) {
234
197
  if (!prev || !prev.type || !thisWt) return null;
235
198
  prefs.stats = prefs.stats || {};
@@ -298,22 +261,21 @@ function parseControl(q) {
298
261
  return null;
299
262
  }
300
263
 
301
- // SESSION EVENT LOG — the raw material for the end-of-session recap (peak-end rule). One row per turn
302
- // where a steer fired or a break-out was credited. SessionStart resets it; SessionEnd renders it.
264
+ // SESSION EVENT LOG — the raw material for the end-of-session recap (peak-end rule). One row per turn a
265
+ // steer actually fired: a COUNT of real steers shown, plus their work-types (for "refine kept coming up").
266
+ // No minutes, no "caught" tally — those were estimate-derived. SessionStart resets it; SessionEnd renders
267
+ // it as a count. (`ev.caughtMins` is intentionally ignored; the estimate path that fed it is gone.)
303
268
  const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
304
269
  function logSessionEvent(ev) {
305
270
  try {
306
- let s = { steers: [], caught: 0, mins: 0 };
271
+ let s = { steers: [] };
307
272
  try {
308
- s = { ...s, ...JSON.parse(readFileSync(SESSION_PATH, "utf8")) };
273
+ const prev = JSON.parse(readFileSync(SESSION_PATH, "utf8"));
274
+ if (Array.isArray(prev?.steers)) s.steers = prev.steers;
309
275
  } catch {
310
276
  /* fresh session */
311
277
  }
312
278
  if (ev.type) s.steers.push({ type: ev.type, wt: ev.wt || null, ts: Date.now() });
313
- if (ev.caughtMins) {
314
- s.caught += 1;
315
- s.mins += ev.caughtMins;
316
- }
317
279
  mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
318
280
  const tmp = `${SESSION_PATH}.tmp`;
319
281
  writeFileSync(tmp, JSON.stringify(s));
@@ -494,12 +456,10 @@ async function main() {
494
456
  const timer = setTimeout(() => ctrl.abort(), remaining);
495
457
  const t0 = Date.now();
496
458
  let injection = null;
497
- let winNote = null; // positive reinforcement: set when THIS turn confirms you broke out of a rut
498
- let winMins = null; // the ~minutes the win note cites — reused verbatim by the insight record
499
- let steerLabel = null; // "what skalpel's doing" for the statusline
459
+ let steerLabel = null; // "what skalpel's doing" for the statusline a DESCRIPTION, never a number
500
460
  let steerType = null; // raw top-steer type (TELL/SINK/…) — for the feedback loop + session log
501
461
  let steerWt = null; // the work-type the steer is about (refine/debug/…)
502
- let creditMins = null; // the +Xmin the n+1 check credited THIS turn -> statusline tick
462
+ let brokeOut = false; // behavioral-only: did expected-waste-ahead drop after last turn's steer (adherence)
503
463
  let outcome = "fetch_error";
504
464
  try {
505
465
  const r = await fetch(`${API}/retrieve`, {
@@ -546,59 +506,35 @@ async function main() {
546
506
  "\n\n[skalpel — the user asked for MORE on this trap; expand: name the shape, the count, and the way out in 2-3 sentences.]";
547
507
  }
548
508
 
549
- // --- n+1 CONFIRMATION: credit the DROP in expected waste-ahead (diff-from-worst) ---
550
- // Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste ahead
551
- // from where you were. THIS turn's waste-ahead is V(N+1) = j.state_hours. If it DROPPED, you
552
- // moved toward resolved -> credit the drop (the real magnitude of time saved, now that the DAG
553
- // state values discriminate stuck-vs-resolved; before they were flat so the drop was always 0).
509
+ // --- n+1 CONFIRMATION: did the user BREAK OUT of last turn's flagged pattern? (behavioral only) ---
510
+ // Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste-ahead from
511
+ // where you were. THIS turn's waste-ahead is V(N+1) = j.state_hours. A meaningful DROP means you
512
+ // moved toward "resolved" a BEHAVIORAL signal that feeds adherence (did acting on the steer pay
513
+ // off). It is NEVER converted into a user-facing "time saved" number: the DAG state values are a
514
+ // server estimate, not a measured clock, so surfacing them as minutes would be fabrication.
554
515
  const vNow = typeof j?.state_hours === "number" ? j.state_hours : null;
555
- const vcNow = typeof j?.state_cost === "number" ? j.state_cost : 0;
556
516
  const pending = readPending();
557
517
  if (pending && typeof pending.v === "number" && vNow !== null) {
558
- const savedH = Math.max(0, pending.v - vNow); // waste-ahead you shed since last turn
559
- const savedC = Math.max(0, (pending.vc || 0) - vcNow);
560
- // COHERENCE: a "catch" must be a REAL, visible >=1min drop. Everything downstream — the counter,
561
- // the "+Xmin this turn" tick, the "N caught" tally, the recap minutes — is driven by the SAME
562
- // number (cm), so caught can NEVER increment without the time moving by the same amount. (The old
563
- // code credited sub-minute drops and forced caughtMins to 1 via Math.max, so caught ticked up
564
- // while creditMins rounded to 0 and the visible time didn't budge — the incoherence.)
565
- const cm = Math.round(savedH * 60);
566
- if (cm >= 1) {
567
- bumpStats(savedH, savedC);
568
- creditMins = cm; // the felt "+Xmin ✓" tick — same value as the catch
569
- logSessionEvent({ caughtMins: cm }); // caught++ and recap minutes, same number
570
- dbg(` -> n+1 CREDIT: waste-ahead ${pending.v}h -> ${vNow}h, +${cm}min saved`);
571
- // REINFORCE GOOD MOVES (the carrot): on a MEANINGFUL break-out (not a trivial drop),
572
- // hand the model a brief positive-acknowledgment note. Sparse by threshold so praise stays
573
- // a signal, not noise — a coach that celebrates every micro-step becomes wallpaper too.
574
- if (savedH >= 0.08) {
575
- const mins = Math.max(1, Math.round(savedH * 60));
576
- winMins = mins;
577
- winNote =
578
- `[skalpel — the user just broke OUT of a pattern that usually costs them ~${mins}min of rework. ` +
579
- `If it feels natural (don't force it), briefly acknowledge the good move in one line — ` +
580
- `e.g. "🔬 skalpel · nice — you skipped the loop you usually get stuck in there" — then continue. ` +
581
- `Reinforce the behavior, don't lecture. Skip it if the moment doesn't fit.]`;
582
- }
583
- } else {
584
- dbg(
585
- ` -> n+1 no-credit: waste-ahead ${pending.v}h -> ${vNow}h (drop < 1min, not counted)`,
586
- );
587
- }
518
+ // >=0.08 in the estimate's units = a real move toward resolved (not estimate jitter). Boolean only.
519
+ brokeOut = pending.v - vNow >= 0.08;
520
+ dbg(
521
+ ` -> n+1 ${brokeOut ? "BREAK-OUT" : "no-breakout"}: waste-ahead ${pending.v} -> ${vNow}`,
522
+ );
588
523
  writePending({}); // this turn's confirmation is consumed
589
524
  }
590
525
  // Arm NEXT turn: when the injection fires (a flagged high-waste moment) AND we have a V for this
591
- // turn, remember it so N+1 can measure how far your waste-ahead dropped.
526
+ // turn, remember it so N+1 can tell whether you broke out.
592
527
  if (injection && vNow !== null) {
593
- writePending({ v: vNow, vc: vcNow });
528
+ writePending({ v: vNow });
594
529
  } else if (!pending || vNow !== null) {
595
530
  writePending({}); // clear only when we actually had data this turn (don't drop a stale carry)
596
531
  }
597
532
 
598
- // IMPLICIT ADHERENCE (the real Grammarly loop): now that the n+1 credit has resolved, record
599
- // whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting
600
- // pay off (a credit this turn). Accretes per steer-type; the gate above reads it next time.
601
- const paid = creditMins != null && creditMins > 0;
533
+ // IMPLICIT ADHERENCE (the real Grammarly loop): now that the break-out check has resolved, record
534
+ // whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting pay
535
+ // off (a break-out this turn). Accretes per steer-type; the gate above reads it next time. This is
536
+ // categorical revealed-preference data, not a number shown to the user.
537
+ const paid = brokeOut;
602
538
  const adh = recordAdherence(prefs, prevSteer, steerWt, paid);
603
539
  writePrefs(prefs);
604
540
  // ADHERENCE INSIGHT ROW (data, not display — no `display` field): the same judgment, exported
@@ -626,11 +562,10 @@ async function main() {
626
562
  record("prompt", outcome, Date.now() - t0);
627
563
  }
628
564
 
629
- // MUTE is absolute — a shushed session surfaces NOTHING (steer, reset, or win). The ack note (a
630
- // one-time "muted" confirmation) still goes through, since that IS the mute turn's response.
565
+ // MUTE is absolute — a shushed session surfaces NOTHING (steer or reset). The ack note (a one-time
566
+ // "muted" confirmation) still goes through, since that IS the mute turn's response.
631
567
  if (prefs.muted_session) {
632
568
  resetNote = null;
633
- winNote = null;
634
569
  injection = null;
635
570
  steerLabel = null;
636
571
  }
@@ -638,13 +573,13 @@ async function main() {
638
573
  // A live cascade reset outranks the server steer for the statusline label (it's the acute one).
639
574
  if (resetNote) steerLabel = "resetting a spiral";
640
575
 
641
- // Statusline state, written ONCE per turn: what skalpel is doing now + the +Xmin the n+1 check just
642
- // credited (the felt "just delivered" tick), plus the raw steer identity so NEXT turn's feedback
643
- // phrase can target it. All null on a silent/no-credit turn -> bar shows just the running total.
644
- // steerId = the join key minted once per STEER turn (a string), written to BOTH steer.json and the
645
- // steer's insight row below, so next turn's adherence record can reference exactly this steer.
576
+ // Statusline state, written ONCE per turn: the plain-English label of what skalpel is doing now (a
577
+ // description, never a number), plus the raw steer identity so NEXT turn's feedback phrase can target
578
+ // it. label is null on a silent turn. steerId = the join key minted once per STEER turn (a string),
579
+ // written to BOTH steer.json and the steer's insight row below, so next turn's adherence record can
580
+ // reference exactly this steer.
646
581
  const steerId = injection ? String(Date.now()) : null;
647
- writeSteer(steerLabel, creditMins, steerType, steerWt, steerId);
582
+ writeSteer(steerLabel, steerType, steerWt, steerId);
648
583
  if (injection) logSessionEvent({ type: steerType, wt: steerWt }); // feed the end-of-session recap
649
584
 
650
585
  // LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — one plain-English row per note kind that
@@ -656,15 +591,6 @@ async function main() {
656
591
  id: steerId,
657
592
  display: steerLabel || "a steer for this spot",
658
593
  label: steerLabel,
659
- mins: creditMins > 0 ? creditMins : null,
660
- harness,
661
- session_id: sessionId,
662
- });
663
- if (winNote)
664
- recordInsight({
665
- kind: "win",
666
- display: `nice — you broke out of a pattern that usually costs ~${winMins}min`,
667
- mins: winMins,
668
594
  harness,
669
595
  session_id: sessionId,
670
596
  });
@@ -685,11 +611,9 @@ async function main() {
685
611
 
686
612
  // A just-finished background build leads (one-shot: surface "your graph's ready" the instant the next
687
613
  // prompt fires, so the user doesn't have to restart a session to see it), then ackNote (feedback
688
- // confirmation), the reset interrupt (most acute), the server steer, and the win note. Any can fire alone.
614
+ // confirmation), the reset interrupt (most acute), and the server steer. Any can fire alone.
689
615
  const readyNote = graphReadyNote();
690
- const additionalContext = [readyNote, ackNote, resetNote, winNote, injection]
691
- .filter(Boolean)
692
- .join("\n\n");
616
+ const additionalContext = [readyNote, ackNote, resetNote, injection].filter(Boolean).join("\n\n");
693
617
  if (additionalContext) {
694
618
  process.stdout.write(
695
619
  JSON.stringify({
package/skalpel-setup.mjs CHANGED
@@ -51,7 +51,7 @@ const isMain =
51
51
  // you in and wired hooks into Claude Code and Codex, having been asked only to print a version.
52
52
  // Unknown input must never install. Guarded on isMain so importing this module (install.test.mjs
53
53
  // pulls in userTurnCount) can never exit the test runner.
54
- const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "__build"]);
54
+ const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "autopsy", "__build"]);
55
55
  const VALUE_FLAGS = new Set(["--api", "--user"]);
56
56
  const USAGE = `skalpel-prosumer — connect your coding history to your behavioral graph
57
57
 
@@ -59,6 +59,7 @@ usage:
59
59
  skalpel-prosumer [setup] [--api <url>] [--user <id>] sign in, learn your history, wire the hooks
60
60
  skalpel-prosumer login (re-)run the Google sign-in
61
61
  skalpel-prosumer logout clear the saved session
62
+ skalpel-prosumer autopsy local, read-only receipt of your verified patterns
62
63
  skalpel-prosumer uninstall remove the hooks from Claude Code + Codex
63
64
 
64
65
  options:
@@ -812,6 +813,13 @@ async function main() {
812
813
  }
813
814
  return;
814
815
  }
816
+ // `skalpel autopsy` — a LOCAL, READ-ONLY, ZERO-NETWORK receipt rendered from logs already on disk.
817
+ // It never signs in, never uploads, never touches the network; it re-reads ~/.claude/projects and
818
+ // renders 1-3 verified, reproduce-able behavioral relations (or an honest zero-state).
819
+ if (sub === "autopsy") {
820
+ spawnSync("node", [join(__dir, "autopsy.mjs"), ...argv.slice(1)], { stdio: "inherit" });
821
+ return;
822
+ }
815
823
  // Persist --api/--user to ~/.skalpel/client.json so the hooks can read them without shell-env
816
824
  // plumbing (env vars SKALPEL_API / SKALPEL_USER still win at hook runtime).
817
825
  if (apiFlag || userFlag) {