skalpel 4.0.35 → 4.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/analytics.mjs +622 -0
- package/autopsy.mjs +14 -8
- package/package.json +1 -1
- package/reveal.mjs +82 -1
- package/skalpel-hook.mjs +9 -6
- package/skalpel-setup.mjs +10 -0
- package/verify-shadow.mjs +14 -2
package/analytics.mjs
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// analytics.mjs — `skalpel analytics`: an INTERACTIVE, keyboard-navigable, LOCAL analytics view.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT IS: one screen over the honest data skalpel already keeps on this machine — the caught
|
|
5
|
+
// lies (verify-shadow.log FALSE rows), the MEASURED red→green cost (real timestamp deltas only),
|
|
6
|
+
// the steer count, and the per-session re-ask trend — navigable with ↑/↓ (or j/k), Enter to drill
|
|
7
|
+
// into a catch, Esc/h/Backspace to go back, q or Ctrl-C to quit.
|
|
8
|
+
//
|
|
9
|
+
// HARD RED LINES (same contract as ledger.mjs / card.mjs):
|
|
10
|
+
// • READ-ONLY + LOCAL + ZERO-NETWORK. Imports only node: core + local sibling modules (their CLI
|
|
11
|
+
// blocks are isMain-guarded, so importing runs nothing). Opens no socket, re-runs no proof,
|
|
12
|
+
// writes no file. It renders what is already on disk — nothing else.
|
|
13
|
+
// • NO FABRICATED / ESTIMATED NUMBERS. Every figure reproduces from a real logged row:
|
|
14
|
+
// - caught lies = ledger FALSE rows (mismatch:true) in verify-shadow.log
|
|
15
|
+
// - measured cost = Σ of (later same-session same-proof PASS ts − caught-FALSE ts) — real
|
|
16
|
+
// timestamps via measureFixDelta; a catch with no later PASS contributes NOTHING
|
|
17
|
+
// - steers = count of valid JSON lines in ~/.skalpel/steers.ndjson (the append-only
|
|
18
|
+
// STEERS SOURCE CONTRACT log; one atomic line per APPLIED steer) — a COUNT only; the banned
|
|
19
|
+
// "minutes saved" metric never appears
|
|
20
|
+
// - re-ask trend = per-session counts of recorded re-ask/interrupt resets in
|
|
21
|
+
// ~/.skalpel/insights.ndjson (metrics.ndjson rows carry no session identity, so deriving a
|
|
22
|
+
// per-session series from them would be invention — we don't)
|
|
23
|
+
// Anything absent renders as an honest "no data yet" — never a made-up zero-padded trend.
|
|
24
|
+
// • NODE CORE ONLY. No npm dependency (no ink/blessed/chalk) — a new dep is itself a
|
|
25
|
+
// cross-device + supply-chain risk for a read-only stats viewer.
|
|
26
|
+
// • THE TERMINAL IS SACRED. Non-TTY (pipe/CI) prints a plain static snapshot and exits 0 without
|
|
27
|
+
// ever touching raw mode. Interactive mode uses the ALTERNATE screen (scrollback untouched) and
|
|
28
|
+
// ONE idempotent cleanup() — raw off, cursor shown, alt-screen left, listeners removed — that
|
|
29
|
+
// runs on EVERY exit path: q/Esc/Ctrl-C, 'exit', SIGINT/SIGTERM/SIGHUP, and any thrown error.
|
|
30
|
+
// • FAIL-OPEN. Any read/parse/render error → cleanup + one honest plain line + exit 0. Never a
|
|
31
|
+
// stack trace at the user, never a hung or broken terminal.
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import { realpathSync } from "node:fs";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { emitKeypressEvents } from "node:readline";
|
|
38
|
+
// READ-ONLY reuse of the honest engine. ledger.mjs derives everything from verify-shadow.log;
|
|
39
|
+
// verify-shadow.mjs single-sources the log path + the fail-count extractor. Importing either runs
|
|
40
|
+
// nothing (their CLI blocks are isMain-guarded).
|
|
41
|
+
import { readLedgerRows, buildLedger, measureFixDelta, formatDuration } from "./ledger.mjs";
|
|
42
|
+
import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
|
|
43
|
+
|
|
44
|
+
// ---------- palette (the same vocabulary as ledger.mjs / card.mjs / the statusline) ----------
|
|
45
|
+
const R = "\x1b[38;2;233;38;31m"; // skalpel red
|
|
46
|
+
const G = "\x1b[38;2;124;184;108m"; // success green
|
|
47
|
+
const CYAN = "\x1b[36m";
|
|
48
|
+
const BOLD = "\x1b[1m";
|
|
49
|
+
const DIM = "\x1b[2m";
|
|
50
|
+
const RESET = "\x1b[0m";
|
|
51
|
+
|
|
52
|
+
const SKALPEL_DIR = join(homedir(), ".skalpel");
|
|
53
|
+
const STEERS_PATH = join(SKALPEL_DIR, "steers.ndjson");
|
|
54
|
+
const INSIGHTS_PATH = join(SKALPEL_DIR, "insights.ndjson");
|
|
55
|
+
// Bound every read so a pathological file can never OOM us (same cap as card.mjs).
|
|
56
|
+
const MAX_READ_BYTES = 4 * 1024 * 1024;
|
|
57
|
+
const SPARK = "▁▂▃▄▅▆▇";
|
|
58
|
+
|
|
59
|
+
// ---------- sanitize (claims/evidence come from captured agent output — never trust them) ----------
|
|
60
|
+
// Strip ANSI CSI sequences + other C0 control chars so logged content can never inject its own
|
|
61
|
+
// escape codes into the live terminal (same defense as card.mjs), then collapse whitespace and cap.
|
|
62
|
+
export function clean(s, n) {
|
|
63
|
+
const t = String(s == null ? "" : s)
|
|
64
|
+
// eslint-disable-next-line no-control-regex
|
|
65
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
66
|
+
// eslint-disable-next-line no-control-regex
|
|
67
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
|
|
68
|
+
.replace(/\s+/g, " ")
|
|
69
|
+
.trim();
|
|
70
|
+
if (n && t.length > n) return t.slice(0, n - 1) + "…";
|
|
71
|
+
return t;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- (1) gather the four panels — every number derived from real local data ----------
|
|
75
|
+
// Steers: the honest count from ~/.skalpel/steers.ndjson — the append-only STEERS SOURCE CONTRACT
|
|
76
|
+
// log (see skalpel-hook.mjs): one atomic line per APPLIED steer, {"ts",saved_min_delta}, never
|
|
77
|
+
// rewritten. This MIRRORS the merged skalpel-statusline.mjs readState() exactly: steers = count of
|
|
78
|
+
// valid JSON lines (trim, skip blank/garbage, JSON.parse guard, must be a non-null object);
|
|
79
|
+
// saved_min = Σ of finite positive saved_min_delta (rounded 1 dp). Readers MUST fail open
|
|
80
|
+
// (absent/unreadable → {steers:0, saved_min:0}) and MUST NOT mutate the file. The whole file is
|
|
81
|
+
// read for an EXACT count (the TUI renders on demand, not every terminal tick — no hot-path cost);
|
|
82
|
+
// the try/catch fails open on a huge/corrupt file, so it never hangs. The banned "minutes saved"
|
|
83
|
+
// metric is never displayed — savedMin is computed only to honor the contract, not shown.
|
|
84
|
+
export function readSteersState(steersPath = STEERS_PATH) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(steersPath, "utf8");
|
|
87
|
+
let steers = 0;
|
|
88
|
+
let savedMin = 0;
|
|
89
|
+
for (const line of raw.split("\n")) {
|
|
90
|
+
const l = line.trim();
|
|
91
|
+
if (!l) continue;
|
|
92
|
+
let rec;
|
|
93
|
+
try {
|
|
94
|
+
rec = JSON.parse(l);
|
|
95
|
+
} catch {
|
|
96
|
+
continue; // a partial/garbage line — skip it (fail-open), keep counting the rest
|
|
97
|
+
}
|
|
98
|
+
if (!rec || typeof rec !== "object") continue;
|
|
99
|
+
steers += 1; // one valid line == one applied steer (append-only ⇒ exact, race-free count)
|
|
100
|
+
if (Number.isFinite(rec.saved_min_delta) && rec.saved_min_delta > 0)
|
|
101
|
+
savedMin += rec.saved_min_delta;
|
|
102
|
+
}
|
|
103
|
+
return { steers, savedMin: Math.round(savedMin * 10) / 10 };
|
|
104
|
+
} catch {
|
|
105
|
+
return { steers: 0, savedMin: 0 }; // absent/unreadable → honest zero-state (fail-open)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Re-ask trend: per-session counts of recorded re-ask/interrupt resets from insights.ndjson.
|
|
110
|
+
// A `kind:"reset"` row is the hook's record of a REAL detected re-ask cascade ("we've gone back
|
|
111
|
+
// and forth N× — resetting"), stamped with session_id. Sessions appear in file order (the file is
|
|
112
|
+
// append-only, so first appearance == chronological order); a session with rows but no resets is
|
|
113
|
+
// an honest 0. metrics.ndjson is NOT a fallback: its rows ({ts,event,outcome,ms}) carry no session
|
|
114
|
+
// identity and no re-ask signal, so no per-session series can be derived from it without inventing
|
|
115
|
+
// one. No usable rows → null (rendered "no data yet").
|
|
116
|
+
const RE_ASK_KINDS = new Set(["reset", "re_ask", "reask", "re-ask", "interrupt"]);
|
|
117
|
+
export function readReaskTrend(insightsPath = INSIGHTS_PATH) {
|
|
118
|
+
let raw;
|
|
119
|
+
try {
|
|
120
|
+
raw = readFileSync(insightsPath, "utf8");
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (raw.length > MAX_READ_BYTES) raw = raw.slice(-MAX_READ_BYTES);
|
|
125
|
+
const order = [];
|
|
126
|
+
const bySession = new Map();
|
|
127
|
+
for (const line of raw.split("\n")) {
|
|
128
|
+
const l = line.trim();
|
|
129
|
+
if (!l) continue;
|
|
130
|
+
let r;
|
|
131
|
+
try {
|
|
132
|
+
r = JSON.parse(l);
|
|
133
|
+
} catch {
|
|
134
|
+
continue; // partial/garbage line — skip (fail-open)
|
|
135
|
+
}
|
|
136
|
+
const sid = typeof r?.session_id === "string" && r.session_id ? r.session_id : null;
|
|
137
|
+
if (!sid) continue; // no session identity → can't attribute honestly
|
|
138
|
+
if (!bySession.has(sid)) {
|
|
139
|
+
bySession.set(sid, { session: sid, count: 0, firstTs: r.ts || null });
|
|
140
|
+
order.push(sid);
|
|
141
|
+
}
|
|
142
|
+
if (RE_ASK_KINDS.has(r.kind)) bySession.get(sid).count += 1;
|
|
143
|
+
}
|
|
144
|
+
if (order.length === 0) return null;
|
|
145
|
+
return order.map((sid) => bySession.get(sid));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// spark(values) → a unicode sparkline, one glyph per value, scaled to the REAL max (0 → ▁).
|
|
149
|
+
export function spark(values) {
|
|
150
|
+
const max = Math.max(...values, 0);
|
|
151
|
+
return values
|
|
152
|
+
.map((v) => {
|
|
153
|
+
if (max <= 0) return SPARK[0];
|
|
154
|
+
const idx = Math.min(SPARK.length - 1, Math.round((v / max) * (SPARK.length - 1)));
|
|
155
|
+
return SPARK[Math.max(0, idx)];
|
|
156
|
+
})
|
|
157
|
+
.join("");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// collectData() — one read-only pass over the local files. Every field is either a real derived
|
|
161
|
+
// number or null ("no data yet"). Never throws (each source fails open independently).
|
|
162
|
+
export function collectData({
|
|
163
|
+
logPath = process.env.SKALPEL_LEDGER_LOG || VERIFY_LOG_PATH,
|
|
164
|
+
steersPath = STEERS_PATH,
|
|
165
|
+
insightsPath = INSIGHTS_PATH,
|
|
166
|
+
} = {}) {
|
|
167
|
+
let rows = null;
|
|
168
|
+
try {
|
|
169
|
+
rows = readLedgerRows(logPath);
|
|
170
|
+
} catch {
|
|
171
|
+
rows = null;
|
|
172
|
+
}
|
|
173
|
+
let ledger = null;
|
|
174
|
+
try {
|
|
175
|
+
ledger = rows == null ? null : buildLedger(rows);
|
|
176
|
+
} catch {
|
|
177
|
+
ledger = null;
|
|
178
|
+
}
|
|
179
|
+
// Caught lies, newest first (the navigable list). Sanitize once here — content is untrusted.
|
|
180
|
+
const catches = (ledger ? [...ledger.falseEntries] : []).reverse().map((e) => ({
|
|
181
|
+
ts: e.ts,
|
|
182
|
+
session: e.session,
|
|
183
|
+
claim_text: clean(e.claim_text, 2000),
|
|
184
|
+
proof_command: clean(e.proof_command, 400),
|
|
185
|
+
evidence: clean(e.evidence, 2000),
|
|
186
|
+
fail_count: e.fail_count,
|
|
187
|
+
fix_delta_ms: e.fix_delta_ms ?? measureFixDelta(e._row, rows || [])?.ms ?? null,
|
|
188
|
+
fixed_at: e.fixed_at ?? measureFixDelta(e._row, rows || [])?.atTs ?? null,
|
|
189
|
+
}));
|
|
190
|
+
// Measured cost: Σ of REAL red→green deltas. A catch with no later same-session same-proof PASS
|
|
191
|
+
// contributes NOTHING (never estimated).
|
|
192
|
+
let costMs = 0;
|
|
193
|
+
let costPairs = 0;
|
|
194
|
+
for (const c of catches) {
|
|
195
|
+
if (c.fix_delta_ms != null && Number.isFinite(c.fix_delta_ms)) {
|
|
196
|
+
costMs += c.fix_delta_ms;
|
|
197
|
+
costPairs += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const { steers } = readSteersState(steersPath);
|
|
201
|
+
const trend = readReaskTrend(insightsPath);
|
|
202
|
+
return {
|
|
203
|
+
logPath,
|
|
204
|
+
ledger, // null → no verify-shadow.log yet
|
|
205
|
+
catches,
|
|
206
|
+
costMs,
|
|
207
|
+
costPairs,
|
|
208
|
+
unmeasured: catches.length - costPairs,
|
|
209
|
+
steers, // count of applied-steer lines in steers.ndjson (0 when absent — fail-open, never null)
|
|
210
|
+
trend, // null → no per-session insight rows
|
|
211
|
+
empty: ledger == null && !steers && trend == null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------- (2) panel text (shared by the static snapshot and the interactive view) ----------
|
|
216
|
+
function costLine(data) {
|
|
217
|
+
if (data.ledger == null) return "no data yet";
|
|
218
|
+
if (data.catches.length === 0) return "0 — nothing to measure";
|
|
219
|
+
if (data.costPairs === 0)
|
|
220
|
+
return `no measured red→green pair yet — ${data.catches.length} caught, none re-passed its own proof later (count only, nothing estimated)`;
|
|
221
|
+
const dur = formatDuration(data.costMs) || "<1s";
|
|
222
|
+
const excl =
|
|
223
|
+
data.unmeasured > 0
|
|
224
|
+
? ` · ${data.unmeasured} caught without a later PASS — excluded, never estimated`
|
|
225
|
+
: "";
|
|
226
|
+
return `${dur} measured red→green across ${data.costPairs} caught lie${data.costPairs === 1 ? "" : "s"} (real timestamps)${excl}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function steersLine(data) {
|
|
230
|
+
// steers is always a finite ≥0 count (steers.ndjson reader fails open to 0, never null), so this
|
|
231
|
+
// is honestly present even at "0 (count only)" — matching the statusline's always-on `N steers`.
|
|
232
|
+
return `${data.steers} (count only)`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function trendLine(data, width) {
|
|
236
|
+
if (data.trend == null) return "no data yet";
|
|
237
|
+
const n = Math.max(1, Math.min(data.trend.length, Math.max(4, width)));
|
|
238
|
+
const last = data.trend.slice(-n);
|
|
239
|
+
const counts = last.map((s) => s.count);
|
|
240
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
241
|
+
const max = Math.max(...counts, 0);
|
|
242
|
+
return `${spark(counts)} ${total} re-ask reset${total === 1 ? "" : "s"} across last ${last.length} session${last.length === 1 ? "" : "s"} (max ${max}/session)`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function catchesHeadline(data) {
|
|
246
|
+
if (data.ledger == null) return "no data yet";
|
|
247
|
+
const { checked, falseCount } = data.ledger;
|
|
248
|
+
return `${falseCount} verified FALSE · ${checked} done-claim${checked === 1 ? "" : "s"} checked`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------- (3) the static snapshot (non-TTY / raw-mode-unavailable path) — plain text ----------
|
|
252
|
+
export function renderStatic(data) {
|
|
253
|
+
const L = [];
|
|
254
|
+
L.push("");
|
|
255
|
+
L.push(" 🔬 skalpel analytics — local, read-only, derived from your own logs");
|
|
256
|
+
L.push(" " + "─".repeat(74));
|
|
257
|
+
if (data.empty) {
|
|
258
|
+
L.push("");
|
|
259
|
+
L.push(' nothing caught yet — keep coding, I\'ll log every "done" your agent claims.');
|
|
260
|
+
L.push("");
|
|
261
|
+
L.push(" Enable the shadow with SKALPEL_VERIFY_SHADOW=1; every completion claim gets its own");
|
|
262
|
+
L.push(" proof re-run and appended to the local ledger. Nothing here is ever estimated.");
|
|
263
|
+
L.push("");
|
|
264
|
+
return L.join("\n");
|
|
265
|
+
}
|
|
266
|
+
L.push(` CAUGHT LIES ${catchesHeadline(data)}`);
|
|
267
|
+
for (const c of data.catches.slice(0, 5)) {
|
|
268
|
+
const failN = c.fail_count != null ? ` (${c.fail_count} failing)` : "";
|
|
269
|
+
L.push(` · "${clean(c.claim_text, 56)}" — $ ${clean(c.proof_command, 40)}${failN}`);
|
|
270
|
+
}
|
|
271
|
+
if (data.catches.length > 5) L.push(` …and ${data.catches.length - 5} more`);
|
|
272
|
+
L.push(` MEASURED COST ${costLine(data)}`);
|
|
273
|
+
L.push(` STEERS APPLIED ${steersLine(data)}`);
|
|
274
|
+
L.push(` RE-ASK TREND ${trendLine(data, 24)}`);
|
|
275
|
+
L.push(" " + "─".repeat(74));
|
|
276
|
+
L.push(` ledger: ${data.logPath}`);
|
|
277
|
+
L.push("");
|
|
278
|
+
return L.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------- (4) interactive rendering helpers ----------
|
|
282
|
+
// Visible-width-aware clip that PRESERVES our own CSI sequences (content is already sanitized, so
|
|
283
|
+
// any escape in the string is ours). Astral code points (e.g. 🔬) budget 2 cells. Always ends with
|
|
284
|
+
// RESET when styling was present so a clipped line can never bleed style into the next.
|
|
285
|
+
export function clipAnsi(line, max) {
|
|
286
|
+
const s = String(line);
|
|
287
|
+
let out = "";
|
|
288
|
+
let vis = 0;
|
|
289
|
+
let i = 0;
|
|
290
|
+
let hadEsc = false;
|
|
291
|
+
while (i < s.length) {
|
|
292
|
+
if (s[i] === "\x1b") {
|
|
293
|
+
const m = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.exec(s.slice(i));
|
|
294
|
+
if (m) {
|
|
295
|
+
out += m[0];
|
|
296
|
+
hadEsc = true;
|
|
297
|
+
i += m[0].length;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
i += 1; // stray ESC — drop it
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const cp = s.codePointAt(i);
|
|
304
|
+
const ch = String.fromCodePoint(cp);
|
|
305
|
+
const w = cp > 0xffff ? 2 : 1;
|
|
306
|
+
if (vis + w > max) break;
|
|
307
|
+
out += ch;
|
|
308
|
+
vis += w;
|
|
309
|
+
i += ch.length;
|
|
310
|
+
}
|
|
311
|
+
return hadEsc ? out + RESET : out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Plain word-wrap (content is sanitized before wrapping; long words hard-split).
|
|
315
|
+
function wrapText(text, width) {
|
|
316
|
+
const w = Math.max(4, width);
|
|
317
|
+
const out = [];
|
|
318
|
+
let cur = "";
|
|
319
|
+
for (let word of String(text).split(" ")) {
|
|
320
|
+
while (word.length > w) {
|
|
321
|
+
if (cur) {
|
|
322
|
+
out.push(cur);
|
|
323
|
+
cur = "";
|
|
324
|
+
}
|
|
325
|
+
out.push(word.slice(0, w));
|
|
326
|
+
word = word.slice(w);
|
|
327
|
+
}
|
|
328
|
+
if (!word) continue;
|
|
329
|
+
if (!cur) cur = word;
|
|
330
|
+
else if (cur.length + 1 + word.length <= w) cur += " " + word;
|
|
331
|
+
else {
|
|
332
|
+
out.push(cur);
|
|
333
|
+
cur = word;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (cur) out.push(cur);
|
|
337
|
+
return out.length ? out : [""];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function listLines(data, cols, rows, state) {
|
|
341
|
+
const L = [];
|
|
342
|
+
L.push(` ${BOLD}🔬 skalpel analytics${RESET} ${DIM}· local · read-only · your own logs${RESET}`);
|
|
343
|
+
L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
|
|
344
|
+
if (data.empty) {
|
|
345
|
+
L.push("");
|
|
346
|
+
L.push(
|
|
347
|
+
` ${BOLD}nothing caught yet${RESET} ${DIM}— keep coding, I'll log every "done" your agent claims.${RESET}`,
|
|
348
|
+
);
|
|
349
|
+
L.push("");
|
|
350
|
+
L.push(
|
|
351
|
+
` ${DIM}Enable the shadow with ${RESET}${BOLD}SKALPEL_VERIFY_SHADOW=1${RESET}${DIM} — every completion claim gets its${RESET}`,
|
|
352
|
+
);
|
|
353
|
+
L.push(
|
|
354
|
+
` ${DIM}own proof re-run and appended locally. Nothing here is ever estimated.${RESET}`,
|
|
355
|
+
);
|
|
356
|
+
L.push("");
|
|
357
|
+
L.push(` ${DIM}q quit${RESET}`);
|
|
358
|
+
return L;
|
|
359
|
+
}
|
|
360
|
+
const headline =
|
|
361
|
+
data.ledger == null
|
|
362
|
+
? `${DIM}no data yet${RESET}`
|
|
363
|
+
: `${R}${BOLD}${data.ledger.falseCount} verified FALSE${RESET} ${DIM}· ${data.ledger.checked} done-claims checked${RESET}`;
|
|
364
|
+
L.push(` ${CYAN}${BOLD}CAUGHT LIES${RESET} ${headline}`);
|
|
365
|
+
// The navigable list — window it so the selection is always visible.
|
|
366
|
+
const fixedBelow = 6; // cost + steers + trend + rule + footer + breathing room
|
|
367
|
+
const capacity = Math.max(0, rows - L.length - fixedBelow);
|
|
368
|
+
const n = data.catches.length;
|
|
369
|
+
const win = Math.min(capacity, n);
|
|
370
|
+
let start = 0;
|
|
371
|
+
if (win > 0) {
|
|
372
|
+
start = Math.min(Math.max(0, state.sel - win + 1), Math.max(0, n - win));
|
|
373
|
+
if (state.sel < start) start = state.sel;
|
|
374
|
+
for (let i = start; i < start + win; i++) {
|
|
375
|
+
const c = data.catches[i];
|
|
376
|
+
const sel = i === state.sel;
|
|
377
|
+
const failN = c.fail_count != null ? ` ${DIM}(${c.fail_count} failing)${RESET}` : "";
|
|
378
|
+
const mark = sel ? `${R}▸${RESET}` : " ";
|
|
379
|
+
const claim = clean(c.claim_text, Math.max(12, Math.floor(cols * 0.45)));
|
|
380
|
+
const proof = clean(c.proof_command, Math.max(8, Math.floor(cols * 0.3)));
|
|
381
|
+
const body = `"${claim}" ${DIM}· $${RESET} ${proof}${failN}`;
|
|
382
|
+
L.push(` ${mark} ${sel ? BOLD : ""}${body}${RESET}`);
|
|
383
|
+
}
|
|
384
|
+
if (start + win < n) L.push(` ${DIM}…and ${n - start - win} more (↓)${RESET}`);
|
|
385
|
+
} else if (n > 0) {
|
|
386
|
+
L.push(` ${DIM}${n} caught — terminal too small to list; drill with Enter${RESET}`);
|
|
387
|
+
}
|
|
388
|
+
L.push(` ${CYAN}${BOLD}MEASURED COST${RESET} ${DIM}${costLine(data)}${RESET}`);
|
|
389
|
+
L.push(` ${CYAN}${BOLD}STEERS APPLIED${RESET} ${DIM}${steersLine(data)}${RESET}`);
|
|
390
|
+
L.push(` ${CYAN}${BOLD}RE-ASK TREND${RESET} ${trendLine(data, Math.max(4, cols - 46))}`);
|
|
391
|
+
L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
|
|
392
|
+
L.push(` ${DIM}↑/↓ j/k move · Enter details · q quit${RESET}`);
|
|
393
|
+
return L;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function detailLines(data, cols, state) {
|
|
397
|
+
const c = data.catches[state.sel];
|
|
398
|
+
const L = [];
|
|
399
|
+
L.push(
|
|
400
|
+
` ${BOLD}🔬 skalpel analytics${RESET} ${DIM}· caught lie ${state.sel + 1} of ${data.catches.length}${RESET}`,
|
|
401
|
+
);
|
|
402
|
+
L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
|
|
403
|
+
if (!c) {
|
|
404
|
+
L.push(` ${DIM}nothing selected${RESET}`);
|
|
405
|
+
L.push(` ${DIM}Esc back · q quit${RESET}`);
|
|
406
|
+
return L;
|
|
407
|
+
}
|
|
408
|
+
const width = Math.max(10, cols - 6);
|
|
409
|
+
L.push(` ${DIM}the agent claimed:${RESET}`);
|
|
410
|
+
for (const w of wrapText(`"${c.claim_text}"`, width)) L.push(` ${BOLD}${w}${RESET}`);
|
|
411
|
+
L.push("");
|
|
412
|
+
L.push(` ${DIM}its own proof, re-run out-of-band:${RESET}`);
|
|
413
|
+
for (const w of wrapText(`$ ${c.proof_command || "(none recorded)"}`, width)) L.push(` ${w}`);
|
|
414
|
+
const failN = extractFailCount(c.evidence);
|
|
415
|
+
L.push(
|
|
416
|
+
` ${R}${BOLD}→ FAIL${RESET}${failN != null ? ` ${DIM}· ${failN} failing${RESET}` : ""}`,
|
|
417
|
+
);
|
|
418
|
+
if (c.evidence) {
|
|
419
|
+
L.push("");
|
|
420
|
+
L.push(` ${DIM}real output:${RESET}`);
|
|
421
|
+
for (const w of wrapText(c.evidence, width).slice(0, 6)) L.push(` ${DIM}${w}${RESET}`);
|
|
422
|
+
}
|
|
423
|
+
L.push("");
|
|
424
|
+
L.push(` ${DIM}claimed FALSE at${RESET} ${c.ts || "unknown"}`);
|
|
425
|
+
if (c.fixed_at != null) {
|
|
426
|
+
const dur = formatDuration(c.fix_delta_ms);
|
|
427
|
+
L.push(
|
|
428
|
+
` ${DIM}same proof PASSED${RESET} ${c.fixed_at}${dur ? ` ${G}· ${dur} later (measured, real timestamps)${RESET}` : ""}`,
|
|
429
|
+
);
|
|
430
|
+
} else {
|
|
431
|
+
L.push(` ${DIM}no later PASS logged for this proof — count only, no time estimated.${RESET}`);
|
|
432
|
+
}
|
|
433
|
+
if (c.session) L.push(` ${DIM}session${RESET} ${DIM}${clean(c.session, 60)}${RESET}`);
|
|
434
|
+
L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
|
|
435
|
+
L.push(` ${DIM}j/k scroll · Esc/h back · q quit${RESET}`);
|
|
436
|
+
return L;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------- (5) the interactive loop — event-driven only, terminal always restored ----------
|
|
440
|
+
function runInteractive(data) {
|
|
441
|
+
const state = { view: "list", sel: 0, scroll: 0 };
|
|
442
|
+
let cleaned = false;
|
|
443
|
+
let pendingDraw = null;
|
|
444
|
+
|
|
445
|
+
// ONE cleanup, idempotent, never throws — every exit path funnels through it.
|
|
446
|
+
const cleanup = () => {
|
|
447
|
+
if (cleaned) return;
|
|
448
|
+
cleaned = true;
|
|
449
|
+
try {
|
|
450
|
+
if (pendingDraw) clearImmediate(pendingDraw);
|
|
451
|
+
} catch {
|
|
452
|
+
/* never throw from cleanup */
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
456
|
+
} catch {
|
|
457
|
+
/* raw mode already off / stdin gone */
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l"); // show cursor + leave the alternate screen
|
|
461
|
+
} catch {
|
|
462
|
+
/* stdout gone — nothing to restore onto */
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
process.stdin.removeAllListeners("keypress");
|
|
466
|
+
process.stdout.removeAllListeners("resize");
|
|
467
|
+
process.removeAllListeners("SIGWINCH");
|
|
468
|
+
process.removeAllListeners("SIGINT");
|
|
469
|
+
process.removeAllListeners("SIGTERM");
|
|
470
|
+
process.removeAllListeners("SIGHUP");
|
|
471
|
+
process.stdin.pause();
|
|
472
|
+
} catch {
|
|
473
|
+
/* best-effort */
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const die = (msg) => {
|
|
478
|
+
cleanup();
|
|
479
|
+
try {
|
|
480
|
+
process.stdout.write(`${msg}\n`);
|
|
481
|
+
} catch {
|
|
482
|
+
/* nothing left to say it to */
|
|
483
|
+
}
|
|
484
|
+
process.exit(0);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// Raw mode inside try/catch — if the terminal can't do it, fall back to the static snapshot.
|
|
488
|
+
try {
|
|
489
|
+
process.stdin.setRawMode(true);
|
|
490
|
+
} catch {
|
|
491
|
+
process.stdout.write(renderStatic(data) + "\n");
|
|
492
|
+
process.exit(0);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
process.on("exit", cleanup); // the backstop: no exit path escapes restoration
|
|
497
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
498
|
+
process.on(sig, () => {
|
|
499
|
+
cleanup();
|
|
500
|
+
process.exit(0);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
process.on("uncaughtException", () => die("skalpel analytics — could not render analytics"));
|
|
504
|
+
process.on("unhandledRejection", () => die("skalpel analytics — could not render analytics"));
|
|
505
|
+
|
|
506
|
+
process.stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen + hidden cursor
|
|
507
|
+
|
|
508
|
+
const draw = () => {
|
|
509
|
+
try {
|
|
510
|
+
const cols = Math.max(10, process.stdout.columns || 80);
|
|
511
|
+
const rowsN = Math.max(3, process.stdout.rows || 24);
|
|
512
|
+
const lines =
|
|
513
|
+
state.view === "detail"
|
|
514
|
+
? detailLines(data, cols, state)
|
|
515
|
+
: listLines(data, cols, rowsN, state);
|
|
516
|
+
// Detail view scrolls when taller than the terminal (keep the footer reachable).
|
|
517
|
+
let visible = lines;
|
|
518
|
+
if (state.view === "detail" && lines.length > rowsN) {
|
|
519
|
+
const maxScroll = lines.length - rowsN;
|
|
520
|
+
state.scroll = Math.min(Math.max(0, state.scroll), maxScroll);
|
|
521
|
+
visible = lines.slice(state.scroll, state.scroll + rowsN);
|
|
522
|
+
} else {
|
|
523
|
+
visible = lines.slice(0, rowsN);
|
|
524
|
+
}
|
|
525
|
+
const body = visible.map((l) => clipAnsi(l, cols - 1)).join("\r\n");
|
|
526
|
+
process.stdout.write("\x1b[H\x1b[2J" + body);
|
|
527
|
+
} catch {
|
|
528
|
+
die("skalpel analytics — could not render analytics");
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// Coalesce resize bursts through ONE unref'd immediate — event-driven, never a poll loop.
|
|
533
|
+
const scheduleDraw = () => {
|
|
534
|
+
if (pendingDraw) return;
|
|
535
|
+
pendingDraw = setImmediate(() => {
|
|
536
|
+
pendingDraw = null;
|
|
537
|
+
draw();
|
|
538
|
+
});
|
|
539
|
+
if (pendingDraw && typeof pendingDraw.unref === "function") pendingDraw.unref();
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
emitKeypressEvents(process.stdin);
|
|
543
|
+
process.stdin.resume();
|
|
544
|
+
process.stdin.on("keypress", (str, key) => {
|
|
545
|
+
try {
|
|
546
|
+
const k = key || {};
|
|
547
|
+
const name = k.name || str || "";
|
|
548
|
+
if ((k.ctrl && name === "c") || name === "q") {
|
|
549
|
+
cleanup();
|
|
550
|
+
process.exit(0);
|
|
551
|
+
}
|
|
552
|
+
if (state.view === "list") {
|
|
553
|
+
if (name === "up" || name === "k") state.sel = Math.max(0, state.sel - 1);
|
|
554
|
+
else if (name === "down" || name === "j")
|
|
555
|
+
state.sel = Math.min(Math.max(0, data.catches.length - 1), state.sel + 1);
|
|
556
|
+
else if (name === "return" || name === "enter") {
|
|
557
|
+
if (data.catches.length > 0) {
|
|
558
|
+
state.view = "detail";
|
|
559
|
+
state.scroll = 0;
|
|
560
|
+
}
|
|
561
|
+
} else if (name === "escape") {
|
|
562
|
+
cleanup();
|
|
563
|
+
process.exit(0);
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
if (name === "escape" || name === "backspace" || name === "h") {
|
|
567
|
+
state.view = "list";
|
|
568
|
+
state.scroll = 0;
|
|
569
|
+
} else if (name === "up" || name === "k") state.scroll = Math.max(0, state.scroll - 1);
|
|
570
|
+
else if (name === "down" || name === "j") state.scroll += 1; // clamped in draw()
|
|
571
|
+
}
|
|
572
|
+
scheduleDraw();
|
|
573
|
+
} catch {
|
|
574
|
+
die("skalpel analytics — could not render analytics");
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
process.stdout.on("resize", scheduleDraw); // node's SIGWINCH surface
|
|
578
|
+
process.on("SIGWINCH", scheduleDraw); // belt-and-suspenders (no-op where unsupported)
|
|
579
|
+
|
|
580
|
+
draw();
|
|
581
|
+
} catch {
|
|
582
|
+
die("skalpel analytics — could not render analytics");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---------- CLI ----------
|
|
587
|
+
const isMain = (() => {
|
|
588
|
+
try {
|
|
589
|
+
return (
|
|
590
|
+
Boolean(process.argv[1]) &&
|
|
591
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
592
|
+
);
|
|
593
|
+
} catch {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
})();
|
|
597
|
+
|
|
598
|
+
if (isMain) {
|
|
599
|
+
let data;
|
|
600
|
+
try {
|
|
601
|
+
data = collectData();
|
|
602
|
+
} catch {
|
|
603
|
+
process.stdout.write("\nskalpel analytics — no data yet\n\n");
|
|
604
|
+
process.exit(0);
|
|
605
|
+
}
|
|
606
|
+
// NON-TTY DEGRADE (pipes, CI, dumb harnesses): plain static snapshot, exit 0, raw mode untouched.
|
|
607
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
608
|
+
try {
|
|
609
|
+
process.stdout.write(renderStatic(data) + "\n");
|
|
610
|
+
} catch {
|
|
611
|
+
/* a closed pipe still exits 0 */
|
|
612
|
+
}
|
|
613
|
+
process.exit(0);
|
|
614
|
+
}
|
|
615
|
+
// A totally-fresh machine has nothing to navigate — print the honest empty screen and exit clean
|
|
616
|
+
// rather than opening an interactive view over four "no data yet" panels.
|
|
617
|
+
if (data.empty) {
|
|
618
|
+
process.stdout.write(renderStatic(data) + "\n");
|
|
619
|
+
process.exit(0);
|
|
620
|
+
}
|
|
621
|
+
runInteractive(data);
|
|
622
|
+
}
|
package/autopsy.mjs
CHANGED
|
@@ -275,6 +275,7 @@ function parseClaudeSession(file, rootDir) {
|
|
|
275
275
|
turns.push({
|
|
276
276
|
session_id: sessionId,
|
|
277
277
|
root: rootDir,
|
|
278
|
+
rootRaw: typeof o.cwd === "string" && o.cwd ? o.cwd : null, // real cwd for same-file identity
|
|
278
279
|
role,
|
|
279
280
|
turn_idx: idx++,
|
|
280
281
|
ts: o.timestamp || null,
|
|
@@ -365,6 +366,7 @@ function parseCodexSession(file) {
|
|
|
365
366
|
const turns = raw.map((t, idx) => ({
|
|
366
367
|
session_id: sessionId,
|
|
367
368
|
root: rootDir,
|
|
369
|
+
rootRaw: cwd || null, // real cwd from session_meta.payload.cwd for same-file identity
|
|
368
370
|
role: t.role,
|
|
369
371
|
turn_idx: idx,
|
|
370
372
|
ts: t.ts,
|
|
@@ -696,18 +698,22 @@ function projTail(root) {
|
|
|
696
698
|
// SAME ACTUAL FILE — not a shared basename. Returns a normalized absolute path, or null when the
|
|
697
699
|
// mention cannot be pinned to a real file:
|
|
698
700
|
// • absolute path in the text → that path IS the identity (works across every vendor).
|
|
699
|
-
// • relative path WITH a directory → resolved against the session's project
|
|
700
|
-
// when that
|
|
701
|
-
// and never Cursor's rootless bubbles) —
|
|
701
|
+
// • relative path WITH a directory → resolved against the session's RAW project cwd (rootRaw), but
|
|
702
|
+
// ONLY when that cwd is known and is a real project (never the
|
|
703
|
+
// home catch-all, and never Cursor's rootless bubbles) —
|
|
704
|
+
// otherwise unknowable.
|
|
702
705
|
// • bare basename (no directory) → null. A basename alone can never prove same-file.
|
|
703
|
-
|
|
706
|
+
// NOTE: identity MUST anchor on the raw, undecoded cwd — never decodeRoot(), whose lossy `-`→`/`
|
|
707
|
+
// round-trip can fabricate a path that is not on disk (a hyphen in a real dir name becomes a
|
|
708
|
+
// separator) or false-join two distinct projects whose decoded forms collide. decodeRoot stays for
|
|
709
|
+
// DISPLAY labels only.
|
|
710
|
+
function resolveFileIdentity(rawPath, abs, rootRaw) {
|
|
704
711
|
if (!rawPath) return null;
|
|
705
712
|
if (abs) return path.posix.normalize("/" + rawPath.replace(/^\/+/, ""));
|
|
706
713
|
if (!rawPath.includes("/")) return null; // bare basename — not a same-file proof
|
|
707
714
|
if (/^\.\.?\//.test(rawPath)) return null; // "./x" or "../x": ambiguous relative to an unknown subdir
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
return path.posix.normalize(base.replace(/\/+$/, "") + "/" + rawPath);
|
|
715
|
+
if (!rootRaw || rootRaw === HOME) return null; // cwd unknown (Cursor/Aider) or home catch-all — unknowable
|
|
716
|
+
return path.posix.normalize(rootRaw.replace(/\/+$/, "") + "/" + rawPath);
|
|
711
717
|
}
|
|
712
718
|
// Detect any email (incl. TLDs truncated by the 200-char span slice, e.g. "name@host.c" or "name@host").
|
|
713
719
|
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
|
|
@@ -1073,7 +1079,7 @@ async function main() {
|
|
|
1073
1079
|
if (errOnly && !kind.startsWith("err")) continue;
|
|
1074
1080
|
// ---- same-file identity index (message text only; tool output is skipped above) ----
|
|
1075
1081
|
if (kind === "sym:file") {
|
|
1076
|
-
const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.
|
|
1082
|
+
const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.rootRaw);
|
|
1077
1083
|
if (identity) {
|
|
1078
1084
|
if (!filePathMap.has(identity))
|
|
1079
1085
|
filePathMap.set(identity, { basename: anchor, byTool: new Map() });
|
package/package.json
CHANGED
package/reveal.mjs
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// Tone: this is the first time the user is seeing what skalpel learned about them, so the note directs
|
|
9
9
|
// the model to TEACH them their own pattern in plain, warm English — never the internal notation, never
|
|
10
10
|
// a stats dump.
|
|
11
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { REVEAL_PATH, revealEnabled, cleanText } from "./insights.mjs";
|
|
14
15
|
|
|
15
16
|
export function graphReadyNote() {
|
|
16
17
|
const p = join(homedir(), ".skalpel", "insights-ready.json");
|
|
@@ -78,3 +79,83 @@ export function graphReadyNote() {
|
|
|
78
79
|
`actually asked. Keep the whole thing short and human.]`
|
|
79
80
|
);
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
// ---- verify catch: the AGENT-facing half of the opt-in "aha catch" (SKALPEL_VERIFY_REVEAL) ----------
|
|
84
|
+
//
|
|
85
|
+
// verify-shadow.mjs re-runs the agent's OWN cited proof out of band; on a GENUINE_FAIL mismatch (it
|
|
86
|
+
// claimed "done", its own test/build REALLY failed) it writes ~/.skalpel/verify-reveal.json. The
|
|
87
|
+
// statusline renders that catch as an AMBIENT line the USER can miss within its TTL. This surfaces the
|
|
88
|
+
// SAME catch to the AGENT deterministically, in the NEXT turn's additionalContext, so the model corrects
|
|
89
|
+
// itself instead of standing by a false "done" — the mechanical nudge, not a line someone has to notice.
|
|
90
|
+
//
|
|
91
|
+
// The notified marker: the reveal file records the ts of the failing re-run; we remember the last ts we
|
|
92
|
+
// told the agent about so each distinct catch fires EXACTLY ONCE. It therefore persists across turns until
|
|
93
|
+
// it is injected once OR verify-shadow retracts the reveal on a later PASS — never a 90s TTL that lapses
|
|
94
|
+
// unseen. Scoped to the SAME session that produced the claim, so a stale cross-session catch never fires.
|
|
95
|
+
const NOTIFIED_PATH = join(homedir(), ".skalpel", "verify-reveal-notified.json");
|
|
96
|
+
|
|
97
|
+
// verifyCatchNote(env, sessionId) -> the bracketed model directive, or null.
|
|
98
|
+
// HONESTY RED LINES (mirrors verify-shadow's reveal): every word traces to the REAL reveal record —
|
|
99
|
+
// the real claim text, the real reconstructed proof command, the real captured failure tail, and a
|
|
100
|
+
// failing COUNT only when the runner actually printed one (r.fail_count is null otherwise). NO fabricated
|
|
101
|
+
// numbers. HARNESS_ERROR (timeout / missing script / wrong cwd) never reaches the reveal file, so this
|
|
102
|
+
// never cries wolf. GATED on SKALPEL_VERIFY_REVEAL: OFF (default) → returns null BEFORE touching disk, so
|
|
103
|
+
// the hook's additionalContext stays byte-identical. Fail-open: any error → null.
|
|
104
|
+
export function verifyCatchNote(env, sessionId) {
|
|
105
|
+
try {
|
|
106
|
+
if (!revealEnabled(env)) return null; // opt-in; default OFF → byte-identical, no disk touch
|
|
107
|
+
let r;
|
|
108
|
+
try {
|
|
109
|
+
r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
return null; // no catch pending
|
|
112
|
+
}
|
|
113
|
+
if (!r || typeof r !== "object" || !r.ts) return null;
|
|
114
|
+
// Only tell the AGENT about a catch from ITS OWN session — never a stale cross-session one.
|
|
115
|
+
const sid = sessionId ? String(sessionId) : "";
|
|
116
|
+
if (!sid || !r.session || String(r.session) !== sid) return null;
|
|
117
|
+
// Fire at most once per distinct catch (keyed by the failing re-run's ts).
|
|
118
|
+
try {
|
|
119
|
+
if (JSON.parse(readFileSync(NOTIFIED_PATH, "utf8"))?.ts === r.ts) return null;
|
|
120
|
+
} catch {
|
|
121
|
+
/* no marker yet — first time we've seen this catch */
|
|
122
|
+
}
|
|
123
|
+
// Record that we've now notified the agent about THIS catch BEFORE returning the note, so it fires
|
|
124
|
+
// once. If we can't persist the marker, DROP the note (same one-shot discipline as graphReadyNote /
|
|
125
|
+
// the auth-expiry note) — better to miss it once than re-inject every turn.
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
|
|
128
|
+
const tmp = `${NOTIFIED_PATH}.tmp`;
|
|
129
|
+
writeFileSync(tmp, JSON.stringify({ ts: r.ts }));
|
|
130
|
+
renameSync(tmp, NOTIFIED_PATH);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Build the directive from REAL fields only (cleanText strips any control chars; slices bound length).
|
|
135
|
+
const proof =
|
|
136
|
+
cleanText(String(r.proof_command || ""))
|
|
137
|
+
.trim()
|
|
138
|
+
.slice(0, 120) || "its own proof";
|
|
139
|
+
const claim =
|
|
140
|
+
cleanText(String(r.claim_text || ""))
|
|
141
|
+
.trim()
|
|
142
|
+
.slice(0, 120) || "it's done";
|
|
143
|
+
const failN =
|
|
144
|
+
Number.isFinite(r.fail_count) && r.fail_count > 0 ? ` (${r.fail_count} failing)` : "";
|
|
145
|
+
const evidence = cleanText(String(r.evidence || ""))
|
|
146
|
+
.trim()
|
|
147
|
+
.slice(0, 160);
|
|
148
|
+
return (
|
|
149
|
+
`[skalpel — a claim-check just RE-RAN the proof you cited and it FAILED. After your last ` +
|
|
150
|
+
`"${claim}"-type message, skalpel re-ran \`${proof}\` out of band and it did NOT pass${failN}. ` +
|
|
151
|
+
`This is YOUR OWN test/build re-run independently — not a new opinion, not a flaky guess. Do NOT ` +
|
|
152
|
+
`tell the user the work is done. Open with ONE short line — "🔬 skalpel · I re-ran \`${proof}\` ` +
|
|
153
|
+
`and it still fails${failN} — not done yet" — then actually diagnose and fix the failure before ` +
|
|
154
|
+
`you claim completion again.` +
|
|
155
|
+
(evidence ? ` The real failing output tail was: ${evidence}` : "") +
|
|
156
|
+
`]`
|
|
157
|
+
);
|
|
158
|
+
} catch {
|
|
159
|
+
return null; // fail-open: the catch note is a bonus, never break the hook
|
|
160
|
+
}
|
|
161
|
+
}
|
package/skalpel-hook.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { join } from "node:path";
|
|
|
14
14
|
import { identity } from "./auth.mjs";
|
|
15
15
|
import { record } from "./metrics.mjs";
|
|
16
16
|
import { recordInsight, cleanText, revealEnabled } from "./insights.mjs";
|
|
17
|
-
import { graphReadyNote } from "./reveal.mjs";
|
|
17
|
+
import { graphReadyNote, verifyCatchNote } from "./reveal.mjs";
|
|
18
18
|
import { tailLines } from "./transcript.mjs";
|
|
19
19
|
|
|
20
20
|
// Tail budget for the two transcript scans below (last-prompt + cascade). Both only ever look at the
|
|
@@ -770,12 +770,15 @@ async function main() {
|
|
|
770
770
|
session_id: sessionId,
|
|
771
771
|
});
|
|
772
772
|
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
773
|
+
// The AGENT-facing "aha catch" (opt-in, SKALPEL_VERIFY_REVEAL) LEADS: if verify-shadow's detached
|
|
774
|
+
// re-run caught the agent claiming "done" while its OWN proof GENUINELY failed, tell the agent so it
|
|
775
|
+
// corrects itself instead of a user having to notice an ambient statusline line. Fires once per catch,
|
|
776
|
+
// scoped to this session; returns null (byte-identical) when the flag is off. Then a just-finished
|
|
777
|
+
// background build ("your graph's ready", one-shot), the one-time auth-expiry note, ackNote (feedback
|
|
778
|
+
// confirmation), the reset interrupt (most acute), and the server steer. Any can fire alone.
|
|
779
|
+
const verifyNote = verifyCatchNote(process.env, sessionId);
|
|
777
780
|
const readyNote = graphReadyNote();
|
|
778
|
-
const additionalContext = [readyNote, authNote, ackNote, resetNote, injection]
|
|
781
|
+
const additionalContext = [verifyNote, readyNote, authNote, ackNote, resetNote, injection]
|
|
779
782
|
.filter(Boolean)
|
|
780
783
|
.join("\n\n");
|
|
781
784
|
if (additionalContext) {
|
package/skalpel-setup.mjs
CHANGED
|
@@ -64,6 +64,7 @@ const KNOWN_SUBS = new Set([
|
|
|
64
64
|
"catches",
|
|
65
65
|
"card",
|
|
66
66
|
"ledger",
|
|
67
|
+
"analytics",
|
|
67
68
|
"__build",
|
|
68
69
|
"__verify-report",
|
|
69
70
|
]);
|
|
@@ -81,6 +82,7 @@ usage:
|
|
|
81
82
|
skalpel catches scan your own history for "done" claims over failing tests
|
|
82
83
|
skalpel card [--last|--id <id>] [--md] share one verified catch: the claim + its own failing proof
|
|
83
84
|
skalpel ledger your running tally: N "done"s checked, M verified FALSE
|
|
85
|
+
skalpel analytics interactive local analytics — caught lies, measured cost, steers, re-asks
|
|
84
86
|
skalpel uninstall [--purge] remove the hooks + local data from this machine
|
|
85
87
|
|
|
86
88
|
options:
|
|
@@ -906,6 +908,14 @@ async function main() {
|
|
|
906
908
|
spawnSync("node", [join(__dir, "ledger.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
907
909
|
return;
|
|
908
910
|
}
|
|
911
|
+
// `skalpel analytics` — INTERACTIVE LOCAL ANALYTICS: a keyboard-navigable, READ-ONLY terminal view
|
|
912
|
+
// derived from the same honest local data as `skalpel ledger` (verify-shadow.log + steers.ndjson +
|
|
913
|
+
// insights.ndjson). Zero network, zero writes, zero estimates. Degrades to a plain static snapshot
|
|
914
|
+
// when stdin/stdout isn't a TTY, and restores the terminal on every exit path.
|
|
915
|
+
if (sub === "analytics") {
|
|
916
|
+
spawnSync("node", [join(__dir, "analytics.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
909
919
|
// `skalpel __verify-report` — the claim-verification SHADOW instrument: reads the local
|
|
910
920
|
// verify-shadow.log and prints the three numbers (claim fire-rate, proof-reconstruction rate, and the
|
|
911
921
|
// headline MISMATCH rate = how often the agent claimed done but its OWN proof, re-run, failed).
|
package/verify-shadow.mjs
CHANGED
|
@@ -384,12 +384,23 @@ function resolveCd(baseCwd, dir) {
|
|
|
384
384
|
return path.isAbsolute(d) ? path.normalize(d) : path.resolve(baseCwd || process.cwd(), d);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
// SHARP-4: a leading `cd <dir>` must not move the re-run OUTSIDE the session's base cwd. An out-of-tree
|
|
388
|
+
// cwd (absolute or `..` escape) would execute an attacker-planted package.json script / Cargo build.rs /
|
|
389
|
+
// eslint config as code — exactly the out-of-band execution the flag check (argsRedirectOutOfTree) blocks.
|
|
390
|
+
// Mirrors isPathEscape's guarantee for the resolved cwd itself. base is never null here → no resolve throw.
|
|
391
|
+
function cwdEscapesBase(cwd, base) {
|
|
392
|
+
const c = path.resolve(cwd);
|
|
393
|
+
const b = path.resolve(base);
|
|
394
|
+
return c !== b && !c.startsWith(b + path.sep);
|
|
395
|
+
}
|
|
396
|
+
|
|
387
397
|
// parseCommandForProof(command, baseCwd) -> { cmd, args, cwd } | null
|
|
388
398
|
// Walks the segments of ONE shell command; honors a leading `cd <dir>` (sets the re-run cwd) and returns
|
|
389
399
|
// the first allowlisted invocation found. Args are the cleaned literal tokens (no shell interpretation).
|
|
390
400
|
export function parseCommandForProof(command, baseCwd) {
|
|
391
401
|
if (!command || typeof command !== "string") return null;
|
|
392
|
-
|
|
402
|
+
const base = baseCwd || process.cwd();
|
|
403
|
+
let cwd = base;
|
|
393
404
|
for (const rawTokens of splitSegments(command)) {
|
|
394
405
|
const toks = cleanSegment(rawTokens);
|
|
395
406
|
if (!toks.length) continue;
|
|
@@ -399,7 +410,8 @@ export function parseCommandForProof(command, baseCwd) {
|
|
|
399
410
|
}
|
|
400
411
|
const cmd = toks[0];
|
|
401
412
|
const args = toks.slice(1, 65).map((t) => String(t).slice(0, 4096)); // bound arg count + length
|
|
402
|
-
|
|
413
|
+
// SHARP-4: refuse an out-of-tree cwd (from a `cd` escape) just as we refuse out-of-tree path flags.
|
|
414
|
+
if (isAllowedProof(cmd, args)) return cwdEscapesBase(cwd, base) ? null : { cmd, args, cwd };
|
|
403
415
|
}
|
|
404
416
|
return null;
|
|
405
417
|
}
|