skalpel 4.0.38 → 4.0.40
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 +37 -8
- package/eval-harness.mjs +17 -3
- package/ledger.mjs +92 -29
- package/package.json +1 -1
- package/skalpel-statusline.mjs +76 -28
- package/verify-shadow.mjs +17 -3
package/analytics.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// • NO FABRICATED / ESTIMATED NUMBERS. Every figure reproduces from a real logged row:
|
|
14
14
|
// - caught lies = ledger FALSE rows (mismatch:true) in verify-shadow.log
|
|
15
15
|
// - measured cost = Σ of (later same-session same-proof PASS ts − caught-FALSE ts) — real
|
|
16
|
-
// timestamps via
|
|
16
|
+
// timestamps via buildFixDeltas; a catch with no later PASS contributes NOTHING
|
|
17
17
|
// - steers = count of valid JSON lines in ~/.skalpel/steers.ndjson (the append-only
|
|
18
18
|
// STEERS SOURCE CONTRACT log; one atomic line per APPLIED steer) — a COUNT only; the banned
|
|
19
19
|
// "minutes saved" metric never appears
|
|
@@ -38,7 +38,7 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
38
38
|
// READ-ONLY reuse of the honest engine. ledger.mjs derives everything from verify-shadow.log;
|
|
39
39
|
// verify-shadow.mjs single-sources the log path + the fail-count extractor. Importing either runs
|
|
40
40
|
// nothing (their CLI blocks are isMain-guarded).
|
|
41
|
-
import { readLedgerRows, buildLedger,
|
|
41
|
+
import { readLedgerRows, buildLedger, formatDuration } from "./ledger.mjs";
|
|
42
42
|
import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
|
|
43
43
|
|
|
44
44
|
// ---------- palette (the same vocabulary as ledger.mjs / card.mjs / the statusline) ----------
|
|
@@ -177,15 +177,21 @@ export function collectData({
|
|
|
177
177
|
ledger = null;
|
|
178
178
|
}
|
|
179
179
|
// Caught lies, newest first (the navigable list). Sanitize once here — content is untrusted.
|
|
180
|
+
// fix_delta_ms/fixed_at are REUSED from buildLedger (which already measured them via buildFixDeltas in
|
|
181
|
+
// one indexed pass) — no per-entry re-scan. The old `?? measureFixDelta(...)` fallbacks were dead weight:
|
|
182
|
+
// buildLedger already sets both to a number-or-null, so the fallback only ever fired on a null (a catch
|
|
183
|
+
// with no later PASS) and recomputed the very same null at O(rows) cost. `ts` is sanitized here too
|
|
184
|
+
// (Finding 1): it is the only ledger-row field rendered raw in the detail view, so it goes through the
|
|
185
|
+
// same clean() as claim_text/proof_command/evidence. A normal ISO ts is unchanged by clean().
|
|
180
186
|
const catches = (ledger ? [...ledger.falseEntries] : []).reverse().map((e) => ({
|
|
181
|
-
ts: e.ts,
|
|
187
|
+
ts: clean(e.ts, 40),
|
|
182
188
|
session: e.session,
|
|
183
189
|
claim_text: clean(e.claim_text, 2000),
|
|
184
190
|
proof_command: clean(e.proof_command, 400),
|
|
185
191
|
evidence: clean(e.evidence, 2000),
|
|
186
192
|
fail_count: e.fail_count,
|
|
187
|
-
fix_delta_ms: e.fix_delta_ms ??
|
|
188
|
-
fixed_at: e.fixed_at ??
|
|
193
|
+
fix_delta_ms: e.fix_delta_ms ?? null,
|
|
194
|
+
fixed_at: e.fixed_at ?? null,
|
|
189
195
|
}));
|
|
190
196
|
// Measured cost: Σ of REAL red→green deltas. A catch with no later same-session same-proof PASS
|
|
191
197
|
// contributes NOTHING (never estimated).
|
|
@@ -279,9 +285,32 @@ export function renderStatic(data) {
|
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
// ---------- (4) interactive rendering helpers ----------
|
|
288
|
+
// East-Asian Wide / Fullwidth BMP code points occupy 2 terminal cells. The old clipAnsi budgeted these
|
|
289
|
+
// (CJK/Hangul/Kana/fullwidth, all ≤ 0xFFFF) as width 1 and so under-clipped — e.g. a wall of "中" ran
|
|
290
|
+
// twice as wide as the terminal. This is the standard compact wcwidth "wide" range set (no dependency):
|
|
291
|
+
// Hangul Jamo, CJK radicals→symbols, Hiragana→CJK-compat, CJK Ext-A, CJK Unified, Yi, Hangul syllables,
|
|
292
|
+
// CJK-compat ideographs, vertical/compat/small forms, and the Fullwidth Forms block.
|
|
293
|
+
function isWideBMP(cp) {
|
|
294
|
+
return (
|
|
295
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
296
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals … CJK Symbols/Punctuation (wide part)
|
|
297
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, Hangul Compat Jamo … CJK Compat
|
|
298
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
|
299
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
300
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi Syllables + Radicals
|
|
301
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
302
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
303
|
+
(cp >= 0xfe10 && cp <= 0xfe19) || // Vertical Forms
|
|
304
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms + Small Form Variants
|
|
305
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms (fullwidth ASCII, punctuation)
|
|
306
|
+
(cp >= 0xffe0 && cp <= 0xffe6) // Fullwidth signs (¢ £ ¥ ₩ …)
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
282
310
|
// 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
|
|
284
|
-
// RESET when styling was present so a clipped
|
|
311
|
+
// any escape in the string is ours). Astral code points (e.g. 🔬) budget 2 cells, as do East-Asian
|
|
312
|
+
// Wide/Fullwidth BMP glyphs (isWideBMP). Always ends with RESET when styling was present so a clipped
|
|
313
|
+
// line can never bleed style into the next.
|
|
285
314
|
export function clipAnsi(line, max) {
|
|
286
315
|
const s = String(line);
|
|
287
316
|
let out = "";
|
|
@@ -302,7 +331,7 @@ export function clipAnsi(line, max) {
|
|
|
302
331
|
}
|
|
303
332
|
const cp = s.codePointAt(i);
|
|
304
333
|
const ch = String.fromCodePoint(cp);
|
|
305
|
-
const w = cp > 0xffff ? 2 : 1;
|
|
334
|
+
const w = cp > 0xffff || isWideBMP(cp) ? 2 : 1;
|
|
306
335
|
if (vis + w > max) break;
|
|
307
336
|
out += ch;
|
|
308
337
|
vis += w;
|
package/eval-harness.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
28
29
|
|
|
29
30
|
// ───────────────────────────── tunables (UNCALIBRATED defaults) ─────────────────────────────
|
|
30
31
|
// These thresholds are NOT tuned against a human gold set unless one is supplied via --gold.
|
|
@@ -1117,9 +1118,22 @@ function main() {
|
|
|
1117
1118
|
console.log("═".repeat(90));
|
|
1118
1119
|
}
|
|
1119
1120
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1121
|
+
// Robust main detection (mirrors verify-shadow.mjs / skalpel-setup.mjs): compare REAL paths via
|
|
1122
|
+
// realpathSync, not a hand-built path.resolve(url.pathname). On Windows, `new URL(import.meta.url)
|
|
1123
|
+
// .pathname` yields "/C:/Users/..." (a leading slash BEFORE the drive letter); path.resolve() on that
|
|
1124
|
+
// string produces "\C:\Users\..." — a different string than path.resolve(process.argv[1])'s
|
|
1125
|
+
// "C:\Users\...". isMain then silently evaluates false, so `node eval-harness.mjs <dir>` (the CLI's
|
|
1126
|
+
// entire purpose) does nothing at all on Windows: no usage, no report, no error, exit 0.
|
|
1127
|
+
const isMain = (() => {
|
|
1128
|
+
try {
|
|
1129
|
+
return (
|
|
1130
|
+
Boolean(process.argv[1]) &&
|
|
1131
|
+
fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))
|
|
1132
|
+
);
|
|
1133
|
+
} catch {
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
})();
|
|
1123
1137
|
if (isMain) main();
|
|
1124
1138
|
|
|
1125
1139
|
export { V1_onset, V2_cue, V3_repeat, FIRE_ALWAYS, FIRE_NEVER, calibrate };
|
package/ledger.mjs
CHANGED
|
@@ -24,9 +24,15 @@
|
|
|
24
24
|
// logged PASS. If no such later PASS exists, we show the count ONLY — never an estimate, never a
|
|
25
25
|
// "minutes saved / lost" tally (that metric stays DEAD).
|
|
26
26
|
// • FAIL-OPEN. Any read/parse error yields an honest zero-state, never a throw at the user.
|
|
27
|
-
import {
|
|
27
|
+
import { openSync, closeSync } from "node:fs";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
29
|
import { realpathSync } from "node:fs";
|
|
30
|
+
// Bounded tail read (same helper the per-turn hook + statusline use) so a pathological/tampered/
|
|
31
|
+
// writer-regressed log can NEVER freeze this reader. transcript.mjs imports only node:fs (no heavy graph).
|
|
32
|
+
import { tailLines } from "./transcript.mjs";
|
|
33
|
+
// cleanText strips terminal-control chars (C0 incl. ESC, DEL, C1). Used to sanitize the untrusted `ts`
|
|
34
|
+
// field at the source (Finding 1 defense-in-depth); it passes a normal ISO string through unchanged.
|
|
35
|
+
import { cleanText } from "./insights.mjs";
|
|
30
36
|
// READ-ONLY reuse of the shadow's single-sourced log path + its honest fail-count extractor. Importing
|
|
31
37
|
// verify-shadow.mjs runs no side effects (its CLI block is guarded on isMain) and modifies nothing there.
|
|
32
38
|
import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
|
|
@@ -35,16 +41,28 @@ import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
|
|
|
35
41
|
// One NDJSON row per logged claim:
|
|
36
42
|
// { ts, session, claim_text, proof_command|null, pass_fail: "PASS"|"FAIL"|"HARNESS_ERROR"|null,
|
|
37
43
|
// outcome: "PASS"|"GENUINE_FAIL"|"HARNESS_ERROR", mismatch: boolean, evidence }
|
|
38
|
-
// The
|
|
44
|
+
// The shadow self-trims the log to ~1MB/500 lines, so the retained window is small. But this reader must
|
|
45
|
+
// never TRUST that: a pathological, tampered, or writer-regressed log could grow without bound and a full
|
|
46
|
+
// readFileSync would freeze the reader (and, via the statusline path, the always-on bar). So we read only
|
|
47
|
+
// the last LEDGER_READ_BYTES via the shared bounded tail read. 2MB is >2× the writer's 1MB cap, so
|
|
48
|
+
// legitimate data is never dropped — the cap only bounds pathological growth. The "cumulative caught"
|
|
49
|
+
// total is therefore over the RETAINED log window (already true — the log rotates); the cap only adds a
|
|
50
|
+
// hard ceiling so growth beyond the writer's contract can never stall a read.
|
|
51
|
+
export const LEDGER_READ_BYTES = 2 * 1024 * 1024;
|
|
39
52
|
export function readLedgerRows(path = VERIFY_LOG_PATH) {
|
|
40
|
-
|
|
53
|
+
// Distinguish "no shadow log yet" (→ null, honest zero-state) from "log exists but empty" (→ []).
|
|
54
|
+
// tailLines returns [] for BOTH, so probe first — exactly as origin/main's readFileSync did: a missing
|
|
55
|
+
// file (ENOENT) OR an existing-but-unreadable file (EACCES) both throw here → null; a readable file
|
|
56
|
+
// (empty or not) proceeds to the bounded read (empty → []).
|
|
41
57
|
try {
|
|
42
|
-
|
|
58
|
+
closeSync(openSync(path, "r"));
|
|
43
59
|
} catch {
|
|
44
|
-
return null;
|
|
60
|
+
return null;
|
|
45
61
|
}
|
|
46
62
|
const rows = [];
|
|
47
|
-
|
|
63
|
+
// Bounded: even a 30MB log reads only the last 2MB, so the reader can never freeze. tailLines drops the
|
|
64
|
+
// partial first line and never throws (any read error → [], i.e. a fail-open empty ledger).
|
|
65
|
+
for (const line of tailLines(path, LEDGER_READ_BYTES)) {
|
|
48
66
|
const l = line.trim();
|
|
49
67
|
if (!l) continue;
|
|
50
68
|
try {
|
|
@@ -65,27 +83,66 @@ function tsMs(ts) {
|
|
|
65
83
|
return Number.isFinite(n) ? n : null;
|
|
66
84
|
}
|
|
67
85
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const proof = falseRow.proof_command || null;
|
|
77
|
-
if (!proof) return null; // can't tie a fix to a claim with no proof — count only
|
|
78
|
-
let best = null;
|
|
79
|
-
for (const r of rows) {
|
|
80
|
-
if (r === falseRow) continue;
|
|
81
|
-
if ((r.session ?? null) !== session) continue;
|
|
82
|
-
if ((r.proof_command || null) !== proof) continue;
|
|
83
|
-
if (r.outcome !== "PASS") continue; // the SAME proof genuinely passing = the real done
|
|
84
|
-
const t1 = tsMs(r.ts);
|
|
85
|
-
if (t1 == null || t1 <= t0) continue; // must be strictly LATER than the false claim
|
|
86
|
-
if (best == null || t1 < best) best = t1;
|
|
86
|
+
// Binary search: the smallest element of an ascending array STRICTLY greater than x, or null. (upper_bound)
|
|
87
|
+
function firstGreater(sorted, x) {
|
|
88
|
+
let lo = 0;
|
|
89
|
+
let hi = sorted.length;
|
|
90
|
+
while (lo < hi) {
|
|
91
|
+
const mid = (lo + hi) >> 1;
|
|
92
|
+
if (sorted[mid] > x) hi = mid;
|
|
93
|
+
else lo = mid + 1;
|
|
87
94
|
}
|
|
88
|
-
return
|
|
95
|
+
return lo < sorted.length ? sorted[lo] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// buildFixDeltas(rows, opts) — the O(n log n) replacement for the old O(falseCount × rows) per-row scan.
|
|
99
|
+
// ONE pass indexes every genuine PASS by (session → proof_command) into an ascending timestamp array;
|
|
100
|
+
// the returned closure answers each caught-false row by binary-searching the EARLIEST PASS ts strictly
|
|
101
|
+
// later than that row's ts. Byte-for-byte the same result as the old per-row measureFixDelta:
|
|
102
|
+
// • earliest later, SAME session, SAME proof_command, outcome === "PASS", ts strictly > the false ts
|
|
103
|
+
// • no proof on the false row → null (can't tie a fix to a proofless claim)
|
|
104
|
+
// • unparseable false-row ts → null
|
|
105
|
+
// The old loop skipped `r === falseRow`, but a false row is a GENUINE_FAIL (never outcome:"PASS"), and
|
|
106
|
+
// even in a tampered log its own ts equals t0, which the strict `> t0` search already excludes — so the
|
|
107
|
+
// self-skip was a no-op and omitting it changes nothing (proven by the equivalence fixtures).
|
|
108
|
+
// opts.excludeNullSession — when true, a false row with a null session yields null (the statusline's
|
|
109
|
+
// Finding-3 guard). ledger.mjs's own view keeps the historical behavior (null-session rows CAN pair with
|
|
110
|
+
// other null-session rows) by leaving it false, so its results stay byte-identical to the old code.
|
|
111
|
+
export function buildFixDeltas(rows, { excludeNullSession = false } = {}) {
|
|
112
|
+
const index = new Map(); // (session ?? null) → Map<proof_command, ascending ts[]>
|
|
113
|
+
for (const r of rows || []) {
|
|
114
|
+
if (r.outcome !== "PASS") continue; // only a genuine PASS can be the "became true done" moment
|
|
115
|
+
const proof = r.proof_command || null;
|
|
116
|
+
if (!proof) continue; // a false row with a proof can never pair with a proofless PASS row
|
|
117
|
+
const t = tsMs(r.ts);
|
|
118
|
+
if (t == null) continue; // matches the old loop's `t1 == null` skip
|
|
119
|
+
const session = r.session ?? null;
|
|
120
|
+
let byProof = index.get(session);
|
|
121
|
+
if (!byProof) {
|
|
122
|
+
byProof = new Map();
|
|
123
|
+
index.set(session, byProof);
|
|
124
|
+
}
|
|
125
|
+
const arr = byProof.get(proof);
|
|
126
|
+
if (arr) arr.push(t);
|
|
127
|
+
else byProof.set(proof, [t]);
|
|
128
|
+
}
|
|
129
|
+
for (const byProof of index.values())
|
|
130
|
+
for (const arr of byProof.values()) arr.sort((a, b) => a - b);
|
|
131
|
+
|
|
132
|
+
// { ms, atTs } for the earliest later same-session same-proof genuine PASS, or null. Same contract as
|
|
133
|
+
// the old measureFixDelta — returns the count-only signal (null) when no real later PASS exists.
|
|
134
|
+
return function fixDeltaFor(falseRow) {
|
|
135
|
+
const t0 = tsMs(falseRow.ts);
|
|
136
|
+
if (t0 == null) return null;
|
|
137
|
+
const session = falseRow.session ?? null;
|
|
138
|
+
if (excludeNullSession && session == null) return null;
|
|
139
|
+
const proof = falseRow.proof_command || null;
|
|
140
|
+
if (!proof) return null; // can't tie a fix to a claim with no proof — count only
|
|
141
|
+
const arr = index.get(session)?.get(proof);
|
|
142
|
+
if (!arr) return null;
|
|
143
|
+
const best = firstGreater(arr, t0); // earliest PASS strictly LATER than the false claim
|
|
144
|
+
return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
|
|
145
|
+
};
|
|
89
146
|
}
|
|
90
147
|
|
|
91
148
|
// ---------- (3) build the ledger view from the rows ----------
|
|
@@ -100,7 +157,11 @@ export function buildLedger(rows) {
|
|
|
100
157
|
else if (r.pass_fail === "PASS") verdict = "verified";
|
|
101
158
|
else verdict = "unverifiable";
|
|
102
159
|
return {
|
|
103
|
-
|
|
160
|
+
// Finding 1 (defense-in-depth): `ts` is an UNTRUSTED log field that flows to a live terminal (the
|
|
161
|
+
// analytics detail view). Sanitize it at the source through the same control-char stripper as the
|
|
162
|
+
// other rendered fields so a tampered ts can't inject ANSI/CSI escapes. A normal ISO ts is
|
|
163
|
+
// unaffected — cleanText strips only C0/C1/DEL, which an ISO string contains none of.
|
|
164
|
+
ts: cleanText(r.ts) || null,
|
|
104
165
|
session: r.session ?? null,
|
|
105
166
|
claim_text: r.claim_text || "",
|
|
106
167
|
// Ship-status rows (category:"ship") record the check under probe_command, not proof_command —
|
|
@@ -117,9 +178,11 @@ export function buildLedger(rows) {
|
|
|
117
178
|
// rows (no proof / harness noise) are logged but NOT counted as checked — we can't stand behind them.
|
|
118
179
|
const checked = entries.filter((e) => e.verdict === "FALSE" || e.verdict === "verified").length;
|
|
119
180
|
const falseEntries = entries.filter((e) => e.verdict === "FALSE");
|
|
120
|
-
// Attach the MEASURED fix delta where — and only where — a real later same-proof PASS exists.
|
|
181
|
+
// Attach the MEASURED fix delta where — and only where — a real later same-proof PASS exists. ONE index
|
|
182
|
+
// pass (buildFixDeltas) then a binary-search per false row: O(n log n), not the old O(falseCount × rows).
|
|
183
|
+
const fixDeltaFor = buildFixDeltas(rows || []);
|
|
121
184
|
for (const e of falseEntries) {
|
|
122
|
-
const d =
|
|
185
|
+
const d = fixDeltaFor(e._row);
|
|
123
186
|
e.fix_delta_ms = d ? d.ms : null;
|
|
124
187
|
e.fixed_at = d ? d.atTs : null;
|
|
125
188
|
}
|
package/package.json
CHANGED
package/skalpel-statusline.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// TIME clause is chosen by BASELINE_TIME_MODE (Ryan flips one constant):
|
|
16
16
|
// · <Xm> caught — the CUMULATIVE LIFETIME sum of every real MEASURED red→green delta (each caught
|
|
17
17
|
// false "done" and the moment that SAME proof, that SAME session, was later logged PASS — real
|
|
18
|
-
// timestamps; reuses ledger.mjs's
|
|
18
|
+
// timestamps; reuses ledger.mjs's buildFixDeltas logic). ALWAYS-ON once real catches exist (it is
|
|
19
19
|
// skalpel's terminal presence — never gated/hidden). A measured total, NEVER an estimate; a catch
|
|
20
20
|
// with no later PASS adds 0. Cumulative framing (not present-tense) keeps it honest for old data.
|
|
21
21
|
// · ~Xm saved — Raahil's #609 estimate: (applied steers) × (the user's OWN median loop cost from
|
|
@@ -139,10 +139,17 @@ function steerLabel() {
|
|
|
139
139
|
// line (fresh + honest — the statusline never invents it, it only renders what the re-run recorded) or
|
|
140
140
|
// null. Auto-clears when stale (past the TTL) so an old catch can't wallpaper the bar. GATED entirely on
|
|
141
141
|
// SKALPEL_VERIFY_REVEAL: when off, this is never called and the bar is byte-identical to the baseline.
|
|
142
|
-
function revealNote() {
|
|
142
|
+
function revealNote(sessionId) {
|
|
143
143
|
try {
|
|
144
144
|
const r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
|
|
145
145
|
if (!r || typeof r.line !== "string" || !r.line.trim()) return null;
|
|
146
|
+
// SESSION SCOPING: verify-reveal.json is a single shared path across every concurrent Claude Code
|
|
147
|
+
// session (multiple tabs/worktrees). Without this check, this pane would show — and misattribute to
|
|
148
|
+
// "your agent" — a catch that actually came from a DIFFERENT session's transcript. Only skip when
|
|
149
|
+
// BOTH sides know their session and they disagree; an unknown session on either side stays permissive.
|
|
150
|
+
// NOT deleted here (unlike the stale-TTL branch below) — it may still be the right reveal for whichever
|
|
151
|
+
// session actually owns it.
|
|
152
|
+
if (r.session && sessionId && r.session !== sessionId) return null;
|
|
146
153
|
const age = Date.now() - Date.parse(r.ts);
|
|
147
154
|
if (!Number.isFinite(age) || age > REVEAL_TTL_MS) {
|
|
148
155
|
try {
|
|
@@ -190,6 +197,13 @@ function verdictNote() {
|
|
|
190
197
|
// garbage/partial lines are skipped; the sum only ever adds ≥0 deltas, so it is honest and never negative.
|
|
191
198
|
function readState() {
|
|
192
199
|
try {
|
|
200
|
+
// Read the WHOLE file — the count MUST stay exact. Unlike verify-shadow.log (writer-trimmed to
|
|
201
|
+
// ~1MB/500 lines), steers.ndjson is APPEND-ONLY with NO writer-side rotation (see the STEERS SOURCE
|
|
202
|
+
// CONTRACT in skalpel-hook.mjs). So a reader byte-cap has no safety margin: past the cap it would
|
|
203
|
+
// FREEZE the "N steers" count while the true count keeps growing — a false, dishonest number on the
|
|
204
|
+
// always-on bar. An exact O(n) line count is cheap (KB in practice; even a 20MB file reads in ~135ms,
|
|
205
|
+
// no freeze), and the O(n²) hot-path cost that actually mattered lives in the verify-shadow.log delta
|
|
206
|
+
// scan, already fixed by buildFixDeltas. So: exact whole-file count here, matching origin/main.
|
|
193
207
|
const raw = readFileSync(join(homedir(), ".skalpel", "steers.ndjson"), "utf8");
|
|
194
208
|
let steers = 0;
|
|
195
209
|
let savedMin = 0;
|
|
@@ -227,7 +241,7 @@ function savedPhrase(min) {
|
|
|
227
241
|
}
|
|
228
242
|
|
|
229
243
|
// ── MEASURED red→green delta (real timestamps only — the honest replacement for the banned "~Xh saved").
|
|
230
|
-
// tsMs +
|
|
244
|
+
// tsMs + buildFixDeltas are the EXACT logic from ledger.mjs, replicated here (not imported) so the hot
|
|
231
245
|
// statusline path never pulls verify-shadow.mjs's heavy graph. Parse an ISO ts to epoch ms, or null.
|
|
232
246
|
function tsMs(ts) {
|
|
233
247
|
if (typeof ts !== "string") return null;
|
|
@@ -235,31 +249,62 @@ function tsMs(ts) {
|
|
|
235
249
|
return Number.isFinite(n) ? n : null;
|
|
236
250
|
}
|
|
237
251
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
// Binary search: the smallest element of an ascending array STRICTLY greater than x, or null. (upper_bound)
|
|
253
|
+
function firstGreater(sorted, x) {
|
|
254
|
+
let lo = 0;
|
|
255
|
+
let hi = sorted.length;
|
|
256
|
+
while (lo < hi) {
|
|
257
|
+
const mid = (lo + hi) >> 1;
|
|
258
|
+
if (sorted[mid] > x) hi = mid;
|
|
259
|
+
else lo = mid + 1;
|
|
260
|
+
}
|
|
261
|
+
return lo < sorted.length ? sorted[lo] : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// buildFixDeltas(rows) — the O(n log n) replacement for the old O(falseCount × rows) per-row scan, which
|
|
265
|
+
// ran on EVERY bar render (the always-on hot path). ONE pass indexes every genuine PASS by
|
|
266
|
+
// (session → proof_command) into an ascending timestamp array; the returned closure answers each
|
|
267
|
+
// caught-false row by binary-searching the EARLIEST PASS ts strictly later than that row's ts. Byte-for-
|
|
268
|
+
// byte the same result as the old per-row measureFixDelta (earliest later, SAME session, SAME
|
|
269
|
+
// proof_command, outcome === "PASS", ts strictly >; no proof → null; unparseable ts → null).
|
|
270
|
+
// Finding 3 (statusline-only): a REAL session id is required — a null-session false row yields null, so a
|
|
271
|
+
// caught false with no session can't cross-attribute a bogus delta against other null-session rows. This
|
|
272
|
+
// mirrors ledger.mjs buildFixDeltas({ excludeNullSession: true }); replicated inline to keep the hot path
|
|
273
|
+
// import-free. The old loop's `r === falseRow` self-skip was a no-op (a false row is a GENUINE_FAIL, never
|
|
274
|
+
// outcome:"PASS", and its own ts equals t0, which the strict `> t0` search already excludes). [ledger.mjs]
|
|
275
|
+
function buildFixDeltas(rows) {
|
|
276
|
+
const index = new Map(); // (session ?? null) → Map<proof_command, ascending ts[]>
|
|
253
277
|
for (const r of rows) {
|
|
254
|
-
if (r
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
278
|
+
if (r.outcome !== "PASS") continue;
|
|
279
|
+
const proof = r.proof_command || null;
|
|
280
|
+
if (!proof) continue;
|
|
281
|
+
const t = tsMs(r.ts);
|
|
282
|
+
if (t == null) continue;
|
|
283
|
+
const session = r.session ?? null;
|
|
284
|
+
let byProof = index.get(session);
|
|
285
|
+
if (!byProof) {
|
|
286
|
+
byProof = new Map();
|
|
287
|
+
index.set(session, byProof);
|
|
288
|
+
}
|
|
289
|
+
const arr = byProof.get(proof);
|
|
290
|
+
if (arr) arr.push(t);
|
|
291
|
+
else byProof.set(proof, [t]);
|
|
261
292
|
}
|
|
262
|
-
|
|
293
|
+
for (const byProof of index.values())
|
|
294
|
+
for (const arr of byProof.values()) arr.sort((a, b) => a - b);
|
|
295
|
+
|
|
296
|
+
return function fixDeltaFor(falseRow) {
|
|
297
|
+
const t0 = tsMs(falseRow.ts);
|
|
298
|
+
if (t0 == null) return null;
|
|
299
|
+
const session = falseRow.session ?? null;
|
|
300
|
+
if (session == null) return null; // Finding 3: no real session id → refuse to measure (count only)
|
|
301
|
+
const proof = falseRow.proof_command || null;
|
|
302
|
+
if (!proof) return null; // can't tie a fix to a claim with no proof — count only
|
|
303
|
+
const arr = index.get(session)?.get(proof);
|
|
304
|
+
if (!arr) return null;
|
|
305
|
+
const best = firstGreater(arr, t0); // earliest PASS strictly LATER than the false claim
|
|
306
|
+
return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
|
|
307
|
+
};
|
|
263
308
|
}
|
|
264
309
|
|
|
265
310
|
// Compact one-line duration for the bar. Input is a real (t_pass − t_false) MEASURED delta; this only
|
|
@@ -300,10 +345,13 @@ function measuredCaughtTotalMs() {
|
|
|
300
345
|
/* partial / garbage line — skip it, keep the rest (fail-open) */
|
|
301
346
|
}
|
|
302
347
|
}
|
|
348
|
+
// ONE index pass over all rows, then a binary-search per caught-false row — O(n log n), not the old
|
|
349
|
+
// O(falseCount × rows) that re-scanned every row for each catch on EVERY bar render.
|
|
350
|
+
const fixDeltaFor = buildFixDeltas(rows);
|
|
303
351
|
let total = 0;
|
|
304
352
|
for (const r of rows) {
|
|
305
353
|
if (r.mismatch !== true) continue; // only a caught false "done" (verify-shadow's money metric)
|
|
306
|
-
const d =
|
|
354
|
+
const d = fixDeltaFor(r); // exact ledger.mjs logic; null-session rows excluded (Finding 3)
|
|
307
355
|
if (d && Number.isFinite(d.ms) && d.ms > 0) total += d.ms; // real measured delta only; else +0
|
|
308
356
|
}
|
|
309
357
|
return total;
|
|
@@ -320,7 +368,7 @@ function main() {
|
|
|
320
368
|
// SKALPEL_VERIFY_REVEAL — when off (the default) this branch never runs and the bar is byte-identical to
|
|
321
369
|
// the always-on baseline below.
|
|
322
370
|
if (revealEnabled(process.env)) {
|
|
323
|
-
const reveal = revealNote();
|
|
371
|
+
const reveal = revealNote(payload.session_id);
|
|
324
372
|
if (reveal) {
|
|
325
373
|
process.stdout.write(`${BOLD}${reveal}${RESET}`);
|
|
326
374
|
return;
|
package/verify-shadow.mjs
CHANGED
|
@@ -877,9 +877,23 @@ function writeReveal(row) {
|
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
879
|
|
|
880
|
-
// clearReveal() — retract a prior catch (the agent actually fixed it: a later proof re-run
|
|
881
|
-
|
|
880
|
+
// clearReveal(session) — retract a prior catch (the agent actually fixed it: a later proof re-run
|
|
881
|
+
// PASSED). SESSION-SCOPED: verify-reveal.json is a single shared path under ~/.skalpel, read/written by
|
|
882
|
+
// every concurrent Claude Code session (multiple tabs/worktrees is a normal prosumer workflow). Without
|
|
883
|
+
// this guard, session B's PASSING proof would silently wipe out session A's still-unresolved GENUINE_FAIL
|
|
884
|
+
// reveal — the user in session A would never see the catch they hadn't gotten to yet, and session B gets
|
|
885
|
+
// credit for "fixing" something it never touched. Only refuse to clear when BOTH sides know their session
|
|
886
|
+
// and they disagree; an unknown session on either side stays permissive (matches prior single-session
|
|
887
|
+
// behavior, and a manual/CLI-driven re-run with no session context still self-heals as before).
|
|
888
|
+
function clearReveal(session) {
|
|
882
889
|
try {
|
|
890
|
+
let existing = null;
|
|
891
|
+
try {
|
|
892
|
+
existing = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
|
|
893
|
+
} catch {
|
|
894
|
+
/* no reveal file, or unreadable — nothing to protect; fall through to the (idempotent) remove */
|
|
895
|
+
}
|
|
896
|
+
if (existing && existing.session && session && existing.session !== session) return; // not ours
|
|
883
897
|
rmSync(REVEAL_PATH, { force: true });
|
|
884
898
|
} catch {
|
|
885
899
|
/* stale reveal self-expires via the statusline's TTL anyway */
|
|
@@ -946,7 +960,7 @@ function maybeSurfaceReveal(row) {
|
|
|
946
960
|
session: row.session || null,
|
|
947
961
|
});
|
|
948
962
|
} else if (isPass) {
|
|
949
|
-
clearReveal(); // resolved — don't wallpaper a catch the agent already fixed
|
|
963
|
+
clearReveal(row.session || null); // resolved — don't wallpaper a catch the agent already fixed
|
|
950
964
|
// The green VERDICT stamp is #612's TEST-pass path only: its line ("re-ran `<proof>`, exit 0") is a
|
|
951
965
|
// real test re-run receipt. A ship SHIP_OK (read-only probe came back positive) has no re-run and no
|
|
952
966
|
// proof_command, so it must NOT borrow that wording — it only clears any prior catch (as #611 did).
|