@tangle-network/traces 0.8.5 → 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.
@@ -137,7 +137,7 @@ function toOpenInferenceSpan(s) {
137
137
  const resourceAttrs = {};
138
138
  if (a["service.name"] != null) resourceAttrs["service.name"] = a["service.name"];
139
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"]) {
140
+ for (const k of ["tangle.subject.key", "git.repository", "git.branch", "git.commit", "tangle.cwd", "traces.repo_resolution_source"]) {
141
141
  if (a[k] != null) resourceAttrs[k] = a[k];
142
142
  }
143
143
  return {
@@ -160,11 +160,11 @@ function serializeSpans(spans) {
160
160
  `;
161
161
  }
162
162
  async function writeOtlpFile(spans, outPath) {
163
- const { mkdtemp: mkdtemp3, writeFile: writeFile4 } = await import("fs/promises");
164
- const { tmpdir: tmpdir3 } = await import("os");
165
- const { join: join16 } = await import("path");
166
- const path = outPath ?? join16(await mkdtemp3(join16(tmpdir3(), "traces-")), "spans.otlp.jsonl");
167
- await writeFile4(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");
168
168
  return path;
169
169
  }
170
170
 
@@ -218,6 +218,8 @@ var ATTR = {
218
218
  GIT_BRANCH_NAME: "git.branch",
219
219
  /** HEAD short sha at conversion time. */
220
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",
221
223
  GIT_BRANCH: "tangle.git_branch",
222
224
  HOST: "tangle.host",
223
225
  /** Basename of the session file. (Renamed from the ambiguous `tangle.source`,
@@ -860,14 +862,18 @@ var CodexAdapter = class {
860
862
  continue;
861
863
  }
862
864
  if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue;
863
- const head = (await readFile4(path, "utf8").catch(() => "")).split("\n", 1)[0] ?? "";
865
+ const head = (await readFile4(path, "utf8").catch(() => "")).split("\n", 40);
864
866
  let cwd = null;
865
867
  let id = basename3(path).replace(/^rollout-[\dT-]+-/, "").replace(/\.jsonl$/, "");
866
- try {
867
- const meta = JSON.parse(head);
868
- if (meta.payload?.cwd) cwd = meta.payload.cwd;
869
- if (meta.payload?.id) id = meta.payload.id;
870
- } 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;
871
877
  }
872
878
  if (opts.cwd && (!cwd || !cwd.startsWith(opts.cwd))) continue;
873
879
  refs.push({ harness: this.harness, sessionId: id, path, cwd, mtimeMs: st.mtimeMs });
@@ -1441,6 +1447,20 @@ var GeminiFamilyAdapter = class {
1441
1447
  chatsRoot() {
1442
1448
  return join8(homedir7(), this.homeDirName, "tmp");
1443
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;
1463
+ }
1444
1464
  async locate(opts = {}) {
1445
1465
  const root = this.chatsRoot();
1446
1466
  let projectDirs;
@@ -1449,8 +1469,10 @@ var GeminiFamilyAdapter = class {
1449
1469
  } catch {
1450
1470
  return [];
1451
1471
  }
1472
+ const projectCwds = await this.loadProjectCwds();
1452
1473
  const refs = [];
1453
1474
  for (const pd of projectDirs) {
1475
+ const cwd = projectCwds.get(pd) ?? null;
1454
1476
  const chatsDir = join8(root, pd, "chats");
1455
1477
  let files;
1456
1478
  try {
@@ -1468,8 +1490,8 @@ var GeminiFamilyAdapter = class {
1468
1490
  continue;
1469
1491
  }
1470
1492
  if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue;
1471
- if (opts.cwd) continue;
1472
- 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 });
1473
1495
  }
1474
1496
  }
1475
1497
  return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -2089,6 +2111,8 @@ function selectAdapters(opts) {
2089
2111
  }
2090
2112
 
2091
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";
2092
2116
  function normalizeRemote(url) {
2093
2117
  const raw = url.trim();
2094
2118
  if (!raw) return null;
@@ -2112,7 +2136,6 @@ function normalizeRemote(url) {
2112
2136
  return null;
2113
2137
  }
2114
2138
  async function readGit(cwd) {
2115
- const { stat: stat11 } = await import("fs/promises");
2116
2139
  await stat11(`${cwd}/.git`);
2117
2140
  const { execFile } = await import("child_process");
2118
2141
  const { promisify } = await import("util");
@@ -2141,17 +2164,154 @@ async function readGit(cwd) {
2141
2164
  commit
2142
2165
  };
2143
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
+ }
2144
2302
  async function resolveRepoAttrs(cwd) {
2145
2303
  if (!cwd) return {};
2146
2304
  const attrs = { [ATTR.CWD]: cwd };
2147
- const basename9 = (() => {
2305
+ const basename10 = (() => {
2148
2306
  const trimmed = cwd.replace(/\/+$/g, "");
2149
2307
  const idx = trimmed.lastIndexOf("/");
2150
2308
  const name = idx === -1 ? trimmed : trimmed.slice(idx + 1);
2151
2309
  return name || trimmed;
2152
2310
  })();
2153
2311
  try {
2154
- const { remote, branch, commit } = await readGit(cwd);
2312
+ const gitRoot = await findGitRoot(cwd);
2313
+ if (!gitRoot) throw new Error("no git root");
2314
+ const { remote, branch, commit } = await readGit(gitRoot);
2155
2315
  const normalized = remote ? normalizeRemote(remote) : null;
2156
2316
  if (normalized) {
2157
2317
  attrs[ATTR.SUBJECT_KEY] = normalized;
@@ -2164,9 +2324,46 @@ async function resolveRepoAttrs(cwd) {
2164
2324
  if (commit) attrs[ATTR.GIT_COMMIT] = commit;
2165
2325
  } catch {
2166
2326
  }
2167
- attrs[ATTR.SUBJECT_KEY] = basename9;
2327
+ attrs[ATTR.SUBJECT_KEY] = basename10;
2168
2328
  return attrs;
2169
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
+ }
2170
2367
  function stampRepoAttrs(spans, attrs) {
2171
2368
  const keys = Object.keys(attrs);
2172
2369
  if (keys.length === 0) return spans;
@@ -2181,8 +2378,9 @@ function stampRepoAttrs(spans, attrs) {
2181
2378
  // src/session-source.ts
2182
2379
  async function parseSession(adapter, ref) {
2183
2380
  const spans = await adapter.parse(ref);
2184
- const repoAttrs = await resolveRepoAttrs(ref.cwd);
2185
- stampRepoAttrs(spans, repoAttrs);
2381
+ const repo = await resolveSessionRepoAttrs(ref.cwd, spans);
2382
+ if (repo.cwd) ref.cwd = repo.cwd;
2383
+ stampRepoAttrs(spans, repo.attrs);
2186
2384
  return spans;
2187
2385
  }
2188
2386
  async function* scanSessions(opts) {
@@ -2214,7 +2412,7 @@ async function* scanSessions(opts) {
2214
2412
  // src/evidence.ts
2215
2413
  import { mkdtemp, writeFile } from "fs/promises";
2216
2414
  import { tmpdir } from "os";
2217
- import { join as join12 } from "path";
2415
+ import { join as join13 } from "path";
2218
2416
  function stringAttr(span2, key) {
2219
2417
  const value = span2.attributes[key];
2220
2418
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -2234,7 +2432,8 @@ function repoFromSpans(spans) {
2234
2432
  attrs.branch ??= stringAttr(span2, ATTR.GIT_BRANCH_NAME);
2235
2433
  attrs.commit ??= stringAttr(span2, ATTR.GIT_COMMIT);
2236
2434
  attrs.cwd ??= stringAttr(span2, ATTR.CWD);
2237
- if (attrs.subjectKey && attrs.repository && attrs.branch && attrs.commit && attrs.cwd) break;
2435
+ attrs.resolutionSource ??= stringAttr(span2, ATTR.REPO_RESOLUTION_SOURCE);
2436
+ if (attrs.subjectKey && attrs.repository && attrs.branch && attrs.commit && attrs.cwd && attrs.resolutionSource) break;
2238
2437
  }
2239
2438
  return attrs;
2240
2439
  }
@@ -2265,6 +2464,7 @@ async function buildPolicyEvidenceRecord(ref, spans, opts = {}) {
2265
2464
  const loopLimit = opts.maxLoopExamples ?? 25;
2266
2465
  const loopFindings = pipelines.stuckLoops.findings;
2267
2466
  const { firstSpanAt, lastSpanAt } = timeBounds(spans);
2467
+ const repo = repoFromSpans(spans);
2268
2468
  return {
2269
2469
  schemaVersion: 1,
2270
2470
  kind: "traces.policy_evidence.session",
@@ -2273,10 +2473,10 @@ async function buildPolicyEvidenceRecord(ref, spans, opts = {}) {
2273
2473
  harness: ref.harness,
2274
2474
  sessionId: ref.sessionId,
2275
2475
  path: ref.path,
2276
- cwd: ref.cwd,
2476
+ cwd: repo.cwd ?? ref.cwd,
2277
2477
  mtimeMs: ref.mtimeMs
2278
2478
  },
2279
- repo: repoFromSpans(spans),
2479
+ repo,
2280
2480
  metrics: {
2281
2481
  spanCount: spans.length,
2282
2482
  llmTurnCount: llmSpans.length,
@@ -2321,7 +2521,7 @@ function serializePolicyEvidence(records) {
2321
2521
  `;
2322
2522
  }
2323
2523
  async function writePolicyEvidenceFile(records, outPath) {
2324
- const path = outPath ?? join12(await mkdtemp(join12(tmpdir(), "traces-evidence-")), "policy-evidence.jsonl");
2524
+ const path = outPath ?? join13(await mkdtemp(join13(tmpdir(), "traces-evidence-")), "policy-evidence.jsonl");
2325
2525
  await writeFile(path, serializePolicyEvidence(records), "utf8");
2326
2526
  return path;
2327
2527
  }
@@ -2502,7 +2702,7 @@ async function applyRedactor(spans, redactor) {
2502
2702
  import { createHash } from "crypto";
2503
2703
  import { mkdtemp as mkdtemp2, readFile as readFile12, writeFile as writeFile2 } from "fs/promises";
2504
2704
  import { tmpdir as tmpdir2 } from "os";
2505
- import { join as join13 } from "path";
2705
+ import { join as join14 } from "path";
2506
2706
  function isObject(value) {
2507
2707
  return value != null && typeof value === "object" && !Array.isArray(value);
2508
2708
  }
@@ -2654,7 +2854,8 @@ function policyEvidenceToSpans(records) {
2654
2854
  [ATTR.GIT_REPOSITORY]: record.repo.repository,
2655
2855
  [ATTR.GIT_BRANCH_NAME]: record.repo.branch,
2656
2856
  [ATTR.GIT_COMMIT]: record.repo.commit,
2657
- [ATTR.CWD]: record.repo.cwd ?? record.session.cwd
2857
+ [ATTR.CWD]: record.repo.cwd ?? record.session.cwd,
2858
+ [ATTR.REPO_RESOLUTION_SOURCE]: record.repo.resolutionSource
2658
2859
  };
2659
2860
  return span({
2660
2861
  traceId,
@@ -2873,11 +3074,308 @@ async function exportTraceEvidenceFile(inputPath, opts = {}) {
2873
3074
  }
2874
3075
  async function writeTraceEvidenceExportFile(inputPath, outPath, opts = {}) {
2875
3076
  const result = await exportTraceEvidenceFile(inputPath, opts);
2876
- const path = outPath ?? join13(await mkdtemp2(join13(tmpdir2(), "traces-export-")), "spans.openinference.jsonl");
3077
+ const path = outPath ?? join14(await mkdtemp2(join14(tmpdir2(), "traces-export-")), "spans.openinference.jsonl");
2877
3078
  await writeFile2(path, serializeSpans(result.spans), "utf8");
2878
3079
  return { ...result, path };
2879
3080
  }
2880
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
+
2881
3379
  // src/observer.ts
2882
3380
  var sleep = (ms, signal) => new Promise((resolve) => {
2883
3381
  const onAbort = () => {
@@ -3042,14 +3540,236 @@ function analyzeReactions(spans) {
3042
3540
  };
3043
3541
  }
3044
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
+
3045
3765
  // src/upload-state.ts
3046
3766
  import { createHash as createHash2 } from "crypto";
3047
- import { mkdir, readFile as readFile13, writeFile as writeFile3 } from "fs/promises";
3767
+ import { mkdir, readFile as readFile15, writeFile as writeFile5 } from "fs/promises";
3048
3768
  import { homedir as homedir11 } from "os";
3049
- import { dirname, join as join14 } from "path";
3769
+ import { dirname as dirname3, join as join17 } from "path";
3050
3770
  function stateFile() {
3051
- const base = process.env.XDG_STATE_HOME ?? join14(homedir11(), ".local", "state");
3052
- return join14(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");
3053
3773
  }
3054
3774
  function uploadKey(harness, sessionId) {
3055
3775
  return `${harness}:${sessionId}`;
@@ -3064,15 +3784,15 @@ function sessionHash(spans) {
3064
3784
  }
3065
3785
  async function loadState() {
3066
3786
  try {
3067
- return JSON.parse(await readFile13(stateFile(), "utf8"));
3787
+ return JSON.parse(await readFile15(stateFile(), "utf8"));
3068
3788
  } catch {
3069
3789
  return {};
3070
3790
  }
3071
3791
  }
3072
3792
  async function saveState(state) {
3073
3793
  const f = stateFile();
3074
- await mkdir(dirname(f), { recursive: true });
3075
- await writeFile3(f, `${JSON.stringify(state, null, 2)}
3794
+ await mkdir(dirname3(f), { recursive: true });
3795
+ await writeFile5(f, `${JSON.stringify(state, null, 2)}
3076
3796
  `, "utf8");
3077
3797
  }
3078
3798
  function alreadyUploaded(state, harness, sessionId, hash) {
@@ -3080,10 +3800,10 @@ function alreadyUploaded(state, harness, sessionId, hash) {
3080
3800
  }
3081
3801
 
3082
3802
  // src/upload.ts
3083
- import { readFile as readFile14 } from "fs/promises";
3803
+ import { readFile as readFile16 } from "fs/promises";
3084
3804
  import { createRequire } from "module";
3085
3805
  import { hostname } from "os";
3086
- import { basename as basename8, join as join15 } from "path";
3806
+ import { basename as basename9, join as join18 } from "path";
3087
3807
  import { hostedClientFromEnv } from "@tangle-network/agent-eval/hosted";
3088
3808
  import { REDACTION_VERSION } from "@tangle-network/agent-eval/traces";
3089
3809
  var require2 = createRequire(import.meta.url);
@@ -3107,7 +3827,7 @@ async function planUpload(opts = {}) {
3107
3827
  async function gitBranch(cwd) {
3108
3828
  if (!cwd) return void 0;
3109
3829
  try {
3110
- const head = await readFile14(join15(cwd, ".git", "HEAD"), "utf8");
3830
+ const head = await readFile16(join18(cwd, ".git", "HEAD"), "utf8");
3111
3831
  const m = head.match(/^ref:\s*refs\/heads\/(.+)$/m);
3112
3832
  return m?.[1]?.trim();
3113
3833
  } catch {
@@ -3117,7 +3837,7 @@ async function gitBranch(cwd) {
3117
3837
  async function sessionMeta(item, uploadedAt) {
3118
3838
  const meta = {
3119
3839
  [ATTR.HARNESS]: item.ref.harness,
3120
- [ATTR.SESSION_FILE]: basename8(item.ref.path),
3840
+ [ATTR.SESSION_FILE]: basename9(item.ref.path),
3121
3841
  [ATTR.HOST]: hostname(),
3122
3842
  [ATTR.UPLOADED_AT]: uploadedAt,
3123
3843
  [ATTR.UPLOADER]: `tangle-traces@${TRACES_VERSION}`,
@@ -3255,6 +3975,7 @@ export {
3255
3975
  selectAdapters,
3256
3976
  normalizeRemote,
3257
3977
  resolveRepoAttrs,
3978
+ resolveSessionRepoAttrs,
3258
3979
  stampRepoAttrs,
3259
3980
  parseSession,
3260
3981
  scanSessions,
@@ -3275,9 +3996,19 @@ export {
3275
3996
  exportTraceEvidenceText,
3276
3997
  exportTraceEvidenceFile,
3277
3998
  writeTraceEvidenceExportFile,
3999
+ inspectSessionIndex,
4000
+ renderInspectionReport,
4001
+ serializeInspectionReport,
4002
+ readSessionIndexFile,
4003
+ writeInspectionReportFile,
3278
4004
  watchSessions,
3279
4005
  REACTIONS,
3280
4006
  analyzeReactions,
4007
+ buildSessionIndex,
4008
+ buildSessionIndexFromRows,
4009
+ collectSessionIndex,
4010
+ serializeSessionIndex,
4011
+ writeSessionIndexFile,
3281
4012
  uploadKey,
3282
4013
  sessionHash,
3283
4014
  loadState,