skalpel 3.4.17 → 3.4.19
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 +6 -6
- package/postinstall/lib/prosumer-hooks.js +2 -0
- package/postinstall/lib/prosumer-hooks.test.js +42 -0
- package/prosumer-hooks/bootstrap.mjs +147 -24
- package/prosumer-hooks/bootstrap.test.mjs +93 -0
- package/prosumer-hooks/incremental-ingest.mjs +530 -0
- package/prosumer-hooks/incremental-ingest.test.mjs +320 -0
- package/prosumer-hooks/install.mjs +28 -5
- package/prosumer-hooks/skalpel-hook-session-end.mjs +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skalpel",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.19",
|
|
4
4
|
"description": "Skalpel — local proxy and TUI for coding agents (skalpel + skalpeld bundle).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://skalpel.ai",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"x64"
|
|
60
60
|
],
|
|
61
61
|
"optionalDependencies": {
|
|
62
|
-
"@skalpelai/skalpel-darwin-arm64": "3.4.
|
|
63
|
-
"@skalpelai/skalpel-darwin-x64": "3.4.
|
|
64
|
-
"@skalpelai/skalpel-linux-arm64": "3.4.
|
|
65
|
-
"@skalpelai/skalpel-linux-x64": "3.4.
|
|
66
|
-
"@skalpelai/skalpel-win32-x64": "3.4.
|
|
62
|
+
"@skalpelai/skalpel-darwin-arm64": "3.4.19",
|
|
63
|
+
"@skalpelai/skalpel-darwin-x64": "3.4.19",
|
|
64
|
+
"@skalpelai/skalpel-linux-arm64": "3.4.19",
|
|
65
|
+
"@skalpelai/skalpel-linux-x64": "3.4.19",
|
|
66
|
+
"@skalpelai/skalpel-win32-x64": "3.4.19"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -8,11 +8,13 @@ const { spawnSync } = require('child_process');
|
|
|
8
8
|
const RUNTIME_FILES = [
|
|
9
9
|
'auth.mjs',
|
|
10
10
|
'bootstrap.mjs',
|
|
11
|
+
'incremental-ingest.mjs',
|
|
11
12
|
'insights.mjs',
|
|
12
13
|
'metrics.mjs',
|
|
13
14
|
'stats.mjs',
|
|
14
15
|
'skalpel-hook.mjs',
|
|
15
16
|
'skalpel-hook-session.mjs',
|
|
17
|
+
'skalpel-hook-session-end.mjs',
|
|
16
18
|
'skalpel-statusline.mjs',
|
|
17
19
|
'install.mjs',
|
|
18
20
|
];
|
|
@@ -13,6 +13,13 @@ const { spawnSync } = require('child_process');
|
|
|
13
13
|
const hooksLib = path.join(__dirname, 'prosumer-hooks.js');
|
|
14
14
|
const statusline = path.resolve(__dirname, '..', '..', 'prosumer-hooks', 'skalpel-statusline.mjs');
|
|
15
15
|
const bootstrap = path.resolve(__dirname, '..', '..', 'prosumer-hooks', 'bootstrap.mjs');
|
|
16
|
+
const sessionEnd = path.resolve(
|
|
17
|
+
__dirname,
|
|
18
|
+
'..',
|
|
19
|
+
'..',
|
|
20
|
+
'prosumer-hooks',
|
|
21
|
+
'skalpel-hook-session-end.mjs'
|
|
22
|
+
);
|
|
16
23
|
const packageJson = path.resolve(__dirname, '..', '..', 'package.json');
|
|
17
24
|
|
|
18
25
|
let pass = 0;
|
|
@@ -104,6 +111,7 @@ function run() {
|
|
|
104
111
|
'npm package files must include the CLI-owned hook/status-line runtime',
|
|
105
112
|
);
|
|
106
113
|
assert.ok(fs.existsSync(statusline), 'status-line source is missing from the packaged directory');
|
|
114
|
+
assert.ok(fs.existsSync(sessionEnd), 'SessionEnd ingest hook is missing from the package');
|
|
107
115
|
});
|
|
108
116
|
|
|
109
117
|
test('graph bootstrap never owns or mutates behavior hooks', () => {
|
|
@@ -206,8 +214,42 @@ function run() {
|
|
|
206
214
|
const cfg = settings(home);
|
|
207
215
|
assert.ok(cfg.hooks.UserPromptSubmit.length > 0);
|
|
208
216
|
assert.ok(cfg.hooks.SessionStart.length > 0);
|
|
217
|
+
assert.strictEqual(cfg.hooks.SessionEnd.length, 1);
|
|
218
|
+
const endHandler = cfg.hooks.SessionEnd[0].hooks[0];
|
|
219
|
+
assert.match(endHandler.command, /skalpel-hook-session-end\.mjs$/);
|
|
220
|
+
assert.strictEqual(endHandler.async, true, 'SessionEnd ingest must not block session exit');
|
|
221
|
+
assert.strictEqual(endHandler.timeout, 55);
|
|
209
222
|
assert.match(cfg.statusLine.command, /skalpel-statusline\.mjs$/);
|
|
210
223
|
assert.strictEqual(fs.existsSync(path.join(home, '.claude', 'CLAUDE.md')), true);
|
|
224
|
+
assert.strictEqual(
|
|
225
|
+
fs.existsSync(path.join(home, '.skalpel', 'hooks', 'incremental-ingest.mjs')),
|
|
226
|
+
true,
|
|
227
|
+
'durable outbox module must be staged with the entrypoint',
|
|
228
|
+
);
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('Codex keeps next-launch fallback instead of installing unsupported SessionEnd', () => {
|
|
235
|
+
const home = tempHome({ claude: true });
|
|
236
|
+
try {
|
|
237
|
+
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path.join(home, '.claude', 'settings.json'),
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
hooks: {
|
|
242
|
+
UserPromptSubmit: [
|
|
243
|
+
{ hooks: [{ type: 'command', command: 'node /old/skalpel-hook.mjs' }] },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
const result = stage(home);
|
|
249
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
250
|
+
const codex = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8');
|
|
251
|
+
assert.doesNotMatch(codex, /SessionEnd/);
|
|
252
|
+
assert.ok(settings(home).hooks.SessionEnd.length > 0, 'Claude should still receive SessionEnd');
|
|
211
253
|
} finally {
|
|
212
254
|
fs.rmSync(home, { recursive: true, force: true });
|
|
213
255
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { basename, join } from "node:path";
|
|
21
21
|
import { createInterface } from "node:readline";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
22
23
|
import { gzipSync } from "node:zlib";
|
|
23
24
|
import { identity } from "./auth.mjs";
|
|
24
25
|
import { recordInsight } from "./insights.mjs";
|
|
@@ -222,6 +223,75 @@ async function postIngest(payload, token, compressed) {
|
|
|
222
223
|
});
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
function count(value) {
|
|
227
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function serverHeartbeat(status) {
|
|
231
|
+
for (const key of ["heartbeat_at", "updated_at", "last_heartbeat_at"]) {
|
|
232
|
+
if (typeof status?.[key] === "string" && status[key]) return status[key];
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function activeMessage(state, errors, heartbeat) {
|
|
238
|
+
let message;
|
|
239
|
+
switch (state) {
|
|
240
|
+
case "queued":
|
|
241
|
+
message = "Graph build queued";
|
|
242
|
+
break;
|
|
243
|
+
case "waiting_quota":
|
|
244
|
+
message = "Waiting for graph capacity";
|
|
245
|
+
break;
|
|
246
|
+
case "running":
|
|
247
|
+
case "judging":
|
|
248
|
+
message = "Learning from your sessions";
|
|
249
|
+
break;
|
|
250
|
+
default:
|
|
251
|
+
message = "Building your intent graph";
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (errors > 0) message += ` · ${errors} session${errors === 1 ? "" : "s"} failed`;
|
|
255
|
+
if (heartbeat) message += " · server active";
|
|
256
|
+
return message;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Normalize the server's evolving job-status contract into the stable NDJSON row consumed by
|
|
260
|
+
// the Go TUI. Production historically returned `running`; the hook used to recognize only
|
|
261
|
+
// `judging`, leaving a real 2/16 build painted forever as 0/16. Unknown nonterminal states remain
|
|
262
|
+
// visible instead of being silently discarded, so adding a server phase is backward-compatible.
|
|
263
|
+
export function normalizeJobStatus(status = {}) {
|
|
264
|
+
const state = typeof status.state === "string" && status.state ? status.state : "running";
|
|
265
|
+
const done = count(status.done);
|
|
266
|
+
const total = count(status.total);
|
|
267
|
+
const errors = count(status.errors);
|
|
268
|
+
const heartbeat = serverHeartbeat(status);
|
|
269
|
+
const terminal = status.terminal === true;
|
|
270
|
+
const requiresReplay = status.requires_replay === true;
|
|
271
|
+
const failed = state === "error" || state === "failed" || (terminal && requiresReplay);
|
|
272
|
+
const complete = state === "done";
|
|
273
|
+
return {
|
|
274
|
+
state,
|
|
275
|
+
done,
|
|
276
|
+
total,
|
|
277
|
+
errors,
|
|
278
|
+
heartbeat,
|
|
279
|
+
terminal,
|
|
280
|
+
requiresReplay,
|
|
281
|
+
complete,
|
|
282
|
+
failed,
|
|
283
|
+
sessions: count(status.n_sessions ?? done),
|
|
284
|
+
message: failed
|
|
285
|
+
? String(
|
|
286
|
+
status.error ||
|
|
287
|
+
(requiresReplay
|
|
288
|
+
? "graph capacity was exhausted; retry the build"
|
|
289
|
+
: "server graph build failed"),
|
|
290
|
+
)
|
|
291
|
+
: activeMessage(state, errors, heartbeat),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
225
295
|
async function pollJob(jobId, uid, token) {
|
|
226
296
|
const statusUrl = `${API}/ingest/status?job_id=${encodeURIComponent(jobId)}`;
|
|
227
297
|
let lastProgress = "";
|
|
@@ -236,18 +306,49 @@ async function pollJob(jobId, uid, token) {
|
|
|
236
306
|
if (response.status === 401) throw new Error("session expired; run `skalpel login` and retry");
|
|
237
307
|
if (!response.ok) continue;
|
|
238
308
|
const status = await response.json().catch(() => ({}));
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
throw new Error("server graph build failed");
|
|
309
|
+
const progress = normalizeJobStatus(status);
|
|
310
|
+
if (progress.complete) {
|
|
311
|
+
return { sessions: progress.sessions, errors: progress.errors };
|
|
243
312
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
313
|
+
if (progress.failed) {
|
|
314
|
+
writeState({
|
|
315
|
+
state: "failed",
|
|
316
|
+
server_state: progress.state,
|
|
317
|
+
user_id: uid,
|
|
318
|
+
job_id: jobId,
|
|
319
|
+
done: progress.done,
|
|
320
|
+
total: progress.total,
|
|
321
|
+
errors: progress.errors,
|
|
322
|
+
heartbeat: progress.heartbeat,
|
|
323
|
+
error: progress.message,
|
|
324
|
+
});
|
|
325
|
+
throw new Error(progress.message);
|
|
326
|
+
}
|
|
327
|
+
const signature = JSON.stringify([
|
|
328
|
+
progress.state,
|
|
329
|
+
progress.done,
|
|
330
|
+
progress.total,
|
|
331
|
+
progress.errors,
|
|
332
|
+
progress.heartbeat,
|
|
333
|
+
]);
|
|
334
|
+
// Persist every successful poll as a local liveness heartbeat. Emit only when the server's
|
|
335
|
+
// observable state changes; Bubble Tea advances its own spinner between status rows.
|
|
336
|
+
writeState({
|
|
337
|
+
state: progress.state,
|
|
338
|
+
user_id: uid,
|
|
339
|
+
job_id: jobId,
|
|
340
|
+
done: progress.done,
|
|
341
|
+
total: progress.total,
|
|
342
|
+
errors: progress.errors,
|
|
343
|
+
heartbeat: progress.heartbeat,
|
|
344
|
+
});
|
|
345
|
+
if (signature !== lastProgress) {
|
|
346
|
+
lastProgress = signature;
|
|
347
|
+
emit(progress.state, progress.message, {
|
|
348
|
+
done: progress.done,
|
|
349
|
+
total: progress.total,
|
|
350
|
+
errors: progress.errors,
|
|
351
|
+
heartbeat: progress.heartbeat,
|
|
251
352
|
});
|
|
252
353
|
}
|
|
253
354
|
}
|
|
@@ -308,6 +409,7 @@ async function ingestAndFinish(files, auth, runStartMs) {
|
|
|
308
409
|
|
|
309
410
|
const accepted = await response.json().catch(() => ({}));
|
|
310
411
|
let sessions;
|
|
412
|
+
let errors = 0;
|
|
311
413
|
if (typeof accepted.n_sessions === "number") {
|
|
312
414
|
sessions = accepted.n_sessions;
|
|
313
415
|
} else if (accepted.job_id) {
|
|
@@ -321,14 +423,18 @@ async function ingestAndFinish(files, auth, runStartMs) {
|
|
|
321
423
|
done: 0,
|
|
322
424
|
total: payload.sessions,
|
|
323
425
|
});
|
|
324
|
-
|
|
426
|
+
const result = await pollJob(accepted.job_id, auth.uid, auth.token);
|
|
427
|
+
sessions = result.sessions;
|
|
428
|
+
errors = result.errors;
|
|
325
429
|
} else {
|
|
326
430
|
throw new Error("ingest response did not include a graph job");
|
|
327
431
|
}
|
|
328
432
|
|
|
329
|
-
emit(
|
|
330
|
-
|
|
331
|
-
|
|
433
|
+
emit(
|
|
434
|
+
"building",
|
|
435
|
+
`Building your graph from ${sessions} session${sessions === 1 ? "" : "s"}${errors > 0 ? ` · ${errors} failed` : ""}…`,
|
|
436
|
+
{ sessions, errors },
|
|
437
|
+
);
|
|
332
438
|
const profile = await fetchProfile(auth.uid, auth.token);
|
|
333
439
|
recordProfile(profile, sessions);
|
|
334
440
|
writeState({
|
|
@@ -364,11 +470,15 @@ async function main() {
|
|
|
364
470
|
return;
|
|
365
471
|
}
|
|
366
472
|
|
|
367
|
-
|
|
473
|
+
// --force is the explicit replay path for a terminal job. Re-polling its old job_id can only
|
|
474
|
+
// return the same idempotent terminal result; a fresh scan/POST is required.
|
|
475
|
+
if (!FORCE && prior.user_id === auth.uid && prior.job_id && prior.state !== "done") {
|
|
368
476
|
emit("resuming", "Resuming the existing graph build");
|
|
369
|
-
const
|
|
477
|
+
const result = await pollJob(prior.job_id, auth.uid, auth.token);
|
|
478
|
+
const { sessions, errors } = result;
|
|
370
479
|
emit("building", `Building your graph from ${sessions} session${sessions === 1 ? "" : "s"}…`, {
|
|
371
480
|
sessions,
|
|
481
|
+
errors,
|
|
372
482
|
});
|
|
373
483
|
const profile = await fetchProfile(auth.uid, auth.token);
|
|
374
484
|
recordProfile(profile, sessions);
|
|
@@ -402,18 +512,31 @@ async function main() {
|
|
|
402
512
|
await ingestAndFinish(files, auth, runStartMs);
|
|
403
513
|
}
|
|
404
514
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
515
|
+
async function run() {
|
|
516
|
+
if (!acquireLock()) {
|
|
517
|
+
emit("skipped", "Coding history is already being learned in another skalpel window", {
|
|
518
|
+
terminal: true,
|
|
519
|
+
});
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
await main()
|
|
411
523
|
.catch((error) => {
|
|
412
524
|
const prior = readState();
|
|
413
525
|
const message = String(error?.message || error).replace(/[\r\n\t]+/g, " ").slice(0, 240);
|
|
414
526
|
writeState({ ...prior, state: "failed", error: message });
|
|
415
|
-
emit("failed", message, {
|
|
527
|
+
emit("failed", message, {
|
|
528
|
+
terminal: true,
|
|
529
|
+
retryable: true,
|
|
530
|
+
done: prior.done ?? 0,
|
|
531
|
+
total: prior.total ?? 0,
|
|
532
|
+
errors: prior.errors ?? 0,
|
|
533
|
+
heartbeat: prior.heartbeat ?? null,
|
|
534
|
+
});
|
|
416
535
|
process.exitCode = 1;
|
|
417
536
|
})
|
|
418
537
|
.finally(releaseLock);
|
|
419
538
|
}
|
|
539
|
+
|
|
540
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
541
|
+
await run();
|
|
542
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { normalizeJobStatus } from "./bootstrap.mjs";
|
|
5
|
+
|
|
6
|
+
test("running jobs expose real counters instead of staying at zero", () => {
|
|
7
|
+
assert.deepEqual(
|
|
8
|
+
normalizeJobStatus({ state: "running", done: 2, total: 16, errors: 0 }),
|
|
9
|
+
{
|
|
10
|
+
state: "running",
|
|
11
|
+
done: 2,
|
|
12
|
+
total: 16,
|
|
13
|
+
errors: 0,
|
|
14
|
+
heartbeat: null,
|
|
15
|
+
terminal: false,
|
|
16
|
+
requiresReplay: false,
|
|
17
|
+
complete: false,
|
|
18
|
+
failed: false,
|
|
19
|
+
sessions: 2,
|
|
20
|
+
message: "Learning from your sessions",
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("waiting_quota remains nonterminal and carries failures plus heartbeat", () => {
|
|
26
|
+
const progress = normalizeJobStatus({
|
|
27
|
+
state: "waiting_quota",
|
|
28
|
+
done: 5,
|
|
29
|
+
total: 16,
|
|
30
|
+
errors: 1,
|
|
31
|
+
heartbeat_at: "2026-07-12T04:00:00Z",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
assert.equal(progress.failed, false);
|
|
35
|
+
assert.equal(progress.complete, false);
|
|
36
|
+
assert.equal(progress.done, 5);
|
|
37
|
+
assert.equal(progress.total, 16);
|
|
38
|
+
assert.equal(progress.errors, 1);
|
|
39
|
+
assert.equal(progress.heartbeat, "2026-07-12T04:00:00Z");
|
|
40
|
+
assert.equal(progress.message, "Waiting for graph capacity · 1 session failed · server active");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("terminal waiting_quota tells the caller to create a fresh replay", () => {
|
|
44
|
+
const progress = normalizeJobStatus({
|
|
45
|
+
state: "waiting_quota",
|
|
46
|
+
done: 2,
|
|
47
|
+
total: 16,
|
|
48
|
+
errors: 0,
|
|
49
|
+
terminal: true,
|
|
50
|
+
requires_replay: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(progress.failed, true);
|
|
54
|
+
assert.equal(progress.terminal, true);
|
|
55
|
+
assert.equal(progress.requiresReplay, true);
|
|
56
|
+
assert.equal(progress.message, "graph capacity was exhausted; retry the build");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("unknown future phases stay visible as active work", () => {
|
|
60
|
+
const progress = normalizeJobStatus({ state: "embedding", done: 9, total: 16 });
|
|
61
|
+
assert.equal(progress.state, "embedding");
|
|
62
|
+
assert.equal(progress.failed, false);
|
|
63
|
+
assert.equal(progress.complete, false);
|
|
64
|
+
assert.equal(progress.message, "Building your intent graph");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("done and error states retain authoritative terminal detail", () => {
|
|
68
|
+
const complete = normalizeJobStatus({
|
|
69
|
+
state: "done",
|
|
70
|
+
done: 14,
|
|
71
|
+
total: 16,
|
|
72
|
+
errors: 2,
|
|
73
|
+
n_sessions: 14,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(complete.complete, true);
|
|
76
|
+
assert.equal(complete.sessions, 14);
|
|
77
|
+
assert.equal(complete.errors, 2);
|
|
78
|
+
|
|
79
|
+
const failed = normalizeJobStatus({
|
|
80
|
+
state: "error",
|
|
81
|
+
done: 2,
|
|
82
|
+
total: 16,
|
|
83
|
+
errors: 14,
|
|
84
|
+
error: "provider quota exhausted",
|
|
85
|
+
updated_at: "2026-07-12T04:01:00Z",
|
|
86
|
+
});
|
|
87
|
+
assert.equal(failed.failed, true);
|
|
88
|
+
assert.equal(failed.message, "provider quota exhausted");
|
|
89
|
+
assert.equal(failed.done, 2);
|
|
90
|
+
assert.equal(failed.total, 16);
|
|
91
|
+
assert.equal(failed.errors, 14);
|
|
92
|
+
assert.equal(failed.heartbeat, "2026-07-12T04:01:00Z");
|
|
93
|
+
});
|