skalpel 4.0.35 → 4.0.37

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/autopsy.mjs CHANGED
@@ -275,6 +275,7 @@ function parseClaudeSession(file, rootDir) {
275
275
  turns.push({
276
276
  session_id: sessionId,
277
277
  root: rootDir,
278
+ rootRaw: typeof o.cwd === "string" && o.cwd ? o.cwd : null, // real cwd for same-file identity
278
279
  role,
279
280
  turn_idx: idx++,
280
281
  ts: o.timestamp || null,
@@ -365,6 +366,7 @@ function parseCodexSession(file) {
365
366
  const turns = raw.map((t, idx) => ({
366
367
  session_id: sessionId,
367
368
  root: rootDir,
369
+ rootRaw: cwd || null, // real cwd from session_meta.payload.cwd for same-file identity
368
370
  role: t.role,
369
371
  turn_idx: idx,
370
372
  ts: t.ts,
@@ -696,18 +698,22 @@ function projTail(root) {
696
698
  // SAME ACTUAL FILE — not a shared basename. Returns a normalized absolute path, or null when the
697
699
  // mention cannot be pinned to a real file:
698
700
  // • absolute path in the text → that path IS the identity (works across every vendor).
699
- // • relative path WITH a directory → resolved against the session's project root (cwd), but ONLY
700
- // when that root is a real project (never the home catch-all,
701
- // and never Cursor's rootless bubbles) — otherwise unknowable.
701
+ // • relative path WITH a directory → resolved against the session's RAW project cwd (rootRaw), but
702
+ // ONLY when that cwd is known and is a real project (never the
703
+ // home catch-all, and never Cursor's rootless bubbles) —
704
+ // otherwise unknowable.
702
705
  // • bare basename (no directory) → null. A basename alone can never prove same-file.
703
- function resolveFileIdentity(rawPath, abs, root) {
706
+ // NOTE: identity MUST anchor on the raw, undecoded cwd — never decodeRoot(), whose lossy `-`→`/`
707
+ // round-trip can fabricate a path that is not on disk (a hyphen in a real dir name becomes a
708
+ // separator) or false-join two distinct projects whose decoded forms collide. decodeRoot stays for
709
+ // DISPLAY labels only.
710
+ function resolveFileIdentity(rawPath, abs, rootRaw) {
704
711
  if (!rawPath) return null;
705
712
  if (abs) return path.posix.normalize("/" + rawPath.replace(/^\/+/, ""));
706
713
  if (!rawPath.includes("/")) return null; // bare basename — not a same-file proof
707
714
  if (/^\.\.?\//.test(rawPath)) return null; // "./x" or "../x": ambiguous relative to an unknown subdir
708
- const base = decodeRoot(root); // "" when root is null (Cursor) or unknown
709
- if (!base || base === HOME) return null; // no real project root to anchor a relative path against
710
- return path.posix.normalize(base.replace(/\/+$/, "") + "/" + rawPath);
715
+ if (!rootRaw || rootRaw === HOME) return null; // cwd unknown (Cursor/Aider) or home catch-all — unknowable
716
+ return path.posix.normalize(rootRaw.replace(/\/+$/, "") + "/" + rawPath);
711
717
  }
712
718
  // Detect any email (incl. TLDs truncated by the 200-char span slice, e.g. "name@host.c" or "name@host").
713
719
  const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
@@ -1073,7 +1079,7 @@ async function main() {
1073
1079
  if (errOnly && !kind.startsWith("err")) continue;
1074
1080
  // ---- same-file identity index (message text only; tool output is skipped above) ----
1075
1081
  if (kind === "sym:file") {
1076
- const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.root);
1082
+ const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.rootRaw);
1077
1083
  if (identity) {
1078
1084
  if (!filePathMap.has(identity))
1079
1085
  filePathMap.set(identity, { basename: anchor, byTool: new Map() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.35",
3
+ "version": "4.0.37",
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/reveal.mjs CHANGED
@@ -8,9 +8,10 @@
8
8
  // Tone: this is the first time the user is seeing what skalpel learned about them, so the note directs
9
9
  // the model to TEACH them their own pattern in plain, warm English — never the internal notation, never
10
10
  // a stats dump.
11
- import { readFileSync, writeFileSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
+ import { REVEAL_PATH, revealEnabled, cleanText } from "./insights.mjs";
14
15
 
15
16
  export function graphReadyNote() {
16
17
  const p = join(homedir(), ".skalpel", "insights-ready.json");
@@ -78,3 +79,83 @@ export function graphReadyNote() {
78
79
  `actually asked. Keep the whole thing short and human.]`
79
80
  );
80
81
  }
82
+
83
+ // ---- verify catch: the AGENT-facing half of the opt-in "aha catch" (SKALPEL_VERIFY_REVEAL) ----------
84
+ //
85
+ // verify-shadow.mjs re-runs the agent's OWN cited proof out of band; on a GENUINE_FAIL mismatch (it
86
+ // claimed "done", its own test/build REALLY failed) it writes ~/.skalpel/verify-reveal.json. The
87
+ // statusline renders that catch as an AMBIENT line the USER can miss within its TTL. This surfaces the
88
+ // SAME catch to the AGENT deterministically, in the NEXT turn's additionalContext, so the model corrects
89
+ // itself instead of standing by a false "done" — the mechanical nudge, not a line someone has to notice.
90
+ //
91
+ // The notified marker: the reveal file records the ts of the failing re-run; we remember the last ts we
92
+ // told the agent about so each distinct catch fires EXACTLY ONCE. It therefore persists across turns until
93
+ // it is injected once OR verify-shadow retracts the reveal on a later PASS — never a 90s TTL that lapses
94
+ // unseen. Scoped to the SAME session that produced the claim, so a stale cross-session catch never fires.
95
+ const NOTIFIED_PATH = join(homedir(), ".skalpel", "verify-reveal-notified.json");
96
+
97
+ // verifyCatchNote(env, sessionId) -> the bracketed model directive, or null.
98
+ // HONESTY RED LINES (mirrors verify-shadow's reveal): every word traces to the REAL reveal record —
99
+ // the real claim text, the real reconstructed proof command, the real captured failure tail, and a
100
+ // failing COUNT only when the runner actually printed one (r.fail_count is null otherwise). NO fabricated
101
+ // numbers. HARNESS_ERROR (timeout / missing script / wrong cwd) never reaches the reveal file, so this
102
+ // never cries wolf. GATED on SKALPEL_VERIFY_REVEAL: OFF (default) → returns null BEFORE touching disk, so
103
+ // the hook's additionalContext stays byte-identical. Fail-open: any error → null.
104
+ export function verifyCatchNote(env, sessionId) {
105
+ try {
106
+ if (!revealEnabled(env)) return null; // opt-in; default OFF → byte-identical, no disk touch
107
+ let r;
108
+ try {
109
+ r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
110
+ } catch {
111
+ return null; // no catch pending
112
+ }
113
+ if (!r || typeof r !== "object" || !r.ts) return null;
114
+ // Only tell the AGENT about a catch from ITS OWN session — never a stale cross-session one.
115
+ const sid = sessionId ? String(sessionId) : "";
116
+ if (!sid || !r.session || String(r.session) !== sid) return null;
117
+ // Fire at most once per distinct catch (keyed by the failing re-run's ts).
118
+ try {
119
+ if (JSON.parse(readFileSync(NOTIFIED_PATH, "utf8"))?.ts === r.ts) return null;
120
+ } catch {
121
+ /* no marker yet — first time we've seen this catch */
122
+ }
123
+ // Record that we've now notified the agent about THIS catch BEFORE returning the note, so it fires
124
+ // once. If we can't persist the marker, DROP the note (same one-shot discipline as graphReadyNote /
125
+ // the auth-expiry note) — better to miss it once than re-inject every turn.
126
+ try {
127
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
128
+ const tmp = `${NOTIFIED_PATH}.tmp`;
129
+ writeFileSync(tmp, JSON.stringify({ ts: r.ts }));
130
+ renameSync(tmp, NOTIFIED_PATH);
131
+ } catch {
132
+ return null;
133
+ }
134
+ // Build the directive from REAL fields only (cleanText strips any control chars; slices bound length).
135
+ const proof =
136
+ cleanText(String(r.proof_command || ""))
137
+ .trim()
138
+ .slice(0, 120) || "its own proof";
139
+ const claim =
140
+ cleanText(String(r.claim_text || ""))
141
+ .trim()
142
+ .slice(0, 120) || "it's done";
143
+ const failN =
144
+ Number.isFinite(r.fail_count) && r.fail_count > 0 ? ` (${r.fail_count} failing)` : "";
145
+ const evidence = cleanText(String(r.evidence || ""))
146
+ .trim()
147
+ .slice(0, 160);
148
+ return (
149
+ `[skalpel — a claim-check just RE-RAN the proof you cited and it FAILED. After your last ` +
150
+ `"${claim}"-type message, skalpel re-ran \`${proof}\` out of band and it did NOT pass${failN}. ` +
151
+ `This is YOUR OWN test/build re-run independently — not a new opinion, not a flaky guess. Do NOT ` +
152
+ `tell the user the work is done. Open with ONE short line — "🔬 skalpel · I re-ran \`${proof}\` ` +
153
+ `and it still fails${failN} — not done yet" — then actually diagnose and fix the failure before ` +
154
+ `you claim completion again.` +
155
+ (evidence ? ` The real failing output tail was: ${evidence}` : "") +
156
+ `]`
157
+ );
158
+ } catch {
159
+ return null; // fail-open: the catch note is a bonus, never break the hook
160
+ }
161
+ }
package/skalpel-hook.mjs CHANGED
@@ -14,7 +14,7 @@ import { join } from "node:path";
14
14
  import { identity } from "./auth.mjs";
15
15
  import { record } from "./metrics.mjs";
16
16
  import { recordInsight, cleanText, revealEnabled } from "./insights.mjs";
17
- import { graphReadyNote } from "./reveal.mjs";
17
+ import { graphReadyNote, verifyCatchNote } from "./reveal.mjs";
18
18
  import { tailLines } from "./transcript.mjs";
19
19
 
20
20
  // Tail budget for the two transcript scans below (last-prompt + cascade). Both only ever look at the
@@ -770,12 +770,15 @@ async function main() {
770
770
  session_id: sessionId,
771
771
  });
772
772
 
773
- // A just-finished background build leads (one-shot: surface "your graph's ready" the instant the next
774
- // prompt fires, so the user doesn't have to restart a session to see it), then the one-time auth-expiry
775
- // note, then ackNote (feedback confirmation), the reset interrupt (most acute), and the server steer.
776
- // Any can fire alone.
773
+ // The AGENT-facing "aha catch" (opt-in, SKALPEL_VERIFY_REVEAL) LEADS: if verify-shadow's detached
774
+ // re-run caught the agent claiming "done" while its OWN proof GENUINELY failed, tell the agent so it
775
+ // corrects itself instead of a user having to notice an ambient statusline line. Fires once per catch,
776
+ // scoped to this session; returns null (byte-identical) when the flag is off. Then a just-finished
777
+ // background build ("your graph's ready", one-shot), the one-time auth-expiry note, ackNote (feedback
778
+ // confirmation), the reset interrupt (most acute), and the server steer. Any can fire alone.
779
+ const verifyNote = verifyCatchNote(process.env, sessionId);
777
780
  const readyNote = graphReadyNote();
778
- const additionalContext = [readyNote, authNote, ackNote, resetNote, injection]
781
+ const additionalContext = [verifyNote, readyNote, authNote, ackNote, resetNote, injection]
779
782
  .filter(Boolean)
780
783
  .join("\n\n");
781
784
  if (additionalContext) {
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).
package/verify-shadow.mjs CHANGED
@@ -384,12 +384,23 @@ function resolveCd(baseCwd, dir) {
384
384
  return path.isAbsolute(d) ? path.normalize(d) : path.resolve(baseCwd || process.cwd(), d);
385
385
  }
386
386
 
387
+ // SHARP-4: a leading `cd <dir>` must not move the re-run OUTSIDE the session's base cwd. An out-of-tree
388
+ // cwd (absolute or `..` escape) would execute an attacker-planted package.json script / Cargo build.rs /
389
+ // eslint config as code — exactly the out-of-band execution the flag check (argsRedirectOutOfTree) blocks.
390
+ // Mirrors isPathEscape's guarantee for the resolved cwd itself. base is never null here → no resolve throw.
391
+ function cwdEscapesBase(cwd, base) {
392
+ const c = path.resolve(cwd);
393
+ const b = path.resolve(base);
394
+ return c !== b && !c.startsWith(b + path.sep);
395
+ }
396
+
387
397
  // parseCommandForProof(command, baseCwd) -> { cmd, args, cwd } | null
388
398
  // Walks the segments of ONE shell command; honors a leading `cd <dir>` (sets the re-run cwd) and returns
389
399
  // the first allowlisted invocation found. Args are the cleaned literal tokens (no shell interpretation).
390
400
  export function parseCommandForProof(command, baseCwd) {
391
401
  if (!command || typeof command !== "string") return null;
392
- let cwd = baseCwd || process.cwd();
402
+ const base = baseCwd || process.cwd();
403
+ let cwd = base;
393
404
  for (const rawTokens of splitSegments(command)) {
394
405
  const toks = cleanSegment(rawTokens);
395
406
  if (!toks.length) continue;
@@ -399,7 +410,8 @@ export function parseCommandForProof(command, baseCwd) {
399
410
  }
400
411
  const cmd = toks[0];
401
412
  const args = toks.slice(1, 65).map((t) => String(t).slice(0, 4096)); // bound arg count + length
402
- if (isAllowedProof(cmd, args)) return { cmd, args, cwd };
413
+ // SHARP-4: refuse an out-of-tree cwd (from a `cd` escape) just as we refuse out-of-tree path flags.
414
+ if (isAllowedProof(cmd, args)) return cwdEscapesBase(cwd, base) ? null : { cmd, args, cwd };
403
415
  }
404
416
  return null;
405
417
  }