skalpel 4.0.35 → 4.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/analytics.mjs +622 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +10 -0
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
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).
|