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,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// install.mjs — wire the hosted skalpel behavior hook into Claude Code AND Codex. Pure Node,
|
|
3
|
+
// idempotent, and non-clobbering:
|
|
4
|
+
// preserves any existing hooks/settings, refreshes ours in place. Run: `node install.mjs` (or --uninstall).
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const uninstall = process.argv.includes("--uninstall");
|
|
11
|
+
|
|
12
|
+
// Where this installer's own files live — the globally-installed package, a source checkout, OR the
|
|
13
|
+
// transient `npx` cache. All three must produce ONE working command, so we can't rely on a bin being
|
|
14
|
+
// on PATH (only `npm i -g` puts it there; `npx` and a source run don't). Instead we copy the three
|
|
15
|
+
// self-contained hook files into a STABLE home and wire an absolute `node <path>` command that works
|
|
16
|
+
// offline, survives npx cache GC, and needs no global install — so `npx @skalpelai/prosumer-hook` is
|
|
17
|
+
// the single command that leaves genuinely-working hooks.
|
|
18
|
+
const PKG_DIR = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const HOOKS_DIR = join(homedir(), ".skalpel", "hooks");
|
|
20
|
+
// Keep the staged basenames == the historical bin names so the idempotency/uninstall MARKER is
|
|
21
|
+
// unchanged: an older bare-bin install (command "skalpel-prosumer-hook") is still recognized and
|
|
22
|
+
// gets healed to the absolute command on the next run.
|
|
23
|
+
const HOOK_FILE = join(HOOKS_DIR, "skalpel-hook.mjs");
|
|
24
|
+
const SESSION_FILE = join(HOOKS_DIR, "skalpel-hook-session.mjs");
|
|
25
|
+
const STATUSLINE_FILE = join(HOOKS_DIR, "skalpel-statusline.mjs");
|
|
26
|
+
|
|
27
|
+
// Copy the hook runtime (the two entrypoints + their shared auth.mjs) into ~/.skalpel/hooks so the
|
|
28
|
+
// wired absolute path is always valid. Overwrites on every install so upgrades refresh the code.
|
|
29
|
+
function stageHookRuntime() {
|
|
30
|
+
// `skalpel setup` invokes the already-staged copy. Avoid copying a file onto itself.
|
|
31
|
+
if (PKG_DIR === HOOKS_DIR) return;
|
|
32
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
33
|
+
// insights.mjs is staged BEFORE the entrypoints that import it — a mid-copy ENOENT
|
|
34
|
+
// must not leave a wired hook missing its import (auth/metrics staging order predates
|
|
35
|
+
// this rule and is guarded by the all-or-nothing check below instead).
|
|
36
|
+
// a hook entrypoint staged without a module it imports — the exact 0.3.12–0.3.16 bug shape.
|
|
37
|
+
copyFileSync(join(PKG_DIR, "insights.mjs"), join(HOOKS_DIR, "insights.mjs")); // hooks import ./insights.mjs
|
|
38
|
+
copyFileSync(join(PKG_DIR, "skalpel-hook.mjs"), HOOK_FILE);
|
|
39
|
+
copyFileSync(join(PKG_DIR, "skalpel-hook-session.mjs"), SESSION_FILE);
|
|
40
|
+
copyFileSync(join(PKG_DIR, "skalpel-statusline.mjs"), STATUSLINE_FILE);
|
|
41
|
+
copyFileSync(join(PKG_DIR, "auth.mjs"), join(HOOKS_DIR, "auth.mjs")); // hooks import ./auth.mjs
|
|
42
|
+
copyFileSync(join(PKG_DIR, "metrics.mjs"), join(HOOKS_DIR, "metrics.mjs")); // hooks import ./metrics.mjs
|
|
43
|
+
copyFileSync(join(PKG_DIR, "stats.mjs"), join(HOOKS_DIR, "stats.mjs")); // `node ~/.skalpel/hooks/stats.mjs`
|
|
44
|
+
}
|
|
45
|
+
if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
|
|
46
|
+
try {
|
|
47
|
+
stageHookRuntime();
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Staging is all-or-nothing. A missing source file means the tarball is incomplete (a file this
|
|
50
|
+
// installer copies was left out of package.json "files"), and copyFileSync throws part-way
|
|
51
|
+
// through — so the *later* copies never run and the hooks are staged without the auth.mjs /
|
|
52
|
+
// metrics.mjs they import. Wiring absolute paths at that point yields hooks that crash on every
|
|
53
|
+
// prompt. Refuse to wire instead; a loud failure beats a silent no-op.
|
|
54
|
+
console.error(`skalpel: could not stage hook runtime: ${e.message}`);
|
|
55
|
+
console.error("skalpel: hooks were NOT wired.");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The wired command. Defaults to the staged absolute `node <path>`; SKALPEL_HOOK_BIN /
|
|
61
|
+
// SKALPEL_HOOK_SESSION_BIN still override (dev, or a deliberate global-bin install).
|
|
62
|
+
const CMD = process.env.SKALPEL_HOOK_BIN || `node ${HOOK_FILE}`;
|
|
63
|
+
const SESSION_CMD = process.env.SKALPEL_HOOK_SESSION_BIN || `node ${SESSION_FILE}`;
|
|
64
|
+
const STATUSLINE_CMD = process.env.SKALPEL_STATUSLINE_BIN || `node ${STATUSLINE_FILE}`;
|
|
65
|
+
const CLAUDE = join(homedir(), ".claude", "settings.json");
|
|
66
|
+
const CODEX_DIR = join(homedir(), ".codex");
|
|
67
|
+
const CODEX = join(CODEX_DIR, "hooks.json");
|
|
68
|
+
const CODEX_TOML = join(CODEX_DIR, "config.toml"); // Codex reads hooks from config.toml [hooks]
|
|
69
|
+
// The idempotency/uninstall marker is the command's basename — always matches exactly what this
|
|
70
|
+
// installer wired, including bare-bin names and absolute `node <path>` commands.
|
|
71
|
+
const markerOf = (cmd) =>
|
|
72
|
+
cmd
|
|
73
|
+
.split(/[/\\]/)
|
|
74
|
+
.pop()
|
|
75
|
+
.replace(/\.(mjs|js|exe)$/, "");
|
|
76
|
+
const HOOKS = [
|
|
77
|
+
["UserPromptSubmit", markerOf(CMD), CMD],
|
|
78
|
+
["SessionStart", markerOf(SESSION_CMD), SESSION_CMD],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function readJson(p) {
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = readFileSync(p, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
return {}; // missing file — a fresh {} is correct
|
|
87
|
+
}
|
|
88
|
+
if (!raw.trim()) return {}; // empty file
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
// The file EXISTS but is corrupt JSON. Returning {} here would make writeJson OVERWRITE and
|
|
93
|
+
// wipe the user's real settings. Never do that: back the broken file up and skip touching it.
|
|
94
|
+
try {
|
|
95
|
+
copyFileSync(p, `${p}.skalpel-bak-${Date.now()}`);
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort backup */
|
|
98
|
+
}
|
|
99
|
+
return null; // signal "do not write" to callers
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function writeJson(p, o) {
|
|
103
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
104
|
+
writeFileSync(p, JSON.stringify(o, null, 2));
|
|
105
|
+
}
|
|
106
|
+
function isOurs(c, marker) {
|
|
107
|
+
if (typeof c !== "string") return false;
|
|
108
|
+
const markers = [
|
|
109
|
+
marker,
|
|
110
|
+
"skalpel-hook",
|
|
111
|
+
"skalpel-hook-session",
|
|
112
|
+
"skalpel-statusline",
|
|
113
|
+
"skalpel-prosumer-hook",
|
|
114
|
+
"skalpel-prosumer-hook-session",
|
|
115
|
+
"skalpel-prosumer-statusline",
|
|
116
|
+
];
|
|
117
|
+
return markers.some((candidate) => {
|
|
118
|
+
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
return new RegExp(`(^|[/\\\\\\s])${escaped}(\\.mjs|\\.js|\\.exe)?($|\\s)`).test(c);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Claude Code: hooks.<event>[] = [{matcher?, hooks:[{type:"command", command:"…"}]}]. Each array
|
|
124
|
+
// entry MUST be a matcher-GROUP with a `hooks` array — a bare {type,command} is invalid and makes
|
|
125
|
+
// Claude Code reject the whole settings file ("Expected array, but received undefined"). Install
|
|
126
|
+
// REFRESHES our entry in place (strip any prior ours — whether it's a correct group OR a legacy
|
|
127
|
+
// bare-command entry a past buggy install wrote — then append a proper group). Idempotent AND
|
|
128
|
+
// self-healing: re-running repairs a file broken by the old writer.
|
|
129
|
+
function claude() {
|
|
130
|
+
const d = existsSync(CLAUDE) ? readJson(CLAUDE) : {};
|
|
131
|
+
if (d === null) return "skipped-corrupt"; // corrupt settings backed up; never overwrite blindly
|
|
132
|
+
d.hooks ??= {};
|
|
133
|
+
let installed = 0;
|
|
134
|
+
let refreshed = 0;
|
|
135
|
+
let removed = 0;
|
|
136
|
+
for (const [event, marker, command] of HOOKS) {
|
|
137
|
+
d.hooks[event] ??= [];
|
|
138
|
+
const arr = d.hooks[event];
|
|
139
|
+
const isMine = (e) => {
|
|
140
|
+
if (isOurs(e?.command, marker)) return true; // legacy bare {type,command} entry
|
|
141
|
+
const inner = Array.isArray(e?.hooks) ? e.hooks : [];
|
|
142
|
+
return inner.some((h) => isOurs(h?.command, marker));
|
|
143
|
+
};
|
|
144
|
+
const had = arr.some(isMine);
|
|
145
|
+
const kept = arr.filter((e) => !isMine(e)); // drop ours (both shapes) AND repair
|
|
146
|
+
// 8s, not 5s: the hosted backend can cold-start after idle, and the hook's own internal
|
|
147
|
+
// DEADLINE_MS (4.5s) needs headroom under this outer harness timeout or it gets killed first.
|
|
148
|
+
const group = { hooks: [{ type: "command", command, timeout: 8 }] };
|
|
149
|
+
d.hooks[event] = uninstall ? kept : [...kept, group];
|
|
150
|
+
if (uninstall) {
|
|
151
|
+
if (had) removed += 1;
|
|
152
|
+
} else if (had) {
|
|
153
|
+
refreshed += 1;
|
|
154
|
+
} else {
|
|
155
|
+
installed += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// statusLine: not a hooks.<event> array, a single top-level key — only touch it if it's unset or
|
|
159
|
+
// already ours, so we never clobber a genuinely custom statusline someone configured themselves.
|
|
160
|
+
const statuslineIsOurs = isOurs(d.statusLine?.command, markerOf(STATUSLINE_CMD));
|
|
161
|
+
if (uninstall) {
|
|
162
|
+
if (statuslineIsOurs) {
|
|
163
|
+
delete d.statusLine;
|
|
164
|
+
removed += 1;
|
|
165
|
+
}
|
|
166
|
+
} else if (!d.statusLine || statuslineIsOurs) {
|
|
167
|
+
if (statuslineIsOurs) refreshed += 1;
|
|
168
|
+
else installed += 1;
|
|
169
|
+
d.statusLine = { type: "command", command: STATUSLINE_CMD };
|
|
170
|
+
}
|
|
171
|
+
writeJson(CLAUDE, d);
|
|
172
|
+
if (uninstall) return removed ? "removed" : "absent";
|
|
173
|
+
return installed ? "installed" : refreshed ? "refreshed" : "absent";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Codex (per developers.openai.com/codex/hooks) reads UserPromptSubmit hooks from config.toml
|
|
177
|
+
// under [[hooks.UserPromptSubmit.hooks]] — TOML, same schema as hooks.json. This Codex ships a
|
|
178
|
+
// config.toml and no hooks.json, so config.toml is the reliable target. We write BOTH (docs accept
|
|
179
|
+
// either) but config.toml is what actually fires. Install strips any prior ours then appends fresh.
|
|
180
|
+
const TOML_MARK = "# skalpel-hook (managed)";
|
|
181
|
+
const TOML_MARKERS = [TOML_MARK, "# skalpel-prosumer-hook (managed)"];
|
|
182
|
+
|
|
183
|
+
function tomlMark(event, marker) {
|
|
184
|
+
return `${TOML_MARK} ${event} ${marker}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function tomlBlock(event, marker, command) {
|
|
188
|
+
return `\n${tomlMark(event, marker)}\n[[hooks.${event}]]\n[[hooks.${event}.hooks]]\ntype = "command"\ncommand = "${command}"\ntimeout = 8\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stripManagedTomlBlocks(txt) {
|
|
192
|
+
const lines = txt.split("\n");
|
|
193
|
+
const out = [];
|
|
194
|
+
let removed = 0;
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < lines.length; ) {
|
|
197
|
+
if (TOML_MARKERS.some((marker) => lines[i].includes(marker))) {
|
|
198
|
+
i += 6;
|
|
199
|
+
removed += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const section = lines[i].match(/^\[\[hooks\.(UserPromptSubmit|SessionStart)\]\]\s*$/);
|
|
204
|
+
if (!section) {
|
|
205
|
+
out.push(lines[i]);
|
|
206
|
+
i += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const event = section[1];
|
|
211
|
+
const marker = HOOKS.find(([hookEvent]) => hookEvent === event)?.[1];
|
|
212
|
+
const block = [lines[i]];
|
|
213
|
+
let j = i + 1;
|
|
214
|
+
while (
|
|
215
|
+
j < lines.length &&
|
|
216
|
+
!/^\[\[hooks\.(UserPromptSubmit|SessionStart)\]\]\s*$/.test(lines[j])
|
|
217
|
+
) {
|
|
218
|
+
j += 1;
|
|
219
|
+
block.push(lines[j - 1]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const command = block
|
|
223
|
+
.map((line) => line.match(/^\s*command\s*=\s*"([^"]*)"\s*$/)?.[1])
|
|
224
|
+
.find(Boolean);
|
|
225
|
+
if (marker && isOurs(command, marker)) {
|
|
226
|
+
removed += 1;
|
|
227
|
+
} else {
|
|
228
|
+
out.push(...block);
|
|
229
|
+
}
|
|
230
|
+
i = j;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { text: out.join("\n"), removed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function codexToml() {
|
|
237
|
+
if (!existsSync(CODEX_DIR)) return "no-codex";
|
|
238
|
+
const txt = existsSync(CODEX_TOML) ? readFileSync(CODEX_TOML, "utf8") : "";
|
|
239
|
+
// Strip current managed blocks and older unmanaged skalpel blocks, so setup heals stale Codex
|
|
240
|
+
// configs that still point at bare PATH commands from pre-staged installs.
|
|
241
|
+
const stripped = stripManagedTomlBlocks(txt);
|
|
242
|
+
let next = stripped.text.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
243
|
+
if (uninstall) {
|
|
244
|
+
writeFileSync(CODEX_TOML, next + "\n");
|
|
245
|
+
return stripped.removed ? "removed" : "absent";
|
|
246
|
+
}
|
|
247
|
+
mkdirSync(CODEX_DIR, { recursive: true });
|
|
248
|
+
for (const [event, marker, command] of HOOKS) {
|
|
249
|
+
next += "\n" + tomlBlock(event, marker, command);
|
|
250
|
+
}
|
|
251
|
+
writeFileSync(CODEX_TOML, next + "\n");
|
|
252
|
+
return stripped.removed ? "refreshed" : "installed";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Also write hooks.json (docs accept either; harmless belt-and-suspenders for Codex builds that read it)
|
|
256
|
+
function codexJson() {
|
|
257
|
+
if (!existsSync(CODEX_DIR)) return "no-codex";
|
|
258
|
+
const d = existsSync(CODEX) ? readJson(CODEX) : {};
|
|
259
|
+
if (d === null) return "skipped-corrupt"; // corrupt file backed up; don't overwrite
|
|
260
|
+
d.hooks ??= {};
|
|
261
|
+
let installed = 0;
|
|
262
|
+
let refreshed = 0;
|
|
263
|
+
let removed = 0;
|
|
264
|
+
for (const [event, marker, command] of HOOKS) {
|
|
265
|
+
d.hooks[event] ??= [];
|
|
266
|
+
if (!d.hooks[event].length) d.hooks[event].push({ hooks: [] });
|
|
267
|
+
const grp = d.hooks[event][0];
|
|
268
|
+
grp.hooks ??= [];
|
|
269
|
+
const had = grp.hooks.some((h) => isOurs(h?.command, marker));
|
|
270
|
+
const kept = grp.hooks.filter((h) => !isOurs(h?.command, marker));
|
|
271
|
+
grp.hooks = uninstall ? kept : [...kept, { type: "command", command, timeout: 8 }];
|
|
272
|
+
if (uninstall) {
|
|
273
|
+
if (had) removed += 1;
|
|
274
|
+
} else if (had) {
|
|
275
|
+
refreshed += 1;
|
|
276
|
+
} else {
|
|
277
|
+
installed += 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
writeJson(CODEX, d);
|
|
281
|
+
if (uninstall) return removed ? "removed" : "absent";
|
|
282
|
+
return installed ? "installed" : refreshed ? "refreshed" : "absent";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Global CLAUDE.md — a STANDING attribution rule, on disk, loaded every session regardless of
|
|
286
|
+
// whether the per-turn hook's network call succeeds. The per-turn hook injects the DATA (the
|
|
287
|
+
// walked pattern + the number for THIS occurrence) but its own copy of the attribution rule only
|
|
288
|
+
// exists in that one injected block — miss the fetch (cold backend, timeout) and the rule never
|
|
289
|
+
// even reaches the model that turn. This block is the fallback that can't be dropped by a network
|
|
290
|
+
// hiccup: it's read from disk at session start, same as any other CLAUDE.md instruction.
|
|
291
|
+
const CLAUDE_MD = join(homedir(), ".claude", "CLAUDE.md");
|
|
292
|
+
const CLAUDE_MD_MARK_START = "<!-- skalpel-hook:attribution (managed) -->";
|
|
293
|
+
const CLAUDE_MD_MARK_END = "<!-- /skalpel-hook:attribution (managed) -->";
|
|
294
|
+
const CLAUDE_MD_MARK_PAIRS = [
|
|
295
|
+
[CLAUDE_MD_MARK_START, CLAUDE_MD_MARK_END],
|
|
296
|
+
[
|
|
297
|
+
"<!-- skalpel-prosumer-hook:attribution (managed) -->",
|
|
298
|
+
"<!-- /skalpel-prosumer-hook:attribution (managed) -->",
|
|
299
|
+
],
|
|
300
|
+
];
|
|
301
|
+
const CLAUDE_MD_BLOCK = `${CLAUDE_MD_MARK_START}
|
|
302
|
+
## skalpel
|
|
303
|
+
|
|
304
|
+
A \`skalpel\` behavioral-graph hook may inject a per-turn context block (starts \`[skalpel — signals
|
|
305
|
+
for THIS turn ...]\`) with behavioral facts about how this user works. The block itself carries the
|
|
306
|
+
surfacing rule for that turn — follow it. There are two tiers:
|
|
307
|
+
|
|
308
|
+
- STRONG (a TELL or PLAN signal fired — a rare, high-confidence finding like "you're on a shape that
|
|
309
|
+
went bad 8× and never landed clean"): you MUST make the user feel it. Open with a \`🔬 skalpel ·\`
|
|
310
|
+
line that SPEAKS THE ACTUAL STAT (the shape, the count, the ~minutes) in plain words, name the loop
|
|
311
|
+
out loud, and you MAY briefly own your part ("I kept refining right there with you"). Then give the
|
|
312
|
+
genuinely-different answer the steer implies.
|
|
313
|
+
- SOFT (only sink/funnel/trigger-type signals): earned-only. Surface a \`🔬 skalpel ·\` line ONLY if a
|
|
314
|
+
signal genuinely changes your answer; otherwise a QUIET closing \`🔬 skalpel · told the model to:
|
|
315
|
+
<terse verbs>\` for an invisible tune, or nothing at all. Silence is the default here.
|
|
316
|
+
|
|
317
|
+
The 🔬 line is skalpel's FINDING from the signals, never your own advice. One 🔬 line per turn at most.
|
|
318
|
+
Never theater, never explain the mechanism.
|
|
319
|
+
${CLAUDE_MD_MARK_END}`;
|
|
320
|
+
|
|
321
|
+
function claudeMd() {
|
|
322
|
+
const raw = existsSync(CLAUDE_MD) ? readFileSync(CLAUDE_MD, "utf8") : "";
|
|
323
|
+
let stripped = raw;
|
|
324
|
+
let had = false;
|
|
325
|
+
for (const [start, end] of CLAUDE_MD_MARK_PAIRS) {
|
|
326
|
+
const startIdx = stripped.indexOf(start);
|
|
327
|
+
const endIdx = stripped.indexOf(end);
|
|
328
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx) {
|
|
329
|
+
stripped = (
|
|
330
|
+
stripped.slice(0, startIdx) + stripped.slice(endIdx + end.length)
|
|
331
|
+
).replace(/\n{3,}/g, "\n\n");
|
|
332
|
+
had = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (uninstall) {
|
|
336
|
+
if (!had) return "absent";
|
|
337
|
+
writeFileSync(CLAUDE_MD, stripped.trimEnd() + "\n");
|
|
338
|
+
return "removed";
|
|
339
|
+
}
|
|
340
|
+
const next = stripped.trimEnd()
|
|
341
|
+
? `${stripped.trimEnd()}\n\n${CLAUDE_MD_BLOCK}\n`
|
|
342
|
+
: `${CLAUDE_MD_BLOCK}\n`;
|
|
343
|
+
mkdirSync(dirname(CLAUDE_MD), { recursive: true });
|
|
344
|
+
writeFileSync(CLAUDE_MD, next);
|
|
345
|
+
return had ? "refreshed" : "installed";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
console.log(
|
|
349
|
+
`skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claude()} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
|
|
350
|
+
);
|
|
351
|
+
console.log(
|
|
352
|
+
uninstall
|
|
353
|
+
? "uninstalled."
|
|
354
|
+
: `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
|
|
355
|
+
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// metrics.mjs — injection telemetry, shared by both hooks. One NDJSON line per hook invocation.
|
|
2
|
+
// Append is atomic on POSIX for small writes, so concurrent hook double-fires never race or corrupt
|
|
3
|
+
// the file (a live-mutated counter file WOULD lose increments under that race). record() never throws
|
|
4
|
+
// and never blocks — telemetry must not be able to break a fail-open hook.
|
|
5
|
+
//
|
|
6
|
+
// Read it back with: node ~/.skalpel/hooks/stats.mjs
|
|
7
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const DIR = join(homedir(), ".skalpel");
|
|
12
|
+
export const METRICS_PATH = join(DIR, "metrics.ndjson");
|
|
13
|
+
|
|
14
|
+
// outcome ∈ inject | silent | server_error | abort | fetch_error | skipped | budget_spent
|
|
15
|
+
// `abort` == the fetch hit our deadline == a graph cold-start hit (the whole diagnosis).
|
|
16
|
+
// ms == wall-clock of the network call (null for skipped / pre-network exits).
|
|
17
|
+
export function record(event, outcome, ms) {
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(DIR, { recursive: true });
|
|
20
|
+
appendFileSync(
|
|
21
|
+
METRICS_PATH,
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
ts: new Date().toISOString(),
|
|
24
|
+
event, // "prompt" | "session"
|
|
25
|
+
outcome,
|
|
26
|
+
ms: ms == null ? null : Math.round(ms),
|
|
27
|
+
}) + "\n",
|
|
28
|
+
);
|
|
29
|
+
} catch {
|
|
30
|
+
/* never block a hook on telemetry */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// skalpel SessionStart hook — fires ONCE when a Claude Code / Codex session opens. Injects the
|
|
3
|
+
// user's standing Amplitude profile (recurring patterns, outcome rates, response habits) so the
|
|
4
|
+
// model starts the session already knowing how they work. The per-turn UserPromptSubmit hook
|
|
5
|
+
// handles the specific query (the graph-walk fork); this handles the standing context. Fail-open.
|
|
6
|
+
//
|
|
7
|
+
// Wire it (Claude ~/.claude/settings.json):
|
|
8
|
+
// {"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node …/skalpel-hook-session.mjs"}]}]}}
|
|
9
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { identity } from "./auth.mjs";
|
|
13
|
+
import { record } from "./metrics.mjs";
|
|
14
|
+
import { recordInsight } from "./insights.mjs";
|
|
15
|
+
|
|
16
|
+
// The statusline counter is PER-SESSION: it shows the estimated rework skalpel flagged during THIS
|
|
17
|
+
// session, not a forever-accumulating lifetime total (which reads as a bragging trophy and is
|
|
18
|
+
// unprovable). SessionStart zeroes it so each new session starts the count fresh. Fail-open — a
|
|
19
|
+
// stats reset must never block the profile injection.
|
|
20
|
+
// RECAP — the felt wins, surfaced at a break point (peak-end rule). Two break points:
|
|
21
|
+
// • "compact" → mid-session, right AFTER compaction (a natural chapter break, user present). The
|
|
22
|
+
// recap is CUMULATIVE ("so far this session") and the tally is NOT reset — work goes on.
|
|
23
|
+
// • else → a fresh session start; recap the PREVIOUS session's tally (re-engagement), then reset.
|
|
24
|
+
// PreCompact/SessionEnd stdout are silent in Claude Code; the post-compact SessionStart CAN inject, so
|
|
25
|
+
// that's where the compaction recap rides. Read BEFORE resetStats wipes it.
|
|
26
|
+
const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
|
|
27
|
+
// Returns { note, mins } — the injected directive AND the minutes it cites, so the local insight
|
|
28
|
+
// record (below) can reuse the exact same number instead of re-deriving it.
|
|
29
|
+
function buildRecap(source) {
|
|
30
|
+
try {
|
|
31
|
+
const s = JSON.parse(readFileSync(SESSION_PATH, "utf8"));
|
|
32
|
+
const mins = Math.round(s.mins || 0);
|
|
33
|
+
const steers = Array.isArray(s.steers) ? s.steers : [];
|
|
34
|
+
// Recap only when there's real saved time to report — the honest, felt number. No "caught" count.
|
|
35
|
+
if (mins < 1) return { note: null, mins: 0 };
|
|
36
|
+
const byWt = {};
|
|
37
|
+
for (const st of steers) if (st.wt) byWt[st.wt] = (byWt[st.wt] || 0) + 1;
|
|
38
|
+
const top = Object.entries(byWt).sort((a, b) => b[1] - a[1])[0];
|
|
39
|
+
const trapTail = top ? ` — ${top[0]} kept coming up` : "";
|
|
40
|
+
const compact = source === "compact";
|
|
41
|
+
const when = compact ? "in the stretch before this compaction" : "last session";
|
|
42
|
+
const line = compact
|
|
43
|
+
? `“🔬 skalpel · in that last stretch I saved you ~${mins}min${trapTail} — fresh context now.”`
|
|
44
|
+
: `“🔬 skalpel · last session I saved you ~${mins}min${trapTail}.”`;
|
|
45
|
+
const note =
|
|
46
|
+
`[skalpel — RECAP (${when}): saved ~${mins}min` +
|
|
47
|
+
(top ? `; the recurring trap was ${top[0]} (${top[1]}×)` : "") +
|
|
48
|
+
`. Open THIS message with ONE natural line so they see it landed — ${line} ` +
|
|
49
|
+
"Then continue normally. Skip it only if it doesn't fit.]";
|
|
50
|
+
return { note, mins };
|
|
51
|
+
} catch {
|
|
52
|
+
return { note: null, mins: 0 }; // no prior session / fresh install
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resetStats() {
|
|
57
|
+
try {
|
|
58
|
+
const dir = join(homedir(), ".skalpel");
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
const p = join(dir, "stats.json");
|
|
61
|
+
// Reset the SESSION counters to zero but PRESERVE the lifetime total across sessions — so the
|
|
62
|
+
// statusline can show both "saved this session" and "total saved". Read current first.
|
|
63
|
+
let s = {
|
|
64
|
+
session_hours: 0,
|
|
65
|
+
session_cost: 0,
|
|
66
|
+
session_events: 0,
|
|
67
|
+
total_hours: 0,
|
|
68
|
+
total_cost: 0,
|
|
69
|
+
total_events: 0,
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
s = { ...s, ...JSON.parse(readFileSync(p, "utf8")) };
|
|
73
|
+
} catch {
|
|
74
|
+
/* fresh */
|
|
75
|
+
}
|
|
76
|
+
s.session_hours = 0;
|
|
77
|
+
s.session_cost = 0;
|
|
78
|
+
s.session_events = 0;
|
|
79
|
+
const tmp = `${p}.tmp`;
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(s));
|
|
81
|
+
renameSync(tmp, p);
|
|
82
|
+
// also clear any pending n+1 prediction from the prior session — a stale prediction must never
|
|
83
|
+
// false-credit an avoided detour across a session boundary.
|
|
84
|
+
writeFileSync(join(dir, "pending.json"), "{}");
|
|
85
|
+
writeFileSync(join(dir, "steer.json"), "{}"); // no live steer until the first prompt this session
|
|
86
|
+
writeFileSync(join(dir, "traj.json"), "[]"); // fresh work-type trajectory each session
|
|
87
|
+
writeFileSync(join(dir, "session.json"), JSON.stringify({ steers: [], caught: 0, mins: 0 })); // recap tally
|
|
88
|
+
// clear the SESSION-scoped mute (a shush lasts one session), but PRESERVE persistent prefs
|
|
89
|
+
// (per-trap "fewer"/"more", usefulness scores) — those are the learned personalization.
|
|
90
|
+
try {
|
|
91
|
+
const pp = join(dir, "prefs.json");
|
|
92
|
+
const pr = JSON.parse(readFileSync(pp, "utf8"));
|
|
93
|
+
if (pr.muted_session) {
|
|
94
|
+
pr.muted_session = false;
|
|
95
|
+
writeFileSync(pp, JSON.stringify(pr));
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
/* no prefs yet */
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* never block on stats */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Config precedence: env > ~/.skalpel/client.json (written by `skalpel-prosumer setup`) > default.
|
|
106
|
+
// Identity: the uid from the Google sign-in (~/.skalpel/auth.json); SKALPEL_USER overrides for dev.
|
|
107
|
+
function fileCfg() {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(readFileSync(join(homedir(), ".skalpel", "client.json"), "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const CFG = fileCfg();
|
|
115
|
+
const API = process.env.SKALPEL_API || CFG.api || "https://graph.skalpel.ai";
|
|
116
|
+
// Shared wall-clock budget for the whole hook (auth-refresh + insights fetch): cap total time so it
|
|
117
|
+
// self-aborts before the harness kill (settings timeout is 8s), and record every outcome. This is
|
|
118
|
+
// MORE generous than the per-turn hook's 4.5s on purpose: /retrieve is kept warm (~1.8s) but /insights
|
|
119
|
+
// is NOT, and its cold-start loads the full graph (corpus + index + agg_dag). At 4.5s a cold /insights
|
|
120
|
+
// aborted -> the whole SessionStart profile silently vanished (no error, no injection). 7s fits a cold
|
|
121
|
+
// load with ~1s margin under the 8s backstop; warm it still returns in ~300ms, so no cost when hot.
|
|
122
|
+
const DEADLINE_MS = 7000;
|
|
123
|
+
|
|
124
|
+
async function main() {
|
|
125
|
+
let payload = {};
|
|
126
|
+
try {
|
|
127
|
+
payload = JSON.parse(readFileSync(0, "utf8") || "{}");
|
|
128
|
+
} catch {
|
|
129
|
+
/* drain stdin, ignore */
|
|
130
|
+
}
|
|
131
|
+
// source distinguishes a real new session (startup/resume/clear) from a mid-session compaction.
|
|
132
|
+
const source = payload.source || payload.hookSpecificOutput?.source || "startup";
|
|
133
|
+
const { note: recapNote, mins: recapMins } = buildRecap(source); // read the tally BEFORE resetStats wipes it
|
|
134
|
+
// Compaction CONTINUES the session — keep the counter + tally running; only a real start zeroes them.
|
|
135
|
+
if (source !== "compact") resetStats();
|
|
136
|
+
// identity from the Google sign-in (refreshed if near expiry); the token authenticates the read so
|
|
137
|
+
// the server keys the profile by the VERIFIED uid. No token (dev/not signed in) -> server fallback.
|
|
138
|
+
const deadline = Date.now() + DEADLINE_MS;
|
|
139
|
+
const { uid, token } = await identity();
|
|
140
|
+
const user = uid || CFG.user || "anon";
|
|
141
|
+
const remaining = deadline - Date.now();
|
|
142
|
+
if (remaining <= 100) {
|
|
143
|
+
record("session", "budget_spent", DEADLINE_MS);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const ctrl = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => ctrl.abort(), remaining);
|
|
148
|
+
const t0 = Date.now();
|
|
149
|
+
let context = null;
|
|
150
|
+
let nSessions = null; // sessions of history behind the standing profile — for the insight record
|
|
151
|
+
let outcome = "fetch_error";
|
|
152
|
+
try {
|
|
153
|
+
const r = await fetch(`${API}/insights?user_id=${encodeURIComponent(user)}`, {
|
|
154
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
155
|
+
signal: ctrl.signal,
|
|
156
|
+
});
|
|
157
|
+
if (r.ok) {
|
|
158
|
+
const j = await r.json();
|
|
159
|
+
context = j?.context;
|
|
160
|
+
nSessions = typeof j?.n_sessions === "number" ? j.n_sessions : null;
|
|
161
|
+
outcome = context ? "inject" : "silent";
|
|
162
|
+
} else {
|
|
163
|
+
outcome = "server_error";
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
outcome = e?.name === "AbortError" ? "abort" : "fetch_error";
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
record("session", outcome, Date.now() - t0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Recap (last session's felt wins) leads, then the standing profile. Either can fire alone — a first
|
|
173
|
+
// session has no recap; a session with no graph yet has no profile but can still recap.
|
|
174
|
+
const additionalContext = [recapNote, context].filter(Boolean).join("\n\n");
|
|
175
|
+
|
|
176
|
+
// LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — plain-English rows for the TUI, one per
|
|
177
|
+
// note that fired. recordInsight swallows everything, so these never affect the hook output.
|
|
178
|
+
if (recapNote)
|
|
179
|
+
recordInsight({
|
|
180
|
+
kind: "recap",
|
|
181
|
+
display: `last session skalpel saved you ~${recapMins}min`,
|
|
182
|
+
mins: recapMins,
|
|
183
|
+
});
|
|
184
|
+
if (context)
|
|
185
|
+
recordInsight({
|
|
186
|
+
kind: "profile",
|
|
187
|
+
display:
|
|
188
|
+
nSessions != null
|
|
189
|
+
? `standing profile loaded — ${nSessions} sessions of history`
|
|
190
|
+
: "standing profile loaded",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (additionalContext) {
|
|
194
|
+
process.stdout.write(
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
main()
|
|
203
|
+
.then(() => process.exit(0))
|
|
204
|
+
.catch(() => process.exit(0));
|