skalpel 4.0.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.9",
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({
@@ -1,65 +1,137 @@
1
1
  #!/usr/bin/env node
2
- // skalpel statusline — reads the running "time saved" counter (bumped by skalpel-hook.mjs every time
3
- // a real recurring pattern fires) and prints one line for Claude Code's status bar. Brave's
4
- // ads-blocked-counter, but for AI-coding thrash. The zero state stays visible so a fresh install
5
- // advertises that skalpel is present before the first confirmed save lands.
6
- import { readFileSync } from "node:fs";
2
+ // skalpel statusline — COUNT-OR-NOTHING. Prints ONLY facts that reproduce from local state, never an
3
+ // estimate dressed up as a measurement. There is no "~Xh saved" and no "+min this turn": those were a
4
+ // SERVER estimate of expected-waste (never a measured clock), so they are gone. What can honestly show:
5
+ // the live re-ask/interrupt COUNT for THIS session, read straight from the transcript Claude Code
6
+ // hands us on stdin — the exact material `skalpel autopsy` re-derives from disk, a count of things
7
+ // that verifiably happened, or
8
+ // • the current steer's plain-English label (what skalpel is doing now) — a description, not a number.
9
+ // A clean session prints NOTHING. Fail-open: any error → print nothing, exit 0. Never blocks the bar.
10
+ import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
7
11
  import { homedir } from "node:os";
8
12
  import { join } from "node:path";
9
13
 
10
- const STATS_PATH = join(homedir(), ".skalpel", "stats.json");
11
- const STEER_PATH = join(homedir(), ".skalpel", "steer.json");
12
-
13
- // ANSI — the status bar renders color. Emphasize the WIN (time saved) in green; show the live steer
14
- // in accent; recede the secondary numbers so the eye lands on saved-time + what skalpel's doing now.
15
- const GREEN = "\x1b[32m";
16
14
  const CYAN = "\x1b[36m";
17
15
  const BOLD = "\x1b[1m";
18
16
  const DIM = "\x1b[2m";
19
17
  const RESET = "\x1b[0m";
20
18
 
21
- function main() {
22
- let s = {};
19
+ // Claude Code pipes a JSON status payload on stdin (transcript_path, session_id, …). Read it ONLY when
20
+ // stdin is actually piped — never block on a TTY (manual invocation) where reading fd 0 would hang.
21
+ function readPayload() {
23
22
  try {
24
- const parsed = JSON.parse(readFileSync(STATS_PATH, "utf8"));
25
- if (parsed && typeof parsed === "object") s = parsed;
23
+ if (process.stdin.isTTY) return {};
24
+ const raw = readFileSync(0, "utf8");
25
+ if (!raw.trim()) return {};
26
+ const parsed = JSON.parse(raw);
27
+ return parsed && typeof parsed === "object" ? parsed : {};
26
28
  } catch {
27
- // Missing/corrupt first-run state is an honest zero, not a reason to hide the status line.
29
+ return {};
28
30
  }
29
- const finiteOrZero = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
30
- const sess = finiteOrZero(s.session_hours).toFixed(1);
31
- const total = finiteOrZero(s.total_hours).toFixed(1);
32
- const steers = finiteOrZero(s.total_events);
31
+ }
33
32
 
34
- // The LIVE steer (written by skalpel-hook.mjs each turn) — the deterministic awareness channel: the
35
- // user SEES skalpel steering right now, even when the model doesn't surface a 🔬 line in the answer.
36
- let steerLabel = null;
37
- let creditMins = null;
33
+ // Read only the TAIL of a (possibly large) transcript — the status bar re-renders often, so we never
34
+ // slurp a multi-MB file. Drop the partial first line so JSON.parse only ever sees whole records.
35
+ function tailLines(path, maxBytes) {
36
+ let fd;
38
37
  try {
39
- const st = JSON.parse(readFileSync(STEER_PATH, "utf8"));
40
- steerLabel = st?.label || null;
41
- creditMins = st?.credit || null;
38
+ fd = openSync(path, "r");
39
+ const size = fstatSync(fd).size;
40
+ const start = Math.max(0, size - maxBytes);
41
+ const len = size - start;
42
+ if (len <= 0) return [];
43
+ const buf = Buffer.allocUnsafe(len);
44
+ readSync(fd, buf, 0, len, start);
45
+ let text = buf.toString("utf8");
46
+ if (start > 0) {
47
+ const nl = text.indexOf("\n");
48
+ if (nl >= 0) text = text.slice(nl + 1);
49
+ }
50
+ return text.split("\n").filter(Boolean);
42
51
  } catch {
43
- /* no steer/credit this turn — just show the counter */
52
+ return [];
53
+ } finally {
54
+ if (fd !== undefined) {
55
+ try {
56
+ closeSync(fd);
57
+ } catch {
58
+ /* already closed */
59
+ }
60
+ }
44
61
  }
45
- // The felt "just delivered" tick — the +Xmin the n+1 check credited THIS turn. Hero position, bold
46
- // green, right after the wordmark: you SEE it land, then it folds into the growing total.
47
- // the per-turn tick sits to the RIGHT of the running total: total = the accumulation, tick = what
48
- // THIS turn just added. Both green (the win).
49
- const tick = creditMins ? ` · ${BOLD}${GREEN}+${creditMins} min saved this turn${RESET}` : "";
50
- // Animate the steering label so it reads as LIVE — Claude Code re-invokes the statusline periodically,
51
- // so a time-derived frame cycles a small spinner. (A single-line status bar can't move text vertically;
52
- // this is the alive-feeling equivalent.) Only spins while a steer is on the board.
53
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
54
- const frame = FRAMES[Math.floor(Date.now() / 120) % FRAMES.length];
55
- const steering = steerLabel ? ` · ${CYAN}${frame} steering: ${steerLabel}${RESET}` : "";
62
+ }
56
63
 
57
- // LIVE session tally how many loops skalpel has caught THIS session (peak-end: the "end" number
58
- // stays visible so the user sees it before they quit, even though the recap itself lands next start).
59
- void sess;
60
- process.stdout.write(
61
- `${BOLD}skalpel${RESET}${steering} · ${GREEN}~${total}h saved${RESET}${tick} · ${DIM}${steers} steers${RESET}`,
62
- );
64
+ // The re-ask signal, counted from disk: over the last few user turns of THIS session's transcript, how
65
+ // many times did the user INTERRUPT ("[Request interrupted by user]") or fire a short CORRECTION ("no",
66
+ // "still wrong", "that's not it"). Mirrors the per-turn hook's own cascade detector so the number the
67
+ // bar shows is the same one the hook acts on — and it is a COUNT of real events, not a time estimate.
68
+ const CORRECTION =
69
+ /^(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;
70
+ function reaskCount(transcriptPath) {
71
+ if (!transcriptPath) return 0;
72
+ const lines = tailLines(transcriptPath, 256 * 1024);
73
+ if (!lines.length) return 0;
74
+ let signals = 0;
75
+ let userTurns = 0;
76
+ for (let i = lines.length - 1; i >= 0 && userTurns < 6; i--) {
77
+ let e;
78
+ try {
79
+ e = JSON.parse(lines[i]);
80
+ } catch {
81
+ continue;
82
+ }
83
+ const raw = e.content ?? e.message?.content;
84
+ const text =
85
+ typeof raw === "string"
86
+ ? raw
87
+ : Array.isArray(raw)
88
+ ? raw
89
+ .filter((b) => b?.type === "text")
90
+ .map((b) => b.text)
91
+ .join(" ")
92
+ : "";
93
+ if (/\[Request interrupted by user\]/i.test(text)) signals++;
94
+ if (e.type === "user" || e.role === "user") {
95
+ userTurns++;
96
+ if (CORRECTION.test(text.trim())) signals++;
97
+ }
98
+ }
99
+ return signals;
100
+ }
101
+
102
+ // The current steer's label — the plain-English "what skalpel is doing" for the latest turn (null on a
103
+ // silent turn, since the hook overwrites it to null when nothing fired). A DESCRIPTION, not a number. We
104
+ // read ONLY `label`; the old `credit` minutes field is not read here and is no longer written at all.
105
+ function steerLabel() {
106
+ try {
107
+ const st = JSON.parse(readFileSync(join(homedir(), ".skalpel", "steer.json"), "utf8"));
108
+ const label = st && typeof st === "object" ? st.label : null;
109
+ return typeof label === "string" && label.trim() ? label.trim() : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function main() {
116
+ const payload = readPayload();
117
+
118
+ // Acute + reproducible: the user has re-asked / interrupted repeatedly this stretch. Surface the count
119
+ // and point at the receipts — every figure in `skalpel autopsy` re-derives from these same local logs.
120
+ const n = reaskCount(payload.transcript_path);
121
+ if (n >= 2) {
122
+ process.stdout.write(
123
+ `${BOLD}skalpel${RESET} · ${CYAN}${n} re-asks/interrupts this stretch${RESET} · ${DIM}run: skalpel autopsy${RESET}`,
124
+ );
125
+ return;
126
+ }
127
+
128
+ // Otherwise, if a steer is live, name what it's doing — a description, no number. Else: print NOTHING.
129
+ const label = steerLabel();
130
+ if (label) {
131
+ process.stdout.write(`${BOLD}skalpel${RESET} · ${CYAN}${label}${RESET}`);
132
+ return;
133
+ }
134
+ // Clean session → COUNT-OR-NOTHING resolves to nothing. Emit no bytes.
63
135
  }
64
136
 
65
137
  main();