skalpel 4.0.34 → 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.34",
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-hook.mjs CHANGED
@@ -146,6 +146,43 @@ function writePending(obj) {
146
146
  }
147
147
  }
148
148
 
149
+ // ALWAYS-ON PRESENCE tally — the honest lifetime record the statusline reads for its baseline line.
150
+ // Concept is Raahil's (#609): steers = count of steers ACTUALLY applied (fired AND not suppressed) across
151
+ // all sessions; saved_min = Σ, over those applied steers, of the user's OWN median cost of one loop of
152
+ // that steer's work-type (the server's `median_loop_min`, from the causal graph's sink/loop stats). It is
153
+ // NEVER a server wall-clock estimate; when the server has no median (null) we credit NOTHING.
154
+ //
155
+ // RACE-FREE by construction (audit Finding 2 — a read-modify-write single-counter loses increments under
156
+ // concurrent hook double-fires; metrics.mjs's own comment warns the same). So this is an APPEND-ONLY log:
157
+ // ONE atomic `appendFileSync` line per applied steer. POSIX guarantees a single small append is atomic, so
158
+ // N concurrent applied-steer writes yield exactly N lines (0 loss). No read, no tmp, no rename, no mutated
159
+ // counter. Same idiom as metrics.ndjson / insights.ndjson.
160
+ //
161
+ // ── STEERS SOURCE CONTRACT (the source of truth for the statusline baseline AND #620's analytics TUI) ──
162
+ // file : ~/.skalpel/steers.ndjson (append-only; one line per APPLIED steer; never rewritten/trimmed)
163
+ // line : {"ts": <epoch ms>, "saved_min_delta": <number ≥ 0>}
164
+ // ts = Date.now() when the steer was applied.
165
+ // saved_min_delta = the median_loop_min credited for THIS steer (≥ 0; 0 when the server sent
166
+ // no positive median — never fabricated).
167
+ // derive: steers = count of valid JSON lines (one line == one applied steer).
168
+ // saved_min = Σ saved_min_delta over valid lines (round to 1 dp; garbage/partial lines skipped).
169
+ // Readers MUST fail open (unreadable/absent → {steers:0, saved_min:0}) and MUST NOT mutate this file.
170
+ const STATE_LOG_PATH = join(homedir(), ".skalpel", "steers.ndjson");
171
+ function bumpState(medianMin) {
172
+ try {
173
+ // credit ONLY a real, positive median from the server; null/NaN/<=0 → 0 credit (no fabrication).
174
+ const savedMinDelta = Number.isFinite(medianMin) && medianMin > 0 ? medianMin : 0;
175
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
176
+ // ONE atomic append == ONE applied steer. Race-free (never a read-modify-write of a counter).
177
+ appendFileSync(
178
+ STATE_LOG_PATH,
179
+ JSON.stringify({ ts: Date.now(), saved_min_delta: savedMinDelta }) + "\n",
180
+ );
181
+ } catch {
182
+ /* the presence tally is a bonus; never block the hook */
183
+ }
184
+ }
185
+
149
186
  // STEER label — the statusline reads this so the user SEES skalpel steering LIVE, every turn a steer
150
187
  // injects, INDEPENDENT of whether the model surfaces a 🔬 line (the deterministic awareness channel).
151
188
  // A one-glance 2-word label derived from the top injected steer type. Overwritten each turn.
