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 +622 -0
- package/package.json +1 -1
- package/skalpel-hook.mjs +49 -0
- package/skalpel-setup.mjs +10 -0
- package/skalpel-statusline.mjs +211 -24
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-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).
|
package/skalpel-statusline.mjs
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// skalpel statusline —
|
|
3
|
-
// estimate dressed up as a measurement.
|
|
4
|
-
//
|
|
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
|
|
8
|
-
// • the current steer's plain-English label (what skalpel is doing now) — a description, not a number
|
|
9
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
153
|
-
// re-run, GENUINELY failed
|
|
154
|
-
// the
|
|
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.
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|