skalpel 4.0.10 → 4.0.11

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.
Files changed (2) hide show
  1. package/autopsy.mjs +703 -157
  2. package/package.json +1 -1
package/autopsy.mjs CHANGED
@@ -1,20 +1,36 @@
1
1
  #!/usr/bin/env node
2
- // autopsy.mjs — `skalpel autopsy`: a LOCAL, READ-ONLY, ZERO-NETWORK receipt.
2
+ // autopsy.mjs — `skalpel autopsy`: a LOCAL, READ-ONLY, ZERO-NETWORK, CROSS-VENDOR receipt.
3
3
  //
4
4
  // It graduates the resident statistical instrument (the relational backtest that returned GO with an
5
5
  // independently-verified 0/20 false-join rate) into a one-shot command. It reads the coding logs that
6
- // are ALREADY on this machine ~/.claude/projects/**/*.jsonl (top-level interactive sessions) and, for
7
- // context only, ~/.skalpel/*.ndjson and renders a plain-text, screenshot-native receipt of 1-3
8
- // VERIFIED behavioral relations. Every number it prints re-derives exactly from the raw logs: each
9
- // relation quotes the user's own lines VERBATIM with {session,turn,ts} citations and a `reproduce:`
10
- // line that re-opens the cited files fresh from disk and re-confirms the count.
6
+ // are ALREADY on this machine, across EVERY local agent it can find — and renders a plain-text,
7
+ // screenshot-native receipt of VERIFIED behavioral relations. Every number it prints re-derives exactly
8
+ // from the raw logs of the vendor it came from: each relation quotes the user's own lines VERBATIM with
9
+ // {session,turn,ts,tool} citations and a `reproduce:` line that re-opens the cited store FRESH from disk
10
+ // and re-confirms the count PER VENDOR.
11
+ //
12
+ // VENDORS READ (all local, read-only):
13
+ // • Claude Code — ~/.claude/projects/**/*.jsonl (top-level interactive sessions)
14
+ // • Codex — ~/.codex/{sessions,archived_sessions}/**/rollout-*.jsonl
15
+ // • Cursor — ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
16
+ // (SQLite `cursorDiskKV` bubbleId:* rows, read via the node: core `node:sqlite`)
17
+ // • Aider — ~/.aider.chat.history.md (parsed if present)
18
+ //
19
+ // THE CROSS-VENDOR MOVE (this cycle): a single model vendor can only ever autopsy its OWN logs (a
20
+ // retention conflict no one else can resolve). skalpel reads them ALL and, when the SAME specific error
21
+ // or file follows you across two different agents, it says so out loud — "this appears in both Claude
22
+ // Code and Cursor" — with each side's count reproducing from that side's raw store. When only one agent
23
+ // has usable history, it shows the single-vendor findings and HONESTLY invites connecting a second.
11
24
  //
12
25
  // HARD INVARIANTS (enforced by construction):
13
- // 1. ZERO NETWORK. This file imports ONLY node:fs / node:path / node:os / node:readline.
14
- // No http/https/net/dns/fetch/child_process. It never opens a socket. It only reads local files.
26
+ // 1. ZERO NETWORK. This file imports ONLY node: core modules (node:fs / node:path / node:os /
27
+ // node:readline / node:sqlite). No http/https/net/dns/fetch/child_process. It never opens a socket.
28
+ // It only reads local files. `node:sqlite` is a Node core module — it is a local file reader, not a
29
+ // network client — and is loaded lazily so an older Node simply skips Cursor instead of crashing.
15
30
  // 2. NO FABRICATED STATS. Nothing is rendered unless it re-derives verbatim from disk. If there is
16
31
  // not enough history to cross the bar, it prints an HONEST ZERO-STATE with a live turn counter —
17
- // never an invented finding.
32
+ // never an invented finding. A cross-vendor claim is made ONLY when >=2 vendors independently
33
+ // reproduce the anchor.
18
34
  // 3. HONEST FOOTER. It states that THIS RECEIPT is generated locally, read-only. It does NOT and must
19
35
  // not claim the whole product is local: the per-turn hook and SessionEnd ingest STILL upload
20
36
  // transcripts today. Zero-network ingest is a later cycle.
@@ -24,6 +40,8 @@
24
40
  // / normalized n-gram), witness per session. Asserted as a headline.
25
41
  // CELL C — density-gated MEASURED SPIRAL: the same error anchor recurring within <=30min gaps inside
26
42
  // one session, with wall-clock duration. Asserted as a headline.
43
+ // CELL X — CROSS-VENDOR recurrence: the SAME specific anchor appearing in sessions from >=2 different
44
+ // agents, each side's count reproduced from that agent's own raw store. The un-absorbable one.
27
45
  // CELL B — "answer already on screen" is DEMOTED to witness-adjudicated only (it is the fakeable-by-
28
46
  // mislabel trust bomb) and is NEVER surfaced as an asserted headline here.
29
47
 
@@ -35,6 +53,30 @@ import readline from "node:readline";
35
53
  const HOME = os.homedir();
36
54
  const PROJECTS_DIR = path.join(HOME, ".claude", "projects");
37
55
  const SKALPEL_DIR = path.join(HOME, ".skalpel");
56
+ const CODEX_SESSIONS_DIRS = [
57
+ path.join(HOME, ".codex", "sessions"),
58
+ path.join(HOME, ".codex", "archived_sessions"),
59
+ ];
60
+ const CURSOR_DB = path.join(
61
+ HOME,
62
+ "Library",
63
+ "Application Support",
64
+ "Cursor",
65
+ "User",
66
+ "globalStorage",
67
+ "state.vscdb",
68
+ );
69
+ const AIDER_HISTORY = path.join(HOME, ".aider.chat.history.md");
70
+
71
+ // vendor identity + display labels (threaded onto every turn as source_tool)
72
+ const SRC = { claude: "claude", codex: "codex", cursor: "cursor", aider: "aider" };
73
+ const SRC_LABEL = {
74
+ claude: "Claude Code",
75
+ codex: "Codex",
76
+ cursor: "Cursor",
77
+ aider: "Aider",
78
+ };
79
+ const srcLabel = (s) => SRC_LABEL[s] || s || "?";
38
80
 
39
81
  // ---------- deterministic helpers (verbatim from the backtest engine) ----------
40
82
  const SPAN = 90;