@@ -559,6 +596,7 @@ async function main() {
559
596
  let steerType = null; // raw top-steer type (TELL/SINK/…) — for the feedback loop + session log
560
597
  let steerWt = null; // the work-type the steer is about (refine/debug/…)
561
598
  let brokeOut = false; // behavioral-only: did expected-waste-ahead drop after last turn's steer (adherence)
599
+ let medianLoopMin = null; // server's per-work-type median loop cost (min) — the ONLY input to "saved"
562
600
 
563
601
  if (!authNote) {
564
602
  const remaining = deadline - Date.now();
@@ -596,6 +634,14 @@ async function main() {
596
634
  steerLabel = j?.steer_label; // surfaced on the statusline (written once below, with any credit)
597
635
  steerType = j?.steer_type || null;
598
636
  steerWt = j?.steer_wt || (j?.entry ? String(j.entry).split("/")[0] : null);
637
+ // the user's OWN median cost of one loop of this steer's work-type (from the graph's sink stats).
638
+ // The ONLY quantity we ever turn into "saved" minutes, and only for an APPLIED steer (below).
639
+ medianLoopMin =
640
+ typeof j?.median_loop_min === "number" &&
641
+ isFinite(j.median_loop_min) &&
642
+ j.median_loop_min > 0
643
+ ? j.median_loop_min
644
+ : null;
599
645
  if (j?.entry) pushTraj(String(j.entry).split("/")[0]); // remember this turn's work-type
600
646
  dbg(` -> sim=${j?.top_sim ?? "?"} ${injection ? "INJECT" : "silent"}`);
601
647
 
@@ -693,6 +739,9 @@ async function main() {
693
739
  const steerId = injection ? String(Date.now()) : null;
694
740
  writeSteer(steerLabel, steerType, steerWt, steerId);
695
741
  if (injection) logSessionEvent({ type: steerType, wt: steerWt }); // feed the end-of-session recap
742
+ // ALWAYS-ON presence tally: a steer was APPLIED this turn (fired + survived suppression). Count it,
743
+ // and credit its honest median-loop cost ONLY when the server sent a real number (null -> +0).
744
+ if (injection) bumpState(medianLoopMin);
696
745
 
697
746
  // LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — one plain-English row per note kind that
698
747
  // fired this turn, post-suppression (a muted/suppressed steer writes nothing). recordInsight
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).
@@ -1,12 +1,31 @@
1
1
  #!/usr/bin/env node
2
- // skalpel statusline — COUNT-OR-NOTHING. Prints ONLY facts that reproduce from local state, never an
3
- // estimate dressed up as a measurement. There is no "~Xh saved" and no "+min this turn": those were a
4
- // SERVER estimate of expected-waste (never a measured clock), so they are gone. What can honestly show:
2
+ // skalpel statusline — HONEST-OR-NOTHING. Prints only facts grounded in local state, never a server
3
+ // estimate dressed up as a measurement. In priority order (highest first):
4
+ // the AHA CATCH reveal agent claimed done, its OWN proof re-run GENUINELY failed (opt-in,
5
+ // SKALPEL_VERIFY_REVEAL; dark by default → byte-identical to the always-on baseline when off); else
6
+ // • the green "✓ verified" stamp — that same proof re-run really PASSED (same opt-in flag); else
5
7
  // • the live re-ask/interrupt COUNT for THIS session, read straight from the transcript Claude Code
6
8
  // hands us on stdin — the exact material `skalpel autopsy` re-derives from disk, a count of things
7
- // that verifiably happened, or
8
- // • the current steer's plain-English label (what skalpel is doing now) — a description, not a number.
9
- // A clean session prints NOTHING. Fail-open: any error → print nothing, exit 0. Never blocks the bar.
9
+ // that verifiably happened; else
10
+ // • the current steer's plain-English label (what skalpel is doing now) — a description, not a number;
11
+ // else
12
+ // • the ALWAYS-ON presence line, "skalpel · N steers[ · <time clause>]".
13
+ // N = steers ACTUALLY applied (fired + not suppressed), counted from the append-only
14
+ // ~/.skalpel/steers.ndjson — a fresh install reads "0 steers", present, never blank. The optional
15
+ // TIME clause is chosen by BASELINE_TIME_MODE (Ryan flips one constant):
16
+ // · <Xm> caught — the CUMULATIVE LIFETIME sum of every real MEASURED red→green delta (each caught
17
+ // false "done" and the moment that SAME proof, that SAME session, was later logged PASS — real
18
+ // timestamps; reuses ledger.mjs's measureFixDelta logic). ALWAYS-ON once real catches exist (it is
19
+ // skalpel's terminal presence — never gated/hidden). A measured total, NEVER an estimate; a catch
20
+ // with no later PASS adds 0. Cumulative framing (not present-tense) keeps it honest for old data.
21
+ // · ~Xm saved — Raahil's #609 estimate: (applied steers) × (the user's OWN median loop cost from
22
+ // their causal graph). A `~`-prefixed ESTIMATE grounded in their own data, shown only when
23
+ // saved_min > 0. Never a server wall-clock number; never "skalpel saved you X" as a fact.
24
+ // Every value here is a real count or a real measured timestamp delta (or a clearly `~`-marked estimate).
25
+ // NOTE: this always-on presence line is a DELIBERATE new behavior — origin/main's clean-idle bar emitted
26
+ // ZERO bytes; this emits "skalpel · 0 steers" (Ryan: always-on presence, non-negotiable). Byte-identical
27
+ // to origin/main is preserved ONLY for the untouched reveal / verdict / re-ask surfaces, not clean-idle.
28
+ // Fail-open: any error → print nothing (or just the presence line), exit 0. Never blocks the bar.
10
29
  import { readFileSync, rmSync } from "node:fs";
11
30
  import { homedir } from "node:os";
12
31
  import { join } from "node:path";
@@ -19,16 +38,34 @@ const BOLD = "\x1b[1m";
19
38
  const DIM = "\x1b[2m";
20
39
  const RESET = "\x1b[0m";
21
40
 
41
+ // ── RENDER MODE — the ONE constant Ryan flips. Governs the optional TIME clause on the always-on
42
+ // `skalpel · N steers` baseline. The steers count + Raahil's #609 BOLD/CYAN styling are ALWAYS present,
43
+ // unchanged, in every mode. Nothing else on the bar (reveal / verdict / re-ask / steer label) is affected.
44
+ // "steers" → skalpel · N steers
45
+ // "measured" → skalpel · N steers · 38m caught (DEFAULT — cumulative lifetime MEASURED red→green)
46
+ // "saved" → skalpel · N steers · ~7m saved (Raahil's own-graph ESTIMATE, from steers.ndjson)
47
+ // "both" → skalpel · N steers · 38m caught · ~7m saved
48
+ const BASELINE_TIME_MODE = "measured";
49
+ const SHOW_MEASURED = BASELINE_TIME_MODE === "measured" || BASELINE_TIME_MODE === "both";
50
+ const SHOW_SAVED = BASELINE_TIME_MODE === "saved" || BASELINE_TIME_MODE === "both";
51
+
22
52
  // AHA CATCH REVEAL — how long a fresh catch stays on the bar before it auto-clears (so it never
23
53
  // wallpapers). verify-shadow.mjs writes the reveal the moment a GENUINE_FAIL mismatch is re-run; it also
24
54
  // retracts it if a later proof re-run PASSES. This TTL covers the abandoned case (user moved on).
25
55
  const REVEAL_TTL_MS = 90_000;
26
56
  // LIVE VERDICT green stamp — the earned "✓ verified" written on a clean PASS. DELIBERATELY short-lived:
27
57
  // it flashes right after the claim, then recedes so a stream of greens stays ambient (quiet trust) and
28
- // never competes with the loud red catch. Short TTL is the whole point — a green that lingers is clutter;
29
- // a green that blinks by, turn after turn, is what makes the rare red land like a thunderclap.
58
+ // never competes with the loud red catch. Short TTL is the whole point — a green that lingers is clutter.
30
59
  const VERDICT_TTL_MS = 20_000;
31
60
 
61
+ // The verify-shadow log the measured clause reads. Defined LOCALLY (not imported from verify-shadow.mjs)
62
+ // on purpose: this runs on EVERY bar render, so it must not pull that module's heavy (child_process /
63
+ // probe) import graph onto the hot path — it only READS the file verify-shadow.mjs owns. verify-shadow.mjs
64
+ // self-trims the log to ~1MB/500 lines, so this bounded tail reads the whole retained log in practice
65
+ // while staying safe against a pathological huge file (a bounded read can only UNDERCOUNT, never overstate).
66
+ const VERIFY_LOG_PATH = join(homedir(), ".skalpel", "verify-shadow.log");
67
+ const SHADOW_READ_BYTES = 1024 * 1024;
68
+
32
69
  // Claude Code pipes a JSON status payload on stdin (transcript_path, session_id, …). Read it ONLY when
33
70
  // stdin is actually piped — never block on a TTY (manual invocation) where reading fd 0 would hang.
34
71
  function readPayload() {
@@ -101,7 +138,7 @@ function steerLabel() {
101
138
  // The opt-in AHA CATCH: read the reveal state file verify-shadow.mjs wrote. Returns the plain reveal
102
139
  // line (fresh + honest — the statusline never invents it, it only renders what the re-run recorded) or
103
140
  // null. Auto-clears when stale (past the TTL) so an old catch can't wallpaper the bar. GATED entirely on
104
- // SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the original.
141
+ // SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the baseline.
105
142
  function revealNote() {
106
143
  try {
107
144
  const r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
@@ -125,7 +162,7 @@ function revealNote() {
125
162
  // The LIVE VERDICT green stamp: read the verdict state verify-shadow.mjs wrote on a clean PASS. Returns
126
163
  // the plain "✓ verified" line (fresh + honest — the statusline never invents it, it only renders what the
127
164
  // re-run recorded) or null. Auto-clears past its short TTL so a stale green never lingers. GATED entirely
128
- // on SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the original.
165
+ // on SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the baseline.
129
166
  function verdictNote() {
130
167
  try {
131
168
  const r = JSON.parse(readFileSync(VERDICT_PATH, "utf8"));
@@ -146,21 +183,148 @@ function verdictNote() {
146
183
  }
147
184
  }
148
185
 
149
- async function main() {
186
+ // The always-on presence tally — derived from ~/.skalpel/steers.ndjson, the APPEND-ONLY log the hook
187
+ // writes (one atomic line per APPLIED steer; see the STEERS SOURCE CONTRACT in skalpel-hook.mjs). This is
188
+ // the RACE-FREE replacement for the old mutated state.json counter (audit Finding 2): steers = the count
189
+ // of valid lines, saved_min = Σ saved_min_delta. Defaults to zeros so the baseline line is never blank;
190
+ // garbage/partial lines are skipped; the sum only ever adds ≥0 deltas, so it is honest and never negative.
191
+ function readState() {
192
+ try {
193
+ const raw = readFileSync(join(homedir(), ".skalpel", "steers.ndjson"), "utf8");
194
+ let steers = 0;
195
+ let savedMin = 0;
196
+ for (const line of raw.split("\n")) {
197
+ const l = line.trim();
198
+ if (!l) continue;
199
+ let rec;
200
+ try {
201
+ rec = JSON.parse(l);
202
+ } catch {
203
+ continue; // a partial/garbage line — skip it (fail-open), keep counting the rest
204
+ }
205
+ if (!rec || typeof rec !== "object") continue;
206
+ steers += 1; // one valid line == one applied steer (append-only ⇒ exact, race-free count)
207
+ if (Number.isFinite(rec.saved_min_delta) && rec.saved_min_delta > 0)
208
+ savedMin += rec.saved_min_delta;
209
+ }
210
+ return { steers, savedMin: Math.round(savedMin * 10) / 10 };
211
+ } catch {
212
+ return { steers: 0, savedMin: 0 }; // absent/unreadable → honest zero-state (fail-open)
213
+ }
214
+ }
215
+
216
+ // The `~`-prefixed saved ESTIMATE, or null when there's no real credit (never show a fake/zero number).
217
+ // Minutes below an hour render as ~Xm; an hour or more as ~Xh (one decimal, trimmed of a trailing .0).
218
+ // [Raahil #609 — preserved verbatim so the "saved"/"both" modes match his line byte-for-byte.]
219
+ function savedPhrase(min) {
220
+ if (!(min > 0)) return null;
221
+ if (min >= 60) {
222
+ const h = min / 60;
223
+ const s = h >= 10 ? String(Math.round(h)) : h.toFixed(1).replace(/\.0$/, "");
224
+ return `~${s}h saved`;
225
+ }
226
+ return `~${Math.round(min)}m saved`;
227
+ }
228
+
229
+ // ── MEASURED red→green delta (real timestamps only — the honest replacement for the banned "~Xh saved").
230
+ // tsMs + measureFixDelta are the EXACT logic from ledger.mjs, replicated here (not imported) so the hot
231
+ // statusline path never pulls verify-shadow.mjs's heavy graph. Parse an ISO ts to epoch ms, or null.
232
+ function tsMs(ts) {
233
+ if (typeof ts !== "string") return null;
234
+ const n = Date.parse(ts);
235
+ return Number.isFinite(n) ? n : null;
236
+ }
237
+
238
+ // measureFixDelta(falseRow, rows) → { ms, atTs } for the EARLIEST later row that is, in the SAME session,
239
+ // the SAME proof_command, and a genuine PASS whose timestamp is strictly after the caught-false claim.
240
+ // That is the real moment the false "done" became a true "done": the exact proof that failed later passed.
241
+ // Returns null when no such row exists (then we show NO time — never an invented minute). [ledger.mjs]
242
+ function measureFixDelta(falseRow, rows) {
243
+ const t0 = tsMs(falseRow.ts);
244
+ if (t0 == null) return null;
245
+ const session = falseRow.session ?? null;
246
+ // Finding 3: a REAL session id is required to tie a fix to a claim. With a null session, the
247
+ // `(r.session ?? null) !== session` guard below would treat every OTHER null-session row as
248
+ // "same session" and cross-attribute a bogus delta — so refuse to measure when session is null.
249
+ if (session == null) return null;
250
+ const proof = falseRow.proof_command || null;
251
+ if (!proof) return null; // can't tie a fix to a claim with no proof — count only
252
+ let best = null;
253
+ for (const r of rows) {
254
+ if (r === falseRow) continue;
255
+ if ((r.session ?? null) !== session) continue;
256
+ if ((r.proof_command || null) !== proof) continue;
257
+ if (r.outcome !== "PASS") continue; // the SAME proof genuinely passing = the real done
258
+ const t1 = tsMs(r.ts);
259
+ if (t1 == null || t1 <= t0) continue; // must be strictly LATER than the false claim
260
+ if (best == null || t1 < best) best = t1;
261
+ }
262
+ return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
263
+ }
264
+
265
+ // Compact one-line duration for the bar. Input is a real (t_pass − t_false) MEASURED delta; this only
266
+ // formats it. FLOORS every unit so it can never round UP into a number larger than the real measurement.
267
+ // Sub-second deltas → null (nothing worth a clause). Seconds < 1m; whole minutes < 1h; else hours.
268
+ function compactMeasured(ms) {
269
+ if (!Number.isFinite(ms) || ms < 1000) return null;
270
+ const s = Math.floor(ms / 1000);
271
+ if (s < 60) return `${s}s`;
272
+ const m = Math.floor(s / 60);
273
+ if (m < 60) return `${m}m`;
274
+ const h = s / 3600;
275
+ return h >= 10
276
+ ? `${Math.floor(h)}h`
277
+ : `${(Math.floor(h * 10) / 10).toFixed(1).replace(/\.0$/, "")}h`;
278
+ }
279
+
280
+ // measuredCaughtTotalMs() → the CUMULATIVE LIFETIME sum, in ms, of EVERY real measured red→green delta:
281
+ // each caught false "done" (mismatch row) that has a later same-session same-proof PASS contributes that
282
+ // one measured (t_pass − t_false) delta; a catch with no later PASS contributes 0 (never estimated). This
283
+ // is an AGGREGATE — only the total number is ever rendered, never any per-catch claim text or proof (those
284
+ // stay in the opt-in reveal + the analytics TUI). Framed on the bar as a cumulative "Xm caught", NOT a
285
+ // present-tense "just caught", so it stays honest for old catches — the TOTAL genuinely happened.
286
+ //
287
+ // This always-on aggregate counter is an EXPLICIT PRODUCT DECISION by the owner: it is skalpel's terminal
288
+ // presence and must never be gated/hidden ("never remove it from the terminal otherwise skalpel is
289
+ // invisible"). Aggregate-only + no fabrication is what keeps that always-on surface honest.
290
+ function measuredCaughtTotalMs() {
291
+ try {
292
+ const lines = tailLines(VERIFY_LOG_PATH, SHADOW_READ_BYTES);
293
+ if (!lines.length) return 0;
294
+ const rows = [];
295
+ for (const l of lines) {
296
+ try {
297
+ const r = JSON.parse(l);
298
+ if (r && typeof r === "object") rows.push(r);
299
+ } catch {
300
+ /* partial / garbage line — skip it, keep the rest (fail-open) */
301
+ }
302
+ }
303
+ let total = 0;
304
+ for (const r of rows) {
305
+ if (r.mismatch !== true) continue; // only a caught false "done" (verify-shadow's money metric)
306
+ const d = measureFixDelta(r, rows); // exact ledger.mjs logic; null-session rows excluded (Finding 3)
307
+ if (d && Number.isFinite(d.ms) && d.ms > 0) total += d.ms; // real measured delta only; else +0
308
+ }
309
+ return total;
310
+ } catch {
311
+ return 0; // any error → no measured total (fail-open: never break or slow the bar)
312
+ }
313
+ }
314
+
315
+ function main() {
150
316
  const payload = readPayload();
151
317
 
152
- // AHA CATCH REVEAL (opt-in). The single highest-priority line: the agent claimed done but its OWN proof,
153
- // re-run, GENUINELY failed. Fully gated on SKALPEL_VERIFY_REVEAL when off, this branch never runs and
154
- // the statusline output is byte-identical to the count-or-nothing original.
318
+ // AHA CATCH REVEAL / green ✓ verified (opt-in). The highest-priority surfaces: the agent claimed done
319
+ // and its OWN proof, re-run, GENUINELY failed (red)or really passed (green). Fully gated on
320
+ // SKALPEL_VERIFY_REVEAL — when off (the default) this branch never runs and the bar is byte-identical to
321
+ // the always-on baseline below.
155
322
  if (revealEnabled(process.env)) {
156
323
  const reveal = revealNote();
157
324
  if (reveal) {
158
325
  process.stdout.write(`${BOLD}${reveal}${RESET}`);
159
326
  return;
160
327
  }
161
- // No red catch pending → the quiet green half. A fresh PASS stamp shows dim (ambient, not shouting)
162
- // for a short beat. Lower priority than the red catch, higher than the re-ask count: it's the most
163
- // recent verdict on the last claim, and it's what earns the trust the red catch spends.
164
328
  const verdict = verdictNote();
165
329
  if (verdict) {
166
330
  process.stdout.write(`${DIM}${GREEN}${verdict}${RESET}`);
@@ -178,17 +342,40 @@ async function main() {
178
342
  return;
179
343
  }
180
344
 
181
- // Otherwise, if a steer is live, name what it's doing — a description, no number. Else: print NOTHING.
345
+ // Otherwise, if a steer is live, name what it's doing — a description, no number.
182
346
  const label = steerLabel();
183
347
  if (label) {
184
348
  process.stdout.write(`${BOLD}skalpel${RESET} · ${CYAN}${label}${RESET}`);
185
349
  return;
186
350
  }
187
- // Clean session → COUNT-OR-NOTHING resolves to nothing. Emit no bytes.
351
+
352
+ // ALWAYS-ON baseline presence line — skalpel's terminal presence (owner product decision: never gated,
353
+ // never hidden). `N steers` is ALWAYS shown (fresh install reads "0 steers" — honest, present, never
354
+ // blank; the user's own applied-steer count). Then the ONE optional time clause selected by
355
+ // BASELINE_TIME_MODE, each an AGGREGATE number only (never per-catch claim text / proof — those live in
356
+ // the opt-in reveal + the TUI), shown only when its real value exists:
357
+ // • MEASURED "· Xm caught" — the CUMULATIVE LIFETIME sum of every real measured red→green delta
358
+ // (measuredCaughtTotalMs). Always-on once real catches exist; a MEASURED wall-clock, never an
359
+ // estimate. Cumulative framing (not present-tense "just caught"), so it stays honest for old catches.
360
+ // • Raahil's `~`-prefixed saved ESTIMATE ("· ~7m saved") — shown only when saved_min > 0 (its data is
361
+ // the user's own steers.ndjson credit, not the shadow log, so it keeps his always-on #609 behavior).
362
+ const { steers, savedMin } = readState();
363
+ let line = `${BOLD}skalpel${RESET} · ${CYAN}${steers} steers${RESET}`;
364
+ if (SHOW_MEASURED) {
365
+ const caught = compactMeasured(measuredCaughtTotalMs()); // null when < 1s of real measured catches
366
+ if (caught) line += ` · ${CYAN}${caught} caught${RESET}`;
367
+ }
368
+ if (SHOW_SAVED) {
369
+ const phrase = savedPhrase(savedMin);
370
+ if (phrase) line += ` · ${CYAN}${phrase}${RESET}`;
371
+ }
372
+ process.stdout.write(line);
188
373
  }
189
374
 
190
- // Fail-open, matching the other three hooks (skalpel-hook / -session / -session-end): any error
191
- // print nothing exit 0, so a thrown error can never spill a stack trace into the live status bar.
192
- main()
193
- .then(() => process.exit(0))
194
- .catch(() => process.exit(0));
375
+ // Fail-open: wrap the whole render so a thrown error can never spill a stack trace into the live status
376
+ // bar. Every helper is already individually try/caught; this is the last-line guard. Exit 0 regardless.
377
+ try {
378
+ main();
379
+ } catch {
380
+ /* never let the statusline break or slow the bar */
381
+ }