skalpel 4.0.10 → 4.0.12
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/autopsy.mjs +925 -163
- 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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 /
|
|
14
|
-
// No http/https/net/dns/fetch/child_process. It never opens a socket.
|
|
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);
|
|
@@ -147,8 +192,14 @@ function extractAnchors(text) {
|
|
|
147
192
|
const fileRe =
|
|
148
193
|
/\b[\w-]+(?:\/[\w.-]+)*\.(?:rs|ts|tsx|jsx|mjs|cjs|go|py|sql|toml|yaml|yml|sh|astro|vue|svelte)\b/g;
|
|
149
194
|
while ((m = fileRe.exec(text))) {
|
|
150
|
-
const
|
|
151
|
-
|
|
195
|
+
const raw = m[0]; // the mentioned path token, e.g. "src/components/Hero.astro" or "Users/ryan/…/Hero.astro"
|
|
196
|
+
const base = raw.split("/").pop();
|
|
197
|
+
if (!GENERIC.has(base.split(".")[0])) {
|
|
198
|
+
// fileRe cannot start on the leading "/", so an absolute path shows up as raw="Users/…" with a
|
|
199
|
+
// "/" immediately before it. Detect that so the path can be resolved to a real file IDENTITY.
|
|
200
|
+
const abs = m.index > 0 && text[m.index - 1] === "/";
|
|
201
|
+
out.push({ kind: "sym:file", anchor: base, at: m.index, rawPath: raw, abs });
|
|
202
|
+
}
|
|
152
203
|
}
|
|
153
204
|
const rustRe = /\b[a-z_][a-z0-9_]{2,}::[a-z_][a-z0-9_:]{2,}/g;
|
|
154
205
|
while ((m = rustRe.exec(text))) push("sym:path", m[0], m.index);
|
|
@@ -167,8 +218,19 @@ function span(text, at, len) {
|
|
|
167
218
|
return text.slice(a, b).replace(/\s+/g, " ").trim().slice(0, 200);
|
|
168
219
|
}
|
|
169
220
|
|
|
170
|
-
//
|
|
171
|
-
|
|
221
|
+
// ~/.claude/projects encodes cwd as a dash-flattened path. Every vendor's project root is normalized to
|
|
222
|
+
// this same encoding so decodeRoot / projTail / isHomeRoot work uniformly regardless of source tool.
|
|
223
|
+
const encodeCwd = (cwd) => (cwd ? cwd.replace(/\//g, "-") : "");
|
|
224
|
+
|
|
225
|
+
// ================= per-vendor parsers → ONE normalized shape =================
|
|
226
|
+
// normalized turn: { session_id, root, role, turn_idx, ts, tms, text, toolErrText, mineErrOnly,
|
|
227
|
+
// toolCalls, boiler, source_tool, bubbleKey }
|
|
228
|
+
// normalized session: { sessionId, rootDir, source_tool, turns:[turn] }
|
|
229
|
+
// fileForSession maps a claude/codex session_id → its raw file so `reproduce` can re-open it fresh.
|
|
230
|
+
const fileForSession = new Map();
|
|
231
|
+
|
|
232
|
+
// ---------- CLAUDE CODE: parse one jsonl session (verbatim shape from the backtest engine) ----------
|
|
233
|
+
function parseClaudeSession(file, rootDir) {
|
|
172
234
|
return new Promise((resolve) => {
|
|
173
235
|
const sessionId = path.basename(file, ".jsonl");
|
|
174
236
|
const turns = [];
|
|
@@ -222,17 +284,298 @@ function parseSession(file, rootDir) {
|
|
|
222
284
|
mineErrOnly: !text && !!toolErrText,
|
|
223
285
|
toolCalls,
|
|
224
286
|
boiler,
|
|
287
|
+
source_tool: SRC.claude,
|
|
288
|
+
bubbleKey: null,
|
|
225
289
|
});
|
|
226
290
|
});
|
|
227
|
-
rl.on("close", () =>
|
|
228
|
-
|
|
291
|
+
rl.on("close", () => {
|
|
292
|
+
fileForSession.set(sessionId, file);
|
|
293
|
+
resolve({ sessionId, rootDir, source_tool: SRC.claude, turns });
|
|
294
|
+
});
|
|
295
|
+
rl.on("error", () => resolve({ sessionId, rootDir, source_tool: SRC.claude, turns }));
|
|
229
296
|
});
|
|
230
297
|
}
|
|
231
298
|
|
|
232
|
-
// ----------
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
299
|
+
// ---------- CODEX: shared turn-emitter (used by BOTH parse and reproduce → identical indexing) ----------
|
|
300
|
+
// A Codex rollout-*.jsonl is an event log. A "turn" is each response_item/message (user/assistant) plus
|
|
301
|
+
// each response_item/function_call_output (tool/command output, where compiler & runtime errors live).
|
|
302
|
+
// developer-role messages are system boilerplate and are skipped.
|
|
303
|
+
function codexTurns(file) {
|
|
304
|
+
let lines;
|
|
305
|
+
try {
|
|
306
|
+
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
307
|
+
} catch {
|
|
308
|
+
return { id: null, cwd: "", turns: [] };
|
|
309
|
+
}
|
|
310
|
+
let id = null,
|
|
311
|
+
cwd = "";
|
|
312
|
+
const turns = [];
|
|
313
|
+
for (const line of lines) {
|
|
314
|
+
if (!line) continue;
|
|
315
|
+
let o;
|
|
316
|
+
try {
|
|
317
|
+
o = JSON.parse(line);
|
|
318
|
+
} catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (o.type === "session_meta" && o.payload) {
|
|
322
|
+
id = o.payload.id || id;
|
|
323
|
+
cwd = o.payload.cwd || cwd;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (o.type !== "response_item" || !o.payload) continue;
|
|
327
|
+
const p = o.payload;
|
|
328
|
+
const ts = o.timestamp || null;
|
|
329
|
+
if (p.type === "message") {
|
|
330
|
+
if (p.role === "developer") continue; // system boilerplate
|
|
331
|
+
const text = (Array.isArray(p.content) ? p.content : [])
|
|
332
|
+
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
|
|
333
|
+
.join("\n")
|
|
334
|
+
.trim();
|
|
335
|
+
if (!text) continue;
|
|
336
|
+
turns.push({
|
|
337
|
+
role: p.role === "assistant" ? "assistant" : "user",
|
|
338
|
+
text,
|
|
339
|
+
toolErrText: "",
|
|
340
|
+
mineErrOnly: false,
|
|
341
|
+
ts,
|
|
342
|
+
from: "message",
|
|
343
|
+
});
|
|
344
|
+
} else if (p.type === "function_call_output") {
|
|
345
|
+
const raw = p.output;
|
|
346
|
+
const s = (typeof raw === "string" ? raw : JSON.stringify(raw || "")).trim();
|
|
347
|
+
if (!s) continue;
|
|
348
|
+
turns.push({
|
|
349
|
+
role: "assistant",
|
|
350
|
+
text: "",
|
|
351
|
+
toolErrText: s.slice(0, 4000),
|
|
352
|
+
mineErrOnly: true,
|
|
353
|
+
ts,
|
|
354
|
+
from: "tool_output",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { id, cwd, turns };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseCodexSession(file) {
|
|
362
|
+
const { id, cwd, turns: raw } = codexTurns(file);
|
|
363
|
+
const sessionId = id || path.basename(file, ".jsonl");
|
|
364
|
+
const rootDir = encodeCwd(cwd);
|
|
365
|
+
const turns = raw.map((t, idx) => ({
|
|
366
|
+
session_id: sessionId,
|
|
367
|
+
root: rootDir,
|
|
368
|
+
role: t.role,
|
|
369
|
+
turn_idx: idx,
|
|
370
|
+
ts: t.ts,
|
|
371
|
+
tms: t.ts ? Date.parse(t.ts) : null,
|
|
372
|
+
text: (t.text || "").slice(0, 6000),
|
|
373
|
+
toolErrText: (t.toolErrText || "").slice(0, 4000),
|
|
374
|
+
mineErrOnly: t.mineErrOnly,
|
|
375
|
+
toolCalls: [],
|
|
376
|
+
boiler: t.text ? isBoiler(t.text) : false,
|
|
377
|
+
source_tool: SRC.codex,
|
|
378
|
+
bubbleKey: null,
|
|
379
|
+
}));
|
|
380
|
+
if (turns.length) fileForSession.set(sessionId, file);
|
|
381
|
+
return { sessionId, rootDir, source_tool: SRC.codex, turns };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function walkRollouts(dir) {
|
|
385
|
+
let out = [];
|
|
386
|
+
let entries = [];
|
|
387
|
+
try {
|
|
388
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
389
|
+
} catch {
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
for (const e of entries) {
|
|
393
|
+
const p = path.join(dir, e.name);
|
|
394
|
+
if (e.isDirectory()) out = out.concat(walkRollouts(p));
|
|
395
|
+
else if (/^rollout-.*\.jsonl$/.test(e.name)) out.push(p);
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function loadCodexSessions() {
|
|
401
|
+
const files = [];
|
|
402
|
+
for (const d of CODEX_SESSIONS_DIRS) files.push(...walkRollouts(d));
|
|
403
|
+
const sessions = [];
|
|
404
|
+
for (const f of files) {
|
|
405
|
+
const s = parseCodexSession(f);
|
|
406
|
+
if (s.turns.length) sessions.push(s);
|
|
407
|
+
}
|
|
408
|
+
return sessions;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readCodexTurnText(file, targetIdx) {
|
|
412
|
+
const { turns } = codexTurns(file);
|
|
413
|
+
const t = turns[targetIdx];
|
|
414
|
+
return t ? t.text || t.toolErrText || null : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- CURSOR: read the SQLite chat store via the node: core `node:sqlite` (lazy, graceful) ----
|
|
418
|
+
// Cursor stores each chat message as a row `bubbleId:<composerId>:<bubbleId>` in cursorDiskKV. The value
|
|
419
|
+
// is JSON with { type:1=user|2=assistant, text, createdAt }. We group by composerId (=session), order by
|
|
420
|
+
// createdAt, and normalize. `node:sqlite` is a Node core module (>=22.5 / stable in 24+); if it is not
|
|
421
|
+
// available we simply skip Cursor — never crash, never fabricate.
|
|
422
|
+
let DatabaseSyncClass = null;
|
|
423
|
+
async function loadCursorSessions() {
|
|
424
|
+
if (!fs.existsSync(CURSOR_DB)) return [];
|
|
425
|
+
try {
|
|
426
|
+
const mod = await import("node:sqlite");
|
|
427
|
+
DatabaseSyncClass = mod.DatabaseSync;
|
|
428
|
+
} catch {
|
|
429
|
+
return []; // Node too old for node:sqlite — skip Cursor honestly
|
|
430
|
+
}
|
|
431
|
+
let rows = [];
|
|
432
|
+
try {
|
|
433
|
+
const db = new DatabaseSyncClass(CURSOR_DB, { readOnly: true });
|
|
434
|
+
rows = db
|
|
435
|
+
.prepare(
|
|
436
|
+
"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",
|
|
437
|
+
)
|
|
438
|
+
.all();
|
|
439
|
+
db.close();
|
|
440
|
+
} catch {
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
// group by composerId; key = bubbleId:<composerId(36)>:<bubbleId>
|
|
444
|
+
const byComposer = new Map();
|
|
445
|
+
for (const r of rows) {
|
|
446
|
+
const composerId = r.key.slice("bubbleId:".length, "bubbleId:".length + 36);
|
|
447
|
+
if (!byComposer.has(composerId)) byComposer.set(composerId, []);
|
|
448
|
+
byComposer.get(composerId).push(r);
|
|
449
|
+
}
|
|
450
|
+
const sessions = [];
|
|
451
|
+
for (const [composerId, bubbles] of byComposer) {
|
|
452
|
+
bubbles.sort((a, b) => (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0));
|
|
453
|
+
const turns = bubbles.map((b, idx) => {
|
|
454
|
+
const text = (b.text || "").trim();
|
|
455
|
+
return {
|
|
456
|
+
session_id: composerId,
|
|
457
|
+
root: null, // Cursor bubbles carry no reliable cwd; project is unknown (never faked)
|
|
458
|
+
role: b.type === 1 ? "user" : "assistant",
|
|
459
|
+
turn_idx: idx,
|
|
460
|
+
ts: b.createdAt || null,
|
|
461
|
+
tms: b.createdAt ? Date.parse(b.createdAt) : null,
|
|
462
|
+
text: text.slice(0, 6000),
|
|
463
|
+
toolErrText: "",
|
|
464
|
+
mineErrOnly: false,
|
|
465
|
+
toolCalls: [],
|
|
466
|
+
boiler: isBoiler(text),
|
|
467
|
+
source_tool: SRC.cursor,
|
|
468
|
+
bubbleKey: b.key,
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
if (turns.length)
|
|
472
|
+
sessions.push({ sessionId: composerId, rootDir: null, source_tool: SRC.cursor, turns });
|
|
473
|
+
}
|
|
474
|
+
return sessions;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function readCursorBubbleText(bubbleKey) {
|
|
478
|
+
if (!DatabaseSyncClass || !bubbleKey) return null;
|
|
479
|
+
try {
|
|
480
|
+
const db = new DatabaseSyncClass(CURSOR_DB, { readOnly: true });
|
|
481
|
+
const row = db
|
|
482
|
+
.prepare("SELECT json_extract(value,'$.text') AS text FROM cursorDiskKV WHERE key = ?")
|
|
483
|
+
.get(bubbleKey);
|
|
484
|
+
db.close();
|
|
485
|
+
return row && row.text ? String(row.text) : null;
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------- AIDER: ~/.aider.chat.history.md (markdown transcript), parsed if present ----------
|
|
492
|
+
// Format: "#### <user message>" lines for the user, assistant prose in between, "> " session banners.
|
|
493
|
+
// Aider does not stamp per-message timestamps, so ts is null (recurrence/cross-vendor still work; the
|
|
494
|
+
// in-session SPIRAL cell, which needs wall-clock, simply won't fire for Aider — honest by construction).
|
|
495
|
+
function loadAiderSessions() {
|
|
496
|
+
if (!fs.existsSync(AIDER_HISTORY)) return [];
|
|
497
|
+
let text;
|
|
498
|
+
try {
|
|
499
|
+
text = fs.readFileSync(AIDER_HISTORY, "utf8");
|
|
500
|
+
} catch {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
const lines = text.split("\n");
|
|
504
|
+
// A "# aider chat started at <ts>" banner opens each session.
|
|
505
|
+
const sessions = [];
|
|
506
|
+
let cur = null,
|
|
507
|
+
idx = 0,
|
|
508
|
+
sc = 0;
|
|
509
|
+
const startSession = (ts) => {
|
|
510
|
+
sc++;
|
|
511
|
+
const sessionId = "aider-" + sc + (ts ? "-" + ts.replace(/[^0-9]/g, "").slice(0, 12) : "");
|
|
512
|
+
cur = { sessionId, rootDir: null, source_tool: SRC.aider, turns: [] };
|
|
513
|
+
idx = 0;
|
|
514
|
+
sessions.push(cur);
|
|
515
|
+
};
|
|
516
|
+
let sessTs = null;
|
|
517
|
+
let pendingRole = null,
|
|
518
|
+
buf = [];
|
|
519
|
+
const flush = () => {
|
|
520
|
+
if (!cur || !pendingRole) {
|
|
521
|
+
buf = [];
|
|
522
|
+
pendingRole = null;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const body = buf.join("\n").trim();
|
|
526
|
+
if (body) {
|
|
527
|
+
cur.turns.push({
|
|
528
|
+
session_id: cur.sessionId,
|
|
529
|
+
root: null,
|
|
530
|
+
role: pendingRole,
|
|
531
|
+
turn_idx: idx++,
|
|
532
|
+
ts: sessTs,
|
|
533
|
+
tms: sessTs ? Date.parse(sessTs) : null,
|
|
534
|
+
text: body.slice(0, 6000),
|
|
535
|
+
toolErrText: "",
|
|
536
|
+
mineErrOnly: false,
|
|
537
|
+
toolCalls: [],
|
|
538
|
+
boiler: isBoiler(body),
|
|
539
|
+
source_tool: SRC.aider,
|
|
540
|
+
bubbleKey: null,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
buf = [];
|
|
544
|
+
pendingRole = null;
|
|
545
|
+
};
|
|
546
|
+
for (const line of lines) {
|
|
547
|
+
const banner = /^#\s+aider chat started at (.+)$/.exec(line);
|
|
548
|
+
if (banner) {
|
|
549
|
+
flush();
|
|
550
|
+
sessTs = banner[1].trim();
|
|
551
|
+
startSession(sessTs);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!cur) startSession(null);
|
|
555
|
+
if (/^####\s+/.test(line)) {
|
|
556
|
+
flush();
|
|
557
|
+
pendingRole = "user";
|
|
558
|
+
buf.push(line.replace(/^####\s+/, ""));
|
|
559
|
+
} else if (/^>\s?/.test(line)) {
|
|
560
|
+
// aider command/tool echo — treat as session output continuation
|
|
561
|
+
if (pendingRole !== "assistant") {
|
|
562
|
+
flush();
|
|
563
|
+
pendingRole = "assistant";
|
|
564
|
+
}
|
|
565
|
+
buf.push(line.replace(/^>\s?/, ""));
|
|
566
|
+
} else {
|
|
567
|
+
if (!pendingRole) pendingRole = "assistant";
|
|
568
|
+
buf.push(line);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
flush();
|
|
572
|
+
return sessions.filter((s) => s.turns.length);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------- reproduce affordance: re-open a cited store FRESH, jump to the turn, re-confirm ----------
|
|
576
|
+
// This is the authoritative re-derivation. It reads the raw store again (independent of the in-memory
|
|
577
|
+
// parse) and confirms the anchor is verbatim present at the cited turn — dispatched per source tool.
|
|
578
|
+
function readClaudeTurnText(file, targetIdx) {
|
|
236
579
|
let lines;
|
|
237
580
|
try {
|
|
238
581
|
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
@@ -277,20 +620,42 @@ function readTurnText(file, targetIdx) {
|
|
|
277
620
|
return null;
|
|
278
621
|
}
|
|
279
622
|
|
|
623
|
+
// dispatch a fresh, independent re-read of ONE cited witness by its source tool.
|
|
624
|
+
function reopenTurnText(w) {
|
|
625
|
+
if (w.source_tool === SRC.cursor) return readCursorBubbleText(w.bubbleKey);
|
|
626
|
+
if (w.source_tool === SRC.aider) return w.verbatim || null; // aider md has no random-access index; span is the record
|
|
627
|
+
const file = fileForSession.get(w.session_id);
|
|
628
|
+
if (!file) return null;
|
|
629
|
+
if (w.source_tool === SRC.codex) return readCodexTurnText(file, w.turn_idx);
|
|
630
|
+
return readClaudeTurnText(file, w.turn_idx);
|
|
631
|
+
}
|
|
632
|
+
|
|
280
633
|
// Re-derive a relation's number from disk. Returns { confirmed, total } — how many cited witnesses
|
|
281
634
|
// still have the anchor verbatim at the cited turn after a fresh, independent re-read.
|
|
282
|
-
function reproduceCount(
|
|
635
|
+
function reproduceCount(anchor, witnesses) {
|
|
283
636
|
let confirmed = 0;
|
|
284
637
|
const total = witnesses.length;
|
|
285
638
|
for (const w of witnesses) {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
639
|
+
const txt = reopenTurnText(w);
|
|
640
|
+
// For files, w.reAnchor is the FULL mentioned path (e.g. "src/components/Hero.astro" or the
|
|
641
|
+
// absolute) — re-confirming it, not the bare basename, is what proves the same actual file.
|
|
642
|
+
const need = w.reAnchor || anchor;
|
|
643
|
+
if (txt && norm(txt).includes(norm(need))) confirmed++;
|
|
290
644
|
}
|
|
291
645
|
return { confirmed, total };
|
|
292
646
|
}
|
|
293
647
|
|
|
648
|
+
// human-readable pointer to the raw source of a cited witness (for the reproduce block).
|
|
649
|
+
function srcPointer(w) {
|
|
650
|
+
if (w.source_tool === SRC.cursor) {
|
|
651
|
+
return `${CURSOR_DB} → cursorDiskKV[${w.bubbleKey}]`;
|
|
652
|
+
}
|
|
653
|
+
if (w.source_tool === SRC.aider)
|
|
654
|
+
return `${AIDER_HISTORY} @${srcLabel(w.source_tool)} turn ${w.turn_idx}`;
|
|
655
|
+
const f = fileForSession.get(w.session_id);
|
|
656
|
+
return f ? `${f} @turn ${w.turn_idx}` : null;
|
|
657
|
+
}
|
|
658
|
+
|
|
294
659
|
// ---------- rendering helpers ----------
|
|
295
660
|
const short = (sid) => (sid || "").slice(0, 8);
|
|
296
661
|
const shortTs = (ts) => {
|
|
@@ -309,8 +674,6 @@ function plainAnchor(kind, anchor) {
|
|
|
309
674
|
}
|
|
310
675
|
|
|
311
676
|
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
677
|
const tail = projTail(root);
|
|
315
678
|
return tail ? ` (${tail})` : "";
|
|
316
679
|
}
|
|
@@ -329,41 +692,46 @@ function projTail(root) {
|
|
|
329
692
|
const tail = parts.slice(-2).join("/");
|
|
330
693
|
return tail ? `~/${tail}` : null;
|
|
331
694
|
}
|
|
695
|
+
// Resolve a mentioned file token to a stable, absolute IDENTITY so cross-vendor "same file" means the
|
|
696
|
+
// SAME ACTUAL FILE — not a shared basename. Returns a normalized absolute path, or null when the
|
|
697
|
+
// mention cannot be pinned to a real file:
|
|
698
|
+
// • absolute path in the text → that path IS the identity (works across every vendor).
|
|
699
|
+
// • relative path WITH a directory → resolved against the session's project root (cwd), but ONLY
|
|
700
|
+
// when that root is a real project (never the home catch-all,
|
|
701
|
+
// and never Cursor's rootless bubbles) — otherwise unknowable.
|
|
702
|
+
// • bare basename (no directory) → null. A basename alone can never prove same-file.
|
|
703
|
+
function resolveFileIdentity(rawPath, abs, root) {
|
|
704
|
+
if (!rawPath) return null;
|
|
705
|
+
if (abs) return path.posix.normalize("/" + rawPath.replace(/^\/+/, ""));
|
|
706
|
+
if (!rawPath.includes("/")) return null; // bare basename — not a same-file proof
|
|
707
|
+
if (/^\.\.?\//.test(rawPath)) return null; // "./x" or "../x": ambiguous relative to an unknown subdir
|
|
708
|
+
const base = decodeRoot(root); // "" when root is null (Cursor) or unknown
|
|
709
|
+
if (!base || base === HOME) return null; // no real project root to anchor a relative path against
|
|
710
|
+
return path.posix.normalize(base.replace(/\/+$/, "") + "/" + rawPath);
|
|
711
|
+
}
|
|
332
712
|
// Detect any email (incl. TLDs truncated by the 200-char span slice, e.g. "name@host.c" or "name@host").
|
|
333
713
|
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
|
|
334
714
|
|
|
335
715
|
// ---------- 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
716
|
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
717
|
const DOMAIN_RE =
|
|
342
718
|
/\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
719
|
function redact(s) {
|
|
344
720
|
if (RAW || !s) return s;
|
|
345
721
|
let t = s;
|
|
346
|
-
// secrets/keys/tokens first (before anything else can nibble their edges)
|
|
347
722
|
t = t.replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[token]");
|
|
348
723
|
t = t.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[token]");
|
|
349
724
|
t = t.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[token]");
|
|
350
725
|
t = t.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g, "[token]");
|
|
351
726
|
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
727
|
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
728
|
t = t.replace(EMAIL_RE, "[email]");
|
|
356
|
-
// IPv4
|
|
357
729
|
t = t.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]");
|
|
358
|
-
// bare domains (company / personal sites)
|
|
359
730
|
t = t.replace(DOMAIN_RE, "[domain]");
|
|
360
731
|
return t;
|
|
361
732
|
}
|
|
362
733
|
|
|
363
734
|
// ---------- 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
735
|
const AUTOPSY_ECHO = [
|
|
368
736
|
"skalpel autopsy",
|
|
369
737
|
"nothing left this machine",
|
|
@@ -381,14 +749,33 @@ const AUTOPSY_ECHO = [
|
|
|
381
749
|
"all hit the error",
|
|
382
750
|
"in the same project",
|
|
383
751
|
"Your session hit the error",
|
|
752
|
+
"cross-vendor",
|
|
753
|
+
"appears in both",
|
|
754
|
+
"connect a 2nd agent",
|
|
755
|
+
"basename collision",
|
|
756
|
+
"basename-only",
|
|
757
|
+
"same actual file",
|
|
758
|
+
"same actual path",
|
|
759
|
+
"same absolute path",
|
|
760
|
+
"same project-root",
|
|
761
|
+
"followed you across",
|
|
762
|
+
"honest-absence",
|
|
763
|
+
"honest absence",
|
|
764
|
+
"single-vendor",
|
|
765
|
+
"cross-vendor autopsy",
|
|
766
|
+
"the same file followed you",
|
|
767
|
+
"distinctive symbol",
|
|
768
|
+
"distinctive-error",
|
|
384
769
|
];
|
|
770
|
+
// Case-insensitive: a receipt/meta-discussion turn can quote the vocabulary in any case (BASENAME,
|
|
771
|
+
// Cross-Vendor, …). Two distinct markers in the first 800 chars ⇒ it is talking ABOUT the autopsy,
|
|
772
|
+
// so it is excluded from anchor mining — the tool must never report on its own machinery.
|
|
385
773
|
const isAutopsyEcho = (s) => {
|
|
386
774
|
if (!s) return false;
|
|
387
|
-
const l = s.slice(0, 800);
|
|
388
|
-
// require two distinct markers to avoid nuking legitimate turns that merely say "recurred" once
|
|
775
|
+
const l = s.slice(0, 800).toLowerCase();
|
|
389
776
|
let hits = 0;
|
|
390
777
|
for (const m of AUTOPSY_ECHO) {
|
|
391
|
-
if (l.includes(m)) {
|
|
778
|
+
if (l.includes(m.toLowerCase())) {
|
|
392
779
|
hits++;
|
|
393
780
|
if (hits >= 2) return true;
|
|
394
781
|
}
|
|
@@ -397,18 +784,225 @@ const isAutopsyEcho = (s) => {
|
|
|
397
784
|
};
|
|
398
785
|
|
|
399
786
|
// ---------- 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
787
|
const isUserTyped = (w) => w.role === "user" && w.from === "message";
|
|
403
788
|
const srcTag = (w) => (isUserTyped(w) ? "you wrote" : "session output");
|
|
404
789
|
|
|
790
|
+
// specificity gates shared by CELL A and CELL X
|
|
791
|
+
const ERR_SPECIFIC = new Set([
|
|
792
|
+
"err:rustc",
|
|
793
|
+
"err:tsc",
|
|
794
|
+
"err:notfound",
|
|
795
|
+
"err:unresolved",
|
|
796
|
+
"err:panic",
|
|
797
|
+
"err:sqlx",
|
|
798
|
+
]);
|
|
799
|
+
const BARE_FILES = new Set([
|
|
800
|
+
"index.ts",
|
|
801
|
+
"index.js",
|
|
802
|
+
"index.tsx",
|
|
803
|
+
"index.mjs",
|
|
804
|
+
"index.jsx",
|
|
805
|
+
"mod.rs",
|
|
806
|
+
"main.rs",
|
|
807
|
+
"lib.rs",
|
|
808
|
+
"package.json",
|
|
809
|
+
"README.md",
|
|
810
|
+
"tsconfig.json",
|
|
811
|
+
"Cargo.toml",
|
|
812
|
+
]);
|
|
813
|
+
// Generic framework/scaffolding filenames whose BASENAME collides across unrelated projects (a Next.js
|
|
814
|
+
// `page.tsx` here, a different `page.tsx` there). A cross-vendor claim on a basename collision would be
|
|
815
|
+
// a mislabel — "the same file" when they are different files — so these are suppressed. A DISTINCTIVE
|
|
816
|
+
// filename (Hero.astro, pricing-tiers.ts) is a strong same-file proxy and is allowed. This is the
|
|
817
|
+
// cross-vendor analogue of CELL A's same-project relatedness gate (which we cannot apply directly: Cursor
|
|
818
|
+
// bubbles carry no cwd).
|
|
819
|
+
const CROSS_COMMON_CORES = new Set([
|
|
820
|
+
"config",
|
|
821
|
+
"next.config",
|
|
822
|
+
"vite.config",
|
|
823
|
+
"tailwind.config",
|
|
824
|
+
"postcss.config",
|
|
825
|
+
"jest.config",
|
|
826
|
+
"vitest.config",
|
|
827
|
+
"rollup.config",
|
|
828
|
+
"webpack.config",
|
|
829
|
+
"tsup.config",
|
|
830
|
+
"turbo",
|
|
831
|
+
"api",
|
|
832
|
+
"page",
|
|
833
|
+
"layout",
|
|
834
|
+
"route",
|
|
835
|
+
"index",
|
|
836
|
+
"main",
|
|
837
|
+
"mod",
|
|
838
|
+
"lib",
|
|
839
|
+
"types",
|
|
840
|
+
"type",
|
|
841
|
+
"utils",
|
|
842
|
+
"util",
|
|
843
|
+
"constants",
|
|
844
|
+
"helpers",
|
|
845
|
+
"helper",
|
|
846
|
+
"styles",
|
|
847
|
+
"style",
|
|
848
|
+
"app",
|
|
849
|
+
"server",
|
|
850
|
+
"client",
|
|
851
|
+
"db",
|
|
852
|
+
"schema",
|
|
853
|
+
"hooks",
|
|
854
|
+
"hook",
|
|
855
|
+
"store",
|
|
856
|
+
"actions",
|
|
857
|
+
"action",
|
|
858
|
+
"providers",
|
|
859
|
+
"provider",
|
|
860
|
+
"theme",
|
|
861
|
+
"env",
|
|
862
|
+
"setup",
|
|
863
|
+
"middleware",
|
|
864
|
+
"loading",
|
|
865
|
+
"error",
|
|
866
|
+
"globals",
|
|
867
|
+
"global",
|
|
868
|
+
"icons",
|
|
869
|
+
"icon",
|
|
870
|
+
"proxy",
|
|
871
|
+
"ci",
|
|
872
|
+
"promote",
|
|
873
|
+
"default",
|
|
874
|
+
"template",
|
|
875
|
+
"head",
|
|
876
|
+
"not-found",
|
|
877
|
+
"docker-compose",
|
|
878
|
+
"dockerfile",
|
|
879
|
+
"makefile",
|
|
880
|
+
"readme",
|
|
881
|
+
"cli",
|
|
882
|
+
"auth",
|
|
883
|
+
"models",
|
|
884
|
+
"model",
|
|
885
|
+
"settings",
|
|
886
|
+
"options",
|
|
887
|
+
"component",
|
|
888
|
+
"components",
|
|
889
|
+
"handler",
|
|
890
|
+
]);
|
|
891
|
+
// Common library / runtime / build tokens: identical across everyone's projects, so they are NOT a
|
|
892
|
+
// distinctive "your work followed you" anchor. A symbol must clear this list (and a frequency ceiling)
|
|
893
|
+
// to be a genuine cross-vendor signal. Not exhaustive — the frequency ceiling is the real backstop.
|
|
894
|
+
const SCAFFOLD_SYMBOLS = new Set([
|
|
895
|
+
"addeventlistener",
|
|
896
|
+
"removeeventlistener",
|
|
897
|
+
"getelementbyid",
|
|
898
|
+
"queryselector",
|
|
899
|
+
"queryselectorall",
|
|
900
|
+
"createelement",
|
|
901
|
+
"appendchild",
|
|
902
|
+
"setattribute",
|
|
903
|
+
"getattribute",
|
|
904
|
+
"classlist",
|
|
905
|
+
"innerhtml",
|
|
906
|
+
"textcontent",
|
|
907
|
+
"usestate",
|
|
908
|
+
"useeffect",
|
|
909
|
+
"usecallback",
|
|
910
|
+
"usememo",
|
|
911
|
+
"usecontext",
|
|
912
|
+
"useref",
|
|
913
|
+
"usereducer",
|
|
914
|
+
"foreach",
|
|
915
|
+
"tostring",
|
|
916
|
+
"valueof",
|
|
917
|
+
"constructor",
|
|
918
|
+
"prototype",
|
|
919
|
+
"stringify",
|
|
920
|
+
"parseint",
|
|
921
|
+
"parsefloat",
|
|
922
|
+
"tolowercase",
|
|
923
|
+
"touppercase",
|
|
924
|
+
"trimstart",
|
|
925
|
+
"trimend",
|
|
926
|
+
"startswith",
|
|
927
|
+
"endswith",
|
|
928
|
+
"indexof",
|
|
929
|
+
"lastindexof",
|
|
930
|
+
"includes",
|
|
931
|
+
"createinterface",
|
|
932
|
+
"readfilesync",
|
|
933
|
+
"writefilesync",
|
|
934
|
+
"readdirsync",
|
|
935
|
+
"existssync",
|
|
936
|
+
"createreadstream",
|
|
937
|
+
"spawnsync",
|
|
938
|
+
"execsync",
|
|
939
|
+
"setinterval",
|
|
940
|
+
"setttimeout",
|
|
941
|
+
"settimeout",
|
|
942
|
+
"clearinterval",
|
|
943
|
+
"cleartimeout",
|
|
944
|
+
"unwrap",
|
|
945
|
+
"to_string",
|
|
946
|
+
"into_iter",
|
|
947
|
+
"as_str",
|
|
948
|
+
"as_ref",
|
|
949
|
+
"clone",
|
|
950
|
+
"collect",
|
|
951
|
+
"serialize",
|
|
952
|
+
"deserialize",
|
|
953
|
+
"async_trait",
|
|
954
|
+
"tokio",
|
|
955
|
+
"serde",
|
|
956
|
+
"sqlx",
|
|
957
|
+
"anyhow",
|
|
958
|
+
"thiserror",
|
|
959
|
+
"node_modules",
|
|
960
|
+
"package_json",
|
|
961
|
+
"tsconfig_json",
|
|
962
|
+
"sourcemappingurl",
|
|
963
|
+
"getserversideprops",
|
|
964
|
+
"getstaticprops",
|
|
965
|
+
"getstaticpaths",
|
|
966
|
+
]);
|
|
967
|
+
|
|
968
|
+
// A distinctive symbol/function name is genuinely PORTABLE across vendors: the identical token string
|
|
969
|
+
// in two agents is the same code entity (unlike a basename). Eligibility: not generic/template/
|
|
970
|
+
// scaffolding, long enough to be specific, and NOT so ubiquitous it is obviously a library token
|
|
971
|
+
// (appearing in more than a handful of distinct sessions ⇒ shared vocabulary, not a personal anchor).
|
|
972
|
+
function symbolDistinctive(kind, anchor, sessionCount) {
|
|
973
|
+
if (kind !== "sym:path" && kind !== "sym:ident") return false;
|
|
974
|
+
const a = anchor.toLowerCase();
|
|
975
|
+
if (isTemplateTok(anchor)) return false;
|
|
976
|
+
if (GENERIC.has(a) || CROSS_COMMON_CORES.has(a) || SCAFFOLD_SYMBOLS.has(a)) return false;
|
|
977
|
+
// any path/ident segment being a scaffold token drags the whole anchor toward library noise
|
|
978
|
+
for (const seg of a.split(/[^a-z0-9]+/).filter(Boolean))
|
|
979
|
+
if (SCAFFOLD_SYMBOLS.has(seg)) return false;
|
|
980
|
+
if (kind === "sym:path") {
|
|
981
|
+
if (anchor.length < 10) return false; // namespaced `a::b` — needs real length to be specific
|
|
982
|
+
} else {
|
|
983
|
+
if (anchor.length < 12) return false; // multi-segment ident — long enough to be non-accidental
|
|
984
|
+
}
|
|
985
|
+
if (sessionCount > 6) return false; // ubiquitous ⇒ library / harness vocabulary, not a personal anchor
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// A cross-vendor NON-FILE anchor (files are matched by absolute-path identity elsewhere): an
|
|
990
|
+
// identifier-carrying error, or a distinctive symbol. Bare / generic tokens never qualify.
|
|
991
|
+
function crossAnchorEligible(kind, anchor, sessionCount) {
|
|
992
|
+
if (isTemplateTok(anchor)) return false;
|
|
993
|
+
if (kind.startsWith("err")) return ERR_SPECIFIC.has(kind);
|
|
994
|
+
if (kind === "sym:path" || kind === "sym:ident")
|
|
995
|
+
return symbolDistinctive(kind, anchor, sessionCount);
|
|
996
|
+
return false; // sym:file is handled by path identity, never by basename here
|
|
997
|
+
}
|
|
998
|
+
|
|
405
999
|
// ---------- main ----------
|
|
406
1000
|
async function main() {
|
|
407
1001
|
const out = [];
|
|
408
1002
|
const P = (s = "") => out.push(s);
|
|
409
1003
|
|
|
410
|
-
//
|
|
411
|
-
//
|
|
1004
|
+
// ============ LOAD every local vendor (read-only) ============
|
|
1005
|
+
// Claude Code — enumerate top-level interactive session files (exclude nested subagent/workflow threads).
|
|
412
1006
|
let roots = [];
|
|
413
1007
|
try {
|
|
414
1008
|
roots = fs.readdirSync(PROJECTS_DIR).filter((d) => {
|
|
@@ -419,9 +1013,9 @@ async function main() {
|
|
|
419
1013
|
}
|
|
420
1014
|
});
|
|
421
1015
|
} catch {
|
|
422
|
-
|
|
1016
|
+
/* no claude history */
|
|
423
1017
|
}
|
|
424
|
-
const
|
|
1018
|
+
const claudeFiles = [];
|
|
425
1019
|
for (const r of roots) {
|
|
426
1020
|
const dir = path.join(PROJECTS_DIR, r);
|
|
427
1021
|
let entries = [];
|
|
@@ -431,9 +1025,9 @@ async function main() {
|
|
|
431
1025
|
continue;
|
|
432
1026
|
}
|
|
433
1027
|
for (const f of entries)
|
|
434
|
-
if (f.endsWith(".jsonl"))
|
|
1028
|
+
if (f.endsWith(".jsonl")) claudeFiles.push({ file: path.join(dir, f), root: r });
|
|
435
1029
|
}
|
|
436
|
-
|
|
1030
|
+
claudeFiles.sort((a, b) => {
|
|
437
1031
|
try {
|
|
438
1032
|
return fs.statSync(b.file).size - fs.statSync(a.file).size;
|
|
439
1033
|
} catch {
|
|
@@ -441,29 +1035,66 @@ async function main() {
|
|
|
441
1035
|
}
|
|
442
1036
|
});
|
|
443
1037
|
|
|
444
|
-
const fileIndex = new Map();
|
|
445
|
-
for (const { file } of files) fileIndex.set(path.basename(file, ".jsonl"), file);
|
|
446
|
-
|
|
447
1038
|
const sessions = [];
|
|
1039
|
+
for (const { file, root } of claudeFiles) {
|
|
1040
|
+
const s = await parseClaudeSession(file, root);
|
|
1041
|
+
if (s.turns.length) sessions.push(s);
|
|
1042
|
+
}
|
|
1043
|
+
// Codex, Cursor, Aider
|
|
1044
|
+
for (const s of loadCodexSessions()) sessions.push(s);
|
|
1045
|
+
for (const s of await loadCursorSessions()) sessions.push(s);
|
|
1046
|
+
for (const s of loadAiderSessions()) sessions.push(s);
|
|
1047
|
+
|
|
1048
|
+
// per-vendor inventory (reproducible counts)
|
|
1049
|
+
const inv = {};
|
|
448
1050
|
let totalTurns = 0;
|
|
449
|
-
for (const
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1051
|
+
for (const s of sessions) {
|
|
1052
|
+
const st = s.source_tool;
|
|
1053
|
+
inv[st] ??= { sessions: 0, turns: 0 };
|
|
1054
|
+
inv[st].sessions += 1;
|
|
1055
|
+
inv[st].turns += s.turns.length;
|
|
1056
|
+
totalTurns += s.turns.length;
|
|
455
1057
|
}
|
|
1058
|
+
const presentVendors = Object.keys(inv).filter((v) => inv[v].sessions > 0);
|
|
456
1059
|
|
|
457
|
-
// ============ CELL A: cross-session recurrence on deterministic anchors ============
|
|
1060
|
+
// ============ CELL A: cross-session recurrence on deterministic anchors (across the UNION) ============
|
|
1061
|
+
// filePathMap indexes file mentions by RESOLVED ABSOLUTE IDENTITY (not basename) so a cross-vendor
|
|
1062
|
+
// "same file" claim can require the SAME ACTUAL FILE. identity → Map(source_tool → Map(sessionId,witness)).
|
|
458
1063
|
const anchorMap = new Map();
|
|
1064
|
+
const filePathMap = new Map();
|
|
459
1065
|
for (const s of sessions) {
|
|
460
1066
|
for (const turn of s.turns) {
|
|
461
1067
|
const mineText = turn.boiler ? "" : turn.text || turn.toolErrText || "";
|
|
462
1068
|
if (!mineText || isAutopsyEcho(mineText)) continue;
|
|
463
1069
|
const errOnly = turn.mineErrOnly;
|
|
464
1070
|
const seenThisTurn = new Set();
|
|
465
|
-
for (const
|
|
1071
|
+
for (const ex of extractAnchors(mineText)) {
|
|
1072
|
+
const { kind, anchor, at } = ex;
|
|
466
1073
|
if (errOnly && !kind.startsWith("err")) continue;
|
|
1074
|
+
// ---- same-file identity index (message text only; tool output is skipped above) ----
|
|
1075
|
+
if (kind === "sym:file") {
|
|
1076
|
+
const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.root);
|
|
1077
|
+
if (identity) {
|
|
1078
|
+
if (!filePathMap.has(identity))
|
|
1079
|
+
filePathMap.set(identity, { basename: anchor, byTool: new Map() });
|
|
1080
|
+
const fe = filePathMap.get(identity);
|
|
1081
|
+
if (!fe.byTool.has(turn.source_tool)) fe.byTool.set(turn.source_tool, new Map());
|
|
1082
|
+
const perSess = fe.byTool.get(turn.source_tool);
|
|
1083
|
+
if (!perSess.has(s.sessionId))
|
|
1084
|
+
perSess.set(s.sessionId, {
|
|
1085
|
+
session_id: s.sessionId,
|
|
1086
|
+
turn_idx: turn.turn_idx,
|
|
1087
|
+
ts: turn.ts,
|
|
1088
|
+
role: turn.role,
|
|
1089
|
+
root: turn.root,
|
|
1090
|
+
from: "message",
|
|
1091
|
+
source_tool: turn.source_tool,
|
|
1092
|
+
bubbleKey: turn.bubbleKey,
|
|
1093
|
+
verbatim: span(mineText, at, ex.rawPath.length),
|
|
1094
|
+
reAnchor: ex.rawPath, // re-confirm the FULL mentioned path on a fresh re-read, not the basename
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
467
1098
|
const key = kind.startsWith("sym") ? anchor : kind + "|" + anchor.toLowerCase();
|
|
468
1099
|
if (seenThisTurn.has(key)) continue;
|
|
469
1100
|
seenThisTurn.add(key);
|
|
@@ -477,59 +1108,24 @@ async function main() {
|
|
|
477
1108
|
role: turn.role,
|
|
478
1109
|
root: turn.root,
|
|
479
1110
|
from: errOnly ? "tool_output" : "message",
|
|
1111
|
+
source_tool: turn.source_tool,
|
|
1112
|
+
bubbleKey: turn.bubbleKey,
|
|
480
1113
|
verbatim: span(mineText, at, anchor.length),
|
|
481
1114
|
});
|
|
482
1115
|
}
|
|
483
1116
|
}
|
|
484
1117
|
}
|
|
485
1118
|
}
|
|
486
|
-
|
|
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
|
-
]);
|
|
1119
|
+
|
|
522
1120
|
const cellA = [];
|
|
523
1121
|
for (const [, rec] of anchorMap) {
|
|
524
1122
|
if (rec.sessions.size < 2) continue;
|
|
525
1123
|
if (isTemplateTok(rec.anchor)) continue;
|
|
526
|
-
// (a) specificity gate
|
|
527
1124
|
let eligible = false;
|
|
528
1125
|
if (rec.kind.startsWith("err")) eligible = ERR_SPECIFIC.has(rec.kind);
|
|
529
1126
|
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
1127
|
if (!eligible) continue;
|
|
532
|
-
//
|
|
1128
|
+
// relatedness gate — largest same-root witness group must be >= 2
|
|
533
1129
|
const byRoot = new Map();
|
|
534
1130
|
for (const w of rec.sessions.values()) {
|
|
535
1131
|
const rk = w.root || "";
|
|
@@ -544,9 +1140,6 @@ async function main() {
|
|
|
544
1140
|
topRoot = rk;
|
|
545
1141
|
}
|
|
546
1142
|
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
1143
|
if (rec.kind.startsWith("err") && isHomeRoot(topRoot)) continue;
|
|
551
1144
|
topWit.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0));
|
|
552
1145
|
let spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
|
|
@@ -560,9 +1153,63 @@ async function main() {
|
|
|
560
1153
|
witnesses: topWit,
|
|
561
1154
|
});
|
|
562
1155
|
}
|
|
563
|
-
// most-recurring, then most-specific first
|
|
564
1156
|
cellA.sort((a, b) => b.relatedN - a.relatedN || b.spec - a.spec);
|
|
565
1157
|
|
|
1158
|
+
// ============ CELL X: CROSS-VENDOR recurrence — the SAME specific anchor across >=2 agents ============
|
|
1159
|
+
// Two disjoint, both-genuine sources:
|
|
1160
|
+
// (1) errors-with-identifiers + distinctive symbols — from anchorMap. The identical token in two
|
|
1161
|
+
// agents IS the same entity (portable by construction), so >=2 distinct source tools qualifies.
|
|
1162
|
+
// (2) files — from filePathMap, keyed by RESOLVED ABSOLUTE IDENTITY. A shared basename is NOT a
|
|
1163
|
+
// match; only the same actual path across >=2 tools qualifies. proofPath carries that identity
|
|
1164
|
+
// so the receipt (and the pre-push self-verify) can print the exact shared file.
|
|
1165
|
+
// Each vendor's witnesses stay separate so the receipt reproduces EACH side from its own raw store.
|
|
1166
|
+
const cellX = [];
|
|
1167
|
+
for (const [, rec] of anchorMap) {
|
|
1168
|
+
if (!crossAnchorEligible(rec.kind, rec.anchor, rec.sessions.size)) continue;
|
|
1169
|
+
const byVendor = new Map();
|
|
1170
|
+
for (const w of rec.sessions.values()) {
|
|
1171
|
+
const st = w.source_tool || SRC.claude;
|
|
1172
|
+
if (!byVendor.has(st)) byVendor.set(st, []);
|
|
1173
|
+
byVendor.get(st).push(w);
|
|
1174
|
+
}
|
|
1175
|
+
if (byVendor.size < 2) continue;
|
|
1176
|
+
const perVendor = [...byVendor.entries()].map(([src, ws]) => ({
|
|
1177
|
+
src,
|
|
1178
|
+
witnesses: ws.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0)),
|
|
1179
|
+
}));
|
|
1180
|
+
const spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
|
|
1181
|
+
cellX.push({
|
|
1182
|
+
kind: rec.kind,
|
|
1183
|
+
anchor: rec.anchor,
|
|
1184
|
+
proofPath: null,
|
|
1185
|
+
vendorCount: byVendor.size,
|
|
1186
|
+
totalSessions: rec.sessions.size,
|
|
1187
|
+
perVendor,
|
|
1188
|
+
spec,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
for (const [identity, fe] of filePathMap) {
|
|
1192
|
+
if (fe.byTool.size < 2) continue; // same actual file must appear in >=2 different agents
|
|
1193
|
+
let totalSessions = 0;
|
|
1194
|
+
const perVendor = [...fe.byTool.entries()].map(([src, perSess]) => {
|
|
1195
|
+
const ws = [...perSess.values()].sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0));
|
|
1196
|
+
totalSessions += ws.length;
|
|
1197
|
+
return { src, witnesses: ws };
|
|
1198
|
+
});
|
|
1199
|
+
cellX.push({
|
|
1200
|
+
kind: "sym:file",
|
|
1201
|
+
anchor: fe.basename,
|
|
1202
|
+
proofPath: identity, // the exact shared absolute path — the same-file proof
|
|
1203
|
+
vendorCount: fe.byTool.size,
|
|
1204
|
+
totalSessions,
|
|
1205
|
+
perVendor,
|
|
1206
|
+
spec: identity.length + 6,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
cellX.sort(
|
|
1210
|
+
(a, b) => b.vendorCount - a.vendorCount || b.totalSessions - a.totalSessions || b.spec - a.spec,
|
|
1211
|
+
);
|
|
1212
|
+
|
|
566
1213
|
// ============ CELL C: density-gated measured spiral (within a session) ============
|
|
567
1214
|
const cellC = [];
|
|
568
1215
|
for (const s of sessions) {
|
|
@@ -600,7 +1247,7 @@ async function main() {
|
|
|
600
1247
|
const first = ts[0],
|
|
601
1248
|
last = ts[ts.length - 1];
|
|
602
1249
|
const durMin = (last.tms - first.tms) / 60000;
|
|
603
|
-
if (durMin <= 0 || durMin > 240) continue;
|
|
1250
|
+
if (durMin <= 0 || durMin > 240) continue;
|
|
604
1251
|
const hasUser = s.turns.some(
|
|
605
1252
|
(t) =>
|
|
606
1253
|
t.role === "user" &&
|
|
@@ -610,10 +1257,8 @@ async function main() {
|
|
|
610
1257
|
t.turn_idx < last.turn_idx,
|
|
611
1258
|
);
|
|
612
1259
|
if (!hasUser) continue;
|
|
613
|
-
const genericExc = rec.kind === "err:exc";
|
|
1260
|
+
const genericExc = rec.kind === "err:exc";
|
|
614
1261
|
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
1262
|
const vb = (t) => {
|
|
618
1263
|
const full = t.text || t.toolErrText || "";
|
|
619
1264
|
const pos = full.toLowerCase().indexOf(rec.anchor.toLowerCase());
|
|
@@ -624,6 +1269,7 @@ async function main() {
|
|
|
624
1269
|
cellC.push({
|
|
625
1270
|
session_id: s.sessionId,
|
|
626
1271
|
root: first.root,
|
|
1272
|
+
source_tool: s.source_tool,
|
|
627
1273
|
anchor: rec.anchor,
|
|
628
1274
|
kind: rec.kind,
|
|
629
1275
|
genericExc,
|
|
@@ -636,15 +1282,13 @@ async function main() {
|
|
|
636
1282
|
ts: t.ts,
|
|
637
1283
|
role: t.role,
|
|
638
1284
|
from: t.mineErrOnly ? "tool_output" : "message",
|
|
1285
|
+
source_tool: t.source_tool,
|
|
1286
|
+
bubbleKey: t.bubbleKey,
|
|
639
1287
|
verbatim: vb(t),
|
|
640
1288
|
})),
|
|
641
1289
|
});
|
|
642
1290
|
}
|
|
643
1291
|
}
|
|
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
1292
|
for (const r of cellC) {
|
|
649
1293
|
let emails = 0;
|
|
650
1294
|
for (const w of r.witnesses) {
|
|
@@ -652,9 +1296,6 @@ async function main() {
|
|
|
652
1296
|
if (m) emails += m.length;
|
|
653
1297
|
}
|
|
654
1298
|
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
1299
|
r.piiDense = emails >= 2 ? 1 : 0;
|
|
659
1300
|
}
|
|
660
1301
|
cellC.sort(
|
|
@@ -665,45 +1306,55 @@ async function main() {
|
|
|
665
1306
|
b.occurrences - a.occurrences,
|
|
666
1307
|
);
|
|
667
1308
|
|
|
668
|
-
// ============ assemble
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1309
|
+
// ============ assemble VERIFIED, re-derived relations (cross-vendor first) ============
|
|
1310
|
+
const usedAnchors = new Set();
|
|
1311
|
+
const anchorKey = (kind, anchor) =>
|
|
1312
|
+
kind.startsWith("sym") ? anchor : kind + "|" + anchor.toLowerCase();
|
|
1313
|
+
|
|
1314
|
+
// --- CELL X: reproduce EACH vendor side independently; keep only survivors that still span >=2 tools.
|
|
1315
|
+
const renderedX = [];
|
|
1316
|
+
for (const r of cellX) {
|
|
1317
|
+
if (renderedX.length >= 2) break;
|
|
1318
|
+
const legs = [];
|
|
1319
|
+
for (const pv of r.perVendor) {
|
|
1320
|
+
const rep = reproduceCount(r.anchor, pv.witnesses);
|
|
1321
|
+
if (rep.confirmed >= 1) legs.push({ src: pv.src, witnesses: pv.witnesses, rep });
|
|
1322
|
+
}
|
|
1323
|
+
if (legs.length < 2) continue; // must still be genuinely cross-vendor after a fresh re-read
|
|
1324
|
+
legs.sort((a, b) => b.rep.confirmed - a.rep.confirmed);
|
|
1325
|
+
renderedX.push({ kind: r.kind, anchor: r.anchor, proofPath: r.proofPath, legs });
|
|
1326
|
+
usedAnchors.add(anchorKey(r.kind, r.anchor));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// --- single-vendor findings (CELL C spiral, then CELL A recurrence), skipping anchors already shown.
|
|
678
1330
|
const errorsA = cellA.filter((r) => r.kind.startsWith("err"));
|
|
679
1331
|
const filesA = cellA.filter((r) => r.kind === "sym:file");
|
|
680
1332
|
const rendered = [];
|
|
681
1333
|
const takeA = (r) => {
|
|
682
|
-
|
|
1334
|
+
if (usedAnchors.has(anchorKey(r.kind, r.anchor))) return false;
|
|
1335
|
+
const rep = reproduceCount(r.anchor, r.witnesses);
|
|
683
1336
|
if (rep.confirmed < 2) return false;
|
|
684
1337
|
rendered.push({ cell: "A", ...r, rep });
|
|
1338
|
+
usedAnchors.add(anchorKey(r.kind, r.anchor));
|
|
685
1339
|
return true;
|
|
686
1340
|
};
|
|
687
|
-
|
|
688
|
-
// class 1: the single strongest measured spiral
|
|
1341
|
+
const SINGLE_MAX = renderedX.length ? 2 : 3;
|
|
689
1342
|
for (const r of cellC) {
|
|
690
|
-
if (rendered.length >=
|
|
691
|
-
|
|
1343
|
+
if (rendered.length >= SINGLE_MAX) break;
|
|
1344
|
+
if (usedAnchors.has(anchorKey(r.kind, r.anchor))) continue;
|
|
1345
|
+
const rep = reproduceCount(r.anchor, r.witnesses);
|
|
692
1346
|
if (rep.confirmed < 2) continue;
|
|
693
1347
|
rendered.push({ cell: "C", ...r, rep });
|
|
1348
|
+
usedAnchors.add(anchorKey(r.kind, r.anchor));
|
|
694
1349
|
break;
|
|
695
1350
|
}
|
|
696
|
-
|
|
697
|
-
// class 2: specific cross-session errors in a shared project (up to 2)
|
|
698
1351
|
let c2 = 0;
|
|
699
1352
|
for (const r of errorsA) {
|
|
700
|
-
if (rendered.length >=
|
|
1353
|
+
if (rendered.length >= SINGLE_MAX || c2 >= 2) break;
|
|
701
1354
|
if (takeA(r)) c2++;
|
|
702
1355
|
}
|
|
703
|
-
|
|
704
|
-
// class 3: specific files you kept returning to (fill remaining slots)
|
|
705
1356
|
for (const r of filesA) {
|
|
706
|
-
if (rendered.length >=
|
|
1357
|
+
if (rendered.length >= SINGLE_MAX) break;
|
|
707
1358
|
takeA(r);
|
|
708
1359
|
}
|
|
709
1360
|
|
|
@@ -711,17 +1362,30 @@ async function main() {
|
|
|
711
1362
|
P();
|
|
712
1363
|
P(" skalpel autopsy");
|
|
713
1364
|
P(" " + RULE);
|
|
1365
|
+
// per-vendor inventory line — every count re-derives from that vendor's raw store.
|
|
1366
|
+
const invParts = presentVendors
|
|
1367
|
+
.map((v) => `${inv[v].sessions} ${srcLabel(v)}${inv[v].sessions === 1 ? "" : ""}`)
|
|
1368
|
+
.join(" · ");
|
|
1369
|
+
const vendorWord = presentVendors.length === 1 ? "agent" : "agents";
|
|
714
1370
|
P(
|
|
715
|
-
` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} · ${totalTurns} turns · nothing left this machine`,
|
|
1371
|
+
` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} across ${presentVendors.length} ${vendorWord} · ${totalTurns} turns · nothing left this machine`,
|
|
716
1372
|
);
|
|
1373
|
+
if (presentVendors.length) {
|
|
1374
|
+
P(
|
|
1375
|
+
" " +
|
|
1376
|
+
presentVendors
|
|
1377
|
+
.map((v) => `${srcLabel(v)}: ${inv[v].sessions} sessions / ${inv[v].turns} turns`)
|
|
1378
|
+
.join(" · "),
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
717
1381
|
P();
|
|
718
1382
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
P(" No reproducible
|
|
1383
|
+
const nothing = renderedX.length === 0 && rendered.length === 0;
|
|
1384
|
+
if (nothing) {
|
|
1385
|
+
P(" No reproducible relation has crossed the bar yet.");
|
|
722
1386
|
P();
|
|
723
1387
|
if (sessions.length === 0) {
|
|
724
|
-
P(" There's no
|
|
1388
|
+
P(" There's no local agent history on this machine to read.");
|
|
725
1389
|
} else {
|
|
726
1390
|
P(
|
|
727
1391
|
` verified findings so far: 0 · scanned ${totalTurns} turns across ${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
|
|
@@ -737,12 +1401,63 @@ async function main() {
|
|
|
737
1401
|
}
|
|
738
1402
|
P();
|
|
739
1403
|
P(" " + RULE);
|
|
740
|
-
|
|
1404
|
+
renderNoCrossVendor(P, presentVendors, renderedX.length > 0);
|
|
1405
|
+
renderConnectInvite(P, presentVendors);
|
|
1406
|
+
renderFooter(P, presentVendors);
|
|
741
1407
|
process.stdout.write(out.join("\n") + "\n");
|
|
742
1408
|
return;
|
|
743
1409
|
}
|
|
744
1410
|
|
|
745
1411
|
let n = 0;
|
|
1412
|
+
|
|
1413
|
+
// --- CELL X (cross-vendor) leads: the finding no single model vendor can produce.
|
|
1414
|
+
for (const rx of renderedX) {
|
|
1415
|
+
n++;
|
|
1416
|
+
P(` ${n}. ⟷ CROSS-VENDOR`);
|
|
1417
|
+
const names = rx.legs.map((l) => srcLabel(l.src));
|
|
1418
|
+
const nameList =
|
|
1419
|
+
names.length === 2
|
|
1420
|
+
? `${names[0]} and ${names[1]}`
|
|
1421
|
+
: names.slice(0, -1).join(", ") + ", and " + names.slice(-1);
|
|
1422
|
+
const counts = rx.legs.map((l) => `${l.rep.total} in ${srcLabel(l.src)}`).join(", ");
|
|
1423
|
+
if (rx.kind === "sym:file") {
|
|
1424
|
+
P(` The SAME FILE followed you across ${nameList} (${counts}):`);
|
|
1425
|
+
P(` ${redact(rx.proofPath)}`);
|
|
1426
|
+
P(` — same absolute path in every agent, not just a shared filename.`);
|
|
1427
|
+
} else if (rx.kind.startsWith("err")) {
|
|
1428
|
+
P(` ${plainAnchor(rx.kind, rx.anchor)} appears in BOTH ${nameList} — the same specific`);
|
|
1429
|
+
P(` error surfaced in more than one agent (${counts}).`);
|
|
1430
|
+
} else {
|
|
1431
|
+
P(` ${plainAnchor(rx.kind, rx.anchor)} appears in BOTH ${nameList} — the same distinctive`);
|
|
1432
|
+
P(` symbol carried across more than one agent (${counts}).`);
|
|
1433
|
+
}
|
|
1434
|
+
P(` No single model vendor can see this; it needs all your logs at once.`);
|
|
1435
|
+
P();
|
|
1436
|
+
for (const leg of rx.legs) {
|
|
1437
|
+
P(` ── ${srcLabel(leg.src)} ──`);
|
|
1438
|
+
for (const w of leg.witnesses.slice(0, 2)) {
|
|
1439
|
+
P(
|
|
1440
|
+
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
|
|
1441
|
+
);
|
|
1442
|
+
P(` "${redact(w.verbatim)}"`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
P();
|
|
1446
|
+
P(` reproduce: re-open EACH agent's raw store fresh and re-confirm the anchor —`);
|
|
1447
|
+
for (const leg of rx.legs) {
|
|
1448
|
+
P(
|
|
1449
|
+
` ${srcLabel(leg.src)}: present verbatim in ${leg.rep.confirmed}/${leg.rep.total} cited sessions`,
|
|
1450
|
+
);
|
|
1451
|
+
for (const w of leg.witnesses.slice(0, 2)) {
|
|
1452
|
+
const p = srcPointer(w);
|
|
1453
|
+
if (p) P(` ${p}`);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
P();
|
|
1457
|
+
P(" " + RULE);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// --- single-vendor findings
|
|
746
1461
|
for (const r of rendered) {
|
|
747
1462
|
n++;
|
|
748
1463
|
P(` ${n}.`);
|
|
@@ -750,25 +1465,24 @@ async function main() {
|
|
|
750
1465
|
const shown = r.witnesses.slice(0, 3);
|
|
751
1466
|
const proj = projTail(r.root);
|
|
752
1467
|
const where = proj ? ` in ${proj}` : "";
|
|
1468
|
+
const tool = srcLabel(shown[0] && shown[0].source_tool);
|
|
753
1469
|
if (r.kind === "sym:file") {
|
|
754
|
-
// "returning to a file" is a behavior sourced from either side; the FILENAME is the shared context.
|
|
755
1470
|
P(` You kept returning to the file ${r.anchor} across ${r.relatedN} separate`);
|
|
756
|
-
P(` sessions${where} — the same file, on different days.`);
|
|
1471
|
+
P(` ${tool} sessions${where} — the same file, on different days.`);
|
|
757
1472
|
} else {
|
|
758
|
-
// errors here are compiler/runtime output — sourced to the SESSION, never "you hit".
|
|
759
1473
|
const anyUser = shown.some(isUserTyped);
|
|
760
1474
|
if (anyUser) {
|
|
761
1475
|
P(` ${plainAnchor(r.kind, r.anchor)} came up across ${r.relatedN} separate`);
|
|
762
|
-
P(` sessions${where} — the same specific error, on different days.`);
|
|
1476
|
+
P(` ${tool} sessions${where} — the same specific error, on different days.`);
|
|
763
1477
|
} else {
|
|
764
|
-
P(` Your sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
|
|
1478
|
+
P(` Your ${tool} sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
|
|
765
1479
|
P(` separate sessions${where} — the same specific error resurfaced across days.`);
|
|
766
1480
|
}
|
|
767
1481
|
}
|
|
768
1482
|
P();
|
|
769
1483
|
for (const w of shown) {
|
|
770
1484
|
P(
|
|
771
|
-
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
|
|
1485
|
+
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)} · ${srcLabel(w.source_tool)}${projLabel(w.root)}`,
|
|
772
1486
|
);
|
|
773
1487
|
P(` "${redact(w.verbatim)}"`);
|
|
774
1488
|
}
|
|
@@ -776,17 +1490,19 @@ async function main() {
|
|
|
776
1490
|
P(` reproduce: re-read the cited sessions fresh from disk at the cited`);
|
|
777
1491
|
P(` turns → anchor present verbatim in ${r.rep.confirmed}/${r.rep.total} cited sessions`);
|
|
778
1492
|
P(
|
|
779
|
-
` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}).
|
|
1493
|
+
` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}). Sources:`,
|
|
780
1494
|
);
|
|
781
1495
|
for (const w of shown) {
|
|
782
|
-
const
|
|
783
|
-
if (
|
|
1496
|
+
const p = srcPointer(w);
|
|
1497
|
+
if (p) P(` ${p}`);
|
|
784
1498
|
}
|
|
785
1499
|
} else {
|
|
786
|
-
// CELL C — measured spiral. State the mechanical fact; the quoted turns self-adjudicate.
|
|
787
1500
|
const proj = projTail(r.root);
|
|
788
1501
|
const where = proj ? ` in ${proj}` : "";
|
|
789
|
-
|
|
1502
|
+
const tool = srcLabel(r.source_tool);
|
|
1503
|
+
P(
|
|
1504
|
+
` ${plainAnchor(r.kind, r.anchor)} recurred ${r.occurrences}× inside one ${tool} session${where}`,
|
|
1505
|
+
);
|
|
790
1506
|
P(
|
|
791
1507
|
` over a ${r.duration_min}-minute window (wall-clock), across ${r.distinct_turns} turns with`,
|
|
792
1508
|
);
|
|
@@ -796,7 +1512,7 @@ async function main() {
|
|
|
796
1512
|
for (const w of r.witnesses) {
|
|
797
1513
|
const mark = isUserTyped(w) ? "●" : "○";
|
|
798
1514
|
P(
|
|
799
|
-
` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)}${projLabel(w.root)}`,
|
|
1515
|
+
` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcLabel(w.source_tool)}${projLabel(w.root)}`,
|
|
800
1516
|
);
|
|
801
1517
|
P(` "${redact(w.verbatim)}"`);
|
|
802
1518
|
}
|
|
@@ -805,23 +1521,69 @@ async function main() {
|
|
|
805
1521
|
P(
|
|
806
1522
|
` anchor present verbatim at all ${r.rep.confirmed}/${r.rep.total} cited turns (of ${r.occurrences} total`,
|
|
807
1523
|
);
|
|
808
|
-
P(
|
|
809
|
-
|
|
810
|
-
|
|
1524
|
+
P(
|
|
1525
|
+
` occurrences); the ${r.duration_min}min span = last.ts − first.ts, both quoted. Source:`,
|
|
1526
|
+
);
|
|
1527
|
+
const p = srcPointer(r.witnesses[0]);
|
|
1528
|
+
if (p) P(` ${p}`);
|
|
811
1529
|
}
|
|
812
1530
|
P();
|
|
813
1531
|
P(" " + RULE);
|
|
814
1532
|
}
|
|
815
1533
|
|
|
816
|
-
|
|
1534
|
+
// --- honest cross-vendor status: strict miss when >=2 agents present, or a connect invite for one.
|
|
1535
|
+
renderNoCrossVendor(P, presentVendors, renderedX.length > 0);
|
|
1536
|
+
renderConnectInvite(P, presentVendors);
|
|
1537
|
+
renderFooter(P, presentVendors);
|
|
817
1538
|
process.stdout.write(out.join("\n") + "\n");
|
|
818
1539
|
}
|
|
819
1540
|
|
|
820
|
-
|
|
1541
|
+
// When >=2 agents ARE connected but nothing genuinely crosses between them, say so plainly instead of
|
|
1542
|
+
// manufacturing a basename-collision "match". This is the honest counterpart to a real CELL X finding.
|
|
1543
|
+
function renderNoCrossVendor(P, presentVendors, hasCrossVendor) {
|
|
1544
|
+
if (presentVendors.length < 2 || hasCrossVendor) return;
|
|
1545
|
+
const names = presentVendors.map(srcLabel);
|
|
1546
|
+
const list =
|
|
1547
|
+
names.length === 2
|
|
1548
|
+
? names.join(" and ")
|
|
1549
|
+
: names.slice(0, -1).join(", ") + ", and " + names.slice(-1);
|
|
1550
|
+
P();
|
|
1551
|
+
P(` No same-file or same-error crossed your tools yet (read: ${list}).`);
|
|
1552
|
+
P(` Cross-vendor is deliberately strict: it fires only when the SAME actual file`);
|
|
1553
|
+
P(` path — or the SAME specific error/symbol — shows up in two different agents.`);
|
|
1554
|
+
P(` A shared filename across different projects is NOT a match and is suppressed.`);
|
|
1555
|
+
P(` Keep working across your tools; the first genuine crossing renders itself here.`);
|
|
1556
|
+
P();
|
|
1557
|
+
P(" " + RULE);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// When only ONE agent has usable history, the cross-vendor verdict cannot exist yet. Say so honestly
|
|
1561
|
+
// and invite a second connection — the multi-vendor connect is the whole point of this cell.
|
|
1562
|
+
function renderConnectInvite(P, presentVendors) {
|
|
1563
|
+
if (presentVendors.length !== 1) return;
|
|
1564
|
+
const have = srcLabel(presentVendors[0]);
|
|
1565
|
+
const others = ["Cursor", "Codex", "Aider"].filter((x) => x !== have);
|
|
1566
|
+
P();
|
|
1567
|
+
P(` You've connected ONE agent so far: ${have}.`);
|
|
1568
|
+
P(` The signal no model vendor can build for you is CROSS-VENDOR — which tool`);
|
|
1569
|
+
P(` actually LANDS a pattern you keep reopening in another. Connect a 2nd agent`);
|
|
1570
|
+
P(` (${others.join(" / ")}) and the next autopsy will show where the SAME error or`);
|
|
1571
|
+
P(` file follows you across tools — reproduced from each tool's own local logs.`);
|
|
1572
|
+
P();
|
|
1573
|
+
P(" " + RULE);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function renderFooter(P, presentVendors) {
|
|
1577
|
+
const stores = [];
|
|
1578
|
+
if (presentVendors.includes(SRC.claude)) stores.push("~/.claude/projects");
|
|
1579
|
+
if (presentVendors.includes(SRC.codex)) stores.push("~/.codex/sessions");
|
|
1580
|
+
if (presentVendors.includes(SRC.cursor)) stores.push("Cursor state.vscdb");
|
|
1581
|
+
if (presentVendors.includes(SRC.aider)) stores.push("~/.aider.chat.history.md");
|
|
1582
|
+
const storeList = stores.length ? stores.join(", ") : "your local agent logs";
|
|
821
1583
|
P();
|
|
822
1584
|
P(" This receipt was generated locally, read-only, with zero network calls —");
|
|
823
|
-
P(
|
|
824
|
-
P(" Every number above re-derives from the raw logs at the cited turns.");
|
|
1585
|
+
P(` rendered from agent logs already on your disk (${storeList}).`);
|
|
1586
|
+
P(" Every number above re-derives from the raw logs at the cited turns, per vendor.");
|
|
825
1587
|
P();
|
|
826
1588
|
if (RAW) {
|
|
827
1589
|
P(" --raw: third-party emails / domains / IPs / secrets are shown UNREDACTED");
|
|
@@ -831,9 +1593,9 @@ function renderFooter(P) {
|
|
|
831
1593
|
P(" spans are redacted to [email]/[domain]/[ip]/[token]. Run with --raw to reveal.");
|
|
832
1594
|
}
|
|
833
1595
|
P();
|
|
834
|
-
P(" Honest scope: this AUTOPSY is local. The rest of skalpel is
|
|
835
|
-
P(" the per-turn hook and session-end ingest still upload transcripts
|
|
836
|
-
P(" Zero-network ingest is a later cycle; this receipt is the first step.");
|
|
1596
|
+
P(" Honest scope: this AUTOPSY is local and cross-vendor. The rest of skalpel is");
|
|
1597
|
+
P(" not yet — the per-turn hook and session-end ingest still upload transcripts");
|
|
1598
|
+
P(" today. Zero-network ingest is a later cycle; this receipt is the first step.");
|
|
837
1599
|
P();
|
|
838
1600
|
}
|
|
839
1601
|
|