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 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 measureFixDelta; a catch with no later PASS contributes NOTHING
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, measureFixDelta, formatDuration } from "./ledger.mjs";
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 ?? measureFixDelta(e._row, rows || [])?.ms ?? null,
188
- fixed_at: e.fixed_at ?? measureFixDelta(e._row, rows || [])?.atTs ?? null,
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. Always ends with
284
- // RESET when styling was present so a clipped line can never bleed style into the next.
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
- const isMain =
1121
- process.argv[1] &&
1122
- path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
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 { readFileSync } from "node:fs";
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 file is size-capped by the shadow (newest rows kept), so a plain read is safe.
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
- let raw;
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
- raw = readFileSync(path, "utf8");
58
+ closeSync(openSync(path, "r"));
43
59
  } catch {
44
- return null; // no shadow log yet → honest zero-state (distinct from "log exists but empty")
60
+ return null;
45
61
  }
46
62
  const rows = [];
47
- for (const line of raw.split("\n")) {
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
- // measureFixDelta(falseRow, rows) { ms, atTs } for the EARLIEST later row that is, in the SAME session,
69
- // the SAME proof_command, and a genuine PASS whose timestamp is strictly after the caught-false claim.
70
- // That is the real moment the false "done" became a true "done": the exact proof that failed later passed.
71
- // Returns null when no such row exists (then the ledger shows the count only — never an invented minute).
72
- export function measureFixDelta(falseRow, rows) {
73
- const t0 = tsMs(falseRow.ts);
74
- if (t0 == null) return null;
75
- const session = falseRow.session ?? null;
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 best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
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
- ts: r.ts || null,
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 = measureFixDelta(e._row, rows || []);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.38",
3
+ "version": "4.0.40",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 measureFixDelta logic). ALWAYS-ON once real catches exist (it is
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 + measureFixDelta are the EXACT logic from ledger.mjs, replicated here (not imported) so the hot
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
- // 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;
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 === 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;
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
- return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
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 = measureFixDelta(r, rows); // exact ledger.mjs logic; null-session rows excluded (Finding 3)
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 PASSED).
881
- function clearReveal() {
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).