skalpel 3.4.6 → 3.4.7

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.
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env node
2
+ // skalpel thin client — the ENTIRE client. Reads the incoming prompt, asks the HOSTED graph for an
3
+ // injection, relays it into Claude Code / Codex. No engine, no Python, no binary: just an HTTP call.
4
+ // Cross-platform because Node is (Claude Code ships Node, so this Just Works on mac/linux/windows).
5
+ // FAIL-OPEN: any error -> emit nothing, exit 0, never block the user's prompt.
6
+ //
7
+ // Wire it (Claude ~/.claude/settings.json):
8
+ // {"hooks":{"UserPromptSubmit":[{"type":"command","command":"node /path/to/skalpel-hook.mjs"}]}}
9
+ // Config: SKALPEL_API (default https://graph.skalpel.ai). Identity comes from the Google sign-in
10
+ // saved by `skalpel-prosumer login` (~/.skalpel/auth.json); SKALPEL_USER overrides for dev.
11
+ import { readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { identity } from "./auth.mjs";
15
+ import { record } from "./metrics.mjs";
16
+ import { recordInsight } from "./insights.mjs";
17
+
18
+ // Config precedence: env > ~/.skalpel/client.json (written by `skalpel-prosumer setup`) > default.
19
+ function fileCfg() {
20
+ try {
21
+ return JSON.parse(readFileSync(join(homedir(), ".skalpel", "client.json"), "utf8"));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+ const CFG = fileCfg();
27
+ const API = process.env.SKALPEL_API || CFG.api || "https://graph.skalpel.ai";
28
+ // ONE shared wall-clock budget for the whole hook (auth-refresh + retrieve). Previously auth's 2s
29
+ // refresh and the fetch timeout were independent and could STACK past the harness kill. A single
30
+ // deadline caps total hook time so it always self-aborts (fail-open, silent) before the harness can
31
+ // kill it → zero visible harness timeouts by construction. Warm /retrieve is ~1.8s; a coincident
32
+ // token refresh (≤2s) can stack, so 4.5s fits both with margin (harness backstop is 8s). A genuine
33
+ // graph cold-load (~5.5s) still aborts — that's the keep-warm layer's job to prevent, not the
34
+ // client's to wait on. (Supersedes the earlier flat 6s TIMEOUT_MS cold-start fix from this branch.)
35
+ const DEADLINE_MS = 4500;
36
+
37
+ function lastUserPrompt(payload) {
38
+ if (payload.prompt) return String(payload.prompt);
39
+ try {
40
+ const lines = readFileSync(payload.transcript_path, "utf8").trim().split("\n");
41
+ for (let i = lines.length - 1; i >= 0; i--) {
42
+ const e = JSON.parse(lines[i]);
43
+ if (e.type === "user" || e.role === "user") {
44
+ const c = e.content ?? e.message?.content;
45
+ if (typeof c === "string") return c;
46
+ if (Array.isArray(c))
47
+ return c
48
+ .filter((b) => b?.type === "text")
49
+ .map((b) => b.text)
50
+ .join(" ");
51
+ }
52
+ }
53
+ } catch {
54
+ /* best-effort */
55
+ }
56
+ return "";
57
+ }
58
+
59
+ // Not a real prompt: IDE/system events (file opened, selection changed, command output) fire
60
+ // UserPromptSubmit too, but their text is a synthetic tag or noise — never embed/match on those.
61
+ function isRealPrompt(q) {
62
+ q = (q || "").trim();
63
+ if (q.length < 3) return false;
64
+ if (/^<(ide_|system-|command-)/.test(q)) return false; // IDE + harness tags
65
+ if (q.split(/\s+/).length === 1 && q.length < 8) return false; // bare filler ("the", "ok", "yes")
66
+ return true;
67
+ }
68
+
69
+ // TRAJECTORY: the pre-emptive human interrupt needs to see your RECENT turns, not just this one. The
70
+ // server matches single-turn state; this reads the last few turns of the transcript for a SPIRAL —
71
+ // interrupt markers + rapid corrections ("no", "still wrong", "that's not it"). The felt "you've cut
72
+ // me off twice, let's reset" — the one steer you can't self-catch, and it's purely local + cheap.
73
+ const _CORRECTION =
74
+ /^(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;
75
+ function detectCascade(payload) {
76
+ try {
77
+ if (!payload.transcript_path) return 0;
78
+ const lines = readFileSync(payload.transcript_path, "utf8").trim().split("\n");
79
+ let signals = 0;
80
+ let userTurns = 0;
81
+ for (let i = lines.length - 1; i >= 0 && userTurns < 6; i--) {
82
+ let e;
83
+ try {
84
+ e = JSON.parse(lines[i]);
85
+ } catch {
86
+ continue;
87
+ }
88
+ const raw = e.content ?? e.message?.content;
89
+ const text =
90
+ typeof raw === "string"
91
+ ? raw
92
+ : Array.isArray(raw)
93
+ ? raw
94
+ .filter((b) => b?.type === "text")
95
+ .map((b) => b.text)
96
+ .join(" ")
97
+ : "";
98
+ if (/\[Request interrupted by user\]/i.test(text)) signals++; // you cut it off
99
+ if (e.type === "user" || e.role === "user") {
100
+ userTurns++;
101
+ if (_CORRECTION.test(text.trim())) signals++; // you re-corrected
102
+ }
103
+ }
104
+ return signals;
105
+ } catch {
106
+ return 0;
107
+ }
108
+ }
109
+
110
+ // The running "rework flagged" counter — one JSON file, incremented every time a real recurring
111
+ // pattern fires (server already gated it on recurs>=3, so this only counts real trends). The number
112
+ // is the ESTIMATED downstream waste on the path we flagged — rework that USUALLY happens from here,
113
+ // not time already saved (we can't prove the nudge changed anything). Per-session: SessionStart
114
+ // zeroes it (see skalpel-hook-session.mjs). Read by the statusline, Brave-ad-block-counter style.
115
+ const STATS_PATH = join(homedir(), ".skalpel", "stats.json");
116
+ const EMPTY_STATS = {
117
+ session_hours: 0,
118
+ session_cost: 0,
119
+ session_events: 0,
120
+ total_hours: 0,
121
+ total_cost: 0,
122
+ total_events: 0,
123
+ };
124
+ function bumpStats(hours, cost) {
125
+ try {
126
+ let s = { ...EMPTY_STATS };
127
+ try {
128
+ s = { ...s, ...JSON.parse(readFileSync(STATS_PATH, "utf8")) };
129
+ } catch {
130
+ /* missing or corrupt -> start fresh, never block on it */
131
+ }
132
+ const r2 = (n) => Math.round(n * 100) / 100;
133
+ const h = hours || 0;
134
+ const c = cost || 0;
135
+ // increment BOTH: session (resets each session) and total (lifetime, never resets)
136
+ s.session_hours = r2(s.session_hours + h);
137
+ s.session_cost = r2(s.session_cost + c);
138
+ s.session_events += 1;
139
+ s.total_hours = r2(s.total_hours + h);
140
+ s.total_cost = r2(s.total_cost + c);
141
+ s.total_events += 1;
142
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
143
+ const tmp = `${STATS_PATH}.tmp`;
144
+ writeFileSync(tmp, JSON.stringify(s));
145
+ renameSync(tmp, STATS_PATH); // atomic swap — a concurrent read never sees a half-written file
146
+ } catch {
147
+ /* stats are a bonus, never block emitting the injection */
148
+ }
149
+ }
150
+
151
+ // n+1 CONFIRMATION store — the honest, Brave-style counter. At turn N we PREDICT a rework path but
152
+ // credit NOTHING yet; we stash the prediction here. At turn N+1 we check whether you actually broke
153
+ // OUT of that predicted path — only THEN do we increment (a real avoided-loop event, not a guess).
154
+ const PENDING_PATH = join(homedir(), ".skalpel", "pending.json");
155
+ function readPending() {
156
+ try {
157
+ return JSON.parse(readFileSync(PENDING_PATH, "utf8"));
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+ function writePending(obj) {
163
+ try {
164
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
165
+ const tmp = `${PENDING_PATH}.tmp`;
166
+ writeFileSync(tmp, JSON.stringify(obj || {}));
167
+ renameSync(tmp, PENDING_PATH);
168
+ } catch {
169
+ /* pending is best-effort; never block the hook */
170
+ }
171
+ }
172
+
173
+ // STEER label — the statusline reads this so the user SEES skalpel steering LIVE, every turn a steer
174
+ // injects, INDEPENDENT of whether the model surfaces a 🔬 line (the deterministic awareness channel).
175
+ // A one-glance 2-word label derived from the top injected steer type. Overwritten each turn.
176
+ const STEER_PATH = join(homedir(), ".skalpel", "steer.json");
177
+ function readSteer() {
178
+ try {
179
+ return JSON.parse(readFileSync(STEER_PATH, "utf8")) || {};
180
+ } catch {
181
+ return {};
182
+ }
183
+ }
184
+ function writeSteer(label, creditMins, type, wt, id) {
185
+ // label = server's plain-English "what skalpel's doing" (or null on a silent turn). credit = the
186
+ // +Xmin the n+1 check just credited THIS turn (or null) -> the statusline flashes "+Xmin ✓", the
187
+ // felt "just delivered" tick, on the exact turn a break-out lands. type/wt = the raw steer identity
188
+ // (e.g. "TELL"/"refine") so the NEXT turn's feedback phrase ("fewer like this") can target it.
189
+ // id = the steer's join key (minted once per steer turn, also on that steer's insight row) so the
190
+ // NEXT turn's adherence record can reference exactly this steer; null on a silent turn.
191
+ try {
192
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
193
+ const tmp = `${STEER_PATH}.tmp`;
194
+ writeFileSync(
195
+ tmp,
196
+ JSON.stringify({
197
+ label: label || null,
198
+ credit: creditMins || null,
199
+ type: type || null,
200
+ wt: wt || null,
201
+ id: id || null,
202
+ ts: Date.now(),
203
+ }),
204
+ );
205
+ renameSync(tmp, STEER_PATH);
206
+ } catch {
207
+ /* steer label is a bonus; never block the hook */
208
+ }
209
+ }
210
+
211
+ // PREFERENCES — the feedback loop (Grammarly's accept/reject, notification-UX's "fewer like this",
212
+ // Eyal's investment): the user talks back to a steer in plain language and skalpel adapts. Persisted
213
+ // across sessions EXCEPT `muted_session` (a session-scoped shush, cleared at SessionStart).
214
+ // muted_session : bool — "mute skalpel" -> shut up for the rest of THIS session
215
+ // suppress[key] : true — "fewer like this" -> stop firing this steer type/trap for me
216
+ // verbose[key] : true — "tell me more…" -> expand this trap's steer when it fires
217
+ // score[type] : {up,down} — implicit + explicit usefulness, for future gating
218
+ const PREFS_PATH = join(homedir(), ".skalpel", "prefs.json");
219
+ function readPrefs() {
220
+ const base = { muted_session: false, suppress: {}, verbose: {}, score: {}, stats: {} };
221
+ try {
222
+ return { ...base, ...JSON.parse(readFileSync(PREFS_PATH, "utf8")) };
223
+ } catch {
224
+ return base;
225
+ }
226
+ }
227
+
228
+ // ADHERENCE — the implicit workhorse (Grammarly's accept/reject, not thumbs). Per steer TYPE we track,
229
+ // from BEHAVIOR alone: shown (fired), acted (you moved off the work-type it pushed you off next turn),
230
+ // paid (acting also produced an n+1 time-saved credit). This is revealed preference — which of your
231
+ // patterns you actually act on — and it accretes across sessions into your personal steer model.
232
+ function recordAdherence(prefs, prev, thisWt, paid) {
233
+ if (!prev || !prev.type || !thisWt) return null;
234
+ prefs.stats = prefs.stats || {};
235
+ const st = prefs.stats[prev.type] || { shown: 0, acted: 0, paid: 0 };
236
+ st.shown += 1;
237
+ const acted = prev.wt && thisWt !== prev.wt; // you left the work-type the steer pushed you off
238
+ if (acted) st.acted += 1;
239
+ if (acted && paid) st.paid += 1;
240
+ prefs.stats[prev.type] = st;
241
+ return { acted: !!acted }; // the same judgment the adherence insight row reuses (never re-derived)
242
+ }
243
+
244
+ // The GATE (de-prioritize what you ignore, like Grammarly demotes never-accepted rules): a steer TYPE
245
+ // you've been shown enough times and consistently DON'T act on gets suppressed for you. Conservative
246
+ // sample + threshold so a couple of ignores never kills a type; only a real pattern of ignoring does.
247
+ function ignoredByAdherence(prefs, type) {
248
+ const st = prefs.stats && prefs.stats[type];
249
+ return !!(st && st.shown >= 6 && st.acted / st.shown < 0.2);
250
+ }
251
+ function writePrefs(p) {
252
+ try {
253
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
254
+ const tmp = `${PREFS_PATH}.tmp`;
255
+ writeFileSync(tmp, JSON.stringify(p));
256
+ renameSync(tmp, PREFS_PATH);
257
+ } catch {
258
+ /* prefs are a bonus; never block the hook */
259
+ }
260
+ }
261
+
262
+ // Parse a control PHRASE aimed at skalpel out of the user's prompt — the explicit feedback channel,
263
+ // captured natively through the prompt (no button UI needed; this IS the harness wrapping). Returns
264
+ // {action} or null. Deliberately narrow so normal prose never trips it: the word "skalpel", or a
265
+ // short standalone reaction right after a steer fired.
266
+ function parseControl(q) {
267
+ const t = (q || "").trim().toLowerCase();
268
+ if (t.length > 60) return null; // a real coding prompt, not a reaction
269
+ const mentionsUs = /\bskalpel\b/.test(t);
270
+ // Explicit feedback is the LIGHT layer on top of the implicit adherence loop — natural-language
271
+ // control the user can type when they want to steer skalpel directly. Mute is the important one.
272
+ if (
273
+ /\b(mute|silence|shut ?up|turn off|disable|stop steering|quiet)\b/.test(t) &&
274
+ (mentionsUs || t.length < 24)
275
+ )
276
+ return { action: "mute" };
277
+ if (
278
+ /\bunmute|re-?enable|turn (it )?back on|resume steering\b/.test(t) &&
279
+ (mentionsUs || t.length < 24)
280
+ )
281
+ return { action: "unmute" };
282
+ if (
283
+ /\b(fewer|less|stop) (of )?(these|this|that|like this|steers?)\b/.test(t) ||
284
+ /\bfewer like this\b/.test(t)
285
+ )
286
+ return { action: "fewer" };
287
+ if (/\b(tell me more|more detail|expand|go deeper|more on this|explain the trap)\b/.test(t))
288
+ return { action: "more" };
289
+ if (
290
+ /\b(that('?s| was)? (helpful|useful|good|great)|nice catch|good (call|catch)|helped|love (this|that)|👍)\b/.test(
291
+ t,
292
+ )
293
+ )
294
+ return { action: "up" };
295
+ if (/\b(not (helpful|useful)|wrong|useless|👎|off base|missed)\b/.test(t) && mentionsUs)
296
+ return { action: "down" };
297
+ return null;
298
+ }
299
+
300
+ // SESSION EVENT LOG — the raw material for the end-of-session recap (peak-end rule). One row per turn
301
+ // where a steer fired or a break-out was credited. SessionStart resets it; SessionEnd renders it.
302
+ const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
303
+ function logSessionEvent(ev) {
304
+ try {
305
+ let s = { steers: [], caught: 0, mins: 0 };
306
+ try {
307
+ s = { ...s, ...JSON.parse(readFileSync(SESSION_PATH, "utf8")) };
308
+ } catch {
309
+ /* fresh session */
310
+ }
311
+ if (ev.type) s.steers.push({ type: ev.type, wt: ev.wt || null, ts: Date.now() });
312
+ if (ev.caughtMins) {
313
+ s.caught += 1;
314
+ s.mins += ev.caughtMins;
315
+ }
316
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
317
+ const tmp = `${SESSION_PATH}.tmp`;
318
+ writeFileSync(tmp, JSON.stringify(s));
319
+ renameSync(tmp, SESSION_PATH);
320
+ } catch {
321
+ /* the recap is a bonus; never block the hook */
322
+ }
323
+ }
324
+
325
+ // Apply a control phrase to prefs, keyed to the LAST steer that fired (readSteer). Returns a short
326
+ // ack note to inject so the model CONFIRMS the change in the user's own terms (that confirmation IS
327
+ // the felt feedback loop), or null when no acknowledgement is needed.
328
+ function applyControl(action, prefs) {
329
+ const last = readSteer();
330
+ const key = last.type ? (last.wt ? `${last.type}:${last.wt}` : last.type) : null;
331
+ const bump = (dir) => {
332
+ if (!last.type) return;
333
+ const sc = prefs.score[last.type] || { up: 0, down: 0 };
334
+ sc[dir] += 1;
335
+ prefs.score[last.type] = sc;
336
+ };
337
+ if (action === "mute") {
338
+ prefs.muted_session = true;
339
+ writePrefs(prefs);
340
+ return (
341
+ "[skalpel — the user asked to MUTE skalpel for this session. Acknowledge in ONE short line " +
342
+ "(“🔬 skalpel · muted for this session — I'll stay quiet”) and surface NO steers the rest of this session.]"
343
+ );
344
+ }
345
+ if (action === "unmute") {
346
+ prefs.muted_session = false;
347
+ writePrefs(prefs);
348
+ return "[skalpel — the user re-enabled skalpel. Acknowledge in one short line and continue.]";
349
+ }
350
+ if (action === "fewer") {
351
+ if (key) prefs.suppress[key] = true;
352
+ bump("down");
353
+ writePrefs(prefs);
354
+ return (
355
+ `[skalpel — the user wants FEWER steers like the last one${last.wt ? ` (${last.wt})` : ""}. ` +
356
+ "Acknowledge in one line (“🔬 skalpel · got it — easing off those”); skalpel will stop firing that trap for them.]"
357
+ );
358
+ }
359
+ if (action === "more") {
360
+ if (key) prefs.verbose[key] = true;
361
+ writePrefs(prefs);
362
+ return (
363
+ `[skalpel — the user wants MORE detail whenever they hit this${last.wt ? ` ${last.wt}` : ""} trap. ` +
364
+ "Acknowledge briefly, and from now on EXPAND that steer: name the shape, the count, and the way out.]"
365
+ );
366
+ }
367
+ if (action === "up") {
368
+ bump("up");
369
+ writePrefs(prefs);
370
+ return null; // positive signal — just record it; no need to make the model announce anything
371
+ }
372
+ if (action === "down") {
373
+ if (key) prefs.suppress[key] = true;
374
+ bump("down");
375
+ writePrefs(prefs);
376
+ return "[skalpel — the user found the last steer unhelpful. Acknowledge briefly; skalpel will ease off that one.]";
377
+ }
378
+ return null;
379
+ }
380
+
381
+ // Plain-English one-liners for the ack INSIGHT RECORD (the TUI row) — the user-visible restatement
382
+ // of the control that fired, never the bracketed model directive applyControl returns. "up" is
383
+ // absent on purpose: applyControl returns no ack note for it, so no record is written.
384
+ const ACK_DISPLAY = {
385
+ mute: "muted for this session",
386
+ unmute: "back on",
387
+ fewer: "easing off those",
388
+ more: "expanding that steer from now on",
389
+ down: "easing off that one",
390
+ };
391
+
392
+ // TRAJECTORY of work-type classes this session — the last few states, sent to the server so it can
393
+ // fire the discriminative TELL (a multi-turn shape that predicts a bad session). SessionStart clears it.
394
+ const TRAJ_PATH = join(homedir(), ".skalpel", "traj.json");
395
+ function readTraj() {
396
+ try {
397
+ return JSON.parse(readFileSync(TRAJ_PATH, "utf8")) || [];
398
+ } catch {
399
+ return [];
400
+ }
401
+ }
402
+ function pushTraj(cls) {
403
+ try {
404
+ const t = readTraj();
405
+ t.push(cls);
406
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
407
+ const tmp = `${TRAJ_PATH}.tmp`;
408
+ writeFileSync(tmp, JSON.stringify(t.slice(-4))); // keep the last 4 turns
409
+ renameSync(tmp, TRAJ_PATH);
410
+ } catch {
411
+ /* trajectory is best-effort */
412
+ }
413
+ }
414
+
415
+ // local debug trail — proves the hook RAN (in any session), and what it decided. Fail-safe.
416
+ function dbg(msg) {
417
+ if (process.env.SKALPEL_DEBUG === "0") return;
418
+ try {
419
+ const p = join(homedir(), ".skalpel");
420
+ mkdirSync(p, { recursive: true });
421
+ appendFileSync(join(p, "hook.log"), `${new Date().toISOString()} ${msg}\n`);
422
+ } catch {
423
+ /* never block on logging */
424
+ }
425
+ }
426
+
427
+ async function main() {
428
+ let payload = {};
429
+ try {
430
+ payload = JSON.parse(readFileSync(0, "utf8") || "{}");
431
+ } catch {
432
+ return;
433
+ }
434
+ const query = lastUserPrompt(payload).trim();
435
+ dbg(`ran api=${API} q=${JSON.stringify(query.slice(0, 50))}`);
436
+
437
+ // Best-effort provenance for the local insight records: which harness fired us + the session id.
438
+ const sessionId = payload.session_id ?? null;
439
+ const fromClaude = payload.transcript_path?.includes(".claude") || payload.hook_event_name;
440
+ const fromCodex = process.env.CODEX_HOME || payload.transcript_path?.includes(".codex");
441
+ const harness = fromClaude ? "claude" : fromCodex ? "codex" : null;
442
+
443
+ if (!isRealPrompt(query)) {
444
+ dbg(" -> skipped (not a real prompt)");
445
+ record("prompt", "skipped", null);
446
+ return;
447
+ }
448
+
449
+ // FEEDBACK CHANNEL (explicit): the user talks back to the last steer in plain language — "mute",
450
+ // "fewer like this", "tell me more", "that helped". Captured natively out of the prompt (no button
451
+ // UI — this is the harness wrapping). Adjusts prefs; the ack note makes the model confirm it.
452
+ const prefs = readPrefs();
453
+ const control = parseControl(query);
454
+ let ackNote = null;
455
+ if (control) {
456
+ ackNote = applyControl(control.action, prefs);
457
+ dbg(` -> control: ${control.action}`);
458
+ }
459
+
460
+ // TRAJECTORY (client-side, pre-server): are you spiraling RIGHT NOW? >=2 corrections/interrupts in
461
+ // the last few turns -> the felt "let's reset" human interrupt, independent of the server steer.
462
+ const cascade = detectCascade(payload);
463
+ let resetNote = null;
464
+ if (cascade >= 2) {
465
+ resetNote =
466
+ `[skalpel — the user has cut you off / re-corrected you ~${cascade} times in the last few turns. ` +
467
+ `You are NOT landing it, and retrying the same approach is their single biggest time sink. STOP. ` +
468
+ `Open with ONE line — "🔬 skalpel · we've gone back and forth a few times — stopping to reset rather ` +
469
+ `than push the same approach:" — then (1) restate in one sentence what they actually want, to confirm ` +
470
+ `you've got it, and (2) take a GENUINELY different tack, not a variation of what just failed.]`;
471
+ dbg(` -> CASCADE ${cascade} -> reset interrupt`);
472
+ }
473
+
474
+ // Shared budget starts BEFORE auth so a token refresh is charged against the same deadline as the
475
+ // retrieve — the two can never stack past DEADLINE_MS.
476
+ const deadline = Date.now() + DEADLINE_MS;
477
+
478
+ // identity from the Google sign-in (refreshed if near expiry); the token authenticates the call so
479
+ // the server keys the graph by the VERIFIED uid. No token (dev/not signed in) -> server falls back.
480
+ const { uid, token } = await identity();
481
+ const user = uid || CFG.user || "anon";
482
+ const headers = { "content-type": "application/json" };
483
+ if (token) headers.authorization = `Bearer ${token}`;
484
+
485
+ const remaining = deadline - Date.now();
486
+ if (remaining <= 100) {
487
+ dbg(" -> budget spent by auth, skipping retrieve");
488
+ record("prompt", "budget_spent", DEADLINE_MS);
489
+ return;
490
+ }
491
+
492
+ const ctrl = new AbortController();
493
+ const timer = setTimeout(() => ctrl.abort(), remaining);
494
+ const t0 = Date.now();
495
+ let injection = null;
496
+ let winNote = null; // positive reinforcement: set when THIS turn confirms you broke out of a rut
497
+ let winMins = null; // the ~minutes the win note cites — reused verbatim by the insight record
498
+ let steerLabel = null; // "what skalpel's doing" for the statusline
499
+ let steerType = null; // raw top-steer type (TELL/SINK/…) — for the feedback loop + session log
500
+ let steerWt = null; // the work-type the steer is about (refine/debug/…)
501
+ let creditMins = null; // the +Xmin the n+1 check credited THIS turn -> statusline tick
502
+ let outcome = "fetch_error";
503
+ try {
504
+ const r = await fetch(`${API}/retrieve`, {
505
+ method: "POST",
506
+ headers,
507
+ // `recent` = the last few work-type classes THIS session (the trajectory) — lets the server fire
508
+ // the discriminative TELL ("you're on refine→refine→refine, the shape that goes bad 8×").
509
+ // `surface: "panel"` only for embedded TUI spawns (the TUI renders insights in its own panel,
510
+ // so the server keeps the steering but drops every "emit a 🔬 line" instruction). The key is
511
+ // omitted entirely otherwise — byte-stable body for non-embedded users.
512
+ body: JSON.stringify({
513
+ user_id: user,
514
+ query,
515
+ recent: readTraj(),
516
+ ...(process.env.SKALPEL_EMBEDDED === "1" ? { surface: "panel" } : {}),
517
+ }),
518
+ signal: ctrl.signal,
519
+ });
520
+ if (r.ok) {
521
+ const j = await r.json();
522
+ injection = j?.injection;
523
+ outcome = injection ? "inject" : "silent";
524
+ steerLabel = j?.steer_label; // surfaced on the statusline (written once below, with any credit)
525
+ steerType = j?.steer_type || null;
526
+ steerWt = j?.steer_wt || (j?.entry ? String(j.entry).split("/")[0] : null);
527
+ if (j?.entry) pushTraj(String(j.entry).split("/")[0]); // remember this turn's work-type
528
+ dbg(` -> sim=${j?.top_sim ?? "?"} ${injection ? "INJECT" : "silent"}`);
529
+
530
+ // readSteer() still holds the PRIOR turn's steer (we overwrite it below) — used after the n+1
531
+ // credit resolves to record adherence (did you act on it, and did acting pay off).
532
+ const prevSteer = readSteer();
533
+ // HONOR prefs + adherence GATE: mute / per-trap "fewer" / a type you consistently ignore all
534
+ // suppress the steer; "more" expands it.
535
+ const key = steerType ? (steerWt ? `${steerType}:${steerWt}` : steerType) : null;
536
+ const ignored = steerType && ignoredByAdherence(prefs, steerType);
537
+ if (prefs.muted_session || (key && prefs.suppress[key]) || ignored) {
538
+ injection = null;
539
+ steerLabel = null;
540
+ dbg(
541
+ ` -> suppressed (${prefs.muted_session ? "muted" : ignored ? "ignored-type" : "fewer"}) ${key || ""}`,
542
+ );
543
+ } else if (key && prefs.verbose[key] && injection) {
544
+ injection +=
545
+ "\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.]";
546
+ }
547
+
548
+ // --- n+1 CONFIRMATION: credit the DROP in expected waste-ahead (diff-from-worst) ---
549
+ // Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste ahead
550
+ // from where you were. THIS turn's waste-ahead is V(N+1) = j.state_hours. If it DROPPED, you
551
+ // moved toward resolved -> credit the drop (the real magnitude of time saved, now that the DAG
552
+ // state values discriminate stuck-vs-resolved; before they were flat so the drop was always 0).
553
+ const vNow = typeof j?.state_hours === "number" ? j.state_hours : null;
554
+ const vcNow = typeof j?.state_cost === "number" ? j.state_cost : 0;
555
+ const pending = readPending();
556
+ if (pending && typeof pending.v === "number" && vNow !== null) {
557
+ const savedH = Math.max(0, pending.v - vNow); // waste-ahead you shed since last turn
558
+ const savedC = Math.max(0, (pending.vc || 0) - vcNow);
559
+ // COHERENCE: a "catch" must be a REAL, visible >=1min drop. Everything downstream — the counter,
560
+ // the "+Xmin this turn" tick, the "N caught" tally, the recap minutes — is driven by the SAME
561
+ // number (cm), so caught can NEVER increment without the time moving by the same amount. (The old
562
+ // code credited sub-minute drops and forced caughtMins to 1 via Math.max, so caught ticked up
563
+ // while creditMins rounded to 0 and the visible time didn't budge — the incoherence.)
564
+ const cm = Math.round(savedH * 60);
565
+ if (cm >= 1) {
566
+ bumpStats(savedH, savedC);
567
+ creditMins = cm; // the felt "+Xmin ✓" tick — same value as the catch
568
+ logSessionEvent({ caughtMins: cm }); // caught++ and recap minutes, same number
569
+ dbg(` -> n+1 CREDIT: waste-ahead ${pending.v}h -> ${vNow}h, +${cm}min saved`);
570
+ // REINFORCE GOOD MOVES (the carrot): on a MEANINGFUL break-out (not a trivial drop),
571
+ // hand the model a brief positive-acknowledgment note. Sparse by threshold so praise stays
572
+ // a signal, not noise — a coach that celebrates every micro-step becomes wallpaper too.
573
+ if (savedH >= 0.08) {
574
+ const mins = Math.max(1, Math.round(savedH * 60));
575
+ winMins = mins;
576
+ winNote =
577
+ `[skalpel — the user just broke OUT of a pattern that usually costs them ~${mins}min of rework. ` +
578
+ `If it feels natural (don't force it), briefly acknowledge the good move in one line — ` +
579
+ `e.g. "🔬 skalpel · nice — you skipped the loop you usually get stuck in there" — then continue. ` +
580
+ `Reinforce the behavior, don't lecture. Skip it if the moment doesn't fit.]`;
581
+ }
582
+ } else {
583
+ dbg(
584
+ ` -> n+1 no-credit: waste-ahead ${pending.v}h -> ${vNow}h (drop < 1min, not counted)`,
585
+ );
586
+ }
587
+ writePending({}); // this turn's confirmation is consumed
588
+ }
589
+ // Arm NEXT turn: when the injection fires (a flagged high-waste moment) AND we have a V for this
590
+ // turn, remember it so N+1 can measure how far your waste-ahead dropped.
591
+ if (injection && vNow !== null) {
592
+ writePending({ v: vNow, vc: vcNow });
593
+ } else if (!pending || vNow !== null) {
594
+ writePending({}); // clear only when we actually had data this turn (don't drop a stale carry)
595
+ }
596
+
597
+ // IMPLICIT ADHERENCE (the real Grammarly loop): now that the n+1 credit has resolved, record
598
+ // whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting
599
+ // pay off (a credit this turn). Accretes per steer-type; the gate above reads it next time.
600
+ const paid = creditMins != null && creditMins > 0;
601
+ const adh = recordAdherence(prefs, prevSteer, steerWt, paid);
602
+ writePrefs(prefs);
603
+ // ADHERENCE INSIGHT ROW (data, not display — no `display` field): the same judgment, exported
604
+ // for the TUI's rail, joined to the prior steer by its minted id. followed = the n+1 credit
605
+ // landed; ignored = you stayed on the very work-type the steer pushed you off AND no credit;
606
+ // anything else (no prior steer / indeterminate turn) writes nothing.
607
+ const adhOutcome = paid ? "followed" : adh && prevSteer.wt && !adh.acted ? "ignored" : null;
608
+ if (prevSteer?.id && adhOutcome)
609
+ recordInsight({
610
+ kind: "adherence",
611
+ ref: prevSteer.id,
612
+ outcome: adhOutcome,
613
+ harness,
614
+ session_id: sessionId,
615
+ });
616
+ } else {
617
+ outcome = "server_error";
618
+ dbg(` -> server ${r.status}`);
619
+ }
620
+ } catch (e) {
621
+ outcome = e?.name === "AbortError" ? "abort" : "fetch_error";
622
+ dbg(` -> fetch failed: ${String(e).slice(0, 60)}`);
623
+ } finally {
624
+ clearTimeout(timer);
625
+ record("prompt", outcome, Date.now() - t0);
626
+ }
627
+
628
+ // MUTE is absolute — a shushed session surfaces NOTHING (steer, reset, or win). The ack note (a
629
+ // one-time "muted" confirmation) still goes through, since that IS the mute turn's response.
630
+ if (prefs.muted_session) {
631
+ resetNote = null;
632
+ winNote = null;
633
+ injection = null;
634
+ steerLabel = null;
635
+ }
636
+
637
+ // A live cascade reset outranks the server steer for the statusline label (it's the acute one).
638
+ if (resetNote) steerLabel = "resetting a spiral";
639
+
640
+ // Statusline state, written ONCE per turn: what skalpel is doing now + the +Xmin the n+1 check just
641
+ // credited (the felt "just delivered" tick), plus the raw steer identity so NEXT turn's feedback
642
+ // phrase can target it. All null on a silent/no-credit turn -> bar shows just the running total.
643
+ // steerId = the join key minted once per STEER turn (a string), written to BOTH steer.json and the
644
+ // steer's insight row below, so next turn's adherence record can reference exactly this steer.
645
+ const steerId = injection ? String(Date.now()) : null;
646
+ writeSteer(steerLabel, creditMins, steerType, steerWt, steerId);
647
+ if (injection) logSessionEvent({ type: steerType, wt: steerWt }); // feed the end-of-session recap
648
+
649
+ // LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — one plain-English row per note kind that
650
+ // fired this turn, post-suppression (a muted/suppressed steer writes nothing). recordInsight
651
+ // swallows everything, so these can never affect the hook output below.
652
+ if (injection)
653
+ recordInsight({
654
+ kind: "steer",
655
+ id: steerId,
656
+ display: steerLabel || "a steer for this spot",
657
+ label: steerLabel,
658
+ mins: creditMins > 0 ? creditMins : null,
659
+ harness,
660
+ session_id: sessionId,
661
+ });
662
+ if (winNote)
663
+ recordInsight({
664
+ kind: "win",
665
+ display: `nice — you broke out of a pattern that usually costs ~${winMins}min`,
666
+ mins: winMins,
667
+ harness,
668
+ session_id: sessionId,
669
+ });
670
+ if (resetNote)
671
+ recordInsight({
672
+ kind: "reset",
673
+ display: `we've gone back and forth ${cascade}× — resetting rather than pushing the same approach`,
674
+ harness,
675
+ session_id: sessionId,
676
+ });
677
+ if (ackNote)
678
+ recordInsight({
679
+ kind: "ack",
680
+ display: ACK_DISPLAY[control.action] || "preference updated",
681
+ harness,
682
+ session_id: sessionId,
683
+ });
684
+
685
+ // ackNote (feedback confirmation) leads, then the reset interrupt (most acute), the server steer,
686
+ // and the win note. Any can fire alone.
687
+ const additionalContext = [ackNote, resetNote, winNote, injection].filter(Boolean).join("\n\n");
688
+ if (additionalContext) {
689
+ process.stdout.write(
690
+ JSON.stringify({
691
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
692
+ }),
693
+ );
694
+ }
695
+ }
696
+
697
+ main()
698
+ .then(() => process.exit(0))
699
+ .catch(() => process.exit(0));