skalpel 4.0.35 → 4.0.36

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/analytics.mjs ADDED
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env node
2
+ // analytics.mjs — `skalpel analytics`: an INTERACTIVE, keyboard-navigable, LOCAL analytics view.
3
+ //
4
+ // WHAT IT IS: one screen over the honest data skalpel already keeps on this machine — the caught
5
+ // lies (verify-shadow.log FALSE rows), the MEASURED red→green cost (real timestamp deltas only),
6
+ // the steer count, and the per-session re-ask trend — navigable with ↑/↓ (or j/k), Enter to drill
7
+ // into a catch, Esc/h/Backspace to go back, q or Ctrl-C to quit.
8
+ //
9
+ // HARD RED LINES (same contract as ledger.mjs / card.mjs):
10
+ // • READ-ONLY + LOCAL + ZERO-NETWORK. Imports only node: core + local sibling modules (their CLI
11
+ // blocks are isMain-guarded, so importing runs nothing). Opens no socket, re-runs no proof,
12
+ // writes no file. It renders what is already on disk — nothing else.
13
+ // • NO FABRICATED / ESTIMATED NUMBERS. Every figure reproduces from a real logged row:
14
+ // - caught lies = ledger FALSE rows (mismatch:true) in verify-shadow.log
15
+ // - measured cost = Σ of (later same-session same-proof PASS ts − caught-FALSE ts) — real
16
+ // timestamps via measureFixDelta; a catch with no later PASS contributes NOTHING
17
+ // - steers = count of valid JSON lines in ~/.skalpel/steers.ndjson (the append-only
18
+ // STEERS SOURCE CONTRACT log; one atomic line per APPLIED steer) — a COUNT only; the banned
19
+ // "minutes saved" metric never appears
20
+ // - re-ask trend = per-session counts of recorded re-ask/interrupt resets in
21
+ // ~/.skalpel/insights.ndjson (metrics.ndjson rows carry no session identity, so deriving a
22
+ // per-session series from them would be invention — we don't)
23
+ // Anything absent renders as an honest "no data yet" — never a made-up zero-padded trend.
24
+ // • NODE CORE ONLY. No npm dependency (no ink/blessed/chalk) — a new dep is itself a
25
+ // cross-device + supply-chain risk for a read-only stats viewer.
26
+ // • THE TERMINAL IS SACRED. Non-TTY (pipe/CI) prints a plain static snapshot and exits 0 without
27
+ // ever touching raw mode. Interactive mode uses the ALTERNATE screen (scrollback untouched) and
28
+ // ONE idempotent cleanup() — raw off, cursor shown, alt-screen left, listeners removed — that
29
+ // runs on EVERY exit path: q/Esc/Ctrl-C, 'exit', SIGINT/SIGTERM/SIGHUP, and any thrown error.
30
+ // • FAIL-OPEN. Any read/parse/render error → cleanup + one honest plain line + exit 0. Never a
31
+ // stack trace at the user, never a hung or broken terminal.
32
+ import { readFileSync } from "node:fs";
33
+ import { realpathSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { emitKeypressEvents } from "node:readline";
38
+ // READ-ONLY reuse of the honest engine. ledger.mjs derives everything from verify-shadow.log;
39
+ // verify-shadow.mjs single-sources the log path + the fail-count extractor. Importing either runs
40
+ // nothing (their CLI blocks are isMain-guarded).
41
+ import { readLedgerRows, buildLedger, measureFixDelta, formatDuration } from "./ledger.mjs";
42
+ import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
43
+
44
+ // ---------- palette (the same vocabulary as ledger.mjs / card.mjs / the statusline) ----------
45
+ const R = "\x1b[38;2;233;38;31m"; // skalpel red
46
+ const G = "\x1b[38;2;124;184;108m"; // success green
47
+ const CYAN = "\x1b[36m";
48
+ const BOLD = "\x1b[1m";
49
+ const DIM = "\x1b[2m";
50
+ const RESET = "\x1b[0m";
51
+
52
+ const SKALPEL_DIR = join(homedir(), ".skalpel");
53
+ const STEERS_PATH = join(SKALPEL_DIR, "steers.ndjson");
54
+ const INSIGHTS_PATH = join(SKALPEL_DIR, "insights.ndjson");
55
+ // Bound every read so a pathological file can never OOM us (same cap as card.mjs).
56
+ const MAX_READ_BYTES = 4 * 1024 * 1024;
57
+ const SPARK = "▁▂▃▄▅▆▇";
58
+
59
+ // ---------- sanitize (claims/evidence come from captured agent output — never trust them) ----------
60
+ // Strip ANSI CSI sequences + other C0 control chars so logged content can never inject its own
61
+ // escape codes into the live terminal (same defense as card.mjs), then collapse whitespace and cap.
62
+ export function clean(s, n) {
63
+ const t = String(s == null ? "" : s)
64
+ // eslint-disable-next-line no-control-regex
65
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
66
+ // eslint-disable-next-line no-control-regex
67
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
68
+ .replace(/\s+/g, " ")
69
+ .trim();
70
+ if (n && t.length > n) return t.slice(0, n - 1) + "…";
71
+ return t;
72
+ }
73
+
74
+ // ---------- (1) gather the four panels — every number derived from real local data ----------
75
+ // Steers: the honest count from ~/.skalpel/steers.ndjson — the append-only STEERS SOURCE CONTRACT
76
+ // log (see skalpel-hook.mjs): one atomic line per APPLIED steer, {"ts",saved_min_delta}, never
77
+ // rewritten. This MIRRORS the merged skalpel-statusline.mjs readState() exactly: steers = count of
78
+ // valid JSON lines (trim, skip blank/garbage, JSON.parse guard, must be a non-null object);
79
+ // saved_min = Σ of finite positive saved_min_delta (rounded 1 dp). Readers MUST fail open
80
+ // (absent/unreadable → {steers:0, saved_min:0}) and MUST NOT mutate the file. The whole file is
81
+ // read for an EXACT count (the TUI renders on demand, not every terminal tick — no hot-path cost);
82
+ // the try/catch fails open on a huge/corrupt file, so it never hangs. The banned "minutes saved"
83
+ // metric is never displayed — savedMin is computed only to honor the contract, not shown.
84
+ export function readSteersState(steersPath = STEERS_PATH) {
85
+ try {
86
+ const raw = readFileSync(steersPath, "utf8");
87
+ let steers = 0;
88
+ let savedMin = 0;
89
+ for (const line of raw.split("\n")) {
90
+ const l = line.trim();
91
+ if (!l) continue;
92
+ let rec;
93
+ try {
94
+ rec = JSON.parse(l);
95
+ } catch {
96
+ continue; // a partial/garbage line — skip it (fail-open), keep counting the rest
97
+ }
98
+ if (!rec || typeof rec !== "object") continue;
99
+ steers += 1; // one valid line == one applied steer (append-only ⇒ exact, race-free count)
100
+ if (Number.isFinite(rec.saved_min_delta) && rec.saved_min_delta > 0)
101
+ savedMin += rec.saved_min_delta;
102
+ }
103
+ return { steers, savedMin: Math.round(savedMin * 10) / 10 };
104
+ } catch {
105
+ return { steers: 0, savedMin: 0 }; // absent/unreadable → honest zero-state (fail-open)
106
+ }
107
+ }
108
+
109
+ // Re-ask trend: per-session counts of recorded re-ask/interrupt resets from insights.ndjson.
110
+ // A `kind:"reset"` row is the hook's record of a REAL detected re-ask cascade ("we've gone back
111
+ // and forth N× — resetting"), stamped with session_id. Sessions appear in file order (the file is
112
+ // append-only, so first appearance == chronological order); a session with rows but no resets is
113
+ // an honest 0. metrics.ndjson is NOT a fallback: its rows ({ts,event,outcome,ms}) carry no session
114
+ // identity and no re-ask signal, so no per-session series can be derived from it without inventing
115
+ // one. No usable rows → null (rendered "no data yet").
116
+ const RE_ASK_KINDS = new Set(["reset", "re_ask", "reask", "re-ask", "interrupt"]);
117
+ export function readReaskTrend(insightsPath = INSIGHTS_PATH) {
118
+ let raw;
119
+ try {
120
+ raw = readFileSync(insightsPath, "utf8");
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (raw.length > MAX_READ_BYTES) raw = raw.slice(-MAX_READ_BYTES);
125
+ const order = [];
126
+ const bySession = new Map();
127
+ for (const line of raw.split("\n")) {
128
+ const l = line.trim();
129
+ if (!l) continue;
130
+ let r;
131
+ try {
132
+ r = JSON.parse(l);
133
+ } catch {
134
+ continue; // partial/garbage line — skip (fail-open)
135
+ }
136
+ const sid = typeof r?.session_id === "string" && r.session_id ? r.session_id : null;
137
+ if (!sid) continue; // no session identity → can't attribute honestly
138
+ if (!bySession.has(sid)) {
139
+ bySession.set(sid, { session: sid, count: 0, firstTs: r.ts || null });
140
+ order.push(sid);
141
+ }
142
+ if (RE_ASK_KINDS.has(r.kind)) bySession.get(sid).count += 1;
143
+ }
144
+ if (order.length === 0) return null;
145
+ return order.map((sid) => bySession.get(sid));
146
+ }
147
+
148
+ // spark(values) → a unicode sparkline, one glyph per value, scaled to the REAL max (0 → ▁).
149
+ export function spark(values) {
150
+ const max = Math.max(...values, 0);
151
+ return values
152
+ .map((v) => {
153
+ if (max <= 0) return SPARK[0];
154
+ const idx = Math.min(SPARK.length - 1, Math.round((v / max) * (SPARK.length - 1)));
155
+ return SPARK[Math.max(0, idx)];
156
+ })
157
+ .join("");
158
+ }
159
+
160
+ // collectData() — one read-only pass over the local files. Every field is either a real derived
161
+ // number or null ("no data yet"). Never throws (each source fails open independently).
162
+ export function collectData({
163
+ logPath = process.env.SKALPEL_LEDGER_LOG || VERIFY_LOG_PATH,
164
+ steersPath = STEERS_PATH,
165
+ insightsPath = INSIGHTS_PATH,
166
+ } = {}) {
167
+ let rows = null;
168
+ try {
169
+ rows = readLedgerRows(logPath);
170
+ } catch {
171
+ rows = null;
172
+ }
173
+ let ledger = null;
174
+ try {
175
+ ledger = rows == null ? null : buildLedger(rows);
176
+ } catch {
177
+ ledger = null;
178
+ }
179
+ // Caught lies, newest first (the navigable list). Sanitize once here — content is untrusted.
180
+ const catches = (ledger ? [...ledger.falseEntries] : []).reverse().map((e) => ({
181
+ ts: e.ts,
182
+ session: e.session,
183
+ claim_text: clean(e.claim_text, 2000),
184
+ proof_command: clean(e.proof_command, 400),
185
+ evidence: clean(e.evidence, 2000),
186
+ fail_count: e.fail_count,
187
+ fix_delta_ms: e.fix_delta_ms ?? measureFixDelta(e._row, rows || [])?.ms ?? null,
188
+ fixed_at: e.fixed_at ?? measureFixDelta(e._row, rows || [])?.atTs ?? null,
189
+ }));
190
+ // Measured cost: Σ of REAL red→green deltas. A catch with no later same-session same-proof PASS
191
+ // contributes NOTHING (never estimated).
192
+ let costMs = 0;
193
+ let costPairs = 0;
194
+ for (const c of catches) {
195
+ if (c.fix_delta_ms != null && Number.isFinite(c.fix_delta_ms)) {
196
+ costMs += c.fix_delta_ms;
197
+ costPairs += 1;
198
+ }
199
+ }
200
+ const { steers } = readSteersState(steersPath);
201
+ const trend = readReaskTrend(insightsPath);
202
+ return {
203
+ logPath,
204
+ ledger, // null → no verify-shadow.log yet
205
+ catches,
206
+ costMs,
207
+ costPairs,
208
+ unmeasured: catches.length - costPairs,
209
+ steers, // count of applied-steer lines in steers.ndjson (0 when absent — fail-open, never null)
210
+ trend, // null → no per-session insight rows
211
+ empty: ledger == null && !steers && trend == null,
212
+ };
213
+ }
214
+
215
+ // ---------- (2) panel text (shared by the static snapshot and the interactive view) ----------
216
+ function costLine(data) {
217
+ if (data.ledger == null) return "no data yet";
218
+ if (data.catches.length === 0) return "0 — nothing to measure";
219
+ if (data.costPairs === 0)
220
+ return `no measured red→green pair yet — ${data.catches.length} caught, none re-passed its own proof later (count only, nothing estimated)`;
221
+ const dur = formatDuration(data.costMs) || "<1s";
222
+ const excl =
223
+ data.unmeasured > 0
224
+ ? ` · ${data.unmeasured} caught without a later PASS — excluded, never estimated`
225
+ : "";
226
+ return `${dur} measured red→green across ${data.costPairs} caught lie${data.costPairs === 1 ? "" : "s"} (real timestamps)${excl}`;
227
+ }
228
+
229
+ function steersLine(data) {
230
+ // steers is always a finite ≥0 count (steers.ndjson reader fails open to 0, never null), so this
231
+ // is honestly present even at "0 (count only)" — matching the statusline's always-on `N steers`.
232
+ return `${data.steers} (count only)`;
233
+ }
234
+
235
+ function trendLine(data, width) {
236
+ if (data.trend == null) return "no data yet";
237
+ const n = Math.max(1, Math.min(data.trend.length, Math.max(4, width)));
238
+ const last = data.trend.slice(-n);
239
+ const counts = last.map((s) => s.count);
240
+ const total = counts.reduce((a, b) => a + b, 0);
241
+ const max = Math.max(...counts, 0);
242
+ return `${spark(counts)} ${total} re-ask reset${total === 1 ? "" : "s"} across last ${last.length} session${last.length === 1 ? "" : "s"} (max ${max}/session)`;
243
+ }
244
+
245
+ function catchesHeadline(data) {
246
+ if (data.ledger == null) return "no data yet";
247
+ const { checked, falseCount } = data.ledger;
248
+ return `${falseCount} verified FALSE · ${checked} done-claim${checked === 1 ? "" : "s"} checked`;
249
+ }
250
+
251
+ // ---------- (3) the static snapshot (non-TTY / raw-mode-unavailable path) — plain text ----------
252
+ export function renderStatic(data) {
253
+ const L = [];
254
+ L.push("");
255
+ L.push(" 🔬 skalpel analytics — local, read-only, derived from your own logs");
256
+ L.push(" " + "─".repeat(74));
257
+ if (data.empty) {
258
+ L.push("");
259
+ L.push(' nothing caught yet — keep coding, I\'ll log every "done" your agent claims.');
260
+ L.push("");
261
+ L.push(" Enable the shadow with SKALPEL_VERIFY_SHADOW=1; every completion claim gets its own");
262
+ L.push(" proof re-run and appended to the local ledger. Nothing here is ever estimated.");
263
+ L.push("");
264
+ return L.join("\n");
265
+ }
266
+ L.push(` CAUGHT LIES ${catchesHeadline(data)}`);
267
+ for (const c of data.catches.slice(0, 5)) {
268
+ const failN = c.fail_count != null ? ` (${c.fail_count} failing)` : "";
269
+ L.push(` · "${clean(c.claim_text, 56)}" — $ ${clean(c.proof_command, 40)}${failN}`);
270
+ }
271
+ if (data.catches.length > 5) L.push(` …and ${data.catches.length - 5} more`);
272
+ L.push(` MEASURED COST ${costLine(data)}`);
273
+ L.push(` STEERS APPLIED ${steersLine(data)}`);
274
+ L.push(` RE-ASK TREND ${trendLine(data, 24)}`);
275
+ L.push(" " + "─".repeat(74));
276
+ L.push(` ledger: ${data.logPath}`);
277
+ L.push("");
278
+ return L.join("\n");
279
+ }
280
+
281
+ // ---------- (4) interactive rendering helpers ----------
282
+ // Visible-width-aware clip that PRESERVES our own CSI sequences (content is already sanitized, so
283
+ // any escape in the string is ours). Astral code points (e.g. 🔬) budget 2 cells. Always ends with
284
+ // RESET when styling was present so a clipped line can never bleed style into the next.
285
+ export function clipAnsi(line, max) {
286
+ const s = String(line);
287
+ let out = "";
288
+ let vis = 0;
289
+ let i = 0;
290
+ let hadEsc = false;
291
+ while (i < s.length) {
292
+ if (s[i] === "\x1b") {
293
+ const m = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.exec(s.slice(i));
294
+ if (m) {
295
+ out += m[0];
296
+ hadEsc = true;
297
+ i += m[0].length;
298
+ continue;
299
+ }
300
+ i += 1; // stray ESC — drop it
301
+ continue;
302
+ }
303
+ const cp = s.codePointAt(i);
304
+ const ch = String.fromCodePoint(cp);
305
+ const w = cp > 0xffff ? 2 : 1;
306
+ if (vis + w > max) break;
307
+ out += ch;
308
+ vis += w;
309
+ i += ch.length;
310
+ }
311
+ return hadEsc ? out + RESET : out;
312
+ }
313
+
314
+ // Plain word-wrap (content is sanitized before wrapping; long words hard-split).
315
+ function wrapText(text, width) {
316
+ const w = Math.max(4, width);
317
+ const out = [];
318
+ let cur = "";
319
+ for (let word of String(text).split(" ")) {
320
+ while (word.length > w) {
321
+ if (cur) {
322
+ out.push(cur);
323
+ cur = "";
324
+ }
325
+ out.push(word.slice(0, w));
326
+ word = word.slice(w);
327
+ }
328
+ if (!word) continue;
329
+ if (!cur) cur = word;
330
+ else if (cur.length + 1 + word.length <= w) cur += " " + word;
331
+ else {
332
+ out.push(cur);
333
+ cur = word;
334
+ }
335
+ }
336
+ if (cur) out.push(cur);
337
+ return out.length ? out : [""];
338
+ }
339
+
340
+ function listLines(data, cols, rows, state) {
341
+ const L = [];
342
+ L.push(` ${BOLD}🔬 skalpel analytics${RESET} ${DIM}· local · read-only · your own logs${RESET}`);
343
+ L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
344
+ if (data.empty) {
345
+ L.push("");
346
+ L.push(
347
+ ` ${BOLD}nothing caught yet${RESET} ${DIM}— keep coding, I'll log every "done" your agent claims.${RESET}`,
348
+ );
349
+ L.push("");
350
+ L.push(
351
+ ` ${DIM}Enable the shadow with ${RESET}${BOLD}SKALPEL_VERIFY_SHADOW=1${RESET}${DIM} — every completion claim gets its${RESET}`,
352
+ );
353
+ L.push(
354
+ ` ${DIM}own proof re-run and appended locally. Nothing here is ever estimated.${RESET}`,
355
+ );
356
+ L.push("");
357
+ L.push(` ${DIM}q quit${RESET}`);
358
+ return L;
359
+ }
360
+ const headline =
361
+ data.ledger == null
362
+ ? `${DIM}no data yet${RESET}`
363
+ : `${R}${BOLD}${data.ledger.falseCount} verified FALSE${RESET} ${DIM}· ${data.ledger.checked} done-claims checked${RESET}`;
364
+ L.push(` ${CYAN}${BOLD}CAUGHT LIES${RESET} ${headline}`);
365
+ // The navigable list — window it so the selection is always visible.
366
+ const fixedBelow = 6; // cost + steers + trend + rule + footer + breathing room
367
+ const capacity = Math.max(0, rows - L.length - fixedBelow);
368
+ const n = data.catches.length;
369
+ const win = Math.min(capacity, n);
370
+ let start = 0;
371
+ if (win > 0) {
372
+ start = Math.min(Math.max(0, state.sel - win + 1), Math.max(0, n - win));
373
+ if (state.sel < start) start = state.sel;
374
+ for (let i = start; i < start + win; i++) {
375
+ const c = data.catches[i];
376
+ const sel = i === state.sel;
377
+ const failN = c.fail_count != null ? ` ${DIM}(${c.fail_count} failing)${RESET}` : "";
378
+ const mark = sel ? `${R}▸${RESET}` : " ";
379
+ const claim = clean(c.claim_text, Math.max(12, Math.floor(cols * 0.45)));
380
+ const proof = clean(c.proof_command, Math.max(8, Math.floor(cols * 0.3)));
381
+ const body = `"${claim}" ${DIM}· $${RESET} ${proof}${failN}`;
382
+ L.push(` ${mark} ${sel ? BOLD : ""}${body}${RESET}`);
383
+ }
384
+ if (start + win < n) L.push(` ${DIM}…and ${n - start - win} more (↓)${RESET}`);
385
+ } else if (n > 0) {
386
+ L.push(` ${DIM}${n} caught — terminal too small to list; drill with Enter${RESET}`);
387
+ }
388
+ L.push(` ${CYAN}${BOLD}MEASURED COST${RESET} ${DIM}${costLine(data)}${RESET}`);
389
+ L.push(` ${CYAN}${BOLD}STEERS APPLIED${RESET} ${DIM}${steersLine(data)}${RESET}`);
390
+ L.push(` ${CYAN}${BOLD}RE-ASK TREND${RESET} ${trendLine(data, Math.max(4, cols - 46))}`);
391
+ L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
392
+ L.push(` ${DIM}↑/↓ j/k move · Enter details · q quit${RESET}`);
393
+ return L;
394
+ }
395
+
396
+ function detailLines(data, cols, state) {
397
+ const c = data.catches[state.sel];
398
+ const L = [];
399
+ L.push(
400
+ ` ${BOLD}🔬 skalpel analytics${RESET} ${DIM}· caught lie ${state.sel + 1} of ${data.catches.length}${RESET}`,
401
+ );
402
+ L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
403
+ if (!c) {
404
+ L.push(` ${DIM}nothing selected${RESET}`);
405
+ L.push(` ${DIM}Esc back · q quit${RESET}`);
406
+ return L;
407
+ }
408
+ const width = Math.max(10, cols - 6);
409
+ L.push(` ${DIM}the agent claimed:${RESET}`);
410
+ for (const w of wrapText(`"${c.claim_text}"`, width)) L.push(` ${BOLD}${w}${RESET}`);
411
+ L.push("");
412
+ L.push(` ${DIM}its own proof, re-run out-of-band:${RESET}`);
413
+ for (const w of wrapText(`$ ${c.proof_command || "(none recorded)"}`, width)) L.push(` ${w}`);
414
+ const failN = extractFailCount(c.evidence);
415
+ L.push(
416
+ ` ${R}${BOLD}→ FAIL${RESET}${failN != null ? ` ${DIM}· ${failN} failing${RESET}` : ""}`,
417
+ );
418
+ if (c.evidence) {
419
+ L.push("");
420
+ L.push(` ${DIM}real output:${RESET}`);
421
+ for (const w of wrapText(c.evidence, width).slice(0, 6)) L.push(` ${DIM}${w}${RESET}`);
422
+ }
423
+ L.push("");
424
+ L.push(` ${DIM}claimed FALSE at${RESET} ${c.ts || "unknown"}`);
425
+ if (c.fixed_at != null) {
426
+ const dur = formatDuration(c.fix_delta_ms);
427
+ L.push(
428
+ ` ${DIM}same proof PASSED${RESET} ${c.fixed_at}${dur ? ` ${G}· ${dur} later (measured, real timestamps)${RESET}` : ""}`,
429
+ );
430
+ } else {
431
+ L.push(` ${DIM}no later PASS logged for this proof — count only, no time estimated.${RESET}`);
432
+ }
433
+ if (c.session) L.push(` ${DIM}session${RESET} ${DIM}${clean(c.session, 60)}${RESET}`);
434
+ L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
435
+ L.push(` ${DIM}j/k scroll · Esc/h back · q quit${RESET}`);
436
+ return L;
437
+ }
438
+
439
+ // ---------- (5) the interactive loop — event-driven only, terminal always restored ----------
440
+ function runInteractive(data) {
441
+ const state = { view: "list", sel: 0, scroll: 0 };
442
+ let cleaned = false;
443
+ let pendingDraw = null;
444
+
445
+ // ONE cleanup, idempotent, never throws — every exit path funnels through it.
446
+ const cleanup = () => {
447
+ if (cleaned) return;
448
+ cleaned = true;
449
+ try {
450
+ if (pendingDraw) clearImmediate(pendingDraw);
451
+ } catch {
452
+ /* never throw from cleanup */
453
+ }
454
+ try {
455
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
456
+ } catch {
457
+ /* raw mode already off / stdin gone */
458
+ }
459
+ try {
460
+ process.stdout.write("\x1b[?25h\x1b[?1049l"); // show cursor + leave the alternate screen
461
+ } catch {
462
+ /* stdout gone — nothing to restore onto */
463
+ }
464
+ try {
465
+ process.stdin.removeAllListeners("keypress");
466
+ process.stdout.removeAllListeners("resize");
467
+ process.removeAllListeners("SIGWINCH");
468
+ process.removeAllListeners("SIGINT");
469
+ process.removeAllListeners("SIGTERM");
470
+ process.removeAllListeners("SIGHUP");
471
+ process.stdin.pause();
472
+ } catch {
473
+ /* best-effort */
474
+ }
475
+ };
476
+
477
+ const die = (msg) => {
478
+ cleanup();
479
+ try {
480
+ process.stdout.write(`${msg}\n`);
481
+ } catch {
482
+ /* nothing left to say it to */
483
+ }
484
+ process.exit(0);
485
+ };
486
+
487
+ // Raw mode inside try/catch — if the terminal can't do it, fall back to the static snapshot.
488
+ try {
489
+ process.stdin.setRawMode(true);
490
+ } catch {
491
+ process.stdout.write(renderStatic(data) + "\n");
492
+ process.exit(0);
493
+ }
494
+
495
+ try {
496
+ process.on("exit", cleanup); // the backstop: no exit path escapes restoration
497
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
498
+ process.on(sig, () => {
499
+ cleanup();
500
+ process.exit(0);
501
+ });
502
+ }
503
+ process.on("uncaughtException", () => die("skalpel analytics — could not render analytics"));
504
+ process.on("unhandledRejection", () => die("skalpel analytics — could not render analytics"));
505
+
506
+ process.stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen + hidden cursor
507
+
508
+ const draw = () => {
509
+ try {
510
+ const cols = Math.max(10, process.stdout.columns || 80);
511
+ const rowsN = Math.max(3, process.stdout.rows || 24);
512
+ const lines =
513
+ state.view === "detail"
514
+ ? detailLines(data, cols, state)
515
+ : listLines(data, cols, rowsN, state);
516
+ // Detail view scrolls when taller than the terminal (keep the footer reachable).
517
+ let visible = lines;
518
+ if (state.view === "detail" && lines.length > rowsN) {
519
+ const maxScroll = lines.length - rowsN;
520
+ state.scroll = Math.min(Math.max(0, state.scroll), maxScroll);
521
+ visible = lines.slice(state.scroll, state.scroll + rowsN);
522
+ } else {
523
+ visible = lines.slice(0, rowsN);
524
+ }
525
+ const body = visible.map((l) => clipAnsi(l, cols - 1)).join("\r\n");
526
+ process.stdout.write("\x1b[H\x1b[2J" + body);
527
+ } catch {
528
+ die("skalpel analytics — could not render analytics");
529
+ }
530
+ };
531
+
532
+ // Coalesce resize bursts through ONE unref'd immediate — event-driven, never a poll loop.
533
+ const scheduleDraw = () => {
534
+ if (pendingDraw) return;
535
+ pendingDraw = setImmediate(() => {
536
+ pendingDraw = null;
537
+ draw();
538
+ });
539
+ if (pendingDraw && typeof pendingDraw.unref === "function") pendingDraw.unref();
540
+ };
541
+
542
+ emitKeypressEvents(process.stdin);
543
+ process.stdin.resume();
544
+ process.stdin.on("keypress", (str, key) => {
545
+ try {
546
+ const k = key || {};
547
+ const name = k.name || str || "";
548
+ if ((k.ctrl && name === "c") || name === "q") {
549
+ cleanup();
550
+ process.exit(0);
551
+ }
552
+ if (state.view === "list") {
553
+ if (name === "up" || name === "k") state.sel = Math.max(0, state.sel - 1);
554
+ else if (name === "down" || name === "j")
555
+ state.sel = Math.min(Math.max(0, data.catches.length - 1), state.sel + 1);
556
+ else if (name === "return" || name === "enter") {
557
+ if (data.catches.length > 0) {
558
+ state.view = "detail";
559
+ state.scroll = 0;
560
+ }
561
+ } else if (name === "escape") {
562
+ cleanup();
563
+ process.exit(0);
564
+ }
565
+ } else {
566
+ if (name === "escape" || name === "backspace" || name === "h") {
567
+ state.view = "list";
568
+ state.scroll = 0;
569
+ } else if (name === "up" || name === "k") state.scroll = Math.max(0, state.scroll - 1);
570
+ else if (name === "down" || name === "j") state.scroll += 1; // clamped in draw()
571
+ }
572
+ scheduleDraw();
573
+ } catch {
574
+ die("skalpel analytics — could not render analytics");
575
+ }
576
+ });
577
+ process.stdout.on("resize", scheduleDraw); // node's SIGWINCH surface
578
+ process.on("SIGWINCH", scheduleDraw); // belt-and-suspenders (no-op where unsupported)
579
+
580
+ draw();
581
+ } catch {
582
+ die("skalpel analytics — could not render analytics");
583
+ }
584
+ }
585
+
586
+ // ---------- CLI ----------
587
+ const isMain = (() => {
588
+ try {
589
+ return (
590
+ Boolean(process.argv[1]) &&
591
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
592
+ );
593
+ } catch {
594
+ return false;
595
+ }
596
+ })();
597
+
598
+ if (isMain) {
599
+ let data;
600
+ try {
601
+ data = collectData();
602
+ } catch {
603
+ process.stdout.write("\nskalpel analytics — no data yet\n\n");
604
+ process.exit(0);
605
+ }
606
+ // NON-TTY DEGRADE (pipes, CI, dumb harnesses): plain static snapshot, exit 0, raw mode untouched.
607
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
608
+ try {
609
+ process.stdout.write(renderStatic(data) + "\n");
610
+ } catch {
611
+ /* a closed pipe still exits 0 */
612
+ }
613
+ process.exit(0);
614
+ }
615
+ // A totally-fresh machine has nothing to navigate — print the honest empty screen and exit clean
616
+ // rather than opening an interactive view over four "no data yet" panels.
617
+ if (data.empty) {
618
+ process.stdout.write(renderStatic(data) + "\n");
619
+ process.exit(0);
620
+ }
621
+ runInteractive(data);
622
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.35",
3
+ "version": "4.0.36",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
package/skalpel-setup.mjs CHANGED
@@ -64,6 +64,7 @@ const KNOWN_SUBS = new Set([
64
64
  "catches",
65
65
  "card",
66
66
  "ledger",
67
+ "analytics",
67
68
  "__build",
68
69
  "__verify-report",
69
70
  ]);
@@ -81,6 +82,7 @@ usage:
81
82
  skalpel catches scan your own history for "done" claims over failing tests
82
83
  skalpel card [--last|--id <id>] [--md] share one verified catch: the claim + its own failing proof
83
84
  skalpel ledger your running tally: N "done"s checked, M verified FALSE
85
+ skalpel analytics interactive local analytics — caught lies, measured cost, steers, re-asks
84
86
  skalpel uninstall [--purge] remove the hooks + local data from this machine
85
87
 
86
88
  options:
@@ -906,6 +908,14 @@ async function main() {
906
908
  spawnSync("node", [join(__dir, "ledger.mjs"), ...argv.slice(1)], { stdio: "inherit" });
907
909
  return;
908
910
  }
911
+ // `skalpel analytics` — INTERACTIVE LOCAL ANALYTICS: a keyboard-navigable, READ-ONLY terminal view
912
+ // derived from the same honest local data as `skalpel ledger` (verify-shadow.log + steers.ndjson +
913
+ // insights.ndjson). Zero network, zero writes, zero estimates. Degrades to a plain static snapshot
914
+ // when stdin/stdout isn't a TTY, and restores the terminal on every exit path.
915
+ if (sub === "analytics") {
916
+ spawnSync("node", [join(__dir, "analytics.mjs"), ...argv.slice(1)], { stdio: "inherit" });
917
+ return;
918
+ }
909
919
  // `skalpel __verify-report` — the claim-verification SHADOW instrument: reads the local
910
920
  // verify-shadow.log and prints the three numbers (claim fire-rate, proof-reconstruction rate, and the
911
921
  // headline MISMATCH rate = how often the agent claimed done but its OWN proof, re-run, failed).