@tangle-network/traces 0.8.0 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,110 @@
1
+ // src/adoption.ts
2
+ import { readFile } from "fs/promises";
3
+ import { join } from "path";
4
+ function toolName(s) {
5
+ const n = s.attributes["tool.name"];
6
+ if (typeof n === "string") return n;
7
+ if (s.name.startsWith("tool.")) return s.name.slice("tool.".length);
8
+ return null;
9
+ }
10
+ function parseInput(s) {
11
+ const c = s.attributes.content;
12
+ if (typeof c !== "string") return {};
13
+ try {
14
+ const v = JSON.parse(c);
15
+ return v && typeof v === "object" ? v : {};
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+ function skillNameOf(input) {
21
+ const v = input.skill ?? input.command;
22
+ return typeof v === "string" && v.length > 0 ? v : "?";
23
+ }
24
+ function subagentTypeOf(input) {
25
+ const v = input.subagent_type;
26
+ return typeof v === "string" && v.length > 0 ? v : "?";
27
+ }
28
+ function countSkillRunsJsonl(raw) {
29
+ const out = {};
30
+ for (const line of raw.split("\n")) {
31
+ const t = line.trim();
32
+ if (!t) continue;
33
+ let o;
34
+ try {
35
+ o = JSON.parse(t);
36
+ } catch {
37
+ continue;
38
+ }
39
+ if (!o || typeof o !== "object") continue;
40
+ const rec = o;
41
+ const names = [];
42
+ if (Array.isArray(rec.skills)) {
43
+ for (const s of rec.skills) if (typeof s === "string" && s) names.push(s);
44
+ }
45
+ if (typeof rec.skill === "string" && rec.skill) names.push(rec.skill);
46
+ if (names.length === 0) names.push("?");
47
+ for (const n of names) out[n] = (out[n] ?? 0) + 1;
48
+ }
49
+ return out;
50
+ }
51
+ async function readLoopRuns(cwds) {
52
+ const counts = {};
53
+ let filesRead = 0;
54
+ const seen = /* @__PURE__ */ new Set();
55
+ for (const cwd of cwds) {
56
+ if (!cwd || seen.has(cwd)) continue;
57
+ seen.add(cwd);
58
+ const path = join(cwd, ".evolve", "skill-runs.jsonl");
59
+ let raw;
60
+ try {
61
+ raw = await readFile(path, "utf8");
62
+ } catch {
63
+ continue;
64
+ }
65
+ filesRead += 1;
66
+ for (const [k, v] of Object.entries(countSkillRunsJsonl(raw))) counts[k] = (counts[k] ?? 0) + v;
67
+ }
68
+ return { counts, filesRead };
69
+ }
70
+ async function analyzeAdoption(spans, opts = {}) {
71
+ const sessions = /* @__PURE__ */ new Set();
72
+ const sessionsWithSkill = /* @__PURE__ */ new Set();
73
+ const sessionsWithSubagent = /* @__PURE__ */ new Set();
74
+ const skillInvocations = {};
75
+ const subagentSpawns = {};
76
+ for (const s of spans) {
77
+ sessions.add(s.trace_id);
78
+ const tn = toolName(s);
79
+ if (tn === "Skill") {
80
+ const name = skillNameOf(parseInput(s));
81
+ skillInvocations[name] = (skillInvocations[name] ?? 0) + 1;
82
+ sessionsWithSkill.add(s.trace_id);
83
+ } else if (tn === "Task" || tn === "Agent") {
84
+ const type = subagentTypeOf(parseInput(s));
85
+ subagentSpawns[type] = (subagentSpawns[type] ?? 0) + 1;
86
+ sessionsWithSubagent.add(s.trace_id);
87
+ }
88
+ }
89
+ const { counts: loopDispatchedRuns, filesRead } = await readLoopRuns(opts.cwds ?? []);
90
+ const totalSkillInvocations = Object.values(skillInvocations).reduce((a, b) => a + b, 0);
91
+ const totalSubagentSpawns = Object.values(subagentSpawns).reduce((a, b) => a + b, 0);
92
+ const totalLoopDispatchedRuns = Object.values(loopDispatchedRuns).reduce((a, b) => a + b, 0);
93
+ return {
94
+ sessionCount: sessions.size,
95
+ sessionsWithSkill: sessionsWithSkill.size,
96
+ skillPenetration: sessions.size === 0 ? 0 : sessionsWithSkill.size / sessions.size,
97
+ skillInvocations,
98
+ totalSkillInvocations,
99
+ subagentSpawns,
100
+ totalSubagentSpawns,
101
+ sessionsWithSubagent: sessionsWithSubagent.size,
102
+ loopDispatchedRuns,
103
+ totalLoopDispatchedRuns,
104
+ skillRunFilesRead: filesRead
105
+ };
106
+ }
107
+
1
108
  // src/otlp.ts
2
109
  function span(input) {
3
110
  const attributes = {
@@ -30,6 +137,9 @@ function toOpenInferenceSpan(s) {
30
137
  const resourceAttrs = {};
31
138
  if (a["service.name"] != null) resourceAttrs["service.name"] = a["service.name"];
32
139
  if (a["agent.name"] != null) resourceAttrs["agent.name"] = a["agent.name"];
140
+ for (const k of ["tangle.subject.key", "git.repository", "git.branch", "git.commit", "tangle.cwd", "traces.repo_resolution_source"]) {
141
+ if (a[k] != null) resourceAttrs[k] = a[k];
142
+ }
33
143
  return {
34
144
  trace_id: s.trace_id,
35
145
  span_id: s.span_id,
@@ -50,11 +160,11 @@ function serializeSpans(spans) {
50
160
  `;
51
161
  }
52
162
  async function writeOtlpFile(spans, outPath) {
53
- const { mkdtemp, writeFile: writeFile3 } = await import("fs/promises");
54
- const { tmpdir } = await import("os");
55
- const { join: join13 } = await import("path");
56
- const path = outPath ?? join13(await mkdtemp(join13(tmpdir(), "traces-")), "spans.otlp.jsonl");
57
- await writeFile3(path, serializeSpans(spans), "utf8");
163
+ const { mkdtemp: mkdtemp5, writeFile: writeFile6 } = await import("fs/promises");
164
+ const { tmpdir: tmpdir5 } = await import("os");
165
+ const { join: join19 } = await import("path");
166
+ const path = outPath ?? join19(await mkdtemp5(join19(tmpdir5(), "traces-")), "spans.otlp.jsonl");
167
+ await writeFile6(path, serializeSpans(spans), "utf8");
58
168
  return path;
59
169
  }
60
170
 
@@ -89,150 +199,39 @@ async function analyzeSpans(spans, opts = {}) {
89
199
  return { otlpPath, result };
90
200
  }
91
201
 
92
- // src/external.ts
93
- import { spawn } from "child_process";
94
- import { readFile, writeFile } from "fs/promises";
95
- function runCommand(command, args = [], opts = {}) {
96
- const timeoutMs = opts.timeoutMs ?? 12e4;
97
- const maxBuffer = opts.maxBuffer ?? 32 * 1024 * 1024;
98
- return new Promise((resolve, reject) => {
99
- let child;
100
- try {
101
- child = spawn(command, [...args], { signal: opts.signal });
102
- } catch (err2) {
103
- reject(err2 instanceof Error ? err2 : new Error(String(err2)));
104
- return;
105
- }
106
- let out = "";
107
- let err = "";
108
- let killed = false;
109
- const timer = setTimeout(() => {
110
- killed = true;
111
- child.kill("SIGKILL");
112
- reject(new Error(`${command}: timed out after ${timeoutMs}ms`));
113
- }, timeoutMs);
114
- const cap = (add, buf) => {
115
- const next = buf + add;
116
- if (next.length > maxBuffer) {
117
- killed = true;
118
- child.kill("SIGKILL");
119
- clearTimeout(timer);
120
- reject(new Error(`${command}: output exceeded ${maxBuffer} bytes`));
121
- }
122
- return next;
123
- };
124
- child.stdout?.on("data", (d) => {
125
- out = cap(String(d), out);
126
- });
127
- child.stderr?.on("data", (d) => {
128
- err = cap(String(d), err);
129
- });
130
- child.on("error", (e) => {
131
- clearTimeout(timer);
132
- if (!killed) reject(e);
133
- });
134
- child.on("close", (code) => {
135
- clearTimeout(timer);
136
- if (!killed) resolve({ stdout: out, stderr: err, code });
137
- });
138
- if (opts.input !== void 0) {
139
- child.stdin?.on("error", () => {
140
- });
141
- child.stdin?.end(opts.input);
142
- }
143
- });
144
- }
145
- function commandAnalyzer(spec) {
146
- return {
147
- name: spec.name,
148
- async analyze(otlpPath, opts = {}) {
149
- try {
150
- const res = await runCommand(spec.command, spec.args(otlpPath, opts.prompt), {
151
- signal: opts.signal,
152
- timeoutMs: spec.timeoutMs
153
- });
154
- if (res.code !== 0) {
155
- return { analyzer: spec.name, ok: false, output: res.stdout, error: res.stderr.trim() || `exit ${res.code}` };
156
- }
157
- return { analyzer: spec.name, ok: true, output: res.stdout.trim(), findings: spec.parse?.(res.stdout) };
158
- } catch (e) {
159
- return { analyzer: spec.name, ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
160
- }
161
- }
162
- };
163
- }
164
- function toCanonicalOpenInference(otlpJsonl, opts = {}) {
165
- return otlpJsonl.split("\n").filter((l) => l.trim()).map((line) => {
166
- const s = JSON.parse(line);
167
- const a = s.attributes ?? {};
168
- const resourceAttrs = { "service.name": a["service.name"] ?? opts.service ?? "traces" };
169
- if (a["agent.name"]) resourceAttrs["agent.name"] = a["agent.name"];
170
- return JSON.stringify({
171
- trace_id: s.trace_id,
172
- span_id: s.span_id,
173
- parent_span_id: s.parent_span_id ?? "",
174
- trace_state: "",
175
- name: s.name,
176
- kind: a["openinference.span.kind"] ?? "CHAIN",
177
- start_time: s.start_time,
178
- end_time: s.end_time,
179
- status: { code: s.status?.code ?? "OK", message: s.status?.message ?? "" },
180
- resource: { attributes: resourceAttrs },
181
- scope: { name: opts.scopeName ?? "tangle-traces", version: "" },
182
- attributes: a
183
- });
184
- }).join("\n");
185
- }
186
- function haloAnalyzer(opts = {}) {
187
- const command = opts.command ?? "halo";
188
- const defaultPrompt = opts.defaultPrompt ?? "diagnose";
189
- return {
190
- name: "halo",
191
- async analyze(otlpPath, o = {}) {
192
- try {
193
- const canonical = toCanonicalOpenInference(await readFile(otlpPath, "utf8"));
194
- const canonPath = `${otlpPath}.halo.jsonl`;
195
- await writeFile(canonPath, canonical);
196
- const args = [canonPath, "-p", o.prompt ?? defaultPrompt];
197
- if (opts.model) args.push("-m", opts.model);
198
- const res = await runCommand(command, args, { signal: o.signal, timeoutMs: opts.timeoutMs });
199
- if (res.code !== 0) {
200
- return { analyzer: "halo", ok: false, output: res.stdout, error: res.stderr.trim() || `exit ${res.code}` };
201
- }
202
- return { analyzer: "halo", ok: true, output: res.stdout.trim() };
203
- } catch (e) {
204
- return { analyzer: "halo", ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
205
- }
206
- }
207
- };
208
- }
209
- function runExternalAnalyzers(otlpPath, analyzers, opts = {}) {
210
- return Promise.all(analyzers.map((a) => a.analyze(otlpPath, opts)));
211
- }
212
- function commandRedactor(spec) {
213
- const name = spec.name ?? spec.command;
214
- return {
215
- name,
216
- async redactText(texts) {
217
- if (texts.length === 0) return [];
218
- const res = await runCommand(spec.command, spec.args ?? [], {
219
- input: JSON.stringify(texts),
220
- timeoutMs: spec.timeoutMs
221
- });
222
- if (res.code !== 0) throw new Error(`redactor ${name}: exit ${res.code}: ${res.stderr.trim()}`);
223
- let parsed;
224
- try {
225
- parsed = JSON.parse(res.stdout);
226
- } catch {
227
- throw new Error(`redactor ${name}: stdout was not JSON`);
228
- }
229
- if (!Array.isArray(parsed) || parsed.length !== texts.length) {
230
- throw new Error(`redactor ${name}: expected a JSON array of ${texts.length} strings`);
231
- }
232
- return parsed.map(String);
233
- }
234
- };
235
- }
202
+ // src/attributes.ts
203
+ var ATTR = {
204
+ /** = the trace/session id. Wire contract (dedup linkage). */
205
+ SESSION_ID: "tangle.sessionId",
206
+ /** "cli" marks a CLI upload so the platform can drop a re-upload of a session
207
+ * that also streamed live. Wire contract. */
208
+ INGEST_SOURCE: "tangle.ingest_source",
209
+ HARNESS: "tangle.harness",
210
+ CWD: "tangle.cwd",
211
+ /** THE per-session grouping key the spine derives subjects from
212
+ * (`deriveSubjectKey` reads `tangle.subject.key` first). Resolves to the git
213
+ * remote (host/owner/repo) when readable, else the cwd path basename. */
214
+ SUBJECT_KEY: "tangle.subject.key",
215
+ /** Normalized git remote (e.g. `github.com/tangle-network/agent-dev-container`). */
216
+ GIT_REPOSITORY: "git.repository",
217
+ /** Current branch at conversion time. */
218
+ GIT_BRANCH_NAME: "git.branch",
219
+ /** HEAD short sha at conversion time. */
220
+ GIT_COMMIT: "git.commit",
221
+ /** How traces resolved the session cwd: recorded cwd, repaired cwd, or span path. */
222
+ REPO_RESOLUTION_SOURCE: "traces.repo_resolution_source",
223
+ GIT_BRANCH: "tangle.git_branch",
224
+ HOST: "tangle.host",
225
+ /** Basename of the session file. (Renamed from the ambiguous `tangle.source`,
226
+ * which collided with `tangle.ingest_source`.) */
227
+ SESSION_FILE: "tangle.session_file",
228
+ UPLOADED_AT: "tangle.uploaded_at",
229
+ UPLOADER: "tangle.uploader",
230
+ REDACTION_VERSION: "redaction.version",
231
+ REDACTION_COUNT: "redaction.count"
232
+ };
233
+ var INGEST_SOURCE_CLI = "cli";
234
+ var DEFAULT_HARNESS = "claude-code";
236
235
 
237
236
  // src/time.ts
238
237
  function parseIsoToEpochMs(ts) {
@@ -310,6 +309,7 @@ function capText(raw) {
310
309
  if (t.length <= CONTENT_CAP) return t;
311
310
  return `${t.slice(0, CONTENT_CAP)}\u2026 [+${t.length - CONTENT_CAP} chars]`;
312
311
  }
312
+ var ACTOR_ATTR = "tangle.actor";
313
313
  function userPromptSpan(o) {
314
314
  return span({
315
315
  traceId: o.traceId,
@@ -321,14 +321,15 @@ function userPromptSpan(o) {
321
321
  service: o.service ?? null,
322
322
  agent: o.agent ?? null,
323
323
  step: o.step,
324
- content: o.content
324
+ content: o.content,
325
+ extra: { [ACTOR_ATTR]: o.actor ?? "human" }
325
326
  });
326
327
  }
327
328
 
328
329
  // src/adapters/amp.ts
329
330
  import { readdir, readFile as readFile2, stat } from "fs/promises";
330
331
  import { homedir } from "os";
331
- import { basename, join } from "path";
332
+ import { basename, join as join2 } from "path";
332
333
  var SERVICE = "amp";
333
334
  function textOf(content) {
334
335
  if (typeof content === "string") return content.slice(0, CONTENT_CAP);
@@ -339,8 +340,8 @@ function textOf(content) {
339
340
  var AmpAdapter = class {
340
341
  harness = "amp";
341
342
  threadsRoot() {
342
- const base = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
343
- return join(base, "amp", "threads");
343
+ const base = process.env.XDG_DATA_HOME ?? join2(homedir(), ".local", "share");
344
+ return join2(base, "amp", "threads");
344
345
  }
345
346
  async locate(opts = {}) {
346
347
  let files;
@@ -352,7 +353,7 @@ var AmpAdapter = class {
352
353
  const refs = [];
353
354
  for (const f of files) {
354
355
  if (!f.startsWith("T-") || !f.endsWith(".json")) continue;
355
- const path = join(this.threadsRoot(), f);
356
+ const path = join2(this.threadsRoot(), f);
356
357
  let st;
357
358
  try {
358
359
  st = await stat(path);
@@ -451,10 +452,98 @@ var AmpAdapter = class {
451
452
  }
452
453
  };
453
454
 
455
+ // src/adapters/actor.ts
456
+ var CMD_MARKERS = [
457
+ "<command-name>",
458
+ "<local-command-caveat>",
459
+ "<command-message>",
460
+ "<command-args>",
461
+ "<command-stdout>"
462
+ ];
463
+ var INJECT_MARKERS = [
464
+ "<system-reminder>",
465
+ "Caveat: The messages below",
466
+ "<user-memory-input>",
467
+ // Harness-injected wrappers that arrive as `userType: external` user turns —
468
+ // they pass the structural human test but are the harness feeding the model,
469
+ // not a person. (Observed in Claude Code task/loop sessions.)
470
+ "<task-notification>",
471
+ "<task-prompt>",
472
+ "<local-command-stdout>",
473
+ "This session is being continued from a previous conversation",
474
+ // Slash-command skill bodies are expanded into a user turn (no <command-name>
475
+ // wrapper) — the skill prompt, not the human.
476
+ "Base directory for this skill:"
477
+ ];
478
+ var SYNTHETIC_MARKERS = [
479
+ "You are in a development environment with full file system access",
480
+ "## What the user asked for",
481
+ "Build the project described below",
482
+ "Filesystem sandboxing defines",
483
+ "<permissions instructions>",
484
+ "You are an autonomous",
485
+ "You are a coding agent",
486
+ "Your task is to build",
487
+ "## Deliverable",
488
+ "## Acceptance criteria",
489
+ "Return ONLY",
490
+ "respond with JSON",
491
+ "<environment_details>",
492
+ "<task>",
493
+ "Review this change before it is pushed",
494
+ "Complete a security review",
495
+ "Focus on correctness, regressions, security issues",
496
+ "Read-only critical audit",
497
+ "Staff-engineer code review"
498
+ ];
499
+ var AGENT_PROMPT = /^(you are\b|you're\b|read[- ]only\b|stop now\b|your task\b|your job\b|review the\b|audit\b|analyze the\b|return (only|a |the )|here is the\b|context:|task:|do not (revert|edit|touch)|work only in\b|you have been\b|act as\b|operate the\b|goal:|objective:)/i;
500
+ function textIsCmdOrInject(s) {
501
+ return CMD_MARKERS.some((m) => s.includes(m)) || INJECT_MARKERS.some((m) => s.includes(m));
502
+ }
503
+ function textIsSynthetic(s) {
504
+ const head = s.slice(0, 600);
505
+ return SYNTHETIC_MARKERS.some((m) => head.includes(m));
506
+ }
507
+ function looksLikeAgentPrompt(s) {
508
+ const t = s.trim();
509
+ if (t.length > 1500) return true;
510
+ return AGENT_PROMPT.test(t);
511
+ }
512
+ function claudeActor(args) {
513
+ if (args.isSidechain === true) return "subagent-spawn";
514
+ if (args.userType != null && args.userType !== "external") return "injected";
515
+ if (textIsCmdOrInject(args.text)) return "injected";
516
+ if (textIsSynthetic(args.text)) return "injected";
517
+ if (args.isFirstUserTurn && looksLikeAgentPrompt(args.text)) return "subagent-spawn";
518
+ return "human";
519
+ }
520
+ function codexActor(args) {
521
+ if (textIsCmdOrInject(args.text)) return "injected";
522
+ if (textIsSynthetic(args.text)) return "injected";
523
+ if (args.isFirstUserTurn && looksLikeAgentPrompt(args.text)) return "injected";
524
+ return "human";
525
+ }
526
+ var JARGON_COMPLAINT = /\b(jargon|plain english|plain language|eli5|explain like|in plain|what does that mean|what does .* mean|i don'?t (understand|get)|gibberish|english please|too technical|speak (plainly|english)|dumb(ed)? it down|simpler|simplify your|no buzzwords?)\b/;
527
+ var STRUCTURE_COMPLAINT = /\b(too long|tl;?dr|get to the point|bluf|lead with|bullet|bulletpoint|wall of text|too verbose|shorter|be concise|stop (yapping|rambling)|less words?|fewer words?|just (the|tell me)|cut the)\b/;
528
+ var CORRECTION = /(\bno[,. ]|^no\b|\bstop\b|\bdon'?t\b|you keep|i (said|told you|asked)|that'?s not (what|right|it)|not what i|didn'?t ask|incorrect|that'?s wrong|you'?re wrong|why are you|why did you|that'?s not the|missed the|not the point|ur wrong|thats not)/;
529
+ var FRUSTRATION = /(ugh|wtf|wth|come on|seriously|again\?|for the (last|nth) time|are you kidding|jesus|christ|frustrat|annoying|wasting|waste of)/;
530
+ var PRAISE = /\b(perfect|exactly|nailed it|love (it|this)|beautiful|great work|ship it|clean|nice work|excellent|that'?s it|yes!+|amazing|chef'?s kiss|brilliant)\b/;
531
+ function classifyReaction(text) {
532
+ const t = text.toLowerCase();
533
+ const hits = [];
534
+ if (JARGON_COMPLAINT.test(t)) hits.push("jargon");
535
+ if (STRUCTURE_COMPLAINT.test(t)) hits.push("structure");
536
+ if (CORRECTION.test(t)) hits.push("correction");
537
+ if (FRUSTRATION.test(t)) hits.push("frustration");
538
+ if (PRAISE.test(t)) hits.push("praise");
539
+ return hits;
540
+ }
541
+ var CORRECTIVE_REACTIONS = ["correction", "frustration", "jargon", "structure"];
542
+
454
543
  // src/adapters/claude.ts
455
544
  import { readdir as readdir2, readFile as readFile3, stat as stat2 } from "fs/promises";
456
545
  import { homedir as homedir2 } from "os";
457
- import { basename as basename2, join as join2 } from "path";
546
+ import { basename as basename2, join as join3 } from "path";
458
547
  var SERVICE2 = "claude-code";
459
548
  function parseLines(raw) {
460
549
  const out = [];
@@ -492,6 +581,7 @@ function parseClaudeStream(events, ctx) {
492
581
  const spans = [];
493
582
  const toolSpanByUseId = /* @__PURE__ */ new Map();
494
583
  let step = ctx.startStep;
584
+ let sawUserTurn = false;
495
585
  for (const ev of events) {
496
586
  const ts = ev.timestamp ?? (/* @__PURE__ */ new Date(0)).toISOString();
497
587
  const uid = ev.uuid ?? `${ctx.idPrefix}step${step}`;
@@ -539,6 +629,13 @@ function parseClaudeStream(events, ctx) {
539
629
  } else if (ev.type === "user" && ev.message) {
540
630
  const prompt = textOf2(ev.message.content);
541
631
  if (prompt) {
632
+ const actor = claudeActor({
633
+ text: prompt,
634
+ isSidechain: ev.isSidechain,
635
+ userType: ev.userType ?? null,
636
+ isFirstUserTurn: !sawUserTurn
637
+ });
638
+ sawUserTurn = true;
542
639
  spans.push(
543
640
  userPromptSpan({
544
641
  traceId: ctx.traceId,
@@ -548,7 +645,8 @@ function parseClaudeStream(events, ctx) {
548
645
  service: SERVICE2,
549
646
  agent: ctx.agent,
550
647
  step,
551
- content: prompt
648
+ content: prompt,
649
+ actor
552
650
  })
553
651
  );
554
652
  step += 1;
@@ -575,7 +673,7 @@ var ClaudeAdapter = class {
575
673
  harness = "claude-code";
576
674
  aliases = ["claude", "claudish", "openclaw", "nanoclaw"];
577
675
  root() {
578
- return join2(homedir2(), ".claude", "projects");
676
+ return join3(homedir2(), ".claude", "projects");
579
677
  }
580
678
  async locate(opts = {}) {
581
679
  const root = this.root();
@@ -587,7 +685,7 @@ var ClaudeAdapter = class {
587
685
  }
588
686
  const refs = [];
589
687
  for (const dir of projectDirs) {
590
- const dirPath = join2(root, dir);
688
+ const dirPath = join3(root, dir);
591
689
  let files;
592
690
  try {
593
691
  files = await readdir2(dirPath);
@@ -596,7 +694,7 @@ var ClaudeAdapter = class {
596
694
  }
597
695
  for (const f of files) {
598
696
  if (!f.endsWith(".jsonl")) continue;
599
- const path = join2(dirPath, f);
697
+ const path = join3(dirPath, f);
600
698
  let st;
601
699
  try {
602
700
  st = await stat2(path);
@@ -643,7 +741,7 @@ var ClaudeAdapter = class {
643
741
  }
644
742
  /** Parse `<session>/subagents/agent-*.jsonl`, parenting each under its Agent call. */
645
743
  async foldSubagents(ref, traceId, main, out) {
646
- const subDir = join2(ref.path.replace(/\.jsonl$/, ""), "subagents");
744
+ const subDir = join3(ref.path.replace(/\.jsonl$/, ""), "subagents");
647
745
  let files;
648
746
  try {
649
747
  files = await readdir2(subDir);
@@ -656,13 +754,13 @@ var ClaudeAdapter = class {
656
754
  const hash = basename2(f, ".jsonl");
657
755
  let meta = {};
658
756
  try {
659
- meta = JSON.parse(await readFile3(join2(subDir, `${hash}.meta.json`), "utf8"));
757
+ meta = JSON.parse(await readFile3(join3(subDir, `${hash}.meta.json`), "utf8"));
660
758
  } catch {
661
759
  }
662
760
  const parent = meta.toolUseId && main.toolSpanByUseId.get(meta.toolUseId)?.span_id || `root:${traceId}`;
663
761
  let raw;
664
762
  try {
665
- raw = await readFile3(join2(subDir, f), "utf8");
763
+ raw = await readFile3(join3(subDir, f), "utf8");
666
764
  } catch {
667
765
  continue;
668
766
  }
@@ -682,7 +780,7 @@ var ClaudeAdapter = class {
682
780
  // src/adapters/codex.ts
683
781
  import { readdir as readdir3, readFile as readFile4, stat as stat3 } from "fs/promises";
684
782
  import { homedir as homedir3 } from "os";
685
- import { basename as basename3, join as join3 } from "path";
783
+ import { basename as basename3, join as join4 } from "path";
686
784
  var SERVICE3 = "codex";
687
785
  function parseLines2(raw) {
688
786
  const out = [];
@@ -718,7 +816,7 @@ async function* walkRollouts(root) {
718
816
  return;
719
817
  }
720
818
  for (const y of years) {
721
- const yp = join3(root, y);
819
+ const yp = join4(root, y);
722
820
  let months;
723
821
  try {
724
822
  months = await readdir3(yp);
@@ -726,7 +824,7 @@ async function* walkRollouts(root) {
726
824
  continue;
727
825
  }
728
826
  for (const m of months) {
729
- const mp = join3(yp, m);
827
+ const mp = join4(yp, m);
730
828
  let days;
731
829
  try {
732
830
  days = await readdir3(mp);
@@ -734,7 +832,7 @@ async function* walkRollouts(root) {
734
832
  continue;
735
833
  }
736
834
  for (const d of days) {
737
- const dp = join3(mp, d);
835
+ const dp = join4(mp, d);
738
836
  let files;
739
837
  try {
740
838
  files = await readdir3(dp);
@@ -742,7 +840,7 @@ async function* walkRollouts(root) {
742
840
  continue;
743
841
  }
744
842
  for (const f of files) {
745
- if (f.startsWith("rollout-") && f.endsWith(".jsonl")) yield join3(dp, f);
843
+ if (f.startsWith("rollout-") && f.endsWith(".jsonl")) yield join4(dp, f);
746
844
  }
747
845
  }
748
846
  }
@@ -752,7 +850,7 @@ var CodexAdapter = class {
752
850
  harness = "codex";
753
851
  aliases = ["codex-acp"];
754
852
  root() {
755
- return process.env.CODEX_HOME ? join3(process.env.CODEX_HOME, "sessions") : join3(homedir3(), ".codex", "sessions");
853
+ return process.env.CODEX_HOME ? join4(process.env.CODEX_HOME, "sessions") : join4(homedir3(), ".codex", "sessions");
756
854
  }
757
855
  async locate(opts = {}) {
758
856
  const refs = [];
@@ -764,14 +862,18 @@ var CodexAdapter = class {
764
862
  continue;
765
863
  }
766
864
  if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue;
767
- const head = (await readFile4(path, "utf8").catch(() => "")).split("\n", 1)[0] ?? "";
865
+ const head = (await readFile4(path, "utf8").catch(() => "")).split("\n", 40);
768
866
  let cwd = null;
769
867
  let id = basename3(path).replace(/^rollout-[\dT-]+-/, "").replace(/\.jsonl$/, "");
770
- try {
771
- const meta = JSON.parse(head);
772
- if (meta.payload?.cwd) cwd = meta.payload.cwd;
773
- if (meta.payload?.id) id = meta.payload.id;
774
- } catch {
868
+ for (const line of head) {
869
+ if (!line) continue;
870
+ try {
871
+ const l = JSON.parse(line);
872
+ if (l.type === "session_meta" && l.payload?.id) id = l.payload.id;
873
+ if (!cwd && l.payload?.cwd) cwd = l.payload.cwd;
874
+ } catch {
875
+ }
876
+ if (cwd) break;
775
877
  }
776
878
  if (opts.cwd && (!cwd || !cwd.startsWith(opts.cwd))) continue;
777
879
  refs.push({ harness: this.harness, sessionId: id, path, cwd, mtimeMs: st.mtimeMs });
@@ -801,6 +903,7 @@ var CodexAdapter = class {
801
903
  const toolByCallId = /* @__PURE__ */ new Map();
802
904
  let step = 0;
803
905
  let lastLlm = rootId;
906
+ let sawUserTurn = false;
804
907
  for (const l of lines) {
805
908
  const ts = l.timestamp ?? (/* @__PURE__ */ new Date(0)).toISOString();
806
909
  if (l.type === "event_msg" && l.payload?.type === "token_count") {
@@ -855,6 +958,8 @@ var CodexAdapter = class {
855
958
  } else if (l.type === "response_item" && l.payload?.type === "message" && l.payload.role === "user") {
856
959
  const prompt = textOf3(l.payload.content);
857
960
  if (prompt) {
961
+ const actor = codexActor({ text: prompt, isFirstUserTurn: !sawUserTurn });
962
+ sawUserTurn = true;
858
963
  spans.push(
859
964
  userPromptSpan({
860
965
  traceId,
@@ -864,7 +969,8 @@ var CodexAdapter = class {
864
969
  content: prompt,
865
970
  service: SERVICE3,
866
971
  agent: SERVICE3,
867
- step
972
+ step,
973
+ actor
868
974
  })
869
975
  );
870
976
  step += 1;
@@ -897,7 +1003,7 @@ var CodexAdapter = class {
897
1003
  // src/adapters/copilot.ts
898
1004
  import { readdir as readdir4, readFile as readFile5, stat as stat4 } from "fs/promises";
899
1005
  import { homedir as homedir4 } from "os";
900
- import { join as join4 } from "path";
1006
+ import { join as join5 } from "path";
901
1007
  var SERVICE4 = "github-copilot";
902
1008
  function parseLines3(raw) {
903
1009
  const out = [];
@@ -914,7 +1020,7 @@ var CopilotAdapter = class {
914
1020
  harness = "github-copilot";
915
1021
  aliases = ["copilot"];
916
1022
  root() {
917
- return join4(process.env.COPILOT_HOME ?? join4(homedir4(), ".copilot"), "session-state");
1023
+ return join5(process.env.COPILOT_HOME ?? join5(homedir4(), ".copilot"), "session-state");
918
1024
  }
919
1025
  async locate(opts = {}) {
920
1026
  let dirs;
@@ -925,7 +1031,7 @@ var CopilotAdapter = class {
925
1031
  }
926
1032
  const refs = [];
927
1033
  for (const id of dirs) {
928
- const path = join4(this.root(), id, "events.jsonl");
1034
+ const path = join5(this.root(), id, "events.jsonl");
929
1035
  let st;
930
1036
  try {
931
1037
  st = await stat4(path);
@@ -1023,7 +1129,7 @@ var CopilotAdapter = class {
1023
1129
  // src/adapters/factory.ts
1024
1130
  import { readdir as readdir5, readFile as readFile6, stat as stat5 } from "fs/promises";
1025
1131
  import { homedir as homedir5 } from "os";
1026
- import { basename as basename4, join as join5 } from "path";
1132
+ import { basename as basename4, join as join6 } from "path";
1027
1133
  var SERVICE5 = "factory";
1028
1134
  function textOf4(content) {
1029
1135
  return capText(
@@ -1045,7 +1151,7 @@ var FactoryAdapter = class {
1045
1151
  harness = "factory";
1046
1152
  aliases = ["factory-droids", "droid"];
1047
1153
  root() {
1048
- return join5(homedir5(), ".factory", "sessions");
1154
+ return join6(homedir5(), ".factory", "sessions");
1049
1155
  }
1050
1156
  async locate(opts = {}) {
1051
1157
  const root = this.root();
@@ -1057,7 +1163,7 @@ var FactoryAdapter = class {
1057
1163
  }
1058
1164
  const refs = [];
1059
1165
  for (const dir of dirs) {
1060
- const dp = join5(root, dir);
1166
+ const dp = join6(root, dir);
1061
1167
  let files;
1062
1168
  try {
1063
1169
  files = await readdir5(dp);
@@ -1068,7 +1174,7 @@ var FactoryAdapter = class {
1068
1174
  if (opts.cwd && !cwd.startsWith(opts.cwd)) continue;
1069
1175
  for (const f of files) {
1070
1176
  if (!f.endsWith(".jsonl")) continue;
1071
- const path = join5(dp, f);
1177
+ const path = join6(dp, f);
1072
1178
  let st;
1073
1179
  try {
1074
1180
  st = await stat5(path);
@@ -1184,7 +1290,7 @@ var FactoryAdapter = class {
1184
1290
  // src/adapters/forge.ts
1185
1291
  import { readdir as readdir6, readFile as readFile7, stat as stat6 } from "fs/promises";
1186
1292
  import { homedir as homedir6 } from "os";
1187
- import { basename as basename5, join as join6 } from "path";
1293
+ import { basename as basename5, join as join7 } from "path";
1188
1294
  var SERVICE6 = "forge";
1189
1295
  function tokens(t) {
1190
1296
  if (!t) return null;
@@ -1198,7 +1304,7 @@ var ForgeAdapter = class {
1198
1304
  aliases = ["forgecode"];
1199
1305
  /** Directories scanned for `*-dump.json` exports. */
1200
1306
  dumpDirs() {
1201
- return [process.cwd(), join6(homedir6(), ".forge")];
1307
+ return [process.cwd(), join7(homedir6(), ".forge")];
1202
1308
  }
1203
1309
  async locate(opts = {}) {
1204
1310
  const refs = [];
@@ -1212,7 +1318,7 @@ var ForgeAdapter = class {
1212
1318
  }
1213
1319
  for (const f of files) {
1214
1320
  if (!f.endsWith("-dump.json")) continue;
1215
- const path = join6(dir, f);
1321
+ const path = join7(dir, f);
1216
1322
  let st;
1217
1323
  try {
1218
1324
  st = await stat6(path);
@@ -1314,7 +1420,7 @@ var ForgeAdapter = class {
1314
1420
  // src/adapters/gemini.ts
1315
1421
  import { readdir as readdir7, readFile as readFile8, stat as stat7 } from "fs/promises";
1316
1422
  import { homedir as homedir7 } from "os";
1317
- import { join as join7 } from "path";
1423
+ import { join as join8 } from "path";
1318
1424
  function iso(ts, fallback) {
1319
1425
  if (typeof ts === "number") return new Date(ts).toISOString();
1320
1426
  if (typeof ts === "string" && ts.length > 0) return ts;
@@ -1339,7 +1445,21 @@ var GeminiFamilyAdapter = class {
1339
1445
  this.homeDirName = cfg.homeDirName;
1340
1446
  }
1341
1447
  chatsRoot() {
1342
- return join7(homedir7(), this.homeDirName, "tmp");
1448
+ return join8(homedir7(), this.homeDirName, "tmp");
1449
+ }
1450
+ /** `<home>/projects.json` maps `{ <absolute-cwd>: <projectName> }`. The session
1451
+ * dir name (`tmp/<projectHash>/`) is that projectName for registered projects,
1452
+ * so inverting the map recovers cwd. (Unregistered/older dirs are opaque cwd
1453
+ * digests and stay null.) Loaded once per locate(). */
1454
+ async loadProjectCwds() {
1455
+ const nameToPath = /* @__PURE__ */ new Map();
1456
+ try {
1457
+ const raw = await readFile8(join8(homedir7(), this.homeDirName, "projects.json"), "utf8");
1458
+ const projects = JSON.parse(raw).projects ?? {};
1459
+ for (const [path, name] of Object.entries(projects)) nameToPath.set(name, path);
1460
+ } catch {
1461
+ }
1462
+ return nameToPath;
1343
1463
  }
1344
1464
  async locate(opts = {}) {
1345
1465
  const root = this.chatsRoot();
@@ -1349,9 +1469,11 @@ var GeminiFamilyAdapter = class {
1349
1469
  } catch {
1350
1470
  return [];
1351
1471
  }
1472
+ const projectCwds = await this.loadProjectCwds();
1352
1473
  const refs = [];
1353
1474
  for (const pd of projectDirs) {
1354
- const chatsDir = join7(root, pd, "chats");
1475
+ const cwd = projectCwds.get(pd) ?? null;
1476
+ const chatsDir = join8(root, pd, "chats");
1355
1477
  let files;
1356
1478
  try {
1357
1479
  files = await readdir7(chatsDir);
@@ -1360,7 +1482,7 @@ var GeminiFamilyAdapter = class {
1360
1482
  }
1361
1483
  for (const f of files) {
1362
1484
  if (!f.startsWith("session-") || !f.endsWith(".json")) continue;
1363
- const path = join7(chatsDir, f);
1485
+ const path = join8(chatsDir, f);
1364
1486
  let st;
1365
1487
  try {
1366
1488
  st = await stat7(path);
@@ -1368,8 +1490,8 @@ var GeminiFamilyAdapter = class {
1368
1490
  continue;
1369
1491
  }
1370
1492
  if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue;
1371
- if (opts.cwd) continue;
1372
- refs.push({ harness: this.harness, sessionId: f.replace(/\.json$/, ""), path, cwd: null, mtimeMs: st.mtimeMs });
1493
+ if (opts.cwd && (!cwd || !cwd.startsWith(opts.cwd))) continue;
1494
+ refs.push({ harness: this.harness, sessionId: f.replace(/\.json$/, ""), path, cwd, mtimeMs: st.mtimeMs });
1373
1495
  }
1374
1496
  }
1375
1497
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -1473,7 +1595,7 @@ var GeminiAdapter = class extends GeminiFamilyAdapter {
1473
1595
  // src/adapters/opencode.ts
1474
1596
  import { readdir as readdir8, readFile as readFile9, stat as stat8 } from "fs/promises";
1475
1597
  import { homedir as homedir8 } from "os";
1476
- import { join as join8 } from "path";
1598
+ import { join as join9 } from "path";
1477
1599
  var SERVICE7 = "opencode";
1478
1600
  function isoOf(ms) {
1479
1601
  return new Date(ms ?? 0).toISOString();
@@ -1486,11 +1608,11 @@ function textOf7(parts) {
1486
1608
  var OpencodeAdapter = class {
1487
1609
  harness = "opencode";
1488
1610
  storage() {
1489
- const base = process.env.XDG_DATA_HOME ?? join8(homedir8(), ".local", "share");
1490
- return join8(base, "opencode", "storage");
1611
+ const base = process.env.XDG_DATA_HOME ?? join9(homedir8(), ".local", "share");
1612
+ return join9(base, "opencode", "storage");
1491
1613
  }
1492
1614
  async locate(opts = {}) {
1493
- const msgRoot = join8(this.storage(), "message");
1615
+ const msgRoot = join9(this.storage(), "message");
1494
1616
  let sessionDirs;
1495
1617
  try {
1496
1618
  sessionDirs = await readdir8(msgRoot);
@@ -1499,7 +1621,7 @@ var OpencodeAdapter = class {
1499
1621
  }
1500
1622
  const refs = [];
1501
1623
  for (const sid of sessionDirs) {
1502
- const dir = join8(msgRoot, sid);
1624
+ const dir = join9(msgRoot, sid);
1503
1625
  let st;
1504
1626
  try {
1505
1627
  st = await stat8(dir);
@@ -1516,7 +1638,7 @@ var OpencodeAdapter = class {
1516
1638
  async parse(ref) {
1517
1639
  const traceId = ref.sessionId;
1518
1640
  const rootId = `root:${traceId}`;
1519
- const partRoot = join8(this.storage(), "part");
1641
+ const partRoot = join9(this.storage(), "part");
1520
1642
  let msgFiles;
1521
1643
  try {
1522
1644
  msgFiles = (await readdir8(ref.path)).filter((f) => f.endsWith(".json"));
@@ -1526,7 +1648,7 @@ var OpencodeAdapter = class {
1526
1648
  const messages = [];
1527
1649
  for (const f of msgFiles) {
1528
1650
  try {
1529
- messages.push({ msg: JSON.parse(await readFile9(join8(ref.path, f), "utf8")), file: f });
1651
+ messages.push({ msg: JSON.parse(await readFile9(join9(ref.path, f), "utf8")), file: f });
1530
1652
  } catch {
1531
1653
  }
1532
1654
  }
@@ -1548,13 +1670,13 @@ var OpencodeAdapter = class {
1548
1670
  for (const { msg } of messages) {
1549
1671
  const mid = msg.id ?? `m${step}`;
1550
1672
  const llmId = `llm:${mid}`;
1551
- const pdir = join8(partRoot, mid);
1673
+ const pdir = join9(partRoot, mid);
1552
1674
  const parts = [];
1553
1675
  try {
1554
1676
  const partFiles = (await readdir8(pdir)).filter((f) => f.endsWith(".json"));
1555
1677
  for (const pf of partFiles) {
1556
1678
  try {
1557
- parts.push(JSON.parse(await readFile9(join8(pdir, pf), "utf8")));
1679
+ parts.push(JSON.parse(await readFile9(join9(pdir, pf), "utf8")));
1558
1680
  } catch {
1559
1681
  }
1560
1682
  }
@@ -1626,7 +1748,7 @@ var OpencodeAdapter = class {
1626
1748
  // src/adapters/pi.ts
1627
1749
  import { readdir as readdir9, readFile as readFile10, stat as stat9 } from "fs/promises";
1628
1750
  import { homedir as homedir9 } from "os";
1629
- import { basename as basename6, join as join9 } from "path";
1751
+ import { basename as basename6, join as join10 } from "path";
1630
1752
  var SERVICE8 = "pi";
1631
1753
  function parseLines5(raw) {
1632
1754
  const out = [];
@@ -1654,7 +1776,7 @@ function textOf8(content) {
1654
1776
  var PiAdapter = class {
1655
1777
  harness = "pi";
1656
1778
  root() {
1657
- return join9(homedir9(), ".pi", "agent", "sessions");
1779
+ return join10(homedir9(), ".pi", "agent", "sessions");
1658
1780
  }
1659
1781
  async locate(opts = {}) {
1660
1782
  const root = this.root();
@@ -1666,7 +1788,7 @@ var PiAdapter = class {
1666
1788
  }
1667
1789
  const refs = [];
1668
1790
  for (const dir of dirs) {
1669
- const dp = join9(root, dir);
1791
+ const dp = join10(root, dir);
1670
1792
  let files;
1671
1793
  try {
1672
1794
  files = await readdir9(dp);
@@ -1677,7 +1799,7 @@ var PiAdapter = class {
1677
1799
  if (opts.cwd && !cwd.startsWith(opts.cwd)) continue;
1678
1800
  for (const f of files) {
1679
1801
  if (!f.endsWith(".jsonl")) continue;
1680
- const path = join9(dp, f);
1802
+ const path = join10(dp, f);
1681
1803
  let st;
1682
1804
  try {
1683
1805
  st = await stat9(path);
@@ -1795,7 +1917,7 @@ var PiAdapter = class {
1795
1917
  // src/adapters/qwen.ts
1796
1918
  import { readdir as readdir10, readFile as readFile11, stat as stat10 } from "fs/promises";
1797
1919
  import { homedir as homedir10 } from "os";
1798
- import { basename as basename7, join as join10 } from "path";
1920
+ import { basename as basename7, join as join11 } from "path";
1799
1921
  var SERVICE9 = "qwen";
1800
1922
  function parseLines6(raw) {
1801
1923
  const out = [];
@@ -1816,7 +1938,7 @@ var QwenAdapter = class {
1816
1938
  harness = "qwen";
1817
1939
  aliases = ["qwen-code"];
1818
1940
  root() {
1819
- return join10(homedir10(), ".qwen", "projects");
1941
+ return join11(homedir10(), ".qwen", "projects");
1820
1942
  }
1821
1943
  async locate(opts = {}) {
1822
1944
  const root = this.root();
@@ -1828,7 +1950,7 @@ var QwenAdapter = class {
1828
1950
  }
1829
1951
  const refs = [];
1830
1952
  for (const pd of projectDirs) {
1831
- const chatsDir = join10(root, pd, "chats");
1953
+ const chatsDir = join11(root, pd, "chats");
1832
1954
  let files;
1833
1955
  try {
1834
1956
  files = await readdir10(chatsDir);
@@ -1838,7 +1960,7 @@ var QwenAdapter = class {
1838
1960
  if (opts.cwd) continue;
1839
1961
  for (const f of files) {
1840
1962
  if (!f.endsWith(".jsonl")) continue;
1841
- const path = join10(chatsDir, f);
1963
+ const path = join11(chatsDir, f);
1842
1964
  let st;
1843
1965
  try {
1844
1966
  st = await stat10(path);
@@ -1988,7 +2110,279 @@ function selectAdapters(opts) {
1988
2110
  });
1989
2111
  }
1990
2112
 
2113
+ // src/repo.ts
2114
+ import { stat as stat11 } from "fs/promises";
2115
+ import { dirname, isAbsolute, join as join12, normalize, parse as parsePath } from "path";
2116
+ function normalizeRemote(url) {
2117
+ const raw = url.trim();
2118
+ if (!raw) return null;
2119
+ const url2 = raw.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(.+)$/);
2120
+ if (url2) {
2121
+ let rest = url2[1];
2122
+ const at = rest.lastIndexOf("@");
2123
+ if (at !== -1) rest = rest.slice(at + 1);
2124
+ rest = rest.replace(/\.git$/, "").replace(/\/+$/g, "");
2125
+ const slash = rest.indexOf("/");
2126
+ if (slash === -1) return null;
2127
+ const host = rest.slice(0, slash).replace(/:\d+$/, "");
2128
+ const path = rest.slice(slash + 1).replace(/^\/+/, "");
2129
+ return host && path ? `${host}/${path}` : null;
2130
+ }
2131
+ const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/);
2132
+ if (scp) {
2133
+ const path = scp[2].replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
2134
+ return path ? `${scp[1]}/${path}` : null;
2135
+ }
2136
+ return null;
2137
+ }
2138
+ async function readGit(cwd) {
2139
+ await stat11(`${cwd}/.git`);
2140
+ const { execFile } = await import("child_process");
2141
+ const { promisify } = await import("util");
2142
+ const run = promisify(execFile);
2143
+ const git = async (args) => {
2144
+ try {
2145
+ const { stdout } = await run("git", ["-C", cwd, ...args], { timeout: 5e3 });
2146
+ const v = stdout.trim();
2147
+ return v.length > 0 ? v : null;
2148
+ } catch {
2149
+ return null;
2150
+ }
2151
+ };
2152
+ let remote = await git(["remote", "get-url", "origin"]);
2153
+ if (!remote) {
2154
+ const first = await git(["remote"]);
2155
+ const name = first?.split("\n")[0]?.trim();
2156
+ if (name) remote = await git(["remote", "get-url", name]);
2157
+ }
2158
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
2159
+ const commit = await git(["rev-parse", "--short", "HEAD"]);
2160
+ return {
2161
+ remote,
2162
+ branch: branch === "HEAD" ? null : branch,
2163
+ // detached HEAD → no branch name
2164
+ commit
2165
+ };
2166
+ }
2167
+ async function pathStat(path) {
2168
+ try {
2169
+ return await stat11(path);
2170
+ } catch {
2171
+ return null;
2172
+ }
2173
+ }
2174
+ async function directoryExists(path) {
2175
+ const s = await pathStat(path);
2176
+ return Boolean(s?.isDirectory());
2177
+ }
2178
+ async function nearestExistingDir(path) {
2179
+ if (!path || !isAbsolute(path)) return null;
2180
+ let cur = normalize(path);
2181
+ for (; ; ) {
2182
+ const s = await pathStat(cur);
2183
+ if (s?.isDirectory()) return cur;
2184
+ if (s?.isFile()) return dirname(cur);
2185
+ const parent = dirname(cur);
2186
+ if (parent === cur) return null;
2187
+ cur = parent;
2188
+ }
2189
+ }
2190
+ async function findGitRoot(path) {
2191
+ let cur = await nearestExistingDir(path);
2192
+ while (cur) {
2193
+ if (await pathStat(join12(cur, ".git"))) return cur;
2194
+ const parent = dirname(cur);
2195
+ if (parent === cur) return null;
2196
+ cur = parent;
2197
+ }
2198
+ return null;
2199
+ }
2200
+ async function repairDashedPath(cwd) {
2201
+ if (!isAbsolute(cwd) || await directoryExists(cwd)) return null;
2202
+ const normalized = normalize(cwd);
2203
+ const parsed = parsePath(normalized);
2204
+ const parts = normalized.slice(parsed.root.length).split("/").filter(Boolean);
2205
+ if (parts.length === 0) return null;
2206
+ let current = parsed.root;
2207
+ let index = 0;
2208
+ while (index < parts.length) {
2209
+ const nextPart = parts[index];
2210
+ if (!nextPart) break;
2211
+ const exact = join12(current, nextPart);
2212
+ if (!await directoryExists(exact)) break;
2213
+ current = exact;
2214
+ index += 1;
2215
+ }
2216
+ while (index < parts.length) {
2217
+ let match = null;
2218
+ let consumed = 0;
2219
+ for (let n = parts.length - index; n >= 1; n -= 1) {
2220
+ const segment = parts.slice(index, index + n).join("-");
2221
+ const candidate = join12(current, segment);
2222
+ if (await directoryExists(candidate)) {
2223
+ match = candidate;
2224
+ consumed = n;
2225
+ break;
2226
+ }
2227
+ }
2228
+ if (!match) return null;
2229
+ current = match;
2230
+ index += consumed;
2231
+ }
2232
+ return current !== normalized ? current : null;
2233
+ }
2234
+ var ABSOLUTE_PATH_RE = /\/(?:home|tmp|private\/tmp|Users|work|workspace|workspaces|mnt|srv|var|opt|repo|app)\/[^\s"'`<>|;]+/g;
2235
+ var MAX_SPAN_PATH_CANDIDATES = 64;
2236
+ var MAX_SPAN_TEXT_CHARS = 2e5;
2237
+ var SOURCE_WEIGHT = {
2238
+ none: 0,
2239
+ "span-path": 5,
2240
+ "ref-cwd": 50,
2241
+ "repaired-cwd": 90,
2242
+ "span-workdir": 100
2243
+ };
2244
+ function cleanPathCandidate(raw) {
2245
+ let value = raw.trim().replace(/\\n/g, "").replace(/[),\].}]+$/g, "").replace(/:\d+(?::\d+)?$/g, "");
2246
+ value = normalize(value);
2247
+ return isAbsolute(value) ? value : null;
2248
+ }
2249
+ function looksLikeWorkdirKey(key) {
2250
+ return key === "cwd" || key === "workdir" || key === "workingDirectory" || key === "working_directory";
2251
+ }
2252
+ function* pathEvidenceFromText(text, source) {
2253
+ if (source === "span-workdir") {
2254
+ const direct = cleanPathCandidate(text);
2255
+ if (direct) yield { path: direct, source, weight: SOURCE_WEIGHT[source] };
2256
+ }
2257
+ for (const match of text.matchAll(ABSOLUTE_PATH_RE)) {
2258
+ const candidate = cleanPathCandidate(match[0]);
2259
+ if (candidate) yield { path: candidate, source: "span-path", weight: SOURCE_WEIGHT["span-path"] };
2260
+ }
2261
+ }
2262
+ function* pathsFromUnknown(value, key, depth = 0) {
2263
+ if (depth > 4 || value == null) return;
2264
+ if (typeof value === "string") {
2265
+ yield* pathEvidenceFromText(value, looksLikeWorkdirKey(key) ? "span-workdir" : "span-path");
2266
+ const trimmed = value.trim();
2267
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
2268
+ try {
2269
+ yield* pathsFromUnknown(JSON.parse(trimmed), key, depth + 1);
2270
+ } catch {
2271
+ }
2272
+ }
2273
+ return;
2274
+ }
2275
+ if (Array.isArray(value)) {
2276
+ for (const item of value) yield* pathsFromUnknown(item, key, depth + 1);
2277
+ return;
2278
+ }
2279
+ if (typeof value === "object") {
2280
+ for (const [childKey, item] of Object.entries(value)) yield* pathsFromUnknown(item, childKey, depth + 1);
2281
+ }
2282
+ }
2283
+ function extractAbsolutePaths(spans) {
2284
+ const paths = [];
2285
+ const seen = /* @__PURE__ */ new Set();
2286
+ let scannedChars = 0;
2287
+ for (const span2 of spans) {
2288
+ for (const value of [span2.name, span2.status.message, ...Object.values(span2.attributes)]) {
2289
+ for (const evidence of pathsFromUnknown(value)) {
2290
+ scannedChars += evidence.path.length;
2291
+ if (scannedChars > MAX_SPAN_TEXT_CHARS) return paths;
2292
+ const key = `${evidence.source}:${evidence.path}`;
2293
+ if (seen.has(key)) continue;
2294
+ seen.add(key);
2295
+ paths.push(evidence);
2296
+ if (paths.length >= MAX_SPAN_PATH_CANDIDATES) return paths;
2297
+ }
2298
+ }
2299
+ }
2300
+ return paths;
2301
+ }
2302
+ async function resolveRepoAttrs(cwd) {
2303
+ if (!cwd) return {};
2304
+ const attrs = { [ATTR.CWD]: cwd };
2305
+ const basename10 = (() => {
2306
+ const trimmed = cwd.replace(/\/+$/g, "");
2307
+ const idx = trimmed.lastIndexOf("/");
2308
+ const name = idx === -1 ? trimmed : trimmed.slice(idx + 1);
2309
+ return name || trimmed;
2310
+ })();
2311
+ try {
2312
+ const gitRoot = await findGitRoot(cwd);
2313
+ if (!gitRoot) throw new Error("no git root");
2314
+ const { remote, branch, commit } = await readGit(gitRoot);
2315
+ const normalized = remote ? normalizeRemote(remote) : null;
2316
+ if (normalized) {
2317
+ attrs[ATTR.SUBJECT_KEY] = normalized;
2318
+ attrs[ATTR.GIT_REPOSITORY] = normalized;
2319
+ if (branch) attrs[ATTR.GIT_BRANCH_NAME] = branch;
2320
+ if (commit) attrs[ATTR.GIT_COMMIT] = commit;
2321
+ return attrs;
2322
+ }
2323
+ if (branch) attrs[ATTR.GIT_BRANCH_NAME] = branch;
2324
+ if (commit) attrs[ATTR.GIT_COMMIT] = commit;
2325
+ } catch {
2326
+ }
2327
+ attrs[ATTR.SUBJECT_KEY] = basename10;
2328
+ return attrs;
2329
+ }
2330
+ async function repoCandidateForPath(path) {
2331
+ return await findGitRoot(path) ?? await nearestExistingDir(path);
2332
+ }
2333
+ async function resolveSessionRepoAttrs(cwd, spans) {
2334
+ const candidates = /* @__PURE__ */ new Map();
2335
+ const add = (value, source, weight = SOURCE_WEIGHT[source]) => {
2336
+ if (!value) return;
2337
+ const existing = candidates.get(value);
2338
+ if (existing) {
2339
+ existing.score += weight;
2340
+ if (SOURCE_WEIGHT[source] > SOURCE_WEIGHT[existing.source]) existing.source = source;
2341
+ return;
2342
+ }
2343
+ candidates.set(value, { cwd: value, source, score: weight, order: candidates.size });
2344
+ };
2345
+ if (cwd) {
2346
+ add(await repairDashedPath(cwd), "repaired-cwd");
2347
+ add(cwd, "ref-cwd");
2348
+ }
2349
+ for (const evidence of extractAbsolutePaths(spans)) {
2350
+ add(await repoCandidateForPath(evidence.path), evidence.source, evidence.weight);
2351
+ }
2352
+ const resolved = [];
2353
+ for (const candidate of candidates.values()) {
2354
+ const attrs = await resolveRepoAttrs(candidate.cwd);
2355
+ resolved.push({ ...candidate, attrs });
2356
+ }
2357
+ const byScore = (a, b) => b.score - a.score || a.order - b.order;
2358
+ const withGit = resolved.filter((candidate) => candidate.attrs[ATTR.GIT_REPOSITORY]).sort(byScore);
2359
+ const selected = withGit[0] ?? resolved.filter((candidate) => candidate.attrs[ATTR.SUBJECT_KEY]).sort(byScore)[0];
2360
+ if (!selected) return { attrs: {}, cwd: null, source: "none" };
2361
+ return {
2362
+ attrs: { ...selected.attrs, [ATTR.REPO_RESOLUTION_SOURCE]: selected.source },
2363
+ cwd: selected.attrs[ATTR.CWD] ?? selected.cwd,
2364
+ source: selected.source
2365
+ };
2366
+ }
2367
+ function stampRepoAttrs(spans, attrs) {
2368
+ const keys = Object.keys(attrs);
2369
+ if (keys.length === 0) return spans;
2370
+ for (const s of spans) {
2371
+ for (const k of keys) {
2372
+ if (s.attributes[k] === void 0) s.attributes[k] = attrs[k];
2373
+ }
2374
+ }
2375
+ return spans;
2376
+ }
2377
+
1991
2378
  // src/session-source.ts
2379
+ async function parseSession(adapter, ref) {
2380
+ const spans = await adapter.parse(ref);
2381
+ const repo = await resolveSessionRepoAttrs(ref.cwd, spans);
2382
+ if (repo.cwd) ref.cwd = repo.cwd;
2383
+ stampRepoAttrs(spans, repo.attrs);
2384
+ return spans;
2385
+ }
1992
2386
  async function* scanSessions(opts) {
1993
2387
  for (const adapter of selectAdapters(opts)) {
1994
2388
  if (opts.signal?.aborted) return;
@@ -2004,7 +2398,7 @@ async function* scanSessions(opts) {
2004
2398
  if (opts.signal?.aborted) return;
2005
2399
  let spans;
2006
2400
  try {
2007
- spans = await adapter.parse(ref);
2401
+ spans = await parseSession(adapter, ref);
2008
2402
  } catch (err) {
2009
2403
  opts.onError?.(err, ref);
2010
2404
  continue;
@@ -2015,106 +2409,235 @@ async function* scanSessions(opts) {
2015
2409
  }
2016
2410
  }
2017
2411
 
2018
- // src/observer.ts
2019
- var sleep = (ms, signal) => new Promise((resolve) => {
2020
- const onAbort = () => {
2021
- clearTimeout(t);
2022
- resolve();
2412
+ // src/evidence.ts
2413
+ import { mkdtemp, writeFile } from "fs/promises";
2414
+ import { tmpdir } from "os";
2415
+ import { join as join13 } from "path";
2416
+ function stringAttr(span2, key) {
2417
+ const value = span2.attributes[key];
2418
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2419
+ }
2420
+ function numberAttr(span2, key) {
2421
+ const value = span2.attributes[key];
2422
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2423
+ }
2424
+ function spanKind(span2) {
2425
+ return stringAttr(span2, "openinference.span.kind");
2426
+ }
2427
+ function repoFromSpans(spans) {
2428
+ const attrs = {};
2429
+ for (const span2 of spans) {
2430
+ attrs.subjectKey ??= stringAttr(span2, ATTR.SUBJECT_KEY);
2431
+ attrs.repository ??= stringAttr(span2, ATTR.GIT_REPOSITORY);
2432
+ attrs.branch ??= stringAttr(span2, ATTR.GIT_BRANCH_NAME);
2433
+ attrs.commit ??= stringAttr(span2, ATTR.GIT_COMMIT);
2434
+ attrs.cwd ??= stringAttr(span2, ATTR.CWD);
2435
+ attrs.resolutionSource ??= stringAttr(span2, ATTR.REPO_RESOLUTION_SOURCE);
2436
+ if (attrs.subjectKey && attrs.repository && attrs.branch && attrs.commit && attrs.cwd && attrs.resolutionSource) break;
2437
+ }
2438
+ return attrs;
2439
+ }
2440
+ function timeBounds(spans) {
2441
+ const times = spans.flatMap((span2) => [span2.start_time, span2.end_time]).filter((value) => value && value !== "now").sort();
2442
+ return {
2443
+ firstSpanAt: times[0] ?? null,
2444
+ lastSpanAt: times[times.length - 1] ?? null
2023
2445
  };
2024
- const t = setTimeout(() => {
2025
- signal?.removeEventListener("abort", onAbort);
2026
- resolve();
2027
- }, ms);
2028
- signal?.addEventListener("abort", onAbort, { once: true });
2029
- });
2030
- function loopKey(sessionId, toolName, argHash) {
2031
- return `${sessionId}\0${toolName}\0${argHash}`;
2032
2446
  }
2033
- async function watchSessions(opts) {
2034
- const windowMs = opts.windowMs ?? 30 * 6e4;
2035
- const intervalMs = Math.max(250, opts.intervalMs ?? 5e3);
2036
- const minLoop = opts.minLoopOccurrences ?? 3;
2037
- const seen = /* @__PURE__ */ new Map();
2038
- while (!opts.signal?.aborted) {
2039
- let sessionCount = 0;
2040
- for await (const { adapter, ref, spans } of scanSessions({
2041
- adapters: opts.adapters,
2042
- all: opts.all,
2043
- harnesses: opts.harnesses,
2044
- cwd: opts.cwd,
2045
- sinceMs: Date.now() - windowMs,
2046
- signal: opts.signal,
2047
- onError: opts.onError
2048
- })) {
2049
- let report;
2050
- try {
2051
- report = await runPipelines(spans, { minLoopOccurrences: minLoop });
2052
- } catch (err) {
2053
- opts.onError?.(err, ref);
2054
- continue;
2055
- }
2056
- sessionCount += 1;
2057
- if (opts.onReport) {
2058
- try {
2059
- await opts.onReport(ref, report);
2060
- } catch (err) {
2061
- opts.onError?.(err, ref);
2062
- }
2447
+ function summarizeTools(spans) {
2448
+ const byTool = /* @__PURE__ */ new Map();
2449
+ for (const span2 of spans) {
2450
+ if (spanKind(span2) !== "TOOL") continue;
2451
+ const name = stringAttr(span2, "tool.name") ?? span2.name.replace(/^tool\./, "");
2452
+ const current = byTool.get(name) ?? { calls: 0, errors: 0 };
2453
+ current.calls += 1;
2454
+ if (span2.status.code === "ERROR") current.errors += 1;
2455
+ byTool.set(name, current);
2456
+ }
2457
+ return [...byTool.entries()].map(([name, row]) => ({ name, calls: row.calls, errors: row.errors })).sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name));
2458
+ }
2459
+ async function buildPolicyEvidenceRecord(ref, spans, opts = {}) {
2460
+ const llmSpans = spans.filter((span2) => spanKind(span2) === "LLM");
2461
+ const toolSpans = spans.filter((span2) => spanKind(span2) === "TOOL");
2462
+ const erroredToolCallCount = toolSpans.filter((span2) => span2.status.code === "ERROR").length;
2463
+ const pipelines = await runPipelines(spans, { minLoopOccurrences: opts.minLoopOccurrences });
2464
+ const loopLimit = opts.maxLoopExamples ?? 25;
2465
+ const loopFindings = pipelines.stuckLoops.findings;
2466
+ const { firstSpanAt, lastSpanAt } = timeBounds(spans);
2467
+ const repo = repoFromSpans(spans);
2468
+ return {
2469
+ schemaVersion: 1,
2470
+ kind: "traces.policy_evidence.session",
2471
+ generatedAt: opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2472
+ session: {
2473
+ harness: ref.harness,
2474
+ sessionId: ref.sessionId,
2475
+ path: ref.path,
2476
+ cwd: repo.cwd ?? ref.cwd,
2477
+ mtimeMs: ref.mtimeMs
2478
+ },
2479
+ repo,
2480
+ metrics: {
2481
+ spanCount: spans.length,
2482
+ llmTurnCount: llmSpans.length,
2483
+ toolCallCount: toolSpans.length,
2484
+ erroredToolCallCount,
2485
+ inputTokens: llmSpans.reduce((sum, span2) => sum + numberAttr(span2, "llm.input_tokens"), 0),
2486
+ outputTokens: llmSpans.reduce((sum, span2) => sum + numberAttr(span2, "llm.output_tokens"), 0),
2487
+ models: [...new Set(llmSpans.map((span2) => stringAttr(span2, "llm.model_name")).filter((value) => Boolean(value)))].sort(),
2488
+ tools: summarizeTools(spans),
2489
+ firstSpanAt,
2490
+ lastSpanAt
2491
+ },
2492
+ signals: {
2493
+ stuckLoopCount: loopFindings.length,
2494
+ affectedRunRatio: pipelines.stuckLoops.affectedRunRatio,
2495
+ stuckLoops: loopFindings.slice(0, loopLimit).map((finding) => ({
2496
+ toolName: finding.toolName,
2497
+ occurrences: finding.occurrences
2498
+ })),
2499
+ stuckLoopsOmitted: Math.max(0, loopFindings.length - loopLimit),
2500
+ toolErrorRate: toolSpans.length === 0 ? 0 : erroredToolCallCount / toolSpans.length
2501
+ },
2502
+ provenance: {
2503
+ source: "traces",
2504
+ evidenceKind: "session-summary",
2505
+ ...opts.otlpPath ? { otlpPath: opts.otlpPath } : {},
2506
+ notCampaignCell: true,
2507
+ note: "This is normalized coding-agent session evidence for downstream policy mining; it is not an eval campaign cell."
2508
+ }
2509
+ };
2510
+ }
2511
+ async function collectPolicyEvidence(opts) {
2512
+ const records = [];
2513
+ for await (const session of scanSessions(opts)) {
2514
+ records.push(await buildPolicyEvidenceRecord(session.ref, session.spans, opts));
2515
+ }
2516
+ return records;
2517
+ }
2518
+ function serializePolicyEvidence(records) {
2519
+ if (records.length === 0) return "";
2520
+ return `${records.map((record) => JSON.stringify(record)).join("\n")}
2521
+ `;
2522
+ }
2523
+ async function writePolicyEvidenceFile(records, outPath) {
2524
+ const path = outPath ?? join13(await mkdtemp(join13(tmpdir(), "traces-evidence-")), "policy-evidence.jsonl");
2525
+ await writeFile(path, serializePolicyEvidence(records), "utf8");
2526
+ return path;
2527
+ }
2528
+
2529
+ // src/external.ts
2530
+ import { spawn } from "child_process";
2531
+ function runCommand(command, args = [], opts = {}) {
2532
+ const timeoutMs = opts.timeoutMs ?? 12e4;
2533
+ const maxBuffer = opts.maxBuffer ?? 32 * 1024 * 1024;
2534
+ return new Promise((resolve, reject) => {
2535
+ let child;
2536
+ try {
2537
+ child = spawn(command, [...args], { signal: opts.signal });
2538
+ } catch (err2) {
2539
+ reject(err2 instanceof Error ? err2 : new Error(String(err2)));
2540
+ return;
2541
+ }
2542
+ let out = "";
2543
+ let err = "";
2544
+ let killed = false;
2545
+ const timer = setTimeout(() => {
2546
+ killed = true;
2547
+ child.kill("SIGKILL");
2548
+ reject(new Error(`${command}: timed out after ${timeoutMs}ms`));
2549
+ }, timeoutMs);
2550
+ const cap = (add, buf) => {
2551
+ const next = buf + add;
2552
+ if (next.length > maxBuffer) {
2553
+ killed = true;
2554
+ child.kill("SIGKILL");
2555
+ clearTimeout(timer);
2556
+ reject(new Error(`${command}: output exceeded ${maxBuffer} bytes`));
2063
2557
  }
2064
- for (const f of report.stuckLoops.findings) {
2065
- const key = loopKey(ref.sessionId, f.toolName, f.argHash);
2066
- if (f.occurrences > (seen.get(key) ?? 0)) {
2067
- seen.set(key, f.occurrences);
2068
- if (opts.onLoop) {
2069
- try {
2070
- await opts.onLoop({
2071
- harness: adapter.harness,
2072
- sessionId: ref.sessionId,
2073
- cwd: ref.cwd,
2074
- toolName: f.toolName,
2075
- argHash: f.argHash,
2076
- occurrences: f.occurrences,
2077
- windowMs: f.windowMs
2078
- });
2079
- } catch (err) {
2080
- opts.onError?.(err, ref);
2081
- }
2082
- }
2558
+ return next;
2559
+ };
2560
+ child.stdout?.on("data", (d) => {
2561
+ out = cap(String(d), out);
2562
+ });
2563
+ child.stderr?.on("data", (d) => {
2564
+ err = cap(String(d), err);
2565
+ });
2566
+ child.on("error", (e) => {
2567
+ clearTimeout(timer);
2568
+ if (!killed) reject(e);
2569
+ });
2570
+ child.on("close", (code) => {
2571
+ clearTimeout(timer);
2572
+ if (!killed) resolve({ stdout: out, stderr: err, code });
2573
+ });
2574
+ if (opts.input !== void 0) {
2575
+ child.stdin?.on("error", () => {
2576
+ });
2577
+ child.stdin?.end(opts.input);
2578
+ }
2579
+ });
2580
+ }
2581
+ function commandAnalyzer(spec) {
2582
+ return {
2583
+ name: spec.name,
2584
+ async analyze(otlpPath, opts = {}) {
2585
+ try {
2586
+ const res = await runCommand(spec.command, spec.args(otlpPath, opts.prompt), {
2587
+ signal: opts.signal,
2588
+ timeoutMs: spec.timeoutMs
2589
+ });
2590
+ if (res.code !== 0) {
2591
+ return { analyzer: spec.name, ok: false, output: res.stdout, error: res.stderr.trim() || `exit ${res.code}` };
2083
2592
  }
2593
+ return { analyzer: spec.name, ok: true, output: res.stdout.trim(), findings: spec.parse?.(res.stdout) };
2594
+ } catch (e) {
2595
+ return { analyzer: spec.name, ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
2084
2596
  }
2085
2597
  }
2086
- try {
2087
- opts.onTick?.({ sessions: sessionCount });
2088
- } catch (err) {
2089
- opts.onError?.(err);
2598
+ };
2599
+ }
2600
+ function haloAnalyzer(opts = {}) {
2601
+ const defaultPrompt = opts.defaultPrompt ?? "diagnose";
2602
+ return commandAnalyzer({
2603
+ name: "halo",
2604
+ command: opts.command ?? "halo",
2605
+ args: (otlpPath, prompt) => {
2606
+ const args = [otlpPath, "-p", prompt ?? defaultPrompt];
2607
+ if (opts.model) args.push("-m", opts.model);
2608
+ return args;
2609
+ },
2610
+ timeoutMs: opts.timeoutMs
2611
+ });
2612
+ }
2613
+ function runExternalAnalyzers(otlpPath, analyzers, opts = {}) {
2614
+ return Promise.all(analyzers.map((a) => a.analyze(otlpPath, opts)));
2615
+ }
2616
+ function commandRedactor(spec) {
2617
+ const name = spec.name ?? spec.command;
2618
+ return {
2619
+ name,
2620
+ async redactText(texts) {
2621
+ if (texts.length === 0) return [];
2622
+ const res = await runCommand(spec.command, spec.args ?? [], {
2623
+ input: JSON.stringify(texts),
2624
+ timeoutMs: spec.timeoutMs
2625
+ });
2626
+ if (res.code !== 0) throw new Error(`redactor ${name}: exit ${res.code}: ${res.stderr.trim()}`);
2627
+ let parsed;
2628
+ try {
2629
+ parsed = JSON.parse(res.stdout);
2630
+ } catch {
2631
+ throw new Error(`redactor ${name}: stdout was not JSON`);
2632
+ }
2633
+ if (!Array.isArray(parsed) || parsed.length !== texts.length) {
2634
+ throw new Error(`redactor ${name}: expected a JSON array of ${texts.length} strings`);
2635
+ }
2636
+ return parsed.map(String);
2090
2637
  }
2091
- if (opts.signal?.aborted) break;
2092
- await sleep(intervalMs, opts.signal);
2093
- }
2638
+ };
2094
2639
  }
2095
2640
 
2096
- // src/attributes.ts
2097
- var ATTR = {
2098
- /** = the trace/session id. Wire contract (dedup linkage). */
2099
- SESSION_ID: "tangle.sessionId",
2100
- /** "cli" marks a CLI upload so the platform can drop a re-upload of a session
2101
- * that also streamed live. Wire contract. */
2102
- INGEST_SOURCE: "tangle.ingest_source",
2103
- HARNESS: "tangle.harness",
2104
- CWD: "tangle.cwd",
2105
- GIT_BRANCH: "tangle.git_branch",
2106
- HOST: "tangle.host",
2107
- /** Basename of the session file. (Renamed from the ambiguous `tangle.source`,
2108
- * which collided with `tangle.ingest_source`.) */
2109
- SESSION_FILE: "tangle.session_file",
2110
- UPLOADED_AT: "tangle.uploaded_at",
2111
- UPLOADER: "tangle.uploader",
2112
- REDACTION_VERSION: "redaction.version",
2113
- REDACTION_COUNT: "redaction.count"
2114
- };
2115
- var INGEST_SOURCE_CLI = "cli";
2116
- var DEFAULT_HARNESS = "claude-code";
2117
-
2118
2641
  // src/redact.ts
2119
2642
  import { DEFAULT_REDACTION_RULES, redactValue } from "@tangle-network/agent-eval/traces";
2120
2643
  var CODING_REDACTION_RULES = [
@@ -2175,20 +2698,1084 @@ async function applyRedactor(spans, redactor) {
2175
2698
  return { spans: out, changed };
2176
2699
  }
2177
2700
 
2178
- // src/upload-state.ts
2701
+ // src/file-export.ts
2179
2702
  import { createHash } from "crypto";
2180
- import { mkdir, readFile as readFile12, writeFile as writeFile2 } from "fs/promises";
2703
+ import { mkdtemp as mkdtemp2, readFile as readFile12, writeFile as writeFile2 } from "fs/promises";
2704
+ import { tmpdir as tmpdir2 } from "os";
2705
+ import { join as join14 } from "path";
2706
+ function isObject(value) {
2707
+ return value != null && typeof value === "object" && !Array.isArray(value);
2708
+ }
2709
+ function stringValue(value) {
2710
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2711
+ }
2712
+ function objectValue(value) {
2713
+ return isObject(value) ? value : void 0;
2714
+ }
2715
+ function stableJson(value) {
2716
+ return JSON.stringify(value, (_key, v) => {
2717
+ if (!isObject(v)) return v;
2718
+ return Object.keys(v).sort().reduce((acc, key) => {
2719
+ acc[key] = v[key];
2720
+ return acc;
2721
+ }, {});
2722
+ });
2723
+ }
2724
+ function hashId(value, chars = 16) {
2725
+ return createHash("sha256").update(stableJson(value)).digest("hex").slice(0, chars);
2726
+ }
2727
+ function isoTime(value) {
2728
+ if (typeof value === "string") {
2729
+ const ms = Date.parse(value);
2730
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : void 0;
2731
+ }
2732
+ if (typeof value === "number" && Number.isFinite(value)) {
2733
+ const ms = value < 1e10 ? value * 1e3 : value;
2734
+ return new Date(ms).toISOString();
2735
+ }
2736
+ return void 0;
2737
+ }
2738
+ function firstTime(obj, keys) {
2739
+ for (const key of keys) {
2740
+ const value = isoTime(obj[key]);
2741
+ if (value) return value;
2742
+ }
2743
+ return void 0;
2744
+ }
2745
+ function firstTimeDeep(value, keys, depth = 5) {
2746
+ if (depth < 0) return void 0;
2747
+ if (isObject(value)) {
2748
+ const direct = firstTime(value, keys);
2749
+ if (direct) return direct;
2750
+ for (const child of Object.values(value)) {
2751
+ const found = firstTimeDeep(child, keys, depth - 1);
2752
+ if (found) return found;
2753
+ }
2754
+ } else if (Array.isArray(value)) {
2755
+ for (const child of value) {
2756
+ const found = firstTimeDeep(child, keys, depth - 1);
2757
+ if (found) return found;
2758
+ }
2759
+ }
2760
+ return void 0;
2761
+ }
2762
+ function copyDefined(target, entries) {
2763
+ for (const [key, value] of Object.entries(entries)) {
2764
+ if (value != null && value !== "") target[key] = value;
2765
+ }
2766
+ }
2767
+ function parseJsonRows(text) {
2768
+ const trimmed = text.trim();
2769
+ if (!trimmed) throw new Error("input file is empty");
2770
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
2771
+ const parsed = JSON.parse(trimmed);
2772
+ if (Array.isArray(parsed)) return { rows: parsed };
2773
+ if (isObject(parsed)) {
2774
+ const events = parsed.events;
2775
+ if (Array.isArray(events)) return { rows: events, wrapper: parsed };
2776
+ return { rows: [parsed] };
2777
+ }
2778
+ throw new Error("input JSON must be an object, array, or JSONL rows");
2779
+ }
2780
+ return {
2781
+ rows: trimmed.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line))
2782
+ };
2783
+ }
2784
+ function isPolicyEvidenceRow(row) {
2785
+ return isObject(row) && row.kind === "traces.policy_evidence.session";
2786
+ }
2787
+ function isOpenInferenceRow(row) {
2788
+ if (!isObject(row)) return false;
2789
+ return typeof row.trace_id === "string" && typeof row.span_id === "string" && typeof row.name === "string" && typeof row.start_time === "string" && typeof row.end_time === "string" && isObject(row.status) && isObject(row.attributes) && isObject(row.resource) && isObject(row.scope);
2790
+ }
2791
+ function eventType(row) {
2792
+ return stringValue(row.type) ?? stringValue(row.event) ?? stringValue(row.name) ?? stringValue(row.kind);
2793
+ }
2794
+ function looksLikeSandboxEvent(row) {
2795
+ if (!isObject(row)) return false;
2796
+ const type = eventType(row)?.toLowerCase();
2797
+ return type === "start" || type === "raw" || type === "result" || type === "done" || type === "error";
2798
+ }
2799
+ function detectFormat(rows, requested) {
2800
+ if (requested !== "auto") return requested;
2801
+ if (rows.length === 0) throw new Error("input contains no rows");
2802
+ if (rows.every(isPolicyEvidenceRow)) return "policy-evidence";
2803
+ if (rows.every(isOpenInferenceRow)) return "openinference";
2804
+ if (rows.some(looksLikeSandboxEvent)) return "sandbox-events";
2805
+ throw new Error("could not detect input format; use --format policy-evidence, sandbox-events, or openinference");
2806
+ }
2807
+ function requirePolicyEvidenceRows(rows) {
2808
+ if (!rows.every(isPolicyEvidenceRow)) throw new Error("policy-evidence input must contain only traces.policy_evidence.session rows");
2809
+ return rows;
2810
+ }
2811
+ function requireOpenInferenceRows(rows) {
2812
+ if (!rows.every(isOpenInferenceRow)) throw new Error("openinference input must contain only complete OpenInference span rows");
2813
+ return rows;
2814
+ }
2815
+ function requireObjectRows(rows) {
2816
+ if (!rows.every(isObject)) throw new Error("sandbox-events input must contain only JSON object events");
2817
+ return rows;
2818
+ }
2819
+ function evidenceTimeBounds(record) {
2820
+ const sessionTime = new Date(record.session.mtimeMs).toISOString();
2821
+ const start = record.metrics.firstSpanAt ?? record.generatedAt ?? sessionTime;
2822
+ const end = record.metrics.lastSpanAt ?? start;
2823
+ return { start, end };
2824
+ }
2825
+ function policyEvidenceToSpans(records) {
2826
+ return records.map((record) => {
2827
+ const { start, end } = evidenceTimeBounds(record);
2828
+ const traceId = record.session.sessionId || hashId(record, 32);
2829
+ const extra = {
2830
+ "traces.source_format": "policy-evidence",
2831
+ "traces.policy_evidence.schema_version": record.schemaVersion,
2832
+ "traces.policy_evidence.kind": record.kind,
2833
+ "traces.session.id": record.session.sessionId,
2834
+ "traces.session.path": record.session.path,
2835
+ "traces.session.cwd": record.session.cwd,
2836
+ "traces.session.mtime_ms": record.session.mtimeMs,
2837
+ "traces.metrics.span_count": record.metrics.spanCount,
2838
+ "traces.metrics.llm_turn_count": record.metrics.llmTurnCount,
2839
+ "traces.metrics.tool_call_count": record.metrics.toolCallCount,
2840
+ "traces.metrics.errored_tool_call_count": record.metrics.erroredToolCallCount,
2841
+ "traces.metrics.input_tokens": record.metrics.inputTokens,
2842
+ "traces.metrics.output_tokens": record.metrics.outputTokens,
2843
+ "traces.metrics.models": record.metrics.models,
2844
+ "traces.metrics.tools": stableJson(record.metrics.tools),
2845
+ "traces.signals.stuck_loop_count": record.signals.stuckLoopCount,
2846
+ "traces.signals.affected_run_ratio": record.signals.affectedRunRatio,
2847
+ "traces.signals.stuck_loops": stableJson(record.signals.stuckLoops),
2848
+ "traces.signals.stuck_loops_omitted": record.signals.stuckLoopsOmitted,
2849
+ "traces.signals.tool_error_rate": record.signals.toolErrorRate,
2850
+ "traces.provenance.source": record.provenance.source,
2851
+ "traces.provenance.evidence_kind": record.provenance.evidenceKind,
2852
+ "traces.provenance.otlp_path": record.provenance.otlpPath,
2853
+ [ATTR.SUBJECT_KEY]: record.repo.subjectKey,
2854
+ [ATTR.GIT_REPOSITORY]: record.repo.repository,
2855
+ [ATTR.GIT_BRANCH_NAME]: record.repo.branch,
2856
+ [ATTR.GIT_COMMIT]: record.repo.commit,
2857
+ [ATTR.CWD]: record.repo.cwd ?? record.session.cwd,
2858
+ [ATTR.REPO_RESOLUTION_SOURCE]: record.repo.resolutionSource
2859
+ };
2860
+ return span({
2861
+ traceId,
2862
+ spanId: `policy:${hashId(record)}`,
2863
+ parentSpanId: null,
2864
+ name: "policy_evidence.session",
2865
+ kind: "AGENT",
2866
+ startTime: start,
2867
+ endTime: end,
2868
+ service: record.session.harness,
2869
+ agent: "traces",
2870
+ content: capText(stableJson({
2871
+ session: record.session,
2872
+ repo: record.repo,
2873
+ metrics: record.metrics,
2874
+ signals: record.signals,
2875
+ provenance: record.provenance
2876
+ })),
2877
+ extra
2878
+ });
2879
+ });
2880
+ }
2881
+ function findStringKey(value, keys, depth = 4) {
2882
+ if (depth < 0) return void 0;
2883
+ if (isObject(value)) {
2884
+ for (const key of keys) {
2885
+ const found = stringValue(value[key]);
2886
+ if (found) return found;
2887
+ }
2888
+ for (const child of Object.values(value)) {
2889
+ const found = findStringKey(child, keys, depth - 1);
2890
+ if (found) return found;
2891
+ }
2892
+ } else if (Array.isArray(value)) {
2893
+ for (const child of value) {
2894
+ const found = findStringKey(child, keys, depth - 1);
2895
+ if (found) return found;
2896
+ }
2897
+ }
2898
+ return void 0;
2899
+ }
2900
+ function eventTimestamp(row) {
2901
+ return firstTimeDeep(row, [
2902
+ "timestamp",
2903
+ "start_time",
2904
+ "startTime",
2905
+ "startedAt",
2906
+ "createdAt",
2907
+ "created_at",
2908
+ "created",
2909
+ "time"
2910
+ ]);
2911
+ }
2912
+ function eventEndTimestamp(row, fallback) {
2913
+ return firstTimeDeep(row, [
2914
+ "end_time",
2915
+ "endTime",
2916
+ "completedAt",
2917
+ "completed_at",
2918
+ "finishedAt",
2919
+ "updatedAt",
2920
+ "updated_at",
2921
+ "updated"
2922
+ ]) ?? fallback;
2923
+ }
2924
+ function eventStatus(row, type) {
2925
+ const lowered = type.toLowerCase();
2926
+ const errorMessage = stringValue(row.error) ?? findStringKey(row, ["error", "errorMessage", "error_message"], 3);
2927
+ if (lowered.includes("error") || errorMessage) {
2928
+ const message = errorMessage ?? stringValue(row.message);
2929
+ return { status: "ERROR", ...message ? { message: capText(message) } : {} };
2930
+ }
2931
+ return { status: "OK" };
2932
+ }
2933
+ function eventKind(row, type) {
2934
+ const lowered = type.toLowerCase();
2935
+ const tool = findStringKey(row, ["tool", "toolName", "tool_name"], 4);
2936
+ if (tool || lowered.includes("tool") || stableJson(row).includes("tool-invocation")) return { kind: "TOOL", tool };
2937
+ if (lowered.includes("llm") || lowered === "token") return { kind: "LLM" };
2938
+ return { kind: "CHAIN" };
2939
+ }
2940
+ function sandboxEventsToSpans(rows, wrapper) {
2941
+ const context = wrapper ?? {};
2942
+ const traceId = findStringKey(context, ["trace_id", "traceId", "session_id", "sessionId", "run_id", "runId"], 3) ?? findStringKey(rows, ["trace_id", "traceId", "session_id", "sessionId", "run_id", "runId"], 4) ?? `sandbox:${hashId(rows, 32)}`;
2943
+ const service = findStringKey(context, ["service", "harness", "source"], 2) ?? "sandbox-opencode";
2944
+ const times = rows.flatMap((row) => {
2945
+ const time = eventTimestamp(row);
2946
+ return time ? [time] : [];
2947
+ }).sort();
2948
+ const rootId = `events:${hashId({ traceId, rows: rows.length })}`;
2949
+ const hasError = rows.some((row) => eventStatus(row, eventType(row) ?? "event").status === "ERROR");
2950
+ const rootStart = times[0] ?? (/* @__PURE__ */ new Date(0)).toISOString();
2951
+ const rootEnd = times[times.length - 1] ?? rootStart;
2952
+ const spans = [
2953
+ span({
2954
+ traceId,
2955
+ spanId: rootId,
2956
+ parentSpanId: null,
2957
+ name: "sandbox.events",
2958
+ kind: "AGENT",
2959
+ startTime: rootStart,
2960
+ endTime: rootEnd,
2961
+ status: hasError ? "ERROR" : "OK",
2962
+ service,
2963
+ agent: service,
2964
+ extra: {
2965
+ "traces.source_format": "sandbox-events",
2966
+ "traces.event_count": rows.length
2967
+ }
2968
+ })
2969
+ ];
2970
+ let previousTime = rootStart;
2971
+ rows.forEach((row, index) => {
2972
+ const type = eventType(row) ?? "event";
2973
+ const time = eventTimestamp(row) ?? previousTime;
2974
+ previousTime = time;
2975
+ const { status, message } = eventStatus(row, type);
2976
+ const { kind, tool } = eventKind(row, type);
2977
+ const name = kind === "TOOL" && tool ? `tool.${tool}` : `event.${type}`;
2978
+ const extra = {
2979
+ "traces.source_format": "sandbox-events",
2980
+ "traces.event.type": type,
2981
+ "traces.event.index": index
2982
+ };
2983
+ copyDefined(extra, {
2984
+ "traces.event.id": stringValue(row.id),
2985
+ "traces.event.raw_type": stringValue(row.type),
2986
+ "traces.event.raw_event": stringValue(row.event)
2987
+ });
2988
+ spans.push(span({
2989
+ traceId,
2990
+ spanId: `event:${index}:${hashId(row)}`,
2991
+ parentSpanId: rootId,
2992
+ name,
2993
+ kind,
2994
+ startTime: time,
2995
+ endTime: eventEndTimestamp(row, time),
2996
+ status,
2997
+ statusMessage: message,
2998
+ service,
2999
+ agent: service,
3000
+ tool: kind === "TOOL" ? tool ?? type : null,
3001
+ step: index,
3002
+ content: capText(stableJson(row)),
3003
+ extra
3004
+ }));
3005
+ });
3006
+ return spans;
3007
+ }
3008
+ function statusCode(value) {
3009
+ return value === "OK" || value === "ERROR" || value === "UNSET" ? value : "UNSET";
3010
+ }
3011
+ function openInferenceToSpans(rows) {
3012
+ return rows.map((row) => {
3013
+ const attrs = isObject(row.attributes) ? { ...row.attributes } : {};
3014
+ const resource = objectValue(row.resource);
3015
+ const resourceAttrs = objectValue(resource?.attributes);
3016
+ if (resourceAttrs) copyDefined(attrs, resourceAttrs);
3017
+ const kind = stringValue(row.kind);
3018
+ if (kind && attrs["openinference.span.kind"] == null) attrs["openinference.span.kind"] = kind;
3019
+ const status = objectValue(row.status);
3020
+ const message = stringValue(status?.message);
3021
+ return {
3022
+ trace_id: stringValue(row.trace_id) ?? hashId(row, 32),
3023
+ span_id: stringValue(row.span_id) ?? hashId({ row, field: "span" }),
3024
+ parent_span_id: stringValue(row.parent_span_id) || null,
3025
+ name: stringValue(row.name) ?? "span",
3026
+ start_time: stringValue(row.start_time) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
3027
+ end_time: stringValue(row.end_time) ?? stringValue(row.start_time) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
3028
+ status: { code: statusCode(status?.code), ...message ? { message } : {} },
3029
+ attributes: attrs
3030
+ };
3031
+ });
3032
+ }
3033
+ function normalizeAttributes(attributes) {
3034
+ const normalized = {};
3035
+ for (const [key, value] of Object.entries(attributes ?? {})) {
3036
+ if (!key || value == null) continue;
3037
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
3038
+ normalized[key] = value;
3039
+ } else if (Array.isArray(value) && value.every(
3040
+ (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean"
3041
+ )) {
3042
+ normalized[key] = value;
3043
+ } else {
3044
+ normalized[key] = stableJson(value);
3045
+ }
3046
+ }
3047
+ return normalized;
3048
+ }
3049
+ function withExportAttributes(spans, attributes) {
3050
+ const normalized = normalizeAttributes(attributes);
3051
+ if (Object.keys(normalized).length === 0) return [...spans];
3052
+ return spans.map((s) => ({ ...s, attributes: { ...s.attributes, ...normalized } }));
3053
+ }
3054
+ function exportTraceEvidenceRows(rows, opts = {}, wrapper) {
3055
+ const format = detectFormat(rows, opts.format ?? "auto");
3056
+ const converted = format === "policy-evidence" ? policyEvidenceToSpans(requirePolicyEvidenceRows(rows)) : format === "openinference" ? openInferenceToSpans(requireOpenInferenceRows(rows)) : sandboxEventsToSpans(requireObjectRows(rows), wrapper);
3057
+ const spans = withExportAttributes(converted, opts.attributes);
3058
+ if (spans.length === 0) throw new Error(`no spans exported from ${format} input`);
3059
+ const redacted = redactSpans(spans);
3060
+ return {
3061
+ format,
3062
+ spans: redacted.spans,
3063
+ redactionCount: redacted.report.redactionCount,
3064
+ redactionsByRule: redacted.report.byRule
3065
+ };
3066
+ }
3067
+ function exportTraceEvidenceText(text, opts = {}) {
3068
+ const parsed = parseJsonRows(text);
3069
+ return exportTraceEvidenceRows(parsed.rows, opts, parsed.wrapper);
3070
+ }
3071
+ async function exportTraceEvidenceFile(inputPath, opts = {}) {
3072
+ const text = await readFile12(inputPath, "utf8");
3073
+ return exportTraceEvidenceText(text, { ...opts, sourcePath: inputPath });
3074
+ }
3075
+ async function writeTraceEvidenceExportFile(inputPath, outPath, opts = {}) {
3076
+ const result = await exportTraceEvidenceFile(inputPath, opts);
3077
+ const path = outPath ?? join14(await mkdtemp2(join14(tmpdir2(), "traces-export-")), "spans.openinference.jsonl");
3078
+ await writeFile2(path, serializeSpans(result.spans), "utf8");
3079
+ return { ...result, path };
3080
+ }
3081
+
3082
+ // src/inspect.ts
3083
+ import { mkdtemp as mkdtemp3, readFile as readFile13, writeFile as writeFile3 } from "fs/promises";
3084
+ import { tmpdir as tmpdir3 } from "os";
3085
+ import { basename as basename8, join as join15 } from "path";
3086
+ var severityRank = {
3087
+ high: 0,
3088
+ medium: 1,
3089
+ low: 2
3090
+ };
3091
+ function allContextFiles(index) {
3092
+ return index.context?.roots.flatMap((root) => root.files) ?? [];
3093
+ }
3094
+ function repoLabel(row) {
3095
+ return row.repo.subjectKey ?? row.repo.repository ?? row.repo.cwd ?? row.session.cwd ?? "(repo unknown)";
3096
+ }
3097
+ function sessionLabel(row) {
3098
+ const id = row.session.sessionId.length > 12 ? row.session.sessionId.slice(0, 12) : row.session.sessionId;
3099
+ return `${row.session.harness}:${id} (${repoLabel(row)})`;
3100
+ }
3101
+ function pct(value) {
3102
+ return `${(value * 100).toFixed(value >= 0.1 ? 0 : 1)}%`;
3103
+ }
3104
+ function topToolErrors(row) {
3105
+ const tools = row.tools.filter((tool) => tool.errors > 0).sort((a, b) => b.errors - a.errors || b.calls - a.calls || a.name.localeCompare(b.name)).slice(0, 3).map((tool) => `${tool.name} ${tool.errors}/${tool.calls}`);
3106
+ return tools.length > 0 ? tools.join(", ") : "no tool-level error breakdown";
3107
+ }
3108
+ function topLoopTools(row) {
3109
+ const byTool = /* @__PURE__ */ new Map();
3110
+ for (const loop of row.signals.stuckLoops) {
3111
+ const current = byTool.get(loop.toolName) ?? { groups: 0, maxOccurrences: 0 };
3112
+ current.groups += 1;
3113
+ current.maxOccurrences = Math.max(current.maxOccurrences, loop.occurrences);
3114
+ byTool.set(loop.toolName, current);
3115
+ }
3116
+ const loops = [...byTool.entries()].sort(([, a], [, b]) => b.groups - a.groups || b.maxOccurrences - a.maxOccurrences).slice(0, 3).map(([toolName2, stats]) => `${toolName2} ${stats.groups} group(s), max x${stats.maxOccurrences}`);
3117
+ const suffix = row.signals.stuckLoopsOmitted > 0 ? `, +${row.signals.stuckLoopsOmitted} omitted` : "";
3118
+ return loops.length > 0 ? `${loops.join(", ")}${suffix}` : `${row.signals.stuckLoopCount} loop(s)`;
3119
+ }
3120
+ function sortFindings(findings) {
3121
+ return findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || b.impact - a.impact || a.id.localeCompare(b.id)).map((finding) => ({ ...finding }));
3122
+ }
3123
+ function contextSourceCounts(index) {
3124
+ return { contextFiles: allContextFiles(index).length };
3125
+ }
3126
+ function addSessionFindings(index, findings) {
3127
+ const sessions = index.sessions;
3128
+ if (index.totals.sessions === 0) {
3129
+ findings.push({
3130
+ id: "index.no-sessions",
3131
+ severity: "high",
3132
+ area: "index",
3133
+ title: "No sessions were indexed",
3134
+ impact: 100,
3135
+ evidence: ["The index contains 0 session rows."],
3136
+ next: "Run `traces list` for the target harness, then rerun `traces index` with the right --harness, --cwd, --since, or --last."
3137
+ });
3138
+ return;
3139
+ }
3140
+ const missingRepo = sessions.filter((row) => !row.repo.subjectKey && !row.repo.repository && !row.repo.cwd && !row.session.cwd);
3141
+ if (missingRepo.length > 0) {
3142
+ findings.push({
3143
+ id: "repo.missing-attribution",
3144
+ severity: missingRepo.length / index.totals.sessions >= 0.25 ? "high" : "medium",
3145
+ area: "repo",
3146
+ title: `${missingRepo.length}/${index.totals.sessions} session(s) have no repo or cwd`,
3147
+ impact: missingRepo.length,
3148
+ evidence: missingRepo.slice(0, 5).map((row) => `${row.session.harness}:${row.session.sessionId} at ${row.session.path}`),
3149
+ next: "Improve adapter cwd recovery or pass --cwd for explicit session files so findings can be grouped by repo.",
3150
+ refs: missingRepo.slice(0, 5).map((row) => row.session.path)
3151
+ });
3152
+ }
3153
+ const loopSessions = sessions.filter((row) => row.signals.stuckLoopCount > 0).sort((a, b) => b.signals.stuckLoopCount - a.signals.stuckLoopCount);
3154
+ if (loopSessions.length > 0) {
3155
+ const stuckLoops = loopSessions.reduce((sum, row) => sum + row.signals.stuckLoopCount, 0);
3156
+ findings.push({
3157
+ id: "session.repeated-call-loops",
3158
+ severity: stuckLoops >= 10 || loopSessions.length / index.totals.sessions >= 0.3 ? "high" : "medium",
3159
+ area: "session",
3160
+ title: `Repeated tool-call loops in ${loopSessions.length}/${index.totals.sessions} session(s)`,
3161
+ impact: stuckLoops,
3162
+ evidence: [
3163
+ `${stuckLoops} repeated-call loop(s) across ${loopSessions.length} session(s).`,
3164
+ ...loopSessions.slice(0, 5).map((row) => `${sessionLabel(row)}: ${row.signals.stuckLoopCount} loop(s); ${topLoopTools(row)}`)
3165
+ ],
3166
+ next: "Inspect the repeated commands and results in the referenced sessions; fix the instruction or tool path that lets the same failed action repeat.",
3167
+ refs: loopSessions.slice(0, 5).map((row) => row.session.path)
3168
+ });
3169
+ }
3170
+ const errorSessions = sessions.filter((row) => row.metrics.erroredToolCallCount >= 10 || row.metrics.toolErrorRate >= 0.1).sort((a, b) => b.metrics.erroredToolCallCount - a.metrics.erroredToolCallCount || b.metrics.toolErrorRate - a.metrics.toolErrorRate);
3171
+ if (errorSessions.length > 0) {
3172
+ const errors = errorSessions.reduce((sum, row) => sum + row.metrics.erroredToolCallCount, 0);
3173
+ const worstRate = Math.max(...errorSessions.map((row) => row.metrics.toolErrorRate));
3174
+ findings.push({
3175
+ id: "session.tool-errors",
3176
+ severity: errors >= 50 || worstRate >= 0.3 ? "high" : "medium",
3177
+ area: "session",
3178
+ title: `High tool-error count/rate in ${errorSessions.length}/${index.totals.sessions} session(s)`,
3179
+ impact: errors,
3180
+ evidence: errorSessions.slice(0, 5).map((row) => `${sessionLabel(row)}: ${row.metrics.erroredToolCallCount}/${row.metrics.toolCallCount} tool errors (${pct(row.metrics.toolErrorRate)}); ${topToolErrors(row)}`),
3181
+ next: "Fix the most common failing command/tool first, then rerun `traces index` to confirm the error count drops.",
3182
+ refs: errorSessions.slice(0, 5).map((row) => row.session.path)
3183
+ });
3184
+ }
3185
+ const largeSessions = sessions.map((row) => ({ row, tokens: row.metrics.inputTokens + row.metrics.outputTokens })).filter(({ tokens: tokens2 }) => tokens2 >= 5e5).sort((a, b) => b.tokens - a.tokens);
3186
+ if (largeSessions.length > 0) {
3187
+ const largest = largeSessions[0]?.tokens ?? 0;
3188
+ findings.push({
3189
+ id: "session.large-token-runs",
3190
+ severity: largest >= 1e6 ? "medium" : "low",
3191
+ area: "session",
3192
+ title: `${largeSessions.length} session(s) reported at least 500k summed tokens`,
3193
+ impact: largest,
3194
+ evidence: largeSessions.slice(0, 5).map(({ row, tokens: tokens2 }) => `${sessionLabel(row)}: ${tokens2.toLocaleString()} reported summed tokens (${row.metrics.inputTokens.toLocaleString()} in, ${row.metrics.outputTokens.toLocaleString()} out)`),
3195
+ next: "Check whether these sessions need shorter handoffs, smaller files, split tasks, or clearer token accounting before optimizing anything else.",
3196
+ refs: largeSessions.slice(0, 5).map(({ row }) => row.session.path)
3197
+ });
3198
+ }
3199
+ }
3200
+ function addContextFindings(index, findings) {
3201
+ const files = allContextFiles(index);
3202
+ if (index.totals.sessions > 0 && files.length === 0) {
3203
+ findings.push({
3204
+ id: "context.none-indexed",
3205
+ severity: "medium",
3206
+ area: "context",
3207
+ title: "No nearby repo instructions or .evolve files were indexed",
3208
+ impact: index.totals.sessions,
3209
+ evidence: ["The index has session rows but 0 context files."],
3210
+ next: "Add AGENTS.md or CLAUDE.md near active repos, or ensure session cwd points at the repo so traces can join sessions to local process docs."
3211
+ });
3212
+ return;
3213
+ }
3214
+ const hasContextRoots = (index.context?.totals.roots ?? 0) > 0;
3215
+ if (hasContextRoots && (index.context?.totals.instructionDocs ?? 0) === 0) {
3216
+ findings.push({
3217
+ id: "context.no-instruction-docs",
3218
+ severity: "medium",
3219
+ area: "context",
3220
+ title: "Context roots exist but no AGENTS.md, CLAUDE.md, or GEMINI.md files were found",
3221
+ impact: index.context?.totals.roots ?? 0,
3222
+ evidence: index.context?.roots.slice(0, 5).map((root) => root.root) ?? [],
3223
+ next: "Add a short repo-level instruction file that links to stable process docs and anti-patterns."
3224
+ });
3225
+ }
3226
+ const longInstructionDocs = files.filter((file) => file.kind === "instruction-doc" && (file.lines ?? 0) >= 100 && file.markdown?.hasToc === false).sort((a, b) => (b.lines ?? 0) - (a.lines ?? 0));
3227
+ if (longInstructionDocs.length > 0) {
3228
+ const longest = longInstructionDocs[0]?.lines ?? 0;
3229
+ findings.push({
3230
+ id: "context.long-docs-without-toc",
3231
+ severity: longest >= 200 ? "medium" : "low",
3232
+ area: "context",
3233
+ title: `${longInstructionDocs.length} long instruction doc(s) are missing a Contents section`,
3234
+ impact: longest,
3235
+ evidence: longInstructionDocs.slice(0, 5).map((file) => `${file.path}: ${file.lines ?? 0} lines, ${file.markdown?.headings ?? 0} heading(s), no Contents section`),
3236
+ next: "Add a compact Contents section or split stable rules into linked docs/processes and docs/anti-patterns files.",
3237
+ refs: longInstructionDocs.slice(0, 5).map((file) => file.path)
3238
+ });
3239
+ }
3240
+ const longNarrativeDocs = files.filter((file) => (file.kind === "reflection" || file.kind === "handoff") && (file.lines ?? 0) >= 40 && file.markdown?.hasToc === false).sort((a, b) => (b.lines ?? 0) - (a.lines ?? 0));
3241
+ if (longNarrativeDocs.length > 0) {
3242
+ const longest = longNarrativeDocs[0]?.lines ?? 0;
3243
+ findings.push({
3244
+ id: "context.long-narrative-docs-without-toc",
3245
+ severity: longest >= 120 ? "medium" : "low",
3246
+ area: "context",
3247
+ title: `${longNarrativeDocs.length} long reflection/handoff doc(s) are hard to scan`,
3248
+ impact: longest,
3249
+ evidence: longNarrativeDocs.slice(0, 5).map((file) => `${file.path}: ${file.lines ?? 0} lines, ${file.markdown?.headings ?? 0} heading(s), no Contents section`),
3250
+ next: "Put the decision, evidence, and next action at the top; add Contents only when the note is long enough to revisit.",
3251
+ refs: longNarrativeDocs.slice(0, 5).map((file) => file.path)
3252
+ });
3253
+ }
3254
+ const invalidJsonlFiles = files.filter((file) => (file.jsonl?.invalidRows ?? 0) > 0).sort((a, b) => (b.jsonl?.invalidRows ?? 0) - (a.jsonl?.invalidRows ?? 0));
3255
+ if (invalidJsonlFiles.length > 0) {
3256
+ const invalidRows = invalidJsonlFiles.reduce((sum, file) => sum + (file.jsonl?.invalidRows ?? 0), 0);
3257
+ findings.push({
3258
+ id: "context.invalid-jsonl",
3259
+ severity: "high",
3260
+ area: "context",
3261
+ title: `${invalidRows} invalid JSONL row(s) in indexed context files`,
3262
+ impact: invalidRows,
3263
+ evidence: invalidJsonlFiles.slice(0, 5).map((file) => `${file.path}: ${file.jsonl?.invalidRows ?? 0}/${file.jsonl?.rows ?? 0} invalid row(s)`),
3264
+ next: "Fix or quarantine invalid rows before mining these logs; malformed rows make downstream joins incomplete.",
3265
+ refs: invalidJsonlFiles.slice(0, 5).map((file) => file.path)
3266
+ });
3267
+ }
3268
+ const skillRunFiles = files.filter((file) => basename8(file.path) === "skill-runs.jsonl" && (file.jsonl?.rows ?? 0) > 0).map((file) => {
3269
+ const rows = file.jsonl?.rows ?? 0;
3270
+ const keys = file.jsonl?.keys ?? {};
3271
+ const linkKeyCounts = {
3272
+ transcriptPath: keys.transcriptPath ?? 0,
3273
+ traceDir: keys.traceDir ?? 0,
3274
+ sessionPath: keys.sessionPath ?? 0,
3275
+ sessionId: keys.sessionId ?? 0
3276
+ };
3277
+ const bestLinkKeyRows = Math.max(
3278
+ linkKeyCounts.transcriptPath,
3279
+ linkKeyCounts.traceDir,
3280
+ linkKeyCounts.sessionPath,
3281
+ linkKeyCounts.sessionId
3282
+ );
3283
+ return { file, rows, bestLinkKeyRows, linkKeyCounts };
3284
+ }).filter(({ rows, bestLinkKeyRows }) => bestLinkKeyRows < rows).sort((a, b) => b.rows - b.bestLinkKeyRows - (a.rows - a.bestLinkKeyRows));
3285
+ if (skillRunFiles.length > 0) {
3286
+ const missing = skillRunFiles.reduce((sum, row) => sum + (row.rows - row.bestLinkKeyRows), 0);
3287
+ findings.push({
3288
+ id: "context.skill-run-trace-links",
3289
+ severity: skillRunFiles.some((row) => row.bestLinkKeyRows === 0) ? "medium" : "low",
3290
+ area: "context",
3291
+ title: `${skillRunFiles.length} skill-run file(s) have incomplete direct session-link fields`,
3292
+ impact: missing,
3293
+ evidence: skillRunFiles.slice(0, 5).map(({ file, rows, bestLinkKeyRows, linkKeyCounts }) => `${file.path}: best link key covers ${bestLinkKeyRows}/${rows} row(s); transcriptPath ${linkKeyCounts.transcriptPath}, traceDir ${linkKeyCounts.traceDir}, sessionPath ${linkKeyCounts.sessionPath}, sessionId ${linkKeyCounts.sessionId}`),
3294
+ next: "Write a transcriptPath or sessionId on every skill-run row so traces can connect skill outcomes to the actual session behavior.",
3295
+ refs: skillRunFiles.slice(0, 5).map(({ file }) => file.path)
3296
+ });
3297
+ }
3298
+ }
3299
+ function inspectSessionIndex(index, opts = {}) {
3300
+ const findings = [];
3301
+ addSessionFindings(index, findings);
3302
+ addContextFindings(index, findings);
3303
+ const ranked = sortFindings(findings).slice(0, opts.maxFindings ?? 25);
3304
+ const sourceCounts = contextSourceCounts(index);
3305
+ return {
3306
+ schemaVersion: 1,
3307
+ kind: "traces.inspection_report",
3308
+ generatedAt: opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3309
+ source: {
3310
+ indexGeneratedAt: index.generatedAt,
3311
+ sessions: index.totals.sessions,
3312
+ contextFiles: sourceCounts.contextFiles
3313
+ },
3314
+ totals: {
3315
+ findings: ranked.length,
3316
+ high: ranked.filter((finding) => finding.severity === "high").length,
3317
+ medium: ranked.filter((finding) => finding.severity === "medium").length,
3318
+ low: ranked.filter((finding) => finding.severity === "low").length
3319
+ },
3320
+ findings: ranked
3321
+ };
3322
+ }
3323
+ function renderInspectionReport(report) {
3324
+ const lines = [];
3325
+ lines.push(
3326
+ `traces inspect - ${report.totals.findings} finding(s) from ${report.source.sessions} session(s), ${report.source.contextFiles} context file(s)`
3327
+ );
3328
+ lines.push(`Index generated: ${report.source.indexGeneratedAt ?? "unknown"}`);
3329
+ lines.push(`Report generated: ${report.generatedAt}`);
3330
+ lines.push(`Severity totals: high ${report.totals.high}, medium ${report.totals.medium}, low ${report.totals.low}`);
3331
+ if (report.findings.length === 0) {
3332
+ lines.push("");
3333
+ lines.push("No ranked findings. Keep rerunning this after larger or fresher indexes.");
3334
+ return `${lines.join("\n")}
3335
+ `;
3336
+ }
3337
+ report.findings.forEach((finding, i) => {
3338
+ lines.push("");
3339
+ lines.push(`## ${i + 1}. [${finding.severity}] ${finding.title}`);
3340
+ lines.push(`Area: ${finding.area} Impact: ${finding.impact}`);
3341
+ lines.push("Evidence:");
3342
+ for (const item of finding.evidence) lines.push(`- ${item}`);
3343
+ lines.push(`Next: ${finding.next}`);
3344
+ if (finding.refs && finding.refs.length > 0) {
3345
+ lines.push("Refs:");
3346
+ for (const ref of finding.refs.slice(0, 5)) lines.push(`- ${ref}`);
3347
+ }
3348
+ });
3349
+ return `${lines.join("\n")}
3350
+ `;
3351
+ }
3352
+ function serializeInspectionReport(report) {
3353
+ return `${JSON.stringify(report, null, 2)}
3354
+ `;
3355
+ }
3356
+ function assertSessionIndex(value) {
3357
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3358
+ throw new Error("session index file must contain a JSON object");
3359
+ }
3360
+ const candidate = value;
3361
+ if (candidate.kind !== "traces.session_index" || candidate.schemaVersion !== 1 || !Array.isArray(candidate.sessions)) {
3362
+ throw new Error("input is not a traces.session_index v1 file");
3363
+ }
3364
+ if (!candidate.totals || typeof candidate.totals !== "object") {
3365
+ throw new Error("session index is missing totals");
3366
+ }
3367
+ }
3368
+ async function readSessionIndexFile(path) {
3369
+ const parsed = JSON.parse(await readFile13(path, "utf8"));
3370
+ assertSessionIndex(parsed);
3371
+ return parsed;
3372
+ }
3373
+ async function writeInspectionReportFile(report, outPath) {
3374
+ const path = outPath ?? join15(await mkdtemp3(join15(tmpdir3(), "traces-inspect-")), "inspection-report.md");
3375
+ await writeFile3(path, renderInspectionReport(report), "utf8");
3376
+ return path;
3377
+ }
3378
+
3379
+ // src/observer.ts
3380
+ var sleep = (ms, signal) => new Promise((resolve) => {
3381
+ const onAbort = () => {
3382
+ clearTimeout(t);
3383
+ resolve();
3384
+ };
3385
+ const t = setTimeout(() => {
3386
+ signal?.removeEventListener("abort", onAbort);
3387
+ resolve();
3388
+ }, ms);
3389
+ signal?.addEventListener("abort", onAbort, { once: true });
3390
+ });
3391
+ function loopKey(sessionId, toolName2, argHash) {
3392
+ return `${sessionId}\0${toolName2}\0${argHash}`;
3393
+ }
3394
+ async function watchSessions(opts) {
3395
+ const windowMs = opts.windowMs ?? 30 * 6e4;
3396
+ const intervalMs = Math.max(250, opts.intervalMs ?? 5e3);
3397
+ const minLoop = opts.minLoopOccurrences ?? 3;
3398
+ const seen = /* @__PURE__ */ new Map();
3399
+ while (!opts.signal?.aborted) {
3400
+ let sessionCount = 0;
3401
+ for await (const { adapter, ref, spans } of scanSessions({
3402
+ adapters: opts.adapters,
3403
+ all: opts.all,
3404
+ harnesses: opts.harnesses,
3405
+ cwd: opts.cwd,
3406
+ sinceMs: Date.now() - windowMs,
3407
+ signal: opts.signal,
3408
+ onError: opts.onError
3409
+ })) {
3410
+ let report;
3411
+ try {
3412
+ report = await runPipelines(spans, { minLoopOccurrences: minLoop });
3413
+ } catch (err) {
3414
+ opts.onError?.(err, ref);
3415
+ continue;
3416
+ }
3417
+ sessionCount += 1;
3418
+ if (opts.onReport) {
3419
+ try {
3420
+ await opts.onReport(ref, report);
3421
+ } catch (err) {
3422
+ opts.onError?.(err, ref);
3423
+ }
3424
+ }
3425
+ for (const f of report.stuckLoops.findings) {
3426
+ const key = loopKey(ref.sessionId, f.toolName, f.argHash);
3427
+ if (f.occurrences > (seen.get(key) ?? 0)) {
3428
+ seen.set(key, f.occurrences);
3429
+ if (opts.onLoop) {
3430
+ try {
3431
+ await opts.onLoop({
3432
+ harness: adapter.harness,
3433
+ sessionId: ref.sessionId,
3434
+ cwd: ref.cwd,
3435
+ toolName: f.toolName,
3436
+ argHash: f.argHash,
3437
+ occurrences: f.occurrences,
3438
+ windowMs: f.windowMs
3439
+ });
3440
+ } catch (err) {
3441
+ opts.onError?.(err, ref);
3442
+ }
3443
+ }
3444
+ }
3445
+ }
3446
+ }
3447
+ try {
3448
+ opts.onTick?.({ sessions: sessionCount });
3449
+ } catch (err) {
3450
+ opts.onError?.(err);
3451
+ }
3452
+ if (opts.signal?.aborted) break;
3453
+ await sleep(intervalMs, opts.signal);
3454
+ }
3455
+ }
3456
+
3457
+ // src/reactions.ts
3458
+ var REACTIONS = ["correction", "frustration", "jargon", "structure", "praise"];
3459
+ function emptySignals() {
3460
+ return { correction: 0, frustration: 0, jargon: 0, structure: 0, praise: 0 };
3461
+ }
3462
+ function spanStep(s) {
3463
+ const v = s.attributes.step;
3464
+ return typeof v === "number" ? v : Number.MAX_SAFE_INTEGER;
3465
+ }
3466
+ function ordered(spans) {
3467
+ return [...spans].sort((a, b) => {
3468
+ const ds = spanStep(a) - spanStep(b);
3469
+ if (ds !== 0) return ds;
3470
+ return a.start_time.localeCompare(b.start_time);
3471
+ });
3472
+ }
3473
+ function isAssistant(s) {
3474
+ const kind = s.attributes["openinference.span.kind"];
3475
+ if (s.name === "user.prompt") return false;
3476
+ return kind === "LLM" || s.name.startsWith("message.assistant");
3477
+ }
3478
+ function isHumanPrompt(s) {
3479
+ return s.name === "user.prompt" && s.attributes[ACTOR_ATTR] === "human";
3480
+ }
3481
+ function contentOf(s) {
3482
+ const c = s.attributes.content;
3483
+ return typeof c === "string" ? c : "";
3484
+ }
3485
+ var MAX_PAIRS = 10;
3486
+ function pairStrength(reactions) {
3487
+ let n = 0;
3488
+ for (const r of reactions) n += CORRECTIVE_REACTIONS.includes(r) ? 2 : 1;
3489
+ return n;
3490
+ }
3491
+ function analyzeReactions(spans) {
3492
+ const byTrace = /* @__PURE__ */ new Map();
3493
+ for (const s of spans) {
3494
+ const arr = byTrace.get(s.trace_id) ?? [];
3495
+ arr.push(s);
3496
+ byTrace.set(s.trace_id, arr);
3497
+ }
3498
+ const sessions = [];
3499
+ const corpus = emptySignals();
3500
+ let corpusTurns = 0;
3501
+ const pairs = [];
3502
+ for (const [sessionId, traceSpans] of byTrace) {
3503
+ const seq = ordered(traceSpans);
3504
+ const sessionSignals = emptySignals();
3505
+ let humanReactionTurns = 0;
3506
+ let lastAssistant = null;
3507
+ for (const s of seq) {
3508
+ if (isAssistant(s)) {
3509
+ const text = contentOf(s);
3510
+ if (text) lastAssistant = text;
3511
+ continue;
3512
+ }
3513
+ if (!isHumanPrompt(s)) continue;
3514
+ if (lastAssistant === null) continue;
3515
+ humanReactionTurns += 1;
3516
+ const reactions = classifyReaction(contentOf(s));
3517
+ if (reactions.length > 0) {
3518
+ for (const r of reactions) {
3519
+ sessionSignals[r] += 1;
3520
+ corpus[r] += 1;
3521
+ }
3522
+ pairs.push({ assistant: lastAssistant, human: contentOf(s), reactions, sessionId });
3523
+ }
3524
+ lastAssistant = null;
3525
+ }
3526
+ corpusTurns += humanReactionTurns;
3527
+ sessions.push({ sessionId, humanReactionTurns, signals: sessionSignals });
3528
+ }
3529
+ const corrective = CORRECTIVE_REACTIONS.reduce((n, r) => n + corpus[r], 0);
3530
+ const positive = corpus.praise;
3531
+ const totalSignals = corrective + positive;
3532
+ const ratio = totalSignals === 0 ? null : positive === 0 ? Number.POSITIVE_INFINITY : corrective / positive;
3533
+ pairs.sort((a, b) => pairStrength(b.reactions) - pairStrength(a.reactions));
3534
+ return {
3535
+ sessions,
3536
+ signals: corpus,
3537
+ humanReactionTurns: corpusTurns,
3538
+ correctiveToPositiveRatio: ratio,
3539
+ triggerPairs: pairs.slice(0, MAX_PAIRS)
3540
+ };
3541
+ }
3542
+
3543
+ // src/session-index.ts
3544
+ import { mkdtemp as mkdtemp4, readdir as readdir11, readFile as readFile14, stat as stat12, writeFile as writeFile4 } from "fs/promises";
3545
+ import { tmpdir as tmpdir4 } from "os";
3546
+ import { dirname as dirname2, join as join16 } from "path";
3547
+ function indexRow(record) {
3548
+ return {
3549
+ session: record.session,
3550
+ repo: record.repo,
3551
+ time: {
3552
+ firstSpanAt: record.metrics.firstSpanAt,
3553
+ lastSpanAt: record.metrics.lastSpanAt
3554
+ },
3555
+ metrics: {
3556
+ spanCount: record.metrics.spanCount,
3557
+ llmTurnCount: record.metrics.llmTurnCount,
3558
+ toolCallCount: record.metrics.toolCallCount,
3559
+ erroredToolCallCount: record.metrics.erroredToolCallCount,
3560
+ inputTokens: record.metrics.inputTokens,
3561
+ outputTokens: record.metrics.outputTokens,
3562
+ toolErrorRate: record.signals.toolErrorRate
3563
+ },
3564
+ models: record.metrics.models,
3565
+ tools: record.metrics.tools,
3566
+ signals: record.signals
3567
+ };
3568
+ }
3569
+ function uniqueSorted(values) {
3570
+ return [...new Set([...values].filter((value) => Boolean(value)))].sort();
3571
+ }
3572
+ function buildSessionIndex(records, opts = {}) {
3573
+ const sessions = records.map(indexRow);
3574
+ return {
3575
+ schemaVersion: 1,
3576
+ kind: "traces.session_index",
3577
+ generatedAt: opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3578
+ ...opts.selection ? { selection: opts.selection } : {},
3579
+ totals: {
3580
+ sessions: sessions.length,
3581
+ spans: sessions.reduce((sum, row) => sum + row.metrics.spanCount, 0),
3582
+ llmTurns: sessions.reduce((sum, row) => sum + row.metrics.llmTurnCount, 0),
3583
+ toolCalls: sessions.reduce((sum, row) => sum + row.metrics.toolCallCount, 0),
3584
+ erroredToolCalls: sessions.reduce((sum, row) => sum + row.metrics.erroredToolCallCount, 0),
3585
+ inputTokens: sessions.reduce((sum, row) => sum + row.metrics.inputTokens, 0),
3586
+ outputTokens: sessions.reduce((sum, row) => sum + row.metrics.outputTokens, 0),
3587
+ stuckLoopSessions: sessions.filter((row) => row.signals.stuckLoopCount > 0).length,
3588
+ stuckLoops: sessions.reduce((sum, row) => sum + row.signals.stuckLoopCount, 0),
3589
+ harnesses: uniqueSorted(sessions.map((row) => row.session.harness)),
3590
+ repos: uniqueSorted(sessions.map((row) => row.repo.subjectKey ?? row.repo.repository ?? row.repo.cwd ?? row.session.cwd ?? void 0)),
3591
+ models: uniqueSorted(sessions.flatMap((row) => row.models)),
3592
+ tools: uniqueSorted(sessions.flatMap((row) => row.tools.map((tool) => tool.name)))
3593
+ },
3594
+ ...opts.context ? { context: opts.context } : {},
3595
+ sessions
3596
+ };
3597
+ }
3598
+ async function pathStat2(path) {
3599
+ try {
3600
+ return await stat12(path);
3601
+ } catch {
3602
+ return null;
3603
+ }
3604
+ }
3605
+ async function fileExists(path) {
3606
+ return Boolean((await pathStat2(path))?.isFile());
3607
+ }
3608
+ async function dirExists(path) {
3609
+ return Boolean((await pathStat2(path))?.isDirectory());
3610
+ }
3611
+ async function findContextRoot(cwd) {
3612
+ if (!cwd) return null;
3613
+ let current = cwd;
3614
+ const s = await pathStat2(current);
3615
+ if (!s) return null;
3616
+ if (!s.isDirectory()) current = dirname2(current);
3617
+ for (; ; ) {
3618
+ if (await pathStat2(join16(current, ".git")) || await dirExists(join16(current, ".evolve")) || await fileExists(join16(current, "AGENTS.md")) || await fileExists(join16(current, "CLAUDE.md"))) {
3619
+ return current;
3620
+ }
3621
+ const parent = dirname2(current);
3622
+ if (parent === current) return s.isDirectory() ? cwd : dirname2(cwd);
3623
+ current = parent;
3624
+ }
3625
+ }
3626
+ function countLines(text) {
3627
+ if (text.length === 0) return 0;
3628
+ return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
3629
+ }
3630
+ function summarizeMarkdown(text) {
3631
+ const lines = text.split("\n");
3632
+ return {
3633
+ headings: lines.filter((line) => /^#{1,6}\s+\S/.test(line)).length,
3634
+ hasToc: /\btable of contents\b/i.test(text) || /^\s*-\s+\[[^\]]+\]\(#[^)]+\)/m.test(text)
3635
+ };
3636
+ }
3637
+ function summarizeJsonl(text) {
3638
+ const keys = {};
3639
+ let rows = 0;
3640
+ let invalidRows = 0;
3641
+ for (const line of text.split("\n")) {
3642
+ if (!line.trim()) continue;
3643
+ rows += 1;
3644
+ try {
3645
+ const parsed = JSON.parse(line);
3646
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
3647
+ for (const key of Object.keys(parsed)) keys[key] = (keys[key] ?? 0) + 1;
3648
+ }
3649
+ } catch {
3650
+ invalidRows += 1;
3651
+ }
3652
+ }
3653
+ return { rows, invalidRows, keys: Object.fromEntries(Object.entries(keys).sort(([a], [b]) => a.localeCompare(b))) };
3654
+ }
3655
+ async function summarizeFile(path, kind) {
3656
+ const s = await pathStat2(path);
3657
+ if (!s?.isFile()) return null;
3658
+ const base = { kind, path, bytes: Number(s.size), mtimeMs: Number(s.mtimeMs) };
3659
+ if (!path.endsWith(".md") && !path.endsWith(".jsonl")) return base;
3660
+ try {
3661
+ const text = await readFile14(path, "utf8");
3662
+ const lines = countLines(text);
3663
+ const jsonl = path.endsWith(".jsonl") ? summarizeJsonl(text) : void 0;
3664
+ return {
3665
+ ...base,
3666
+ lines,
3667
+ ...path.endsWith(".md") ? { markdown: summarizeMarkdown(text) } : {},
3668
+ ...jsonl ? { jsonlRows: jsonl.rows, jsonl } : {}
3669
+ };
3670
+ } catch {
3671
+ return base;
3672
+ }
3673
+ }
3674
+ async function walkMarkdown(root, kind, maxFiles = 100) {
3675
+ const out = [];
3676
+ async function walk(dir) {
3677
+ if (out.length >= maxFiles) return;
3678
+ let entries;
3679
+ try {
3680
+ entries = await readdir11(dir, { withFileTypes: true });
3681
+ } catch {
3682
+ return;
3683
+ }
3684
+ for (const entry of entries) {
3685
+ if (out.length >= maxFiles) return;
3686
+ const path = join16(dir, entry.name);
3687
+ if (entry.isDirectory()) {
3688
+ await walk(path);
3689
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
3690
+ const summary = await summarizeFile(path, kind);
3691
+ if (summary) out.push(summary);
3692
+ }
3693
+ }
3694
+ }
3695
+ await walk(root);
3696
+ return out;
3697
+ }
3698
+ async function collectContextRoot(root) {
3699
+ const files = [];
3700
+ for (const name of ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]) {
3701
+ const summary = await summarizeFile(join16(root, name), "instruction-doc");
3702
+ if (summary) files.push(summary);
3703
+ }
3704
+ const evolve = join16(root, ".evolve");
3705
+ for (const name of ["skill-runs.jsonl", "governor.jsonl", "experiments.jsonl"]) {
3706
+ const summary = await summarizeFile(join16(evolve, name), "evolve-jsonl");
3707
+ if (summary) files.push(summary);
3708
+ }
3709
+ for (const name of ["scorecard.json"]) {
3710
+ const summary = await summarizeFile(join16(evolve, name), "evolve-json");
3711
+ if (summary) files.push(summary);
3712
+ }
3713
+ files.push(...await walkMarkdown(join16(evolve, "reflections"), "reflection"));
3714
+ files.push(...await walkMarkdown(join16(evolve, "handoffs"), "handoff"));
3715
+ return { root, files: files.sort((a, b) => a.path.localeCompare(b.path)) };
3716
+ }
3717
+ async function collectContextIndex(records) {
3718
+ const rootSet = /* @__PURE__ */ new Set();
3719
+ for (const record of records) {
3720
+ const root = await findContextRoot(record.repo.cwd ?? record.session.cwd);
3721
+ if (root) rootSet.add(root);
3722
+ }
3723
+ const roots = await Promise.all([...rootSet].sort().map(collectContextRoot));
3724
+ const files = roots.flatMap((root) => root.files);
3725
+ return {
3726
+ totals: {
3727
+ roots: roots.length,
3728
+ files: files.length,
3729
+ instructionDocs: files.filter((file) => file.kind === "instruction-doc").length,
3730
+ evolveFiles: files.filter((file) => file.kind.startsWith("evolve-")).length,
3731
+ jsonlRows: files.reduce((sum, file) => sum + (file.jsonlRows ?? 0), 0),
3732
+ invalidJsonlRows: files.reduce((sum, file) => sum + (file.jsonl?.invalidRows ?? 0), 0)
3733
+ },
3734
+ roots
3735
+ };
3736
+ }
3737
+ async function buildSessionIndexFromRows(rows, opts = {}) {
3738
+ const generatedAt = opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
3739
+ const records = await Promise.all(rows.map(
3740
+ (row) => buildPolicyEvidenceRecord(row.ref, row.spans, {
3741
+ generatedAt,
3742
+ minLoopOccurrences: opts.minLoopOccurrences
3743
+ })
3744
+ ));
3745
+ const context = opts.includeContext === false ? void 0 : await collectContextIndex(records);
3746
+ return buildSessionIndex(records, { ...opts, generatedAt, context });
3747
+ }
3748
+ async function collectSessionIndex(opts = {}) {
3749
+ const rows = [];
3750
+ for await (const session of scanSessions(opts)) {
3751
+ rows.push({ ref: session.ref, spans: session.spans });
3752
+ }
3753
+ return buildSessionIndexFromRows(rows, opts);
3754
+ }
3755
+ function serializeSessionIndex(index) {
3756
+ return `${JSON.stringify(index, null, 2)}
3757
+ `;
3758
+ }
3759
+ async function writeSessionIndexFile(index, outPath) {
3760
+ const path = outPath ?? join16(await mkdtemp4(join16(tmpdir4(), "traces-index-")), "session-index.json");
3761
+ await writeFile4(path, serializeSessionIndex(index), "utf8");
3762
+ return path;
3763
+ }
3764
+
3765
+ // src/upload-state.ts
3766
+ import { createHash as createHash2 } from "crypto";
3767
+ import { mkdir, readFile as readFile15, writeFile as writeFile5 } from "fs/promises";
2181
3768
  import { homedir as homedir11 } from "os";
2182
- import { dirname, join as join11 } from "path";
3769
+ import { dirname as dirname3, join as join17 } from "path";
2183
3770
  function stateFile() {
2184
- const base = process.env.XDG_STATE_HOME ?? join11(homedir11(), ".local", "state");
2185
- return join11(base, "tangle-traces", "uploaded.json");
3771
+ const base = process.env.XDG_STATE_HOME ?? join17(homedir11(), ".local", "state");
3772
+ return join17(base, "tangle-traces", "uploaded.json");
2186
3773
  }
2187
3774
  function uploadKey(harness, sessionId) {
2188
3775
  return `${harness}:${sessionId}`;
2189
3776
  }
2190
3777
  function sessionHash(spans) {
2191
- const h = createHash("sha256");
3778
+ const h = createHash2("sha256");
2192
3779
  for (const s of spans) {
2193
3780
  h.update(`${s.span_id}\0${s.name}\0${s.start_time}\0${s.status.code}
2194
3781
  `);
@@ -2197,15 +3784,15 @@ function sessionHash(spans) {
2197
3784
  }
2198
3785
  async function loadState() {
2199
3786
  try {
2200
- return JSON.parse(await readFile12(stateFile(), "utf8"));
3787
+ return JSON.parse(await readFile15(stateFile(), "utf8"));
2201
3788
  } catch {
2202
3789
  return {};
2203
3790
  }
2204
3791
  }
2205
3792
  async function saveState(state) {
2206
3793
  const f = stateFile();
2207
- await mkdir(dirname(f), { recursive: true });
2208
- await writeFile2(f, `${JSON.stringify(state, null, 2)}
3794
+ await mkdir(dirname3(f), { recursive: true });
3795
+ await writeFile5(f, `${JSON.stringify(state, null, 2)}
2209
3796
  `, "utf8");
2210
3797
  }
2211
3798
  function alreadyUploaded(state, harness, sessionId, hash) {
@@ -2213,10 +3800,10 @@ function alreadyUploaded(state, harness, sessionId, hash) {
2213
3800
  }
2214
3801
 
2215
3802
  // src/upload.ts
2216
- import { readFile as readFile13 } from "fs/promises";
3803
+ import { readFile as readFile16 } from "fs/promises";
2217
3804
  import { createRequire } from "module";
2218
3805
  import { hostname } from "os";
2219
- import { basename as basename8, join as join12 } from "path";
3806
+ import { basename as basename9, join as join18 } from "path";
2220
3807
  import { hostedClientFromEnv } from "@tangle-network/agent-eval/hosted";
2221
3808
  import { REDACTION_VERSION } from "@tangle-network/agent-eval/traces";
2222
3809
  var require2 = createRequire(import.meta.url);
@@ -2240,7 +3827,7 @@ async function planUpload(opts = {}) {
2240
3827
  async function gitBranch(cwd) {
2241
3828
  if (!cwd) return void 0;
2242
3829
  try {
2243
- const head = await readFile13(join12(cwd, ".git", "HEAD"), "utf8");
3830
+ const head = await readFile16(join18(cwd, ".git", "HEAD"), "utf8");
2244
3831
  const m = head.match(/^ref:\s*refs\/heads\/(.+)$/m);
2245
3832
  return m?.[1]?.trim();
2246
3833
  } catch {
@@ -2250,7 +3837,7 @@ async function gitBranch(cwd) {
2250
3837
  async function sessionMeta(item, uploadedAt) {
2251
3838
  const meta = {
2252
3839
  [ATTR.HARNESS]: item.ref.harness,
2253
- [ATTR.SESSION_FILE]: basename8(item.ref.path),
3840
+ [ATTR.SESSION_FILE]: basename9(item.ref.path),
2254
3841
  [ATTR.HOST]: hostname(),
2255
3842
  [ATTR.UPLOADED_AT]: uploadedAt,
2256
3843
  [ATTR.UPLOADER]: `tangle-traces@${TRACES_VERSION}`,
@@ -2348,25 +3935,31 @@ async function executeUpload(plan, opts = {}) {
2348
3935
  }
2349
3936
 
2350
3937
  export {
3938
+ countSkillRunsJsonl,
3939
+ analyzeAdoption,
2351
3940
  span,
2352
3941
  toOpenInferenceSpan,
2353
3942
  serializeSpans,
2354
3943
  writeOtlpFile,
2355
3944
  analyzeSpans,
2356
- runCommand,
2357
- commandAnalyzer,
2358
- toCanonicalOpenInference,
2359
- haloAnalyzer,
2360
- runExternalAnalyzers,
2361
- commandRedactor,
3945
+ ATTR,
3946
+ INGEST_SOURCE_CLI,
3947
+ DEFAULT_HARNESS,
2362
3948
  parseIsoToEpochMs,
2363
3949
  parseSince,
2364
3950
  toRuntimeStore,
2365
3951
  runPipelines,
2366
3952
  CONTENT_CAP,
2367
3953
  capText,
3954
+ ACTOR_ATTR,
2368
3955
  userPromptSpan,
2369
3956
  AmpAdapter,
3957
+ textIsSynthetic,
3958
+ looksLikeAgentPrompt,
3959
+ claudeActor,
3960
+ codexActor,
3961
+ classifyReaction,
3962
+ CORRECTIVE_REACTIONS,
2370
3963
  ClaudeAdapter,
2371
3964
  CodexAdapter,
2372
3965
  CopilotAdapter,
@@ -2380,15 +3973,42 @@ export {
2380
3973
  resolveAdapter,
2381
3974
  knownHarnesses,
2382
3975
  selectAdapters,
3976
+ normalizeRemote,
3977
+ resolveRepoAttrs,
3978
+ resolveSessionRepoAttrs,
3979
+ stampRepoAttrs,
3980
+ parseSession,
2383
3981
  scanSessions,
2384
- watchSessions,
2385
- ATTR,
2386
- INGEST_SOURCE_CLI,
2387
- DEFAULT_HARNESS,
3982
+ buildPolicyEvidenceRecord,
3983
+ collectPolicyEvidence,
3984
+ serializePolicyEvidence,
3985
+ writePolicyEvidenceFile,
3986
+ runCommand,
3987
+ commandAnalyzer,
3988
+ haloAnalyzer,
3989
+ runExternalAnalyzers,
3990
+ commandRedactor,
2388
3991
  CODING_REDACTION_RULES,
2389
3992
  TRACES_REDACTION_RULES,
2390
3993
  redactSpans,
2391
3994
  applyRedactor,
3995
+ exportTraceEvidenceRows,
3996
+ exportTraceEvidenceText,
3997
+ exportTraceEvidenceFile,
3998
+ writeTraceEvidenceExportFile,
3999
+ inspectSessionIndex,
4000
+ renderInspectionReport,
4001
+ serializeInspectionReport,
4002
+ readSessionIndexFile,
4003
+ writeInspectionReportFile,
4004
+ watchSessions,
4005
+ REACTIONS,
4006
+ analyzeReactions,
4007
+ buildSessionIndex,
4008
+ buildSessionIndexFromRows,
4009
+ collectSessionIndex,
4010
+ serializeSessionIndex,
4011
+ writeSessionIndexFile,
2392
4012
  uploadKey,
2393
4013
  sessionHash,
2394
4014
  loadState,