skalpel 4.0.8 → 4.0.10
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 +844 -0
- package/incremental-ingest.mjs +37 -17
- package/install.mjs +1 -0
- package/package.json +1 -1
- package/skalpel-hook-session.mjs +27 -47
- package/skalpel-hook.mjs +50 -126
- package/skalpel-setup.mjs +9 -1
- package/skalpel-statusline.mjs +117 -45
package/autopsy.mjs
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// autopsy.mjs — `skalpel autopsy`: a LOCAL, READ-ONLY, ZERO-NETWORK receipt.
|
|
3
|
+
//
|
|
4
|
+
// It graduates the resident statistical instrument (the relational backtest that returned GO with an
|
|
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.
|
|
11
|
+
//
|
|
12
|
+
// 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.
|
|
15
|
+
// 2. NO FABRICATED STATS. Nothing is rendered unless it re-derives verbatim from disk. If there is
|
|
16
|
+
// not enough history to cross the bar, it prints an HONEST ZERO-STATE with a live turn counter —
|
|
17
|
+
// never an invented finding.
|
|
18
|
+
// 3. HONEST FOOTER. It states that THIS RECEIPT is generated locally, read-only. It does NOT and must
|
|
19
|
+
// not claim the whole product is local: the per-turn hook and SessionEnd ingest STILL upload
|
|
20
|
+
// transcripts today. Zero-network ingest is a later cycle.
|
|
21
|
+
//
|
|
22
|
+
// Cells graduated from the backtest:
|
|
23
|
+
// CELL A — cross-session RESTATEMENT/RECURRENCE on a DETERMINISTIC anchor (error string / code symbol
|
|
24
|
+
// / normalized n-gram), witness per session. Asserted as a headline.
|
|
25
|
+
// CELL C — density-gated MEASURED SPIRAL: the same error anchor recurring within <=30min gaps inside
|
|
26
|
+
// one session, with wall-clock duration. Asserted as a headline.
|
|
27
|
+
// CELL B — "answer already on screen" is DEMOTED to witness-adjudicated only (it is the fakeable-by-
|
|
28
|
+
// mislabel trust bomb) and is NEVER surfaced as an asserted headline here.
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import readline from "node:readline";
|
|
34
|
+
|
|
35
|
+
const HOME = os.homedir();
|
|
36
|
+
const PROJECTS_DIR = path.join(HOME, ".claude", "projects");
|
|
37
|
+
const SKALPEL_DIR = path.join(HOME, ".skalpel");
|
|
38
|
+
|
|
39
|
+
// ---------- deterministic helpers (verbatim from the backtest engine) ----------
|
|
40
|
+
const SPAN = 90;
|
|
41
|
+
const norm = (s) =>
|
|
42
|
+
s
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
const tokens = (s) => norm(s).split(" ").filter(Boolean);
|
|
48
|
+
|
|
49
|
+
const BOILER_MARKERS = [
|
|
50
|
+
"[skalpel",
|
|
51
|
+
"<system-reminder",
|
|
52
|
+
"deferred_tools",
|
|
53
|
+
"MCP server",
|
|
54
|
+
"You are Claude Code",
|
|
55
|
+
"IMPORTANT: These instructions OVERRIDE",
|
|
56
|
+
"hookSpecificOutput",
|
|
57
|
+
"additionalContext",
|
|
58
|
+
"behavioral profile",
|
|
59
|
+
"this context may or may not be relevant",
|
|
60
|
+
"system-reminder",
|
|
61
|
+
"Operating doctrine",
|
|
62
|
+
"claudeMd",
|
|
63
|
+
"userEmail",
|
|
64
|
+
"currentDate",
|
|
65
|
+
];
|
|
66
|
+
const isBoiler = (t) => {
|
|
67
|
+
const l = t.slice(0, 400);
|
|
68
|
+
return BOILER_MARKERS.some((m) => l.includes(m));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const GENERIC = new Set([
|
|
72
|
+
"error",
|
|
73
|
+
"value",
|
|
74
|
+
"string",
|
|
75
|
+
"number",
|
|
76
|
+
"object",
|
|
77
|
+
"result",
|
|
78
|
+
"index",
|
|
79
|
+
"data",
|
|
80
|
+
"type",
|
|
81
|
+
"function",
|
|
82
|
+
"const",
|
|
83
|
+
"return",
|
|
84
|
+
"import",
|
|
85
|
+
"export",
|
|
86
|
+
"module",
|
|
87
|
+
"class",
|
|
88
|
+
"name",
|
|
89
|
+
"file",
|
|
90
|
+
"test",
|
|
91
|
+
"code",
|
|
92
|
+
"true",
|
|
93
|
+
"false",
|
|
94
|
+
"null",
|
|
95
|
+
"undefined",
|
|
96
|
+
"array",
|
|
97
|
+
"list",
|
|
98
|
+
"item",
|
|
99
|
+
"count",
|
|
100
|
+
"total",
|
|
101
|
+
"main",
|
|
102
|
+
"build",
|
|
103
|
+
"node",
|
|
104
|
+
"none",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const WORKFLOW_TEMPLATE = new Set([
|
|
108
|
+
"agents_empty_result",
|
|
109
|
+
"agents_done",
|
|
110
|
+
"agents_error",
|
|
111
|
+
"agents_skipped",
|
|
112
|
+
"agents_running",
|
|
113
|
+
"agents_total",
|
|
114
|
+
"output_file",
|
|
115
|
+
"tool_use_id",
|
|
116
|
+
"task_notification",
|
|
117
|
+
"task_id",
|
|
118
|
+
"run_id",
|
|
119
|
+
]);
|
|
120
|
+
const isTemplateTok = (a) => WORKFLOW_TEMPLATE.has(a.toLowerCase());
|
|
121
|
+
|
|
122
|
+
// ---------- anchor extraction (deterministic; verbatim from the backtest engine) ----------
|
|
123
|
+
function extractAnchors(text) {
|
|
124
|
+
const out = [];
|
|
125
|
+
const push = (kind, anchor, at) => {
|
|
126
|
+
if (anchor) out.push({ kind, anchor, at });
|
|
127
|
+
};
|
|
128
|
+
let m;
|
|
129
|
+
|
|
130
|
+
const errRes = [
|
|
131
|
+
[/error\[E\d{2,4}\]/g, "rustc"],
|
|
132
|
+
[/\bTS\d{4}\b/g, "tsc"],
|
|
133
|
+
[/\b[A-Z][a-zA-Z]{2,}(?:Error|Exception)\b/g, "exc"],
|
|
134
|
+
[/\b(?:ENOENT|EADDRINUSE|ECONNREFUSED|EACCES|ETIMEDOUT|EPIPE|EEXIST|ENOTFOUND)\b/g, "errno"],
|
|
135
|
+
[/panicked at [^\n]{0,60}/g, "panic"],
|
|
136
|
+
[/\bcannot find (?:module|name|type|value) ['"`]?[\w./@-]+/gi, "notfound"],
|
|
137
|
+
[/\bunresolved import [\w:]+/gi, "unresolved"],
|
|
138
|
+
[/\bborrow of moved value\b/gi, "borrowck"],
|
|
139
|
+
[/SQLX_OFFLINE/g, "sqlx"],
|
|
140
|
+
[/\bsegmentation fault\b/gi, "segfault"],
|
|
141
|
+
];
|
|
142
|
+
for (const [re, tag] of errRes) {
|
|
143
|
+
re.lastIndex = 0;
|
|
144
|
+
while ((m = re.exec(text))) push("err:" + tag, m[0].trim(), m.index);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const fileRe =
|
|
148
|
+
/\b[\w-]+(?:\/[\w.-]+)*\.(?:rs|ts|tsx|jsx|mjs|cjs|go|py|sql|toml|yaml|yml|sh|astro|vue|svelte)\b/g;
|
|
149
|
+
while ((m = fileRe.exec(text))) {
|
|
150
|
+
const base = m[0].split("/").pop();
|
|
151
|
+
if (!GENERIC.has(base.split(".")[0])) push("sym:file", base, m.index);
|
|
152
|
+
}
|
|
153
|
+
const rustRe = /\b[a-z_][a-z0-9_]{2,}::[a-z_][a-z0-9_:]{2,}/g;
|
|
154
|
+
while ((m = rustRe.exec(text))) push("sym:path", m[0], m.index);
|
|
155
|
+
const identRe = /\b[a-z][a-z0-9]*(?:_[a-z0-9]+){2,}\b|\b[a-z]+(?:[A-Z][a-z0-9]+){2,}\b/g;
|
|
156
|
+
while ((m = identRe.exec(text))) {
|
|
157
|
+
const id = m[0];
|
|
158
|
+
if (id.length >= 10 && !GENERIC.has(id.toLowerCase())) push("sym:ident", id, m.index);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function span(text, at, len) {
|
|
165
|
+
const a = Math.max(0, at - SPAN),
|
|
166
|
+
b = Math.min(text.length, at + len + SPAN);
|
|
167
|
+
return text.slice(a, b).replace(/\s+/g, " ").trim().slice(0, 200);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------- parse one session (verbatim shape from the backtest engine) ----------
|
|
171
|
+
function parseSession(file, rootDir) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const sessionId = path.basename(file, ".jsonl");
|
|
174
|
+
const turns = [];
|
|
175
|
+
let idx = 0;
|
|
176
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
177
|
+
rl.on("line", (line) => {
|
|
178
|
+
if (!line) return;
|
|
179
|
+
let o;
|
|
180
|
+
try {
|
|
181
|
+
o = JSON.parse(line);
|
|
182
|
+
} catch {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const t = o.type;
|
|
186
|
+
if (t !== "user" && t !== "assistant") return;
|
|
187
|
+
const msg = o.message;
|
|
188
|
+
if (!msg) return;
|
|
189
|
+
let text = "",
|
|
190
|
+
role = t,
|
|
191
|
+
toolErrText = "";
|
|
192
|
+
const toolCalls = [];
|
|
193
|
+
const c = msg.content;
|
|
194
|
+
if (typeof c === "string") {
|
|
195
|
+
text = c;
|
|
196
|
+
} else if (Array.isArray(c)) {
|
|
197
|
+
for (const b of c) {
|
|
198
|
+
if (b.type === "text") text += (b.text || "") + "\n";
|
|
199
|
+
else if (b.type === "tool_use") toolCalls.push(b.name);
|
|
200
|
+
else if (b.type === "tool_result") {
|
|
201
|
+
let tc = b.content;
|
|
202
|
+
if (typeof tc === "string") toolErrText += tc.slice(0, 2000) + "\n";
|
|
203
|
+
else if (Array.isArray(tc))
|
|
204
|
+
for (const x of tc)
|
|
205
|
+
if (x && x.type === "text") toolErrText += (x.text || "").slice(0, 2000) + "\n";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
text = text.trim();
|
|
210
|
+
toolErrText = toolErrText.trim();
|
|
211
|
+
if (!text && !toolErrText && toolCalls.length === 0) return;
|
|
212
|
+
const boiler = text ? isBoiler(text) : false;
|
|
213
|
+
turns.push({
|
|
214
|
+
session_id: sessionId,
|
|
215
|
+
root: rootDir,
|
|
216
|
+
role,
|
|
217
|
+
turn_idx: idx++,
|
|
218
|
+
ts: o.timestamp || null,
|
|
219
|
+
tms: o.timestamp ? Date.parse(o.timestamp) : null,
|
|
220
|
+
text: text.slice(0, 6000),
|
|
221
|
+
toolErrText: toolErrText.slice(0, 4000),
|
|
222
|
+
mineErrOnly: !text && !!toolErrText,
|
|
223
|
+
toolCalls,
|
|
224
|
+
boiler,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
rl.on("close", () => resolve({ sessionId, rootDir, turns }));
|
|
228
|
+
rl.on("error", () => resolve({ sessionId, rootDir, turns }));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
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) {
|
|
236
|
+
let lines;
|
|
237
|
+
try {
|
|
238
|
+
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
let idx = 0;
|
|
243
|
+
for (const line of lines) {
|
|
244
|
+
if (!line) continue;
|
|
245
|
+
let o;
|
|
246
|
+
try {
|
|
247
|
+
o = JSON.parse(line);
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (o.type !== "user" && o.type !== "assistant") continue;
|
|
252
|
+
const msg = o.message;
|
|
253
|
+
if (!msg) continue;
|
|
254
|
+
let text = "",
|
|
255
|
+
toolErrText = "";
|
|
256
|
+
const c = msg.content;
|
|
257
|
+
let hasTool = false;
|
|
258
|
+
if (typeof c === "string") text = c;
|
|
259
|
+
else if (Array.isArray(c))
|
|
260
|
+
for (const b of c) {
|
|
261
|
+
if (b.type === "text") text += (b.text || "") + "\n";
|
|
262
|
+
else if (b.type === "tool_use") hasTool = true;
|
|
263
|
+
else if (b.type === "tool_result") {
|
|
264
|
+
let tc = b.content;
|
|
265
|
+
if (typeof tc === "string") toolErrText += tc.slice(0, 2000) + "\n";
|
|
266
|
+
else if (Array.isArray(tc))
|
|
267
|
+
for (const x of tc)
|
|
268
|
+
if (x && x.type === "text") toolErrText += (x.text || "").slice(0, 2000) + "\n";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
text = text.trim();
|
|
272
|
+
toolErrText = toolErrText.trim();
|
|
273
|
+
if (!text && !toolErrText && !hasTool) continue;
|
|
274
|
+
if (idx === targetIdx) return text || toolErrText;
|
|
275
|
+
idx++;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Re-derive a relation's number from disk. Returns { confirmed, total } — how many cited witnesses
|
|
281
|
+
// still have the anchor verbatim at the cited turn after a fresh, independent re-read.
|
|
282
|
+
function reproduceCount(fileIndex, anchor, witnesses) {
|
|
283
|
+
let confirmed = 0;
|
|
284
|
+
const total = witnesses.length;
|
|
285
|
+
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);
|
|
289
|
+
if (txt && norm(txt).includes(norm(anchor))) confirmed++;
|
|
290
|
+
}
|
|
291
|
+
return { confirmed, total };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------- rendering helpers ----------
|
|
295
|
+
const short = (sid) => (sid || "").slice(0, 8);
|
|
296
|
+
const shortTs = (ts) => {
|
|
297
|
+
if (!ts) return "??";
|
|
298
|
+
// 2026-07-12T21:02:49.558Z -> 2026-07-12 21:02
|
|
299
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(ts);
|
|
300
|
+
return m ? `${m[1]} ${m[2]}` : ts.slice(0, 16);
|
|
301
|
+
};
|
|
302
|
+
const RULE = "─".repeat(64);
|
|
303
|
+
|
|
304
|
+
function plainAnchor(kind, anchor) {
|
|
305
|
+
if (kind.startsWith("err")) return `the error ${JSON.stringify(anchor)}`;
|
|
306
|
+
if (kind === "sym:file") return `the file ${anchor}`;
|
|
307
|
+
if (kind === "sym:path") return `the symbol ${anchor}`;
|
|
308
|
+
return `the symbol ${anchor}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
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
|
+
const tail = projTail(root);
|
|
315
|
+
return tail ? ` (${tail})` : "";
|
|
316
|
+
}
|
|
317
|
+
// ~/.claude/projects encodes cwd as a dash-flattened path. The HOME dir ("-Users-ryan") is the
|
|
318
|
+
// catch-all launch root for most sessions — it is NOT a project, so it never counts as "related context".
|
|
319
|
+
function decodeRoot(root) {
|
|
320
|
+
return root ? "/" + root.replace(/^-/, "").replace(/-/g, "/") : "";
|
|
321
|
+
}
|
|
322
|
+
const isHomeRoot = (root) => {
|
|
323
|
+
const d = decodeRoot(root);
|
|
324
|
+
return !d || d === HOME;
|
|
325
|
+
};
|
|
326
|
+
function projTail(root) {
|
|
327
|
+
if (isHomeRoot(root)) return null; // home dir is not a project label
|
|
328
|
+
const parts = root.replace(/^-/, "").split("-").filter(Boolean);
|
|
329
|
+
const tail = parts.slice(-2).join("/");
|
|
330
|
+
return tail ? `~/${tail}` : null;
|
|
331
|
+
}
|
|
332
|
+
// Detect any email (incl. TLDs truncated by the 200-char span slice, e.g. "name@host.c" or "name@host").
|
|
333
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
|
|
334
|
+
|
|
335
|
+
// ---------- 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
|
+
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
|
+
const DOMAIN_RE =
|
|
342
|
+
/\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
|
+
function redact(s) {
|
|
344
|
+
if (RAW || !s) return s;
|
|
345
|
+
let t = s;
|
|
346
|
+
// secrets/keys/tokens first (before anything else can nibble their edges)
|
|
347
|
+
t = t.replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[token]");
|
|
348
|
+
t = t.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[token]");
|
|
349
|
+
t = t.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[token]");
|
|
350
|
+
t = t.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g, "[token]");
|
|
351
|
+
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
|
+
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
|
+
t = t.replace(EMAIL_RE, "[email]");
|
|
356
|
+
// IPv4
|
|
357
|
+
t = t.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]");
|
|
358
|
+
// bare domains (company / personal sites)
|
|
359
|
+
t = t.replace(DOMAIN_RE, "[domain]");
|
|
360
|
+
return t;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------- 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
|
+
const AUTOPSY_ECHO = [
|
|
368
|
+
"skalpel autopsy",
|
|
369
|
+
"nothing left this machine",
|
|
370
|
+
"density-gated spiral",
|
|
371
|
+
"re-surfaced across days",
|
|
372
|
+
"resurfaced across days",
|
|
373
|
+
"reproduce: re-read the cited",
|
|
374
|
+
"verified findings so far",
|
|
375
|
+
"This receipt was generated locally",
|
|
376
|
+
"the same corner of the codebase",
|
|
377
|
+
"No reproducible cross-session relation",
|
|
378
|
+
"recurred",
|
|
379
|
+
"cited turns",
|
|
380
|
+
"false join",
|
|
381
|
+
"all hit the error",
|
|
382
|
+
"in the same project",
|
|
383
|
+
"Your session hit the error",
|
|
384
|
+
];
|
|
385
|
+
const isAutopsyEcho = (s) => {
|
|
386
|
+
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
|
|
389
|
+
let hits = 0;
|
|
390
|
+
for (const m of AUTOPSY_ECHO) {
|
|
391
|
+
if (l.includes(m)) {
|
|
392
|
+
hits++;
|
|
393
|
+
if (hits >= 2) return true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// ---------- 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
|
+
const isUserTyped = (w) => w.role === "user" && w.from === "message";
|
|
403
|
+
const srcTag = (w) => (isUserTyped(w) ? "you wrote" : "session output");
|
|
404
|
+
|
|
405
|
+
// ---------- main ----------
|
|
406
|
+
async function main() {
|
|
407
|
+
const out = [];
|
|
408
|
+
const P = (s = "") => out.push(s);
|
|
409
|
+
|
|
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).
|
|
412
|
+
let roots = [];
|
|
413
|
+
try {
|
|
414
|
+
roots = fs.readdirSync(PROJECTS_DIR).filter((d) => {
|
|
415
|
+
try {
|
|
416
|
+
return fs.statSync(path.join(PROJECTS_DIR, d)).isDirectory();
|
|
417
|
+
} catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
} catch {
|
|
422
|
+
// no history at all
|
|
423
|
+
}
|
|
424
|
+
const files = [];
|
|
425
|
+
for (const r of roots) {
|
|
426
|
+
const dir = path.join(PROJECTS_DIR, r);
|
|
427
|
+
let entries = [];
|
|
428
|
+
try {
|
|
429
|
+
entries = fs.readdirSync(dir);
|
|
430
|
+
} catch {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
for (const f of entries)
|
|
434
|
+
if (f.endsWith(".jsonl")) files.push({ file: path.join(dir, f), root: r });
|
|
435
|
+
}
|
|
436
|
+
files.sort((a, b) => {
|
|
437
|
+
try {
|
|
438
|
+
return fs.statSync(b.file).size - fs.statSync(a.file).size;
|
|
439
|
+
} catch {
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const fileIndex = new Map();
|
|
445
|
+
for (const { file } of files) fileIndex.set(path.basename(file, ".jsonl"), file);
|
|
446
|
+
|
|
447
|
+
const sessions = [];
|
|
448
|
+
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
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ============ CELL A: cross-session recurrence on deterministic anchors ============
|
|
458
|
+
const anchorMap = new Map();
|
|
459
|
+
for (const s of sessions) {
|
|
460
|
+
for (const turn of s.turns) {
|
|
461
|
+
const mineText = turn.boiler ? "" : turn.text || turn.toolErrText || "";
|
|
462
|
+
if (!mineText || isAutopsyEcho(mineText)) continue;
|
|
463
|
+
const errOnly = turn.mineErrOnly;
|
|
464
|
+
const seenThisTurn = new Set();
|
|
465
|
+
for (const { kind, anchor, at } of extractAnchors(mineText)) {
|
|
466
|
+
if (errOnly && !kind.startsWith("err")) continue;
|
|
467
|
+
const key = kind.startsWith("sym") ? anchor : kind + "|" + anchor.toLowerCase();
|
|
468
|
+
if (seenThisTurn.has(key)) continue;
|
|
469
|
+
seenThisTurn.add(key);
|
|
470
|
+
if (!anchorMap.has(key)) anchorMap.set(key, { kind, anchor, sessions: new Map() });
|
|
471
|
+
const rec = anchorMap.get(key);
|
|
472
|
+
if (!rec.sessions.has(s.sessionId)) {
|
|
473
|
+
rec.sessions.set(s.sessionId, {
|
|
474
|
+
session_id: s.sessionId,
|
|
475
|
+
turn_idx: turn.turn_idx,
|
|
476
|
+
ts: turn.ts,
|
|
477
|
+
role: turn.role,
|
|
478
|
+
root: turn.root,
|
|
479
|
+
from: errOnly ? "tool_output" : "message",
|
|
480
|
+
verbatim: span(mineText, at, anchor.length),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
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
|
+
]);
|
|
522
|
+
const cellA = [];
|
|
523
|
+
for (const [, rec] of anchorMap) {
|
|
524
|
+
if (rec.sessions.size < 2) continue;
|
|
525
|
+
if (isTemplateTok(rec.anchor)) continue;
|
|
526
|
+
// (a) specificity gate
|
|
527
|
+
let eligible = false;
|
|
528
|
+
if (rec.kind.startsWith("err")) eligible = ERR_SPECIFIC.has(rec.kind);
|
|
529
|
+
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
|
+
if (!eligible) continue;
|
|
532
|
+
// (b) relatedness gate — largest same-root witness group must be >= 2
|
|
533
|
+
const byRoot = new Map();
|
|
534
|
+
for (const w of rec.sessions.values()) {
|
|
535
|
+
const rk = w.root || "";
|
|
536
|
+
if (!byRoot.has(rk)) byRoot.set(rk, []);
|
|
537
|
+
byRoot.get(rk).push(w);
|
|
538
|
+
}
|
|
539
|
+
let topRoot = "",
|
|
540
|
+
topWit = [];
|
|
541
|
+
for (const [rk, ws] of byRoot)
|
|
542
|
+
if (ws.length > topWit.length) {
|
|
543
|
+
topWit = ws;
|
|
544
|
+
topRoot = rk;
|
|
545
|
+
}
|
|
546
|
+
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
|
+
if (rec.kind.startsWith("err") && isHomeRoot(topRoot)) continue;
|
|
551
|
+
topWit.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0));
|
|
552
|
+
let spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
|
|
553
|
+
cellA.push({
|
|
554
|
+
kind: rec.kind,
|
|
555
|
+
anchor: rec.anchor,
|
|
556
|
+
relatedN: topWit.length,
|
|
557
|
+
root: topRoot,
|
|
558
|
+
spec,
|
|
559
|
+
nSessTotal: rec.sessions.size,
|
|
560
|
+
witnesses: topWit,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// most-recurring, then most-specific first
|
|
564
|
+
cellA.sort((a, b) => b.relatedN - a.relatedN || b.spec - a.spec);
|
|
565
|
+
|
|
566
|
+
// ============ CELL C: density-gated measured spiral (within a session) ============
|
|
567
|
+
const cellC = [];
|
|
568
|
+
for (const s of sessions) {
|
|
569
|
+
const perAnchor = new Map();
|
|
570
|
+
for (const turn of s.turns) {
|
|
571
|
+
const mineText = turn.boiler ? "" : turn.text || turn.toolErrText || "";
|
|
572
|
+
if (!mineText || !turn.tms || isAutopsyEcho(mineText)) continue;
|
|
573
|
+
const set = new Set();
|
|
574
|
+
for (const { kind, anchor } of extractAnchors(mineText)) {
|
|
575
|
+
if (!kind.startsWith("err")) continue;
|
|
576
|
+
const key = kind + "|" + anchor.toLowerCase();
|
|
577
|
+
if (set.has(key)) continue;
|
|
578
|
+
set.add(key);
|
|
579
|
+
if (!perAnchor.has(key)) perAnchor.set(key, { anchor, kind, turns: [] });
|
|
580
|
+
perAnchor.get(key).turns.push(turn);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const [, rec] of perAnchor) {
|
|
584
|
+
const GAP_MAX = 30 * 60000;
|
|
585
|
+
const all = rec.turns.slice().sort((a, b) => a.tms - b.tms);
|
|
586
|
+
let bestRun = [],
|
|
587
|
+
cur = [all[0]];
|
|
588
|
+
for (let i = 1; i < all.length; i++) {
|
|
589
|
+
if (all[i].tms - all[i - 1].tms <= GAP_MAX) cur.push(all[i]);
|
|
590
|
+
else {
|
|
591
|
+
if (cur.length > bestRun.length) bestRun = cur;
|
|
592
|
+
cur = [all[i]];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (cur.length > bestRun.length) bestRun = cur;
|
|
596
|
+
const ts = bestRun;
|
|
597
|
+
if (ts.length < 3) continue;
|
|
598
|
+
const distinctTurns = new Set(ts.map((t) => t.turn_idx)).size;
|
|
599
|
+
if (distinctTurns < 3) continue;
|
|
600
|
+
const first = ts[0],
|
|
601
|
+
last = ts[ts.length - 1];
|
|
602
|
+
const durMin = (last.tms - first.tms) / 60000;
|
|
603
|
+
if (durMin <= 0 || durMin > 240) continue; // >240min => idle gap, not an active spiral
|
|
604
|
+
const hasUser = s.turns.some(
|
|
605
|
+
(t) =>
|
|
606
|
+
t.role === "user" &&
|
|
607
|
+
!t.boiler &&
|
|
608
|
+
t.text &&
|
|
609
|
+
t.turn_idx > first.turn_idx &&
|
|
610
|
+
t.turn_idx < last.turn_idx,
|
|
611
|
+
);
|
|
612
|
+
if (!hasUser) continue;
|
|
613
|
+
const genericExc = rec.kind === "err:exc"; // bare exception class: weak spiral anchor, demote
|
|
614
|
+
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
|
+
const vb = (t) => {
|
|
618
|
+
const full = t.text || t.toolErrText || "";
|
|
619
|
+
const pos = full.toLowerCase().indexOf(rec.anchor.toLowerCase());
|
|
620
|
+
return pos < 0
|
|
621
|
+
? full.replace(/\s+/g, " ").trim().slice(0, 200)
|
|
622
|
+
: span(full, pos, rec.anchor.length);
|
|
623
|
+
};
|
|
624
|
+
cellC.push({
|
|
625
|
+
session_id: s.sessionId,
|
|
626
|
+
root: first.root,
|
|
627
|
+
anchor: rec.anchor,
|
|
628
|
+
kind: rec.kind,
|
|
629
|
+
genericExc,
|
|
630
|
+
occurrences: ts.length,
|
|
631
|
+
distinct_turns: distinctTurns,
|
|
632
|
+
duration_min: +durMin.toFixed(1),
|
|
633
|
+
witnesses: [first, mid, last].map((t) => ({
|
|
634
|
+
session_id: s.sessionId,
|
|
635
|
+
turn_idx: t.turn_idx,
|
|
636
|
+
ts: t.ts,
|
|
637
|
+
role: t.role,
|
|
638
|
+
from: t.mineErrOnly ? "tool_output" : "message",
|
|
639
|
+
verbatim: vb(t),
|
|
640
|
+
})),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
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
|
+
for (const r of cellC) {
|
|
649
|
+
let emails = 0;
|
|
650
|
+
for (const w of r.witnesses) {
|
|
651
|
+
const m = (w.verbatim || "").match(EMAIL_RE);
|
|
652
|
+
if (m) emails += m.length;
|
|
653
|
+
}
|
|
654
|
+
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
|
+
r.piiDense = emails >= 2 ? 1 : 0;
|
|
659
|
+
}
|
|
660
|
+
cellC.sort(
|
|
661
|
+
(a, b) =>
|
|
662
|
+
a.piiDense - b.piiDense ||
|
|
663
|
+
a.genericExc - b.genericExc ||
|
|
664
|
+
b.duration_min - a.duration_min ||
|
|
665
|
+
b.occurrences - a.occurrences,
|
|
666
|
+
);
|
|
667
|
+
|
|
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).
|
|
678
|
+
const errorsA = cellA.filter((r) => r.kind.startsWith("err"));
|
|
679
|
+
const filesA = cellA.filter((r) => r.kind === "sym:file");
|
|
680
|
+
const rendered = [];
|
|
681
|
+
const takeA = (r) => {
|
|
682
|
+
const rep = reproduceCount(fileIndex, r.anchor, r.witnesses);
|
|
683
|
+
if (rep.confirmed < 2) return false;
|
|
684
|
+
rendered.push({ cell: "A", ...r, rep });
|
|
685
|
+
return true;
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// class 1: the single strongest measured spiral
|
|
689
|
+
for (const r of cellC) {
|
|
690
|
+
if (rendered.length >= 3) break;
|
|
691
|
+
const rep = reproduceCount(fileIndex, r.anchor, r.witnesses);
|
|
692
|
+
if (rep.confirmed < 2) continue;
|
|
693
|
+
rendered.push({ cell: "C", ...r, rep });
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// class 2: specific cross-session errors in a shared project (up to 2)
|
|
698
|
+
let c2 = 0;
|
|
699
|
+
for (const r of errorsA) {
|
|
700
|
+
if (rendered.length >= 3 || c2 >= 2) break;
|
|
701
|
+
if (takeA(r)) c2++;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// class 3: specific files you kept returning to (fill remaining slots)
|
|
705
|
+
for (const r of filesA) {
|
|
706
|
+
if (rendered.length >= 3) break;
|
|
707
|
+
takeA(r);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ---------- render ----------
|
|
711
|
+
P();
|
|
712
|
+
P(" skalpel autopsy");
|
|
713
|
+
P(" " + RULE);
|
|
714
|
+
P(
|
|
715
|
+
` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} · ${totalTurns} turns · nothing left this machine`,
|
|
716
|
+
);
|
|
717
|
+
P();
|
|
718
|
+
|
|
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.");
|
|
722
|
+
P();
|
|
723
|
+
if (sessions.length === 0) {
|
|
724
|
+
P(" There's no Claude Code history on this machine to read.");
|
|
725
|
+
} else {
|
|
726
|
+
P(
|
|
727
|
+
` verified findings so far: 0 · scanned ${totalTurns} turns across ${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
|
|
728
|
+
);
|
|
729
|
+
P();
|
|
730
|
+
P(" The bar is deliberately high. A headline needs a SPECIFIC anchor — a");
|
|
731
|
+
P(" concrete file or symbol, or an error carrying an identifier (not a bare");
|
|
732
|
+
P(" ECONNREFUSED / TypeError) — recurring in the SAME project across two");
|
|
733
|
+
P(" sessions, or the same error spiralling inside one. Generic error-class");
|
|
734
|
+
P(" collisions across unrelated projects are suppressed on purpose. This is");
|
|
735
|
+
P(" a live count, not a verdict: keep coding and the first real finding will");
|
|
736
|
+
P(" render itself here, with receipts.");
|
|
737
|
+
}
|
|
738
|
+
P();
|
|
739
|
+
P(" " + RULE);
|
|
740
|
+
renderFooter(P);
|
|
741
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let n = 0;
|
|
746
|
+
for (const r of rendered) {
|
|
747
|
+
n++;
|
|
748
|
+
P(` ${n}.`);
|
|
749
|
+
if (r.cell === "A") {
|
|
750
|
+
const shown = r.witnesses.slice(0, 3);
|
|
751
|
+
const proj = projTail(r.root);
|
|
752
|
+
const where = proj ? ` in ${proj}` : "";
|
|
753
|
+
if (r.kind === "sym:file") {
|
|
754
|
+
// "returning to a file" is a behavior sourced from either side; the FILENAME is the shared context.
|
|
755
|
+
P(` You kept returning to the file ${r.anchor} across ${r.relatedN} separate`);
|
|
756
|
+
P(` sessions${where} — the same file, on different days.`);
|
|
757
|
+
} else {
|
|
758
|
+
// errors here are compiler/runtime output — sourced to the SESSION, never "you hit".
|
|
759
|
+
const anyUser = shown.some(isUserTyped);
|
|
760
|
+
if (anyUser) {
|
|
761
|
+
P(` ${plainAnchor(r.kind, r.anchor)} came up across ${r.relatedN} separate`);
|
|
762
|
+
P(` sessions${where} — the same specific error, on different days.`);
|
|
763
|
+
} else {
|
|
764
|
+
P(` Your sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
|
|
765
|
+
P(` separate sessions${where} — the same specific error resurfaced across days.`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
P();
|
|
769
|
+
for (const w of shown) {
|
|
770
|
+
P(
|
|
771
|
+
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
|
|
772
|
+
);
|
|
773
|
+
P(` "${redact(w.verbatim)}"`);
|
|
774
|
+
}
|
|
775
|
+
P();
|
|
776
|
+
P(` reproduce: re-read the cited sessions fresh from disk at the cited`);
|
|
777
|
+
P(` turns → anchor present verbatim in ${r.rep.confirmed}/${r.rep.total} cited sessions`);
|
|
778
|
+
P(
|
|
779
|
+
` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}). Files:`,
|
|
780
|
+
);
|
|
781
|
+
for (const w of shown) {
|
|
782
|
+
const f = fileIndex.get(w.session_id);
|
|
783
|
+
if (f) P(` ${f} @turn ${w.turn_idx}`);
|
|
784
|
+
}
|
|
785
|
+
} else {
|
|
786
|
+
// CELL C — measured spiral. State the mechanical fact; the quoted turns self-adjudicate.
|
|
787
|
+
const proj = projTail(r.root);
|
|
788
|
+
const where = proj ? ` in ${proj}` : "";
|
|
789
|
+
P(` ${plainAnchor(r.kind, r.anchor)} recurred ${r.occurrences}× inside one session${where}`);
|
|
790
|
+
P(
|
|
791
|
+
` over a ${r.duration_min}-minute window (wall-clock), across ${r.distinct_turns} turns with`,
|
|
792
|
+
);
|
|
793
|
+
P(` your replies in between — a density-gated spiral (gaps <=30min).`);
|
|
794
|
+
P(` The cited turns (● you wrote · ○ session output):`);
|
|
795
|
+
P();
|
|
796
|
+
for (const w of r.witnesses) {
|
|
797
|
+
const mark = isUserTyped(w) ? "●" : "○";
|
|
798
|
+
P(
|
|
799
|
+
` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)}${projLabel(w.root)}`,
|
|
800
|
+
);
|
|
801
|
+
P(` "${redact(w.verbatim)}"`);
|
|
802
|
+
}
|
|
803
|
+
P();
|
|
804
|
+
P(` reproduce: re-read this session fresh from disk at the cited turns →`);
|
|
805
|
+
P(
|
|
806
|
+
` anchor present verbatim at all ${r.rep.confirmed}/${r.rep.total} cited turns (of ${r.occurrences} total`,
|
|
807
|
+
);
|
|
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}`);
|
|
811
|
+
}
|
|
812
|
+
P();
|
|
813
|
+
P(" " + RULE);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
renderFooter(P);
|
|
817
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function renderFooter(P) {
|
|
821
|
+
P();
|
|
822
|
+
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.");
|
|
825
|
+
P();
|
|
826
|
+
if (RAW) {
|
|
827
|
+
P(" --raw: third-party emails / domains / IPs / secrets are shown UNREDACTED");
|
|
828
|
+
P(" (local screen only). Drop --raw before sharing a screenshot.");
|
|
829
|
+
} else {
|
|
830
|
+
P(" Shareable-safe: third-party emails, domains, IPs and secrets in the quoted");
|
|
831
|
+
P(" spans are redacted to [email]/[domain]/[ip]/[token]. Run with --raw to reveal.");
|
|
832
|
+
}
|
|
833
|
+
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.");
|
|
837
|
+
P();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
main().catch((e) => {
|
|
841
|
+
// fail closed and quiet: never crash a user's terminal, never invent output.
|
|
842
|
+
process.stderr.write(`skalpel autopsy: ${e && e.message ? e.message : e}\n`);
|
|
843
|
+
process.exit(0);
|
|
844
|
+
});
|