skalpel 3.4.17 → 3.4.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.4.17",
3
+ "version": "3.4.18",
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.17",
63
- "@skalpelai/skalpel-darwin-x64": "3.4.17",
64
- "@skalpelai/skalpel-linux-arm64": "3.4.17",
65
- "@skalpelai/skalpel-linux-x64": "3.4.17",
66
- "@skalpelai/skalpel-win32-x64": "3.4.17"
62
+ "@skalpelai/skalpel-darwin-arm64": "3.4.18",
63
+ "@skalpelai/skalpel-darwin-x64": "3.4.18",
64
+ "@skalpelai/skalpel-linux-arm64": "3.4.18",
65
+ "@skalpelai/skalpel-linux-x64": "3.4.18",
66
+ "@skalpelai/skalpel-win32-x64": "3.4.18"
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
- if (status.state === "done") return status.n_sessions ?? 0;
240
- if (status.state === "error") {
241
- writeState({ state: "failed", user_id: uid, error: "server graph build failed" });
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
- const progress = `${status.done ?? 0}/${status.total ?? 0}`;
245
- if (status.state === "judging" && progress !== lastProgress) {
246
- lastProgress = progress;
247
- writeState({ state: "judging", user_id: uid, job_id: jobId, ...status });
248
- emit("judging", `Judging your sessions… ${progress}`, {
249
- done: status.done ?? 0,
250
- total: status.total ?? 0,
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
- sessions = await pollJob(accepted.job_id, auth.uid, auth.token);
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("building", `Building your graph from ${sessions} session${sessions === 1 ? "" : "s"}…`, {
330
- sessions,
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
- if (prior.user_id === auth.uid && prior.job_id && prior.state !== "done") {
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 sessions = await pollJob(prior.job_id, auth.uid, auth.token);
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
- if (!acquireLock()) {
406
- emit("skipped", "Coding history is already being learned in another skalpel window", {
407
- terminal: true,
408
- });
409
- } else {
410
- main()
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, { terminal: true, retryable: true });
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
+ });