@@ -52,6 +94,7 @@ const BOILER_MARKERS = [
52
94
  "deferred_tools",
53
95
  "MCP server",
54
96
  "You are Claude Code",
97
+ "You are Codex",
55
98
  "IMPORTANT: These instructions OVERRIDE",
56
99
  "hookSpecificOutput",
57
100
  "additionalContext",
@@ -62,6 +105,8 @@ const BOILER_MARKERS = [
62
105
  "claudeMd",
63
106
  "userEmail",
64
107
  "currentDate",
108
+ "<permissions instructions>",
109
+ "AGENTS.md instructions for",
65
110
  ];
66
111
  const isBoiler = (t) => {
67
112
  const l = t.slice(0, 400);
@@ -167,8 +212,19 @@ function span(text, at, len) {
167
212
  return text.slice(a, b).replace(/\s+/g, " ").trim().slice(0, 200);
168
213
  }
169
214
 
170
- // ---------- parse one session (verbatim shape from the backtest engine) ----------
171
- function parseSession(file, rootDir) {
215
+ // ~/.claude/projects encodes cwd as a dash-flattened path. Every vendor's project root is normalized to
216
+ // this same encoding so decodeRoot / projTail / isHomeRoot work uniformly regardless of source tool.
217
+ const encodeCwd = (cwd) => (cwd ? cwd.replace(/\//g, "-") : "");
218
+
219
+ // ================= per-vendor parsers → ONE normalized shape =================
220
+ // normalized turn: { session_id, root, role, turn_idx, ts, tms, text, toolErrText, mineErrOnly,
221
+ // toolCalls, boiler, source_tool, bubbleKey }
222
+ // normalized session: { sessionId, rootDir, source_tool, turns:[turn] }
223
+ // fileForSession maps a claude/codex session_id → its raw file so `reproduce` can re-open it fresh.
224
+ const fileForSession = new Map();
225
+
226
+ // ---------- CLAUDE CODE: parse one jsonl session (verbatim shape from the backtest engine) ----------
227
+ function parseClaudeSession(file, rootDir) {
172
228
  return new Promise((resolve) => {
173
229
  const sessionId = path.basename(file, ".jsonl");
174
230
  const turns = [];
@@ -222,17 +278,298 @@ function parseSession(file, rootDir) {
222
278
  mineErrOnly: !text && !!toolErrText,
223
279
  toolCalls,
224
280
  boiler,
281
+ source_tool: SRC.claude,
282
+ bubbleKey: null,
225
283
  });
226
284
  });
227
- rl.on("close", () => resolve({ sessionId, rootDir, turns }));
228
- rl.on("error", () => resolve({ sessionId, rootDir, turns }));
285
+ rl.on("close", () => {
286
+ fileForSession.set(sessionId, file);
287
+ resolve({ sessionId, rootDir, source_tool: SRC.claude, turns });
288
+ });
289
+ rl.on("error", () => resolve({ sessionId, rootDir, source_tool: SRC.claude, turns }));
229
290
  });
230
291
  }
231
292
 
232
- // ---------- reproduce affordance: re-open a cited file FRESH, jump to the turn, re-confirm ----------
233
- // This is the authoritative re-derivation. It reads the raw jsonl again (independent of the in-memory
234
- // parse) and confirms the anchor is verbatim present at the cited turn.
235
- function readTurnText(file, targetIdx) {
293
+ // ---------- CODEX: shared turn-emitter (used by BOTH parse and reproduce identical indexing) ----------
294
+ // A Codex rollout-*.jsonl is an event log. A "turn" is each response_item/message (user/assistant) plus
295
+ // each response_item/function_call_output (tool/command output, where compiler & runtime errors live).
296
+ // developer-role messages are system boilerplate and are skipped.
297
+ function codexTurns(file) {
298
+ let lines;
299
+ try {
300
+ lines = fs.readFileSync(file, "utf8").split("\n");
301
+ } catch {
302
+ return { id: null, cwd: "", turns: [] };
303
+ }
304
+ let id = null,
305
+ cwd = "";
306
+ const turns = [];
307
+ for (const line of lines) {
308
+ if (!line) continue;
309
+ let o;
310
+ try {
311
+ o = JSON.parse(line);
312
+ } catch {
313
+ continue;
314
+ }
315
+ if (o.type === "session_meta" && o.payload) {
316
+ id = o.payload.id || id;
317
+ cwd = o.payload.cwd || cwd;
318
+ continue;
319
+ }
320
+ if (o.type !== "response_item" || !o.payload) continue;
321
+ const p = o.payload;
322
+ const ts = o.timestamp || null;
323
+ if (p.type === "message") {
324
+ if (p.role === "developer") continue; // system boilerplate
325
+ const text = (Array.isArray(p.content) ? p.content : [])
326
+ .map((c) => (c && typeof c.text === "string" ? c.text : ""))
327
+ .join("\n")
328
+ .trim();
329
+ if (!text) continue;
330
+ turns.push({
331
+ role: p.role === "assistant" ? "assistant" : "user",
332
+ text,
333
+ toolErrText: "",
334
+ mineErrOnly: false,
335
+ ts,
336
+ from: "message",
337
+ });
338
+ } else if (p.type === "function_call_output") {
339
+ const raw = p.output;
340
+ const s = (typeof raw === "string" ? raw : JSON.stringify(raw || "")).trim();
341
+ if (!s) continue;
342
+ turns.push({
343
+ role: "assistant",
344
+ text: "",
345
+ toolErrText: s.slice(0, 4000),
346
+ mineErrOnly: true,
347
+ ts,
348
+ from: "tool_output",
349
+ });
350
+ }
351
+ }
352
+ return { id, cwd, turns };
353
+ }
354
+
355
+ function parseCodexSession(file) {
356
+ const { id, cwd, turns: raw } = codexTurns(file);
357
+ const sessionId = id || path.basename(file, ".jsonl");
358
+ const rootDir = encodeCwd(cwd);
359
+ const turns = raw.map((t, idx) => ({
360
+ session_id: sessionId,
361
+ root: rootDir,
362
+ role: t.role,
363
+ turn_idx: idx,
364
+ ts: t.ts,
365
+ tms: t.ts ? Date.parse(t.ts) : null,
366
+ text: (t.text || "").slice(0, 6000),
367
+ toolErrText: (t.toolErrText || "").slice(0, 4000),
368
+ mineErrOnly: t.mineErrOnly,
369
+ toolCalls: [],
370
+ boiler: t.text ? isBoiler(t.text) : false,
371
+ source_tool: SRC.codex,
372
+ bubbleKey: null,
373
+ }));
374
+ if (turns.length) fileForSession.set(sessionId, file);
375
+ return { sessionId, rootDir, source_tool: SRC.codex, turns };
376
+ }
377
+
378
+ function walkRollouts(dir) {
379
+ let out = [];
380
+ let entries = [];
381
+ try {
382
+ entries = fs.readdirSync(dir, { withFileTypes: true });
383
+ } catch {
384
+ return out;
385
+ }
386
+ for (const e of entries) {
387
+ const p = path.join(dir, e.name);
388
+ if (e.isDirectory()) out = out.concat(walkRollouts(p));
389
+ else if (/^rollout-.*\.jsonl$/.test(e.name)) out.push(p);
390
+ }
391
+ return out;
392
+ }
393
+
394
+ function loadCodexSessions() {
395
+ const files = [];
396
+ for (const d of CODEX_SESSIONS_DIRS) files.push(...walkRollouts(d));
397
+ const sessions = [];
398
+ for (const f of files) {
399
+ const s = parseCodexSession(f);
400
+ if (s.turns.length) sessions.push(s);
401
+ }
402
+ return sessions;
403
+ }
404
+
405
+ function readCodexTurnText(file, targetIdx) {
406
+ const { turns } = codexTurns(file);
407
+ const t = turns[targetIdx];
408
+ return t ? t.text || t.toolErrText || null : null;
409
+ }
410
+
411
+ // ---------- CURSOR: read the SQLite chat store via the node: core `node:sqlite` (lazy, graceful) ----
412
+ // Cursor stores each chat message as a row `bubbleId:<composerId>:<bubbleId>` in cursorDiskKV. The value
413
+ // is JSON with { type:1=user|2=assistant, text, createdAt }. We group by composerId (=session), order by
414
+ // createdAt, and normalize. `node:sqlite` is a Node core module (>=22.5 / stable in 24+); if it is not
415
+ // available we simply skip Cursor — never crash, never fabricate.
416
+ let DatabaseSyncClass = null;
417
+ async function loadCursorSessions() {
418
+ if (!fs.existsSync(CURSOR_DB)) return [];
419
+ try {
420
+ const mod = await import("node:sqlite");
421
+ DatabaseSyncClass = mod.DatabaseSync;
422
+ } catch {
423
+ return []; // Node too old for node:sqlite — skip Cursor honestly
424
+ }
425
+ let rows = [];
426
+ try {
427
+ const db = new DatabaseSyncClass(CURSOR_DB, { readOnly: true });
428
+ rows = db
429
+ .prepare(
430
+ "SELECT key, json_extract(value,'$.type') AS type, json_extract(value,'$.text') AS text, json_extract(value,'$.createdAt') AS createdAt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' AND length(json_extract(value,'$.text'))>0",
431
+ )
432
+ .all();
433
+ db.close();
434
+ } catch {
435
+ return [];
436
+ }
437
+ // group by composerId; key = bubbleId:<composerId(36)>:<bubbleId>
438
+ const byComposer = new Map();
439
+ for (const r of rows) {
440
+ const composerId = r.key.slice("bubbleId:".length, "bubbleId:".length + 36);
441
+ if (!byComposer.has(composerId)) byComposer.set(composerId, []);
442
+ byComposer.get(composerId).push(r);
443
+ }
444
+ const sessions = [];
445
+ for (const [composerId, bubbles] of byComposer) {
446
+ bubbles.sort((a, b) => (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0));
447
+ const turns = bubbles.map((b, idx) => {
448
+ const text = (b.text || "").trim();
449
+ return {
450
+ session_id: composerId,
451
+ root: null, // Cursor bubbles carry no reliable cwd; project is unknown (never faked)
452
+ role: b.type === 1 ? "user" : "assistant",
453
+ turn_idx: idx,
454
+ ts: b.createdAt || null,
455
+ tms: b.createdAt ? Date.parse(b.createdAt) : null,
456
+ text: text.slice(0, 6000),
457
+ toolErrText: "",
458
+ mineErrOnly: false,
459
+ toolCalls: [],
460
+ boiler: isBoiler(text),
461
+ source_tool: SRC.cursor,
462
+ bubbleKey: b.key,
463
+ };
464
+ });
465
+ if (turns.length)
466
+ sessions.push({ sessionId: composerId, rootDir: null, source_tool: SRC.cursor, turns });
467
+ }
468
+ return sessions;
469
+ }
470
+
471
+ function readCursorBubbleText(bubbleKey) {
472
+ if (!DatabaseSyncClass || !bubbleKey) return null;
473
+ try {
474
+ const db = new DatabaseSyncClass(CURSOR_DB, { readOnly: true });
475
+ const row = db
476
+ .prepare("SELECT json_extract(value,'$.text') AS text FROM cursorDiskKV WHERE key = ?")
477
+ .get(bubbleKey);
478
+ db.close();
479
+ return row && row.text ? String(row.text) : null;
480
+ } catch {
481
+ return null;
482
+ }
483
+ }
484
+
485
+ // ---------- AIDER: ~/.aider.chat.history.md (markdown transcript), parsed if present ----------
486
+ // Format: "#### <user message>" lines for the user, assistant prose in between, "> " session banners.
487
+ // Aider does not stamp per-message timestamps, so ts is null (recurrence/cross-vendor still work; the
488
+ // in-session SPIRAL cell, which needs wall-clock, simply won't fire for Aider — honest by construction).
489
+ function loadAiderSessions() {
490
+ if (!fs.existsSync(AIDER_HISTORY)) return [];
491
+ let text;
492
+ try {
493
+ text = fs.readFileSync(AIDER_HISTORY, "utf8");
494
+ } catch {
495
+ return [];
496
+ }
497
+ const lines = text.split("\n");
498
+ // A "# aider chat started at <ts>" banner opens each session.
499
+ const sessions = [];
500
+ let cur = null,
501
+ idx = 0,
502
+ sc = 0;
503
+ const startSession = (ts) => {
504
+ sc++;
505
+ const sessionId = "aider-" + sc + (ts ? "-" + ts.replace(/[^0-9]/g, "").slice(0, 12) : "");
506
+ cur = { sessionId, rootDir: null, source_tool: SRC.aider, turns: [] };
507
+ idx = 0;
508
+ sessions.push(cur);
509
+ };
510
+ let sessTs = null;
511
+ let pendingRole = null,
512
+ buf = [];
513
+ const flush = () => {
514
+ if (!cur || !pendingRole) {
515
+ buf = [];
516
+ pendingRole = null;
517
+ return;
518
+ }
519
+ const body = buf.join("\n").trim();
520
+ if (body) {
521
+ cur.turns.push({
522
+ session_id: cur.sessionId,
523
+ root: null,
524
+ role: pendingRole,
525
+ turn_idx: idx++,
526
+ ts: sessTs,
527
+ tms: sessTs ? Date.parse(sessTs) : null,
528
+ text: body.slice(0, 6000),
529
+ toolErrText: "",
530
+ mineErrOnly: false,
531
+ toolCalls: [],
532
+ boiler: isBoiler(body),
533
+ source_tool: SRC.aider,
534
+ bubbleKey: null,
535
+ });
536
+ }
537
+ buf = [];
538
+ pendingRole = null;
539
+ };
540
+ for (const line of lines) {
541
+ const banner = /^#\s+aider chat started at (.+)$/.exec(line);
542
+ if (banner) {
543
+ flush();
544
+ sessTs = banner[1].trim();
545
+ startSession(sessTs);
546
+ continue;
547
+ }
548
+ if (!cur) startSession(null);
549
+ if (/^####\s+/.test(line)) {
550
+ flush();
551
+ pendingRole = "user";
552
+ buf.push(line.replace(/^####\s+/, ""));
553
+ } else if (/^>\s?/.test(line)) {
554
+ // aider command/tool echo — treat as session output continuation
555
+ if (pendingRole !== "assistant") {
556
+ flush();
557
+ pendingRole = "assistant";
558
+ }
559
+ buf.push(line.replace(/^>\s?/, ""));
560
+ } else {
561
+ if (!pendingRole) pendingRole = "assistant";
562
+ buf.push(line);
563
+ }
564
+ }
565
+ flush();
566
+ return sessions.filter((s) => s.turns.length);
567
+ }
568
+
569
+ // ---------- reproduce affordance: re-open a cited store FRESH, jump to the turn, re-confirm ----------
570
+ // This is the authoritative re-derivation. It reads the raw store again (independent of the in-memory
571
+ // parse) and confirms the anchor is verbatim present at the cited turn — dispatched per source tool.
572
+ function readClaudeTurnText(file, targetIdx) {
236
573
  let lines;
237
574
  try {
238
575
  lines = fs.readFileSync(file, "utf8").split("\n");
@@ -277,20 +614,39 @@ function readTurnText(file, targetIdx) {
277
614
  return null;
278
615
  }
279
616
 
617
+ // dispatch a fresh, independent re-read of ONE cited witness by its source tool.
618
+ function reopenTurnText(w) {
619
+ if (w.source_tool === SRC.cursor) return readCursorBubbleText(w.bubbleKey);
620
+ if (w.source_tool === SRC.aider) return w.verbatim || null; // aider md has no random-access index; span is the record
621
+ const file = fileForSession.get(w.session_id);
622
+ if (!file) return null;
623
+ if (w.source_tool === SRC.codex) return readCodexTurnText(file, w.turn_idx);
624
+ return readClaudeTurnText(file, w.turn_idx);
625
+ }
626
+
280
627
  // Re-derive a relation's number from disk. Returns { confirmed, total } — how many cited witnesses
281
628
  // still have the anchor verbatim at the cited turn after a fresh, independent re-read.
282
- function reproduceCount(fileIndex, anchor, witnesses) {
629
+ function reproduceCount(anchor, witnesses) {
283
630
  let confirmed = 0;
284
631
  const total = witnesses.length;
285
632
  for (const w of witnesses) {
286
- const file = fileIndex.get(w.session_id);
287
- if (!file) continue;
288
- const txt = readTurnText(file, w.turn_idx);
633
+ const txt = reopenTurnText(w);
289
634
  if (txt && norm(txt).includes(norm(anchor))) confirmed++;
290
635
  }
291
636
  return { confirmed, total };
292
637
  }
293
638
 
639
+ // human-readable pointer to the raw source of a cited witness (for the reproduce block).
640
+ function srcPointer(w) {
641
+ if (w.source_tool === SRC.cursor) {
642
+ return `${CURSOR_DB} → cursorDiskKV[${w.bubbleKey}]`;
643
+ }
644
+ if (w.source_tool === SRC.aider)
645
+ return `${AIDER_HISTORY} @${srcLabel(w.source_tool)} turn ${w.turn_idx}`;
646
+ const f = fileForSession.get(w.session_id);
647
+ return f ? `${f} @turn ${w.turn_idx}` : null;
648
+ }
649
+
294
650
  // ---------- rendering helpers ----------
295
651
  const short = (sid) => (sid || "").slice(0, 8);
296
652
  const shortTs = (ts) => {
@@ -309,8 +665,6 @@ function plainAnchor(kind, anchor) {
309
665
  }
310
666
 
311
667
  function projLabel(root) {
312
- // ~/.claude/projects encodes the cwd as a dash-flattened path; show a short tail if meaningful.
313
- // The HOME catch-all root is not a project — suppress it (handled by projTail returning null).
314
668
  const tail = projTail(root);
315
669
  return tail ? ` (${tail})` : "";
316
670
  }
@@ -333,37 +687,25 @@ function projTail(root) {
333
687
  const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
334
688
 
335
689
  // ---------- PII redaction (default ON; --raw reveals locally) ----------
336
- // The default receipt is meant to be screenshot-shareable. Third-party emails, personal/company
337
- // domains, IPs and secrets in the quoted verbatim spans are redacted to typed placeholders. `--raw`
338
- // (a purely local flag; there is still no network) reveals the unredacted spans for the user's eyes.
339
690
  const RAW = process.argv.slice(2).includes("--raw");
340
- // TLD allowlist so real domains are caught but code filenames (foo.rs / bar.mjs / package.json) are NOT.
341
691
  const DOMAIN_RE =
342
692
  /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|ai|io|co|org|net|dev|app|xyz|so|gg|inc|us|uk|ca|de|eu|me|info|biz|tech|studio|cloud|sh|live|news|gov|edu|ly)\b/gi;
343
693
  function redact(s) {
344
694
  if (RAW || !s) return s;
345
695
  let t = s;
346
- // secrets/keys/tokens first (before anything else can nibble their edges)
347
696
  t = t.replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[token]");
348
697
  t = t.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[token]");
349
698
  t = t.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[token]");
350
699
  t = t.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g, "[token]");
351
700
  t = t.replace(/\b[Bb]earer\s+[A-Za-z0-9._-]{16,}/g, "Bearer [token]");
352
- // full URLs (host + path) collapse to [url] — a URL can carry a personal host and query params
353
701
  t = t.replace(/\bhttps?:\/\/[^\s"'<>)\]]+/gi, "[url]");
354
- // emails (before bare-domain pass; matches even TLD-truncated tails at a 200-char span boundary)
355
702
  t = t.replace(EMAIL_RE, "[email]");
356
- // IPv4
357
703
  t = t.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]");
358
- // bare domains (company / personal sites)
359
704
  t = t.replace(DOMAIN_RE, "[domain]");
360
705
  return t;
361
706
  }
362
707
 
363
708
  // ---------- autopsy self-echo filter ----------
364
- // This command prints error strings and symbols into the terminal; that output is itself captured in
365
- // the transcript. Without this guard a prior receipt's own text would re-enter as fresh "occurrences"
366
- // and manufacture recurrence/spirals out of nothing. Any turn that looks like autopsy output is dropped.
367
709
  const AUTOPSY_ECHO = [
368
710
  "skalpel autopsy",
369
711
  "nothing left this machine",
@@ -381,11 +723,13 @@ const AUTOPSY_ECHO = [
381
723
  "all hit the error",
382
724
  "in the same project",
383
725
  "Your session hit the error",
726
+ "cross-vendor",
727
+ "appears in both",
728
+ "connect a 2nd agent",
384
729
  ];
385
730
  const isAutopsyEcho = (s) => {
386
731
  if (!s) return false;
387
732
  const l = s.slice(0, 800);
388
- // require two distinct markers to avoid nuking legitimate turns that merely say "recurred" once
389
733
  let hits = 0;
390
734
  for (const m of AUTOPSY_ECHO) {
391
735
  if (l.includes(m)) {
@@ -397,18 +741,128 @@ const isAutopsyEcho = (s) => {
397
741
  };
398
742
 
399
743
  // ---------- sourcing: user-typed vs tool/session output ----------
400
- // A witness is "you wrote" ONLY when it is a user-role turn whose anchor came from the typed message
401
- // body. Assistant text and tool_result output are "session output" — never attributed to the user.
402
744
  const isUserTyped = (w) => w.role === "user" && w.from === "message";
403
745
  const srcTag = (w) => (isUserTyped(w) ? "you wrote" : "session output");
404
746
 
747
+ // specificity gates shared by CELL A and CELL X
748
+ const ERR_SPECIFIC = new Set([
749
+ "err:rustc",
750
+ "err:tsc",
751
+ "err:notfound",
752
+ "err:unresolved",
753
+ "err:panic",
754
+ "err:sqlx",
755
+ ]);
756
+ const BARE_FILES = new Set([
757
+ "index.ts",
758
+ "index.js",
759
+ "index.tsx",
760
+ "index.mjs",
761
+ "index.jsx",
762
+ "mod.rs",
763
+ "main.rs",
764
+ "lib.rs",
765
+ "package.json",
766
+ "README.md",
767
+ "tsconfig.json",
768
+ "Cargo.toml",
769
+ ]);
770
+ // Generic framework/scaffolding filenames whose BASENAME collides across unrelated projects (a Next.js
771
+ // `page.tsx` here, a different `page.tsx` there). A cross-vendor claim on a basename collision would be
772
+ // a mislabel — "the same file" when they are different files — so these are suppressed. A DISTINCTIVE
773
+ // filename (Hero.astro, pricing-tiers.ts) is a strong same-file proxy and is allowed. This is the
774
+ // cross-vendor analogue of CELL A's same-project relatedness gate (which we cannot apply directly: Cursor
775
+ // bubbles carry no cwd).
776
+ const CROSS_COMMON_CORES = new Set([
777
+ "config",
778
+ "next.config",
779
+ "vite.config",
780
+ "tailwind.config",
781
+ "postcss.config",
782
+ "jest.config",
783
+ "vitest.config",
784
+ "rollup.config",
785
+ "webpack.config",
786
+ "tsup.config",
787
+ "turbo",
788
+ "api",
789
+ "page",
790
+ "layout",
791
+ "route",
792
+ "index",
793
+ "main",
794
+ "mod",
795
+ "lib",
796
+ "types",
797
+ "type",
798
+ "utils",
799
+ "util",
800
+ "constants",
801
+ "helpers",
802
+ "helper",
803
+ "styles",
804
+ "style",
805
+ "app",
806
+ "server",
807
+ "client",
808
+ "db",
809
+ "schema",
810
+ "hooks",
811
+ "hook",
812
+ "store",
813
+ "actions",
814
+ "action",
815
+ "providers",
816
+ "provider",
817
+ "theme",
818
+ "env",
819
+ "setup",
820
+ "middleware",
821
+ "loading",
822
+ "error",
823
+ "globals",
824
+ "global",
825
+ "icons",
826
+ "icon",
827
+ "proxy",
828
+ "ci",
829
+ "promote",
830
+ "default",
831
+ "template",
832
+ "head",
833
+ "not-found",
834
+ "docker-compose",
835
+ "dockerfile",
836
+ "makefile",
837
+ "readme",
838
+ "cli",
839
+ "auth",
840
+ "models",
841
+ "model",
842
+ "settings",
843
+ "options",
844
+ "component",
845
+ "components",
846
+ "handler",
847
+ ]);
848
+ const fileCore = (anchor) => anchor.replace(/\.[^.]+$/, "").toLowerCase();
849
+ // A cross-vendor anchor must be SPECIFIC: an identifier-carrying error, or a DISTINCTIVE (non-generic)
850
+ // filename that is a strong proxy for the same actual file across tools.
851
+ function anchorEligible(kind, anchor) {
852
+ if (isTemplateTok(anchor)) return false;
853
+ if (kind.startsWith("err")) return ERR_SPECIFIC.has(kind);
854
+ if (kind === "sym:file")
855
+ return !BARE_FILES.has(anchor) && !CROSS_COMMON_CORES.has(fileCore(anchor));
856
+ return false; // sym:path / sym:ident are harness/runtime token noise — never headline-eligible
857
+ }
858
+
405
859
  // ---------- main ----------
406
860
  async function main() {
407
861
  const out = [];
408
862
  const P = (s = "") => out.push(s);
409
863
 
410
- // enumerate ALL top-level interactive session files (exclude nested subagent/workflow threads,
411
- // which live under <session>/subagents/ and are not distinct user sessions).
864
+ // ============ LOAD every local vendor (read-only) ============
865
+ // Claude Code enumerate top-level interactive session files (exclude nested subagent/workflow threads).
412
866
  let roots = [];
413
867
  try {
414
868
  roots = fs.readdirSync(PROJECTS_DIR).filter((d) => {
@@ -419,9 +873,9 @@ async function main() {
419
873
  }
420
874
  });
421
875
  } catch {
422
- // no history at all
876
+ /* no claude history */
423
877
  }
424
- const files = [];
878
+ const claudeFiles = [];
425
879
  for (const r of roots) {
426
880
  const dir = path.join(PROJECTS_DIR, r);
427
881
  let entries = [];
@@ -431,9 +885,9 @@ async function main() {
431
885
  continue;
432
886
  }
433
887
  for (const f of entries)
434
- if (f.endsWith(".jsonl")) files.push({ file: path.join(dir, f), root: r });
888
+ if (f.endsWith(".jsonl")) claudeFiles.push({ file: path.join(dir, f), root: r });
435
889
  }
436
- files.sort((a, b) => {
890
+ claudeFiles.sort((a, b) => {
437
891
  try {
438
892
  return fs.statSync(b.file).size - fs.statSync(a.file).size;
439
893
  } catch {
@@ -441,20 +895,29 @@ async function main() {
441
895
  }
442
896
  });
443
897
 
444
- const fileIndex = new Map();
445
- for (const { file } of files) fileIndex.set(path.basename(file, ".jsonl"), file);
446
-
447
898
  const sessions = [];
899
+ for (const { file, root } of claudeFiles) {
900
+ const s = await parseClaudeSession(file, root);
901
+ if (s.turns.length) sessions.push(s);
902
+ }
903
+ // Codex, Cursor, Aider
904
+ for (const s of loadCodexSessions()) sessions.push(s);
905
+ for (const s of await loadCursorSessions()) sessions.push(s);
906
+ for (const s of loadAiderSessions()) sessions.push(s);
907
+
908
+ // per-vendor inventory (reproducible counts)
909
+ const inv = {};
448
910
  let totalTurns = 0;
449
- for (const { file, root } of files) {
450
- const s = await parseSession(file, root);
451
- if (s.turns.length) {
452
- sessions.push(s);
453
- totalTurns += s.turns.length;
454
- }
911
+ for (const s of sessions) {
912
+ const st = s.source_tool;
913
+ inv[st] ??= { sessions: 0, turns: 0 };
914
+ inv[st].sessions += 1;
915
+ inv[st].turns += s.turns.length;
916
+ totalTurns += s.turns.length;
455
917
  }
918
+ const presentVendors = Object.keys(inv).filter((v) => inv[v].sessions > 0);
456
919
 
457
- // ============ CELL A: cross-session recurrence on deterministic anchors ============
920
+ // ============ CELL A: cross-session recurrence on deterministic anchors (across the UNION) ============
458
921
  const anchorMap = new Map();
459
922
  for (const s of sessions) {
460
923
  for (const turn of s.turns) {
@@ -477,59 +940,24 @@ async function main() {
477
940
  role: turn.role,
478
941
  root: turn.root,
479
942
  from: errOnly ? "tool_output" : "message",
943
+ source_tool: turn.source_tool,
944
+ bubbleKey: turn.bubbleKey,
480
945
  verbatim: span(mineText, at, anchor.length),
481
946
  });
482
947
  }
483
948
  }
484
949
  }
485
950
  }
486
- // THE HEADLINE BAR (all must hold to be asserted as a cross-session headline):
487
- // (a) SPECIFIC anchor — a concrete file/path/symbol, OR an error carrying a specific identifier
488
- // (rustc code E####, TS####, a named missing module/import, a panic with a location, SQLX_OFFLINE).
489
- // A BARE generic error class (ECONNREFUSED / ENOENT / TypeError / segfault / "borrow of moved
490
- // value") is SUPPRESSED — the token recurs across unrelated domains; it is noise, not insight.
491
- // (b) RELATED CONTEXT — the recurring witnesses must share a context. The concrete, deterministic
492
- // relatedness signal on disk is the project root (~/.claude/projects encodes the cwd). We take
493
- // the largest same-root group of witnesses and require it to be >= 2. A generic token colliding
494
- // across different project dirs (an IPC race here, a pricing-404 there) never clears this.
495
- // (c) NON-OBVIOUS — carried by (a)+(b): the SAME specific anchor recurring in the SAME project on
496
- // different days is a real friction point, not something the user would trivially recite.
497
- // (The strongest non-obvious signal — a density-gated in-session SPIRAL — is CELL C, below.)
498
- // Bare exception classes and cross-session sym:path / sym:ident hits (harness/runtime tokens like
499
- // resumeFromRunId, processTicksAndRejections) are still computed but NEVER asserted as a headline.
500
- const ERR_SPECIFIC = new Set([
501
- "err:rustc",
502
- "err:tsc",
503
- "err:notfound",
504
- "err:unresolved",
505
- "err:panic",
506
- "err:sqlx",
507
- ]);
508
- const BARE_FILES = new Set([
509
- "index.ts",
510
- "index.js",
511
- "index.tsx",
512
- "index.mjs",
513
- "index.jsx",
514
- "mod.rs",
515
- "main.rs",
516
- "lib.rs",
517
- "package.json",
518
- "README.md",
519
- "tsconfig.json",
520
- "Cargo.toml",
521
- ]);
951
+
522
952
  const cellA = [];
523
953
  for (const [, rec] of anchorMap) {
524
954
  if (rec.sessions.size < 2) continue;
525
955
  if (isTemplateTok(rec.anchor)) continue;
526
- // (a) specificity gate
527
956
  let eligible = false;
528
957
  if (rec.kind.startsWith("err")) eligible = ERR_SPECIFIC.has(rec.kind);
529
958
  else if (rec.kind === "sym:file") eligible = !BARE_FILES.has(rec.anchor);
530
- // sym:path / sym:ident are intentionally NOT headline-eligible (harness/runtime token noise).
531
959
  if (!eligible) continue;
532
- // (b) relatedness gate — largest same-root witness group must be >= 2
960
+ // relatedness gate — largest same-root witness group must be >= 2
533
961
  const byRoot = new Map();
534
962
  for (const w of rec.sessions.values()) {
535
963
  const rk = w.root || "";
@@ -544,9 +972,6 @@ async function main() {
544
972
  topRoot = rk;
545
973
  }
546
974
  if (topWit.length < 2) continue;
547
- // An ERROR string's relatedness comes only from the project it recurs in — the HOME catch-all root is
548
- // not a project, so a bare-home error recurrence is not "related context". A FILE anchor is itself the
549
- // shared context (same file, different days), so it is allowed to recur across the home root.
550
975
  if (rec.kind.startsWith("err") && isHomeRoot(topRoot)) continue;
551
976
  topWit.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0));
552
977
  let spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
@@ -560,9 +985,40 @@ async function main() {
560
985
  witnesses: topWit,
561
986
  });
562
987
  }
563
- // most-recurring, then most-specific first
564
988
  cellA.sort((a, b) => b.relatedN - a.relatedN || b.spec - a.spec);
565
989
 
990
+ // ============ CELL X: CROSS-VENDOR recurrence — same specific anchor across >=2 agents ============
991
+ // Built from the same anchorMap (one witness per session). An anchor qualifies when its witnessing
992
+ // sessions span >=2 distinct source tools AND it clears the specificity gate. Each vendor's count and
993
+ // witnesses are kept separate so the receipt reproduces EACH side from its own raw store.
994
+ const cellX = [];
995
+ for (const [, rec] of anchorMap) {
996
+ if (!anchorEligible(rec.kind, rec.anchor)) continue;
997
+ const byVendor = new Map();
998
+ for (const w of rec.sessions.values()) {
999
+ const st = w.source_tool || SRC.claude;
1000
+ if (!byVendor.has(st)) byVendor.set(st, []);
1001
+ byVendor.get(st).push(w);
1002
+ }
1003
+ if (byVendor.size < 2) continue;
1004
+ const perVendor = [...byVendor.entries()].map(([src, ws]) => ({
1005
+ src,
1006
+ witnesses: ws.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0)),
1007
+ }));
1008
+ const spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
1009
+ cellX.push({
1010
+ kind: rec.kind,
1011
+ anchor: rec.anchor,
1012
+ vendorCount: byVendor.size,
1013
+ totalSessions: rec.sessions.size,
1014
+ perVendor,
1015
+ spec,
1016
+ });
1017
+ }
1018
+ cellX.sort(
1019
+ (a, b) => b.vendorCount - a.vendorCount || b.totalSessions - a.totalSessions || b.spec - a.spec,
1020
+ );
1021
+
566
1022
  // ============ CELL C: density-gated measured spiral (within a session) ============
567
1023
  const cellC = [];
568
1024
  for (const s of sessions) {
@@ -600,7 +1056,7 @@ async function main() {
600
1056
  const first = ts[0],
601
1057
  last = ts[ts.length - 1];
602
1058
  const durMin = (last.tms - first.tms) / 60000;
603
- if (durMin <= 0 || durMin > 240) continue; // >240min => idle gap, not an active spiral
1059
+ if (durMin <= 0 || durMin > 240) continue;
604
1060
  const hasUser = s.turns.some(
605
1061
  (t) =>
606
1062
  t.role === "user" &&
@@ -610,10 +1066,8 @@ async function main() {
610
1066
  t.turn_idx < last.turn_idx,
611
1067
  );
612
1068
  if (!hasUser) continue;
613
- const genericExc = rec.kind === "err:exc"; // bare exception class: weak spiral anchor, demote
1069
+ const genericExc = rec.kind === "err:exc";
614
1070
  const mid = ts[Math.floor(ts.length / 2)];
615
- // center the quoted span on the anchor occurrence so the reader SEES it in the quote (the reproduce
616
- // check reads full turn text; without centering the anchor can fall outside the 200-char window).
617
1071
  const vb = (t) => {
618
1072
  const full = t.text || t.toolErrText || "";
619
1073
  const pos = full.toLowerCase().indexOf(rec.anchor.toLowerCase());
@@ -624,6 +1078,7 @@ async function main() {
624
1078
  cellC.push({
625
1079
  session_id: s.sessionId,
626
1080
  root: first.root,
1081
+ source_tool: s.source_tool,
627
1082
  anchor: rec.anchor,
628
1083
  kind: rec.kind,
629
1084
  genericExc,
@@ -636,15 +1091,13 @@ async function main() {
636
1091
  ts: t.ts,
637
1092
  role: t.role,
638
1093
  from: t.mineErrOnly ? "tool_output" : "message",
1094
+ source_tool: t.source_tool,
1095
+ bubbleKey: t.bubbleKey,
639
1096
  verbatim: vb(t),
640
1097
  })),
641
1098
  });
642
1099
  }
643
1100
  }
644
- // Email-density demotion: a spiral whose witness spans are full of third-party emails is not a single-
645
- // cause loop — it is a batch job hitting many different hosts that happen to share one generic errno
646
- // (the same cross-context-collision pathology, just inside one session). A PII-clean coding spiral (an
647
- // endpoint timing out, a hook aborting) is the real, shareable signal and must rank above it.
648
1101
  for (const r of cellC) {
649
1102
  let emails = 0;
650
1103
  for (const w of r.witnesses) {
@@ -652,9 +1105,6 @@ async function main() {
652
1105
  if (m) emails += m.length;
653
1106
  }
654
1107
  r.emailHits = emails;
655
- // >= 2 email hits across the spans = a contact-list batch (many hosts). A lone hit is almost always
656
- // an npm spec / version string / git ref false-positive, so it must not demote a clean coding spiral.
657
- // (Real emails are redacted from the DISPLAY regardless; this threshold only governs RANKING.)
658
1108
  r.piiDense = emails >= 2 ? 1 : 0;
659
1109
  }
660
1110
  cellC.sort(
@@ -665,45 +1115,55 @@ async function main() {
665
1115
  b.occurrences - a.occurrences,
666
1116
  );
667
1117
 
668
- // ============ assemble up to 3 VERIFIED, re-derived relations ============
669
- // Every survivor is re-derived from disk; only relations whose anchor is still verbatim in >= 2 cited
670
- // sessions/turns are rendered. Bare exception classes and cross-session sym:ident/sym:path hits
671
- // (harness/runtime tokens) are computed but NEVER asserted as a cross-session headline.
672
- // Editorial order (strongest, most non-obvious first):
673
- // class 1 the single strongest measured SPIRAL (CELL C): density-gated, single-context, wall-clock.
674
- // This is the most non-obvious signal, so it leads. A generic error CLASS is allowed here
675
- // ONLY because a spiral is one session in one context — it is NOT a cross-context collision.
676
- // class 2 — SPECIFIC cross-session errors sharing a project root (up to 2).
677
- // class 3 SPECIFIC files you kept returning to in the same project (fill remaining slots).
1118
+ // ============ assemble VERIFIED, re-derived relations (cross-vendor first) ============
1119
+ const usedAnchors = new Set();
1120
+ const anchorKey = (kind, anchor) =>
1121
+ kind.startsWith("sym") ? anchor : kind + "|" + anchor.toLowerCase();
1122
+
1123
+ // --- CELL X: reproduce EACH vendor side independently; keep only survivors that still span >=2 tools.
1124
+ const renderedX = [];
1125
+ for (const r of cellX) {
1126
+ if (renderedX.length >= 2) break;
1127
+ const legs = [];
1128
+ for (const pv of r.perVendor) {
1129
+ const rep = reproduceCount(r.anchor, pv.witnesses);
1130
+ if (rep.confirmed >= 1) legs.push({ src: pv.src, witnesses: pv.witnesses, rep });
1131
+ }
1132
+ if (legs.length < 2) continue; // must still be genuinely cross-vendor after a fresh re-read
1133
+ legs.sort((a, b) => b.rep.confirmed - a.rep.confirmed);
1134
+ renderedX.push({ kind: r.kind, anchor: r.anchor, legs });
1135
+ usedAnchors.add(anchorKey(r.kind, r.anchor));
1136
+ }
1137
+
1138
+ // --- single-vendor findings (CELL C spiral, then CELL A recurrence), skipping anchors already shown.
678
1139
  const errorsA = cellA.filter((r) => r.kind.startsWith("err"));
679
1140
  const filesA = cellA.filter((r) => r.kind === "sym:file");
680
1141
  const rendered = [];
681
1142
  const takeA = (r) => {
682
- const rep = reproduceCount(fileIndex, r.anchor, r.witnesses);
1143
+ if (usedAnchors.has(anchorKey(r.kind, r.anchor))) return false;
1144
+ const rep = reproduceCount(r.anchor, r.witnesses);
683
1145
  if (rep.confirmed < 2) return false;
684
1146
  rendered.push({ cell: "A", ...r, rep });
1147
+ usedAnchors.add(anchorKey(r.kind, r.anchor));
685
1148
  return true;
686
1149
  };
687
-
688
- // class 1: the single strongest measured spiral
1150
+ const SINGLE_MAX = renderedX.length ? 2 : 3;
689
1151
  for (const r of cellC) {
690
- if (rendered.length >= 3) break;
691
- const rep = reproduceCount(fileIndex, r.anchor, r.witnesses);
1152
+ if (rendered.length >= SINGLE_MAX) break;
1153
+ if (usedAnchors.has(anchorKey(r.kind, r.anchor))) continue;
1154
+ const rep = reproduceCount(r.anchor, r.witnesses);
692
1155
  if (rep.confirmed < 2) continue;
693
1156
  rendered.push({ cell: "C", ...r, rep });
1157
+ usedAnchors.add(anchorKey(r.kind, r.anchor));
694
1158
  break;
695
1159
  }
696
-
697
- // class 2: specific cross-session errors in a shared project (up to 2)
698
1160
  let c2 = 0;
699
1161
  for (const r of errorsA) {
700
- if (rendered.length >= 3 || c2 >= 2) break;
1162
+ if (rendered.length >= SINGLE_MAX || c2 >= 2) break;
701
1163
  if (takeA(r)) c2++;
702
1164
  }
703
-
704
- // class 3: specific files you kept returning to (fill remaining slots)
705
1165
  for (const r of filesA) {
706
- if (rendered.length >= 3) break;
1166
+ if (rendered.length >= SINGLE_MAX) break;
707
1167
  takeA(r);
708
1168
  }
709
1169
 
@@ -711,17 +1171,30 @@ async function main() {
711
1171
  P();
712
1172
  P(" skalpel autopsy");
713
1173
  P(" " + RULE);
1174
+ // per-vendor inventory line — every count re-derives from that vendor's raw store.
1175
+ const invParts = presentVendors
1176
+ .map((v) => `${inv[v].sessions} ${srcLabel(v)}${inv[v].sessions === 1 ? "" : ""}`)
1177
+ .join(" · ");
1178
+ const vendorWord = presentVendors.length === 1 ? "agent" : "agents";
714
1179
  P(
715
- ` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} · ${totalTurns} turns · nothing left this machine`,
1180
+ ` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} across ${presentVendors.length} ${vendorWord} · ${totalTurns} turns · nothing left this machine`,
716
1181
  );
1182
+ if (presentVendors.length) {
1183
+ P(
1184
+ " " +
1185
+ presentVendors
1186
+ .map((v) => `${srcLabel(v)}: ${inv[v].sessions} sessions / ${inv[v].turns} turns`)
1187
+ .join(" · "),
1188
+ );
1189
+ }
717
1190
  P();
718
1191
 
719
- if (rendered.length === 0) {
720
- // HONEST ZERO-STATE — a live counter, never a fabricated finding.
721
- P(" No reproducible cross-session relation has crossed the bar yet.");
1192
+ const nothing = renderedX.length === 0 && rendered.length === 0;
1193
+ if (nothing) {
1194
+ P(" No reproducible relation has crossed the bar yet.");
722
1195
  P();
723
1196
  if (sessions.length === 0) {
724
- P(" There's no Claude Code history on this machine to read.");
1197
+ P(" There's no local agent history on this machine to read.");
725
1198
  } else {
726
1199
  P(
727
1200
  ` verified findings so far: 0 · scanned ${totalTurns} turns across ${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
@@ -737,12 +1210,58 @@ async function main() {
737
1210
  }
738
1211
  P();
739
1212
  P(" " + RULE);
740
- renderFooter(P);
1213
+ renderConnectInvite(P, presentVendors);
1214
+ renderFooter(P, presentVendors);
741
1215
  process.stdout.write(out.join("\n") + "\n");
742
1216
  return;
743
1217
  }
744
1218
 
745
1219
  let n = 0;
1220
+
1221
+ // --- CELL X (cross-vendor) leads: the finding no single model vendor can produce.
1222
+ for (const rx of renderedX) {
1223
+ n++;
1224
+ P(` ${n}. ⟷ CROSS-VENDOR`);
1225
+ const names = rx.legs.map((l) => srcLabel(l.src));
1226
+ const nameList =
1227
+ names.length === 2
1228
+ ? `${names[0]} and ${names[1]}`
1229
+ : names.slice(0, -1).join(", ") + ", and " + names.slice(-1);
1230
+ const counts = rx.legs.map((l) => `${l.rep.total} in ${srcLabel(l.src)}`).join(", ");
1231
+ if (rx.kind === "sym:file") {
1232
+ P(` ${plainAnchor(rx.kind, rx.anchor)} shows up in BOTH ${nameList} — the same file`);
1233
+ P(` followed you across different agents (${counts}).`);
1234
+ } else {
1235
+ P(` ${plainAnchor(rx.kind, rx.anchor)} appears in BOTH ${nameList} — the same specific`);
1236
+ P(` error surfaced in more than one agent (${counts}).`);
1237
+ }
1238
+ P(` No single model vendor can see this; it needs all your logs at once.`);
1239
+ P();
1240
+ for (const leg of rx.legs) {
1241
+ P(` ── ${srcLabel(leg.src)} ──`);
1242
+ for (const w of leg.witnesses.slice(0, 2)) {
1243
+ P(
1244
+ ` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
1245
+ );
1246
+ P(` "${redact(w.verbatim)}"`);
1247
+ }
1248
+ }
1249
+ P();
1250
+ P(` reproduce: re-open EACH agent's raw store fresh and re-confirm the anchor —`);
1251
+ for (const leg of rx.legs) {
1252
+ P(
1253
+ ` ${srcLabel(leg.src)}: present verbatim in ${leg.rep.confirmed}/${leg.rep.total} cited sessions`,
1254
+ );
1255
+ for (const w of leg.witnesses.slice(0, 2)) {
1256
+ const p = srcPointer(w);
1257
+ if (p) P(` ${p}`);
1258
+ }
1259
+ }
1260
+ P();
1261
+ P(" " + RULE);
1262
+ }
1263
+
1264
+ // --- single-vendor findings
746
1265
  for (const r of rendered) {
747
1266
  n++;
748
1267
  P(` ${n}.`);
@@ -750,25 +1269,24 @@ async function main() {
750
1269
  const shown = r.witnesses.slice(0, 3);
751
1270
  const proj = projTail(r.root);
752
1271
  const where = proj ? ` in ${proj}` : "";
1272
+ const tool = srcLabel(shown[0] && shown[0].source_tool);
753
1273
  if (r.kind === "sym:file") {
754
- // "returning to a file" is a behavior sourced from either side; the FILENAME is the shared context.
755
1274
  P(` You kept returning to the file ${r.anchor} across ${r.relatedN} separate`);
756
- P(` sessions${where} — the same file, on different days.`);
1275
+ P(` ${tool} sessions${where} — the same file, on different days.`);
757
1276
  } else {
758
- // errors here are compiler/runtime output — sourced to the SESSION, never "you hit".
759
1277
  const anyUser = shown.some(isUserTyped);
760
1278
  if (anyUser) {
761
1279
  P(` ${plainAnchor(r.kind, r.anchor)} came up across ${r.relatedN} separate`);
762
- P(` sessions${where} — the same specific error, on different days.`);
1280
+ P(` ${tool} sessions${where} — the same specific error, on different days.`);
763
1281
  } else {
764
- P(` Your sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
1282
+ P(` Your ${tool} sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
765
1283
  P(` separate sessions${where} — the same specific error resurfaced across days.`);
766
1284
  }
767
1285
  }
768
1286
  P();
769
1287
  for (const w of shown) {
770
1288
  P(
771
- ` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
1289
+ ` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)} · ${srcLabel(w.source_tool)}${projLabel(w.root)}`,
772
1290
  );
773
1291
  P(` "${redact(w.verbatim)}"`);
774
1292
  }
@@ -776,17 +1294,19 @@ async function main() {
776
1294
  P(` reproduce: re-read the cited sessions fresh from disk at the cited`);
777
1295
  P(` turns → anchor present verbatim in ${r.rep.confirmed}/${r.rep.total} cited sessions`);
778
1296
  P(
779
- ` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}). Files:`,
1297
+ ` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}). Sources:`,
780
1298
  );
781
1299
  for (const w of shown) {
782
- const f = fileIndex.get(w.session_id);
783
- if (f) P(` ${f} @turn ${w.turn_idx}`);
1300
+ const p = srcPointer(w);
1301
+ if (p) P(` ${p}`);
784
1302
  }
785
1303
  } else {
786
- // CELL C — measured spiral. State the mechanical fact; the quoted turns self-adjudicate.
787
1304
  const proj = projTail(r.root);
788
1305
  const where = proj ? ` in ${proj}` : "";
789
- P(` ${plainAnchor(r.kind, r.anchor)} recurred ${r.occurrences}× inside one session${where}`);
1306
+ const tool = srcLabel(r.source_tool);
1307
+ P(
1308
+ ` ${plainAnchor(r.kind, r.anchor)} recurred ${r.occurrences}× inside one ${tool} session${where}`,
1309
+ );
790
1310
  P(
791
1311
  ` over a ${r.duration_min}-minute window (wall-clock), across ${r.distinct_turns} turns with`,
792
1312
  );
@@ -796,7 +1316,7 @@ async function main() {
796
1316
  for (const w of r.witnesses) {
797
1317
  const mark = isUserTyped(w) ? "●" : "○";
798
1318
  P(
799
- ` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)}${projLabel(w.root)}`,
1319
+ ` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcLabel(w.source_tool)}${projLabel(w.root)}`,
800
1320
  );
801
1321
  P(` "${redact(w.verbatim)}"`);
802
1322
  }
@@ -805,23 +1325,49 @@ async function main() {
805
1325
  P(
806
1326
  ` anchor present verbatim at all ${r.rep.confirmed}/${r.rep.total} cited turns (of ${r.occurrences} total`,
807
1327
  );
808
- P(` occurrences); the ${r.duration_min}min span = last.ts − first.ts, both quoted. File:`);
809
- const f = fileIndex.get(r.session_id);
810
- if (f) P(` ${f}`);
1328
+ P(
1329
+ ` occurrences); the ${r.duration_min}min span = last.ts − first.ts, both quoted. Source:`,
1330
+ );
1331
+ const p = srcPointer(r.witnesses[0]);
1332
+ if (p) P(` ${p}`);
811
1333
  }
812
1334
  P();
813
1335
  P(" " + RULE);
814
1336
  }
815
1337
 
816
- renderFooter(P);
1338
+ // --- honest connect-a-2nd-agent invite (only when a single vendor is present)
1339
+ renderConnectInvite(P, presentVendors);
1340
+ renderFooter(P, presentVendors);
817
1341
  process.stdout.write(out.join("\n") + "\n");
818
1342
  }
819
1343
 
820
- function renderFooter(P) {
1344
+ // When only ONE agent has usable history, the cross-vendor verdict cannot exist yet. Say so honestly
1345
+ // and invite a second connection — the multi-vendor connect is the whole point of this cell.
1346
+ function renderConnectInvite(P, presentVendors) {
1347
+ if (presentVendors.length !== 1) return;
1348
+ const have = srcLabel(presentVendors[0]);
1349
+ const others = ["Cursor", "Codex", "Aider"].filter((x) => x !== have);
1350
+ P();
1351
+ P(` You've connected ONE agent so far: ${have}.`);
1352
+ P(` The signal no model vendor can build for you is CROSS-VENDOR — which tool`);
1353
+ P(` actually LANDS a pattern you keep reopening in another. Connect a 2nd agent`);
1354
+ P(` (${others.join(" / ")}) and the next autopsy will show where the SAME error or`);
1355
+ P(` file follows you across tools — reproduced from each tool's own local logs.`);
1356
+ P();
1357
+ P(" " + RULE);
1358
+ }
1359
+
1360
+ function renderFooter(P, presentVendors) {
1361
+ const stores = [];
1362
+ if (presentVendors.includes(SRC.claude)) stores.push("~/.claude/projects");
1363
+ if (presentVendors.includes(SRC.codex)) stores.push("~/.codex/sessions");
1364
+ if (presentVendors.includes(SRC.cursor)) stores.push("Cursor state.vscdb");
1365
+ if (presentVendors.includes(SRC.aider)) stores.push("~/.aider.chat.history.md");
1366
+ const storeList = stores.length ? stores.join(", ") : "your local agent logs";
821
1367
  P();
822
1368
  P(" This receipt was generated locally, read-only, with zero network calls —");
823
- P(" rendered from files already on your disk (~/.claude/projects, ~/.skalpel).");
824
- P(" Every number above re-derives from the raw logs at the cited turns.");
1369
+ P(` rendered from agent logs already on your disk (${storeList}).`);
1370
+ P(" Every number above re-derives from the raw logs at the cited turns, per vendor.");
825
1371
  P();
826
1372
  if (RAW) {
827
1373
  P(" --raw: third-party emails / domains / IPs / secrets are shown UNREDACTED");
@@ -831,9 +1377,9 @@ function renderFooter(P) {
831
1377
  P(" spans are redacted to [email]/[domain]/[ip]/[token]. Run with --raw to reveal.");
832
1378
  }
833
1379
  P();
834
- P(" Honest scope: this AUTOPSY is local. The rest of skalpel is not yet —");
835
- P(" the per-turn hook and session-end ingest still upload transcripts today.");
836
- P(" Zero-network ingest is a later cycle; this receipt is the first step.");
1380
+ P(" Honest scope: this AUTOPSY is local and cross-vendor. The rest of skalpel is");
1381
+ P(" not yet — the per-turn hook and session-end ingest still upload transcripts");
1382
+ P(" today. Zero-network ingest is a later cycle; this receipt is the first step.");
837
1383
  P();
838
1384
  }
839
1385
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.10",
3
+ "version": "4.0.11",
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": {