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.
- package/package.json +7 -6
- package/postinstall/index.js +27 -11
- package/postinstall/lib/prosumer-hooks.js +111 -0
- package/postinstall/uninstall.js +8 -0
- package/prosumer-hooks/auth.mjs +93 -0
- package/prosumer-hooks/bootstrap.mjs +382 -0
- package/prosumer-hooks/insights.mjs +62 -0
- package/prosumer-hooks/install.mjs +355 -0
- package/prosumer-hooks/metrics.mjs +32 -0
- package/prosumer-hooks/skalpel-hook-session.mjs +204 -0
- package/prosumer-hooks/skalpel-hook.mjs +699 -0
- package/prosumer-hooks/skalpel-statusline.mjs +62 -0
- package/prosumer-hooks/stats.mjs +73 -0
|
@@ -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));
|