@mmerterden/multi-agent-pipeline 20.23.2 → 20.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +8 -6
  3. package/README.tr.md +7 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -6
  7. package/docs/features.md +9 -4
  8. package/install/catalog-history.json +1 -1
  9. package/install/templates/claude-hooks.json +14 -1
  10. package/install/templates/codex-hooks.json +18 -0
  11. package/manifest.json +126 -98
  12. package/package.json +1 -1
  13. package/pipeline/agents/task-clarifier.md +1 -1
  14. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
  15. package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
  16. package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
  17. package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
  18. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  19. package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
  20. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  21. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  22. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
  23. package/pipeline/contract/fixtures/runs-failed.json +1 -1
  24. package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
  25. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
  26. package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
  27. package/pipeline/contract/fixtures/runs-running.json +1 -1
  28. package/pipeline/lib/analysis-jira-write.sh +70 -0
  29. package/pipeline/lib/credential-inventory.sh +5 -5
  30. package/pipeline/lib/decisions.mjs +130 -0
  31. package/pipeline/lib/fetch-crashlytics.sh +6 -8
  32. package/pipeline/lib/model-rung.sh +1 -1
  33. package/pipeline/lib/plan-todos.sh +177 -37
  34. package/pipeline/lib/post-pr-review.sh +4 -6
  35. package/pipeline/lib/route-state.sh +1 -1
  36. package/pipeline/lib/shadow-git.sh +4 -5
  37. package/pipeline/lib/task-graph.mjs +446 -0
  38. package/pipeline/lib/tdd-evidence.mjs +579 -0
  39. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  40. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  41. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -11
  42. package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
  43. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  44. package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
  45. package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
  46. package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
  47. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  48. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  49. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  50. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  51. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  52. package/pipeline/multi-agent-refs/help/en.md +2 -0
  53. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  54. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  55. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  56. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
  57. package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
  58. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  59. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  60. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  61. package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
  62. package/pipeline/rules/response-shape.md +18 -0
  63. package/pipeline/schemas/agent-state.schema.json +36 -1
  64. package/pipeline/schemas/phases.json +4 -0
  65. package/pipeline/schemas/plan-todos.schema.json +104 -3
  66. package/pipeline/schemas/planning-output.schema.json +46 -0
  67. package/pipeline/schemas/prefs.schema.json +122 -3
  68. package/pipeline/scripts/_code-graph.mjs +2 -2
  69. package/pipeline/scripts/_jsonl.mjs +151 -0
  70. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  71. package/pipeline/scripts/_redact.mjs +124 -10
  72. package/pipeline/scripts/_transcript-hosts.mjs +406 -0
  73. package/pipeline/scripts/analysis-story-tree.mjs +153 -8
  74. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  75. package/pipeline/scripts/build-stack-plugins.mjs +3 -3
  76. package/pipeline/scripts/capture-flush.sh +7 -7
  77. package/pipeline/scripts/code-graph-rules/android.json +1 -1
  78. package/pipeline/scripts/cost-analyze.mjs +32 -43
  79. package/pipeline/scripts/cost-table.json +35 -19
  80. package/pipeline/scripts/decisions-log.mjs +118 -0
  81. package/pipeline/scripts/dep-bump.mjs +1556 -0
  82. package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
  83. package/pipeline/scripts/edit-syntax-gate.sh +32 -0
  84. package/pipeline/scripts/find-polluter.mjs +281 -0
  85. package/pipeline/scripts/gate-ledger.mjs +46 -13
  86. package/pipeline/scripts/gc-abandoned.sh +2 -2
  87. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  88. package/pipeline/scripts/git-path.mjs +1 -2
  89. package/pipeline/scripts/host-hooks.mjs +265 -0
  90. package/pipeline/scripts/jira-attach.sh +1 -1
  91. package/pipeline/scripts/keychain.py +3 -3
  92. package/pipeline/scripts/mutation-probe.mjs +484 -0
  93. package/pipeline/scripts/output-quality-check.sh +1 -1
  94. package/pipeline/scripts/phase-tracker.sh +5 -6
  95. package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
  96. package/pipeline/scripts/plan-complexity.mjs +259 -0
  97. package/pipeline/scripts/pre-push-check.sh +3 -6
  98. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  99. package/pipeline/scripts/rework-escalation.mjs +264 -0
  100. package/pipeline/scripts/test-strength.mjs +5 -3
  101. package/pipeline/scripts/transcript-index.mjs +1131 -0
  102. package/pipeline/scripts/uninstall.mjs +90 -4
  103. package/pipeline/scripts/update-issue-progress.sh +4 -6
  104. package/pipeline/scripts/usage-report.mjs +2 -2
  105. package/pipeline/scripts/validate-planning.mjs +48 -6
  106. package/pipeline/skills/.skill-manifest.json +20 -12
  107. package/pipeline/skills/.skills-index.json +23 -1
  108. package/pipeline/skills/shared/README.md +5 -3
  109. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
  110. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
  111. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
  112. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
  113. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
  114. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  115. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
  116. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
  117. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
  118. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
  119. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  120. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  121. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  122. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  123. package/pipeline/skills/skills-index.md +3 -1
@@ -0,0 +1,1131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file transcript-index.mjs - what a run did, read out of its session
4
+ * transcripts, as evidence a post-mortem can cite.
5
+ *
6
+ * The run log says what each phase decided. The transcript says what the
7
+ * session actually did on the way: which tool calls it repeated, where it sat
8
+ * waiting, where the context was compacted, which subagent failed, what was
9
+ * denied. This reads a Claude Code session file (`<session>.jsonl`, plus the
10
+ * subagent files beside it), a Copilot CLI session log
11
+ * (`session-state/<session>/events.jsonl`) or a Codex rollout
12
+ * (`sessions/YYYY/MM/DD/rollout-*.jsonl`), detecting the format per file
13
+ * (_transcript-hosts.mjs), and extracts those facts deterministically, so the
14
+ * same transcript always gives the same index and every finding carries the
15
+ * `file:line` it came from. Interpreting them is left to the model that writes
16
+ * the post-mortem (/multi-agent:postmortem); nothing here guesses.
17
+ *
18
+ * Extracted:
19
+ * turns one per prompt the user typed (or slash command), with tool
20
+ * call count, token usage and duration
21
+ * toolCalls name, a redacted input excerpt, duration (reported by the
22
+ * tool when it says, else the timestamp difference), error flag
23
+ * loops identical calls (same tool, same input) made at least
24
+ * `loopThreshold` times in one file, with the longest streak
25
+ * stalls gaps of at least `stallMinutes` between two records while the
26
+ * session was working, with the tool that was running
27
+ * compactions boundaries with trigger and token figures, and the summary
28
+ * turn that follows each
29
+ * subagents each dispatch with its type, result status, duration, tokens,
30
+ * and the background notification when one arrives
31
+ * errors tool errors, denials, hook blocks, API errors, interrupts
32
+ * invocations slash commands and skills
33
+ * usage token totals, each assistant message counted once
34
+ *
35
+ * PRIVACY. A transcript holds whatever the session read. Every string taken
36
+ * from one passes through the redactor before it is placed in the index:
37
+ * credentials by the outbound gate's certain rules (lib/redact.mjs) and the
38
+ * shapes those have no prefix for (_redact.mjs redactCredentials), then links, addresses, hosts, machine paths and home directories (_redact.mjs
39
+ * scrubCounted), then the transcript directory's own project slug, which
40
+ * spells the home directory with dashes. The index reports how many items of
41
+ * each kind were replaced, and before anything is printed the whole output is
42
+ * run through the outbound gate once more: a certain finding left in it stops
43
+ * the script with nothing written. Nothing is sent anywhere.
44
+ *
45
+ * Usage:
46
+ * transcript-index.mjs <session.jsonl>... index these files
47
+ * transcript-index.mjs --run <task-id|#N> find the run's session files, index them
48
+ * transcript-index.mjs --run <id> --locate only report where the run and its files are
49
+ * options: --json --no-subagents --max-mb N --max-events N
50
+ * --loop-threshold N --stall-minutes N --excerpt N
51
+ *
52
+ * TRANSCRIPTS_ROOT overrides ~/.claude/projects, COPILOT_TRANSCRIPTS_ROOT
53
+ * ~/.copilot/session-state (else $COPILOT_HOME/session-state),
54
+ * CODEX_TRANSCRIPTS_ROOT ~/.codex/sessions (else $CODEX_HOME/sessions), and
55
+ * LOGS_ROOT the run log root.
56
+ *
57
+ * Exit codes: 0 indexed | 2 usage | 3 nothing to read | 4 redaction guard
58
+ *
59
+ * @module pipeline/scripts/transcript-index
60
+ */
61
+
62
+ import { createHash } from "node:crypto";
63
+ import { existsSync, readdirSync, statSync } from "node:fs";
64
+ import { homedir } from "node:os";
65
+ import { basename, dirname, join } from "node:path";
66
+ import { readJsonl } from "./_jsonl.mjs";
67
+ import { createAdapter, detectFormat, sessionCwd } from "./_transcript-hosts.mjs";
68
+ import { redactCredentials, scrubCounted } from "./_redact.mjs";
69
+ import { listRuns, logsRoot, taskIdVariants } from "./_run-paths.mjs";
70
+ import { redact } from "../lib/redact.mjs";
71
+ import { scan } from "../lib/outbound-gate.mjs";
72
+ import { readRegularJson } from "../lib/regular-file.mjs";
73
+ import { runMain } from "../lib/fatal.mjs";
74
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
75
+
76
+ export const DEFAULTS = {
77
+ loopThreshold: 3,
78
+ stallMinutes: 5,
79
+ excerpt: 160,
80
+ maxEvents: 400,
81
+ maxBytes: 256 * 1024 * 1024,
82
+ subagents: true,
83
+ };
84
+
85
+ const SUBAGENT_TOOLS = new Set(["Agent", "Task", "task", "spawn_agent"]);
86
+ const SKILL_TOOL = /^skill$/i;
87
+ const DENIAL =
88
+ /doesn't want to proceed|User rejected tool use|^Permission to use \S+ (?:has been|was) denied|permission denied by the user/i;
89
+ const HOOK_BLOCK = /\bhook (?:error|blocked)|PreToolUse:|PostToolUse:/;
90
+ const INTERRUPT = /^\[Request interrupted by user/;
91
+
92
+ export const defaultProjectsRoot = () =>
93
+ process.env.TRANSCRIPTS_ROOT || join(homedir(), ".claude", "projects");
94
+
95
+ export const defaultCopilotRoot = () =>
96
+ process.env.COPILOT_TRANSCRIPTS_ROOT ||
97
+ join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "session-state");
98
+
99
+ export const defaultCodexRoot = () =>
100
+ process.env.CODEX_TRANSCRIPTS_ROOT ||
101
+ join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
102
+
103
+ export { detectFormat };
104
+
105
+ const COPILOT_LOG = "events.jsonl";
106
+
107
+ const CODEX_NAME = /^rollout-[\dT:-]+-([0-9a-f]{8})[0-9a-f-]*\.jsonl$/i;
108
+
109
+ /**
110
+ * The name a file is cited by. Every Copilot CLI log is `events.jsonl`, so it
111
+ * is prefixed with the first eight characters of its session directory; a
112
+ * Codex rollout name carries its start time and full session id, so it is cut
113
+ * to `rollout-<first eight of the id>.jsonl`. Claude Code names stay as they are.
114
+ */
115
+ export function displayName(path) {
116
+ const name = basename(path);
117
+ if (name === COPILOT_LOG) return `${basename(dirname(path)).slice(0, 8)}/${name}`;
118
+ const codex = name.match(CODEX_NAME);
119
+ return codex ? `rollout-${codex[1]}.jsonl` : name;
120
+ }
121
+
122
+ /** A session id as Claude Code writes it (a UUID), or any token safe as a file name. */
123
+ const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
124
+
125
+ /** The directory name Claude Code gives a working directory under ~/.claude/projects. */
126
+ export const projectSlug = (dir) => String(dir).replace(/[^A-Za-z0-9]/g, "-");
127
+
128
+ // --- redaction ---------------------------------------------------------------
129
+
130
+ const MARKER = /\[redacted:([a-z0-9-]+)\]/g;
131
+ const countMarkers = (s) => {
132
+ const out = {};
133
+ for (const m of s.matchAll(MARKER)) out[m[1]] = (out[m[1]] || 0) + 1;
134
+ return out;
135
+ };
136
+ const TOKEN_CHAR = /[A-Za-z0-9_\-./+=:@~%]/;
137
+
138
+ /**
139
+ * `text` cut to `limit` characters, backed off to the start of a token that
140
+ * the cut would split, so no half-credential survives on the boundary.
141
+ */
142
+ function cut(text, limit) {
143
+ if (text.length <= limit) return text;
144
+ let end = limit;
145
+ while (end > 0 && TOKEN_CHAR.test(text[end]) && TOKEN_CHAR.test(text[end - 1])) end--;
146
+ return `${text.slice(0, end).trimEnd()} ...`;
147
+ }
148
+
149
+ /**
150
+ * One redactor per index, so its counts are the index's counts.
151
+ *
152
+ * @param {{home?: string}} [opts] the home directory whose dashed spelling (a
153
+ * transcript directory name) is replaced; defaults to this machine's
154
+ */
155
+ export function createRedactor({ home = homedir() } = {}) {
156
+ const byKind = {};
157
+ const bump = (kind, n = 1) => (byKind[kind] = (byKind[kind] || 0) + n);
158
+ const homeSlug = home && home.length > 1 ? projectSlug(home) : null;
159
+ const GENERIC_SLUG = /(?<![\w~])-(?:Users|home)-[A-Za-z0-9_]+/g;
160
+
161
+ function clean(value) {
162
+ if (value === null || value === undefined) return "";
163
+ const raw = String(value);
164
+ if (!raw) return "";
165
+ const before = countMarkers(raw);
166
+ let out = redactCredentials(redact(raw));
167
+ for (const [k, n] of Object.entries(countMarkers(out))) {
168
+ const added = n - (before[k] || 0);
169
+ if (added > 0) bump(k, added);
170
+ }
171
+ const s = scrubCounted(out);
172
+ out = s.text;
173
+ for (const [k, n] of Object.entries(s.counts)) bump(k, n);
174
+ if (homeSlug && out.includes(homeSlug)) {
175
+ const parts = out.split(homeSlug);
176
+ bump("home-path", parts.length - 1);
177
+ out = parts.join("~");
178
+ }
179
+ out = out.replace(GENERIC_SLUG, () => {
180
+ bump("home-path");
181
+ return "~";
182
+ });
183
+ return out;
184
+ }
185
+
186
+ /** A short, single-line, redacted excerpt. */
187
+ function excerpt(value, limit = DEFAULTS.excerpt) {
188
+ const flat = String(value ?? "")
189
+ .replace(/\s+/g, " ")
190
+ .trim();
191
+ return cut(clean(cut(flat, Math.max(limit * 4, 1024))), limit);
192
+ }
193
+
194
+ const total = () => Object.values(byKind).reduce((a, n) => a + n, 0);
195
+ return { clean, excerpt, byKind, total };
196
+ }
197
+
198
+ /**
199
+ * Throws when `text` still carries something the outbound gate is certain is a
200
+ * credential. Only the rule names are reported, never the match.
201
+ */
202
+ export function assertClean(text) {
203
+ const hits = scan(text).filter((f) => f.certain);
204
+ if (hits.length) {
205
+ const names = [...new Set(hits.map((h) => h.rule))].join(", ");
206
+ const err = new Error(`redaction incomplete: ${names}`);
207
+ err.code = "REDACTION";
208
+ throw err;
209
+ }
210
+ }
211
+
212
+ // --- reading -----------------------------------------------------------------
213
+
214
+ const textOf = (content) => {
215
+ if (typeof content === "string") return content;
216
+ if (!Array.isArray(content)) return "";
217
+ return content
218
+ .map((b) => (typeof b === "string" ? b : b?.type === "text" ? b.text || "" : ""))
219
+ .filter(Boolean)
220
+ .join("\n");
221
+ };
222
+
223
+ const stable = (v) => {
224
+ if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
225
+ if (v && typeof v === "object")
226
+ return `{${Object.keys(v)
227
+ .sort()
228
+ .map((k) => `${JSON.stringify(k)}:${stable(v[k])}`)
229
+ .join(",")}}`;
230
+ return JSON.stringify(v ?? null);
231
+ };
232
+
233
+ const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 12);
234
+
235
+ /** The part of a tool input worth showing: the command, the path, the pattern. */
236
+ function inputGist(name, input) {
237
+ if (!input || typeof input !== "object") return "";
238
+ for (const k of [
239
+ "command",
240
+ "cmd",
241
+ "file_path",
242
+ "path",
243
+ "pattern",
244
+ "url",
245
+ "query",
246
+ "skill",
247
+ "description",
248
+ "input",
249
+ ]) {
250
+ if (typeof input[k] === "string" && input[k]) return input[k];
251
+ }
252
+ return stable(input);
253
+ }
254
+
255
+ const tsOf = (rec) => {
256
+ const t = Date.parse(rec?.timestamp || "");
257
+ return Number.isFinite(t) ? t : null;
258
+ };
259
+
260
+ const numberOr = (v, fallback = 0) => (Number.isFinite(Number(v)) ? Number(v) : fallback);
261
+
262
+ /** The .jsonl names in `dir`, sorted; none when it cannot be read. */
263
+ function listJsonl(dir) {
264
+ try {
265
+ return readdirSync(dir)
266
+ .filter((f) => f.endsWith(".jsonl"))
267
+ .sort();
268
+ } catch {
269
+ return [];
270
+ }
271
+ }
272
+
273
+ /** Session files plus, unless disabled, the subagent files Claude Code keeps beside them. */
274
+ function expandFiles(paths, withSubagents) {
275
+ const out = [];
276
+ for (const p of paths) {
277
+ out.push(p);
278
+ if (!withSubagents) continue;
279
+ const dir = join(dirname(p), basename(p, ".jsonl"), "subagents");
280
+ for (const f of listJsonl(dir)) out.push(join(dir, f));
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /**
286
+ * Index one or more transcript files.
287
+ *
288
+ * @param {string[]} paths
289
+ * @param {Partial<typeof DEFAULTS> & {home?: string}} [options]
290
+ */
291
+ export async function indexTranscripts(paths, options = {}) {
292
+ const o = { ...DEFAULTS, ...options };
293
+ const R = createRedactor({ home: o.home });
294
+ const ex = (v) => R.excerpt(v, o.excerpt);
295
+ const stallMs = o.stallMinutes * 60_000;
296
+
297
+ const files = [];
298
+ const turns = [];
299
+ const toolCalls = [];
300
+ const compactions = [];
301
+ const subagents = [];
302
+ const errors = [];
303
+ const invocations = [];
304
+ const stalls = [];
305
+ const loops = [];
306
+ const timeline = [];
307
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0 };
308
+ const seenMessages = new Set();
309
+ const subagentByToolId = new Map();
310
+ const subagentByAgentId = new Map();
311
+ let idleGaps = 0;
312
+
313
+ for (const path of expandFiles(paths, o.subagents)) {
314
+ const name = displayName(path);
315
+ const at = (line) => `${name}:${line}`;
316
+ const stats = {};
317
+ const fileRow = {
318
+ name,
319
+ path: R.clean(path),
320
+ format: null,
321
+ sidechain: false,
322
+ usagePartial: false,
323
+ malformedAt: [],
324
+ };
325
+ let adapt = null;
326
+ const pending = new Map();
327
+ const groups = new Map();
328
+ let lastKey = null;
329
+ let streak = 0;
330
+ let turn = null;
331
+ let prev = null;
332
+
333
+ const openTurn = (line, ts, prompt) => {
334
+ turn = {
335
+ at: at(line),
336
+ prompt,
337
+ firstTs: ts,
338
+ lastTs: ts,
339
+ toolCalls: 0,
340
+ assistantMessages: 0,
341
+ errors: 0,
342
+ durationMs: null,
343
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, peakContext: 0 },
344
+ };
345
+ turns.push(turn);
346
+ };
347
+ const push = (event) => timeline.push(event);
348
+
349
+ for await (const row of readJsonl(path, { stats, maxBytes: o.maxBytes })) {
350
+ if (!row.record) {
351
+ if (row.reason !== "truncated") fileRow.malformedAt.push(at(row.line));
352
+ continue;
353
+ }
354
+ if (!adapt) {
355
+ fileRow.format = detectFormat(row.record);
356
+ adapt = createAdapter(fileRow.format);
357
+ }
358
+ const line = row.line;
359
+ for (const rec of adapt(row.record)) {
360
+ const ts = tsOf(rec);
361
+ if (rec.isSidechain && fileRow.format === "claude") fileRow.sidechain = true;
362
+
363
+ if (ts !== null && prev && prev.ts !== null) {
364
+ const gap = ts - prev.ts;
365
+ if (gap >= stallMs) {
366
+ const startsTurn = rec.type === "user" && isPrompt(rec);
367
+ if (startsTurn && pending.size === 0) idleGaps++;
368
+ else {
369
+ const running = [...pending.values()].at(-1);
370
+ stalls.push({
371
+ from: at(prev.line),
372
+ to: at(line),
373
+ gapMs: gap,
374
+ pendingTool: running ? running.name : null,
375
+ });
376
+ }
377
+ }
378
+ }
379
+ if (ts !== null) prev = { ts, line };
380
+ if (turn && ts !== null) turn.lastTs = ts;
381
+
382
+ if (rec.type === "x-usage") {
383
+ const add = rec.usage || {};
384
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"]) {
385
+ const n = numberOr(add[key]);
386
+ usage[key] += n;
387
+ if (turn && rec.inTurn) turn.usage[key] += n;
388
+ }
389
+ if (rec.countsMessage) usage.messages++;
390
+ if (turn && rec.inTurn) {
391
+ const ctx = numberOr(add.input) + numberOr(add.cacheRead) + numberOr(add.cacheWrite);
392
+ if (ctx > turn.usage.peakContext) turn.usage.peakContext = ctx;
393
+ }
394
+ continue;
395
+ }
396
+
397
+ if (rec.type === "system") {
398
+ if (rec.subtype === "compact_boundary") {
399
+ const m = rec.compactMetadata || {};
400
+ compactions.push({
401
+ at: at(line),
402
+ kind: "boundary",
403
+ trigger: R.clean(m.trigger || "unknown"),
404
+ preTokens: numberOr(m.preTokens, null),
405
+ postTokens: numberOr(m.postTokens, null),
406
+ });
407
+ push({ at: at(line), role: "system", kind: "compaction" });
408
+ } else if (rec.subtype === "turn_duration") {
409
+ if (turn) turn.durationMs = numberOr(rec.durationMs, null);
410
+ } else if (Array.isArray(rec.hookErrors) && rec.hookErrors.length) {
411
+ errors.push({
412
+ at: at(line),
413
+ kind: "hook",
414
+ tool: null,
415
+ text: ex(textOf(rec.hookErrors)),
416
+ });
417
+ if (turn) turn.errors++;
418
+ } else if (rec.level === "error") {
419
+ errors.push({ at: at(line), kind: "system", tool: null, text: ex(rec.content) });
420
+ if (turn) turn.errors++;
421
+ }
422
+ continue;
423
+ }
424
+
425
+ if (rec.type === "user") {
426
+ const content = rec.message?.content;
427
+ if (rec.isCompactSummary) {
428
+ compactions.push({ at: at(line), kind: "summary" });
429
+ continue;
430
+ }
431
+ if (Array.isArray(content) && content.some((b) => b?.type === "tool_result")) {
432
+ let first = true;
433
+ for (const b of content) {
434
+ if (b?.type !== "tool_result") continue;
435
+ const call = pending.get(b.tool_use_id);
436
+ pending.delete(b.tool_use_id);
437
+ const result =
438
+ first && rec.toolUseResult !== undefined ? rec.toolUseResult : undefined;
439
+ first = false;
440
+ const body = textOf(b.content) || (typeof result === "string" ? result : "");
441
+ const denied =
442
+ b.denied === true ||
443
+ DENIAL.test(body) ||
444
+ (typeof result === "string" && DENIAL.test(result));
445
+ const isError = b.is_error === true || denied;
446
+ if (call) {
447
+ call.resultAt = at(line);
448
+ call.isError = isError;
449
+ const reported =
450
+ result && typeof result === "object"
451
+ ? (result.durationMs ?? result.totalDurationMs)
452
+ : undefined;
453
+ if (Number.isFinite(reported)) {
454
+ call.durationMs = reported;
455
+ call.durationSource = "reported";
456
+ } else if (call.ts !== null && ts !== null) {
457
+ call.durationMs = ts - call.ts;
458
+ call.durationSource = "timestamps";
459
+ }
460
+ if (call.group) call.group.errors += isError ? 1 : 0;
461
+ const sub = subagentByToolId.get(b.tool_use_id);
462
+ if (sub) {
463
+ sub.resultAt = at(line);
464
+ if (result && typeof result === "object") {
465
+ sub.status = R.clean(result.status || (isError ? "error" : "completed"));
466
+ if (result.agentId) subagentByAgentId.set(String(result.agentId), sub);
467
+ if (Number.isFinite(result.totalDurationMs))
468
+ sub.durationMs = result.totalDurationMs;
469
+ if (Number.isFinite(result.totalTokens)) sub.tokens = result.totalTokens;
470
+ if (Number.isFinite(result.totalToolUseCount))
471
+ sub.toolUses = result.totalToolUseCount;
472
+ } else {
473
+ sub.status = isError ? "error" : "completed";
474
+ }
475
+ if (
476
+ !sub.durationMs &&
477
+ call.durationMs !== undefined &&
478
+ sub.status !== "async_launched"
479
+ )
480
+ sub.durationMs = call.durationMs;
481
+ sub.result = ex(body);
482
+ }
483
+ }
484
+ if (isError) {
485
+ const kind = denied ? "denial" : HOOK_BLOCK.test(body) ? "hook" : "error";
486
+ errors.push({ at: at(line), kind, tool: call ? call.name : null, text: ex(body) });
487
+ if (turn) turn.errors++;
488
+ push({ at: at(line), role: "user", kind, name: call ? call.name : null });
489
+ }
490
+ }
491
+ continue;
492
+ }
493
+
494
+ const text = textOf(content);
495
+ if (rec.isSidechain) continue;
496
+ const notice = text.match(/<task-notification>([\s\S]*?)<\/task-notification>/);
497
+ if (notice) {
498
+ const id = (notice[1].match(/<task-id>([^<]+)<\/task-id>/) || [])[1];
499
+ const status = (notice[1].match(/<status>([^<]+)<\/status>/) || [])[1];
500
+ const sub = id ? subagentByAgentId.get(id.trim()) : null;
501
+ if (sub) {
502
+ sub.notifiedAt = at(line);
503
+ if (status) sub.status = R.clean(status.trim());
504
+ const st = rec.subagentStats;
505
+ if (st) {
506
+ if (Number.isFinite(st.tokens)) sub.tokens = st.tokens;
507
+ if (Number.isFinite(st.durationMs)) sub.durationMs = st.durationMs;
508
+ if (Number.isFinite(st.toolUses)) sub.toolUses = st.toolUses;
509
+ }
510
+ }
511
+ push({ at: at(line), role: "user", kind: "notification", text: ex(status || "") });
512
+ continue;
513
+ }
514
+ if (INTERRUPT.test(text)) {
515
+ errors.push({ at: at(line), kind: "interrupt", tool: null, text: ex(text) });
516
+ if (turn) turn.errors++;
517
+ push({ at: at(line), role: "user", kind: "interrupt" });
518
+ continue;
519
+ }
520
+ if (rec.isMeta) continue;
521
+ const command = text.match(/<command-name>\s*([^<\s]+)\s*<\/command-name>/);
522
+ if (command) {
523
+ const cmd = R.clean(command[1]);
524
+ invocations.push({ at: at(line), kind: "command", name: cmd });
525
+ openTurn(line, ts, cmd);
526
+ push({ at: at(line), role: "user", kind: "command", name: cmd });
527
+ continue;
528
+ }
529
+ if (!text.trim()) continue;
530
+ const prompt = ex(text);
531
+ openTurn(line, ts, prompt);
532
+ push({ at: at(line), role: "user", kind: "prompt", text: prompt });
533
+ continue;
534
+ }
535
+
536
+ if (rec.type === "assistant") {
537
+ const msg = rec.message || {};
538
+ if (!turn && !rec.isSidechain) openTurn(line, ts, null);
539
+ if (turn) turn.assistantMessages++;
540
+ const u = msg.usage;
541
+ if (u && !(msg.id && seenMessages.has(msg.id))) {
542
+ if (msg.id) seenMessages.add(msg.id);
543
+ const add = {
544
+ input: numberOr(u.input_tokens),
545
+ output: numberOr(u.output_tokens),
546
+ cacheRead: numberOr(u.cache_read_input_tokens),
547
+ cacheWrite: numberOr(u.cache_creation_input_tokens),
548
+ };
549
+ for (const k of Object.keys(add)) usage[k] += add[k];
550
+ usage.messages++;
551
+ if (turn) {
552
+ for (const k of Object.keys(add)) turn.usage[k] += add[k];
553
+ const ctx = add.input + add.cacheRead + add.cacheWrite;
554
+ if (ctx > turn.usage.peakContext) turn.usage.peakContext = ctx;
555
+ }
556
+ }
557
+ if (rec.isApiErrorMessage) {
558
+ errors.push({
559
+ at: at(line),
560
+ kind: "api-error",
561
+ tool: null,
562
+ text: ex(textOf(msg.content)),
563
+ });
564
+ if (turn) turn.errors++;
565
+ }
566
+ for (const b of Array.isArray(msg.content) ? msg.content : []) {
567
+ if (b?.type === "text" && b.text?.trim()) {
568
+ push({ at: at(line), role: "assistant", kind: "text", text: ex(b.text) });
569
+ continue;
570
+ }
571
+ if (b?.type !== "tool_use") continue;
572
+ const toolName = R.clean(b.name || "unknown");
573
+ const input = b.input || {};
574
+ const key = `${b.name}\u0000${stable(input)}`;
575
+ let group = groups.get(key);
576
+ if (!group) {
577
+ group = { name: toolName, hash: digest(key), at: [], errors: 0, consecutive: 0 };
578
+ group.input = ex(inputGist(b.name, input));
579
+ groups.set(key, group);
580
+ }
581
+ group.at.push(at(line));
582
+ streak = key === lastKey ? streak + 1 : 1;
583
+ lastKey = key;
584
+ if (streak > group.consecutive) group.consecutive = streak;
585
+ const call = {
586
+ at: at(line),
587
+ name: toolName,
588
+ input: group.input,
589
+ ts,
590
+ durationMs: undefined,
591
+ durationSource: null,
592
+ resultAt: null,
593
+ isError: false,
594
+ group,
595
+ };
596
+ toolCalls.push(call);
597
+ if (b.id) pending.set(b.id, call);
598
+ if (turn) turn.toolCalls++;
599
+ push({
600
+ at: at(line),
601
+ role: "assistant",
602
+ kind: "tool",
603
+ name: toolName,
604
+ text: call.input,
605
+ });
606
+
607
+ if (SUBAGENT_TOOLS.has(b.name)) {
608
+ const sub = {
609
+ at: at(line),
610
+ agentType: R.clean(input.subagent_type || input.agent_type || "general-purpose"),
611
+ description: ex(input.description || input.message || ""),
612
+ background: input.run_in_background === true || input.mode === "background",
613
+ model: input.model ? R.clean(input.model) : null,
614
+ status: null,
615
+ resultAt: null,
616
+ notifiedAt: null,
617
+ durationMs: null,
618
+ tokens: null,
619
+ toolUses: null,
620
+ result: null,
621
+ };
622
+ subagents.push(sub);
623
+ if (b.id) subagentByToolId.set(b.id, sub);
624
+ }
625
+ if (SKILL_TOOL.test(b.name || "") && (input.skill || input.command)) {
626
+ invocations.push({
627
+ at: at(line),
628
+ kind: "skill",
629
+ name: R.clean(input.skill || input.command),
630
+ });
631
+ }
632
+ if (b.name === "SlashCommand" && input.command) {
633
+ invocations.push({ at: at(line), kind: "command", name: R.clean(input.command) });
634
+ }
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ for (const g of groups.values()) {
641
+ if (g.at.length >= o.loopThreshold) {
642
+ loops.push({
643
+ name: g.name,
644
+ hash: g.hash,
645
+ count: g.at.length,
646
+ consecutive: g.consecutive,
647
+ errors: g.errors,
648
+ input: g.input,
649
+ at: g.at,
650
+ });
651
+ }
652
+ }
653
+ fileRow.usagePartial = Boolean(adapt?.usagePartial?.());
654
+ fileRow.lines = stats.lines;
655
+ fileRow.malformed = stats.malformed;
656
+ fileRow.oversized = stats.oversized;
657
+ fileRow.truncated = stats.truncated;
658
+ fileRow.unreadable = stats.unreadable;
659
+ fileRow.bytesRead = stats.bytes;
660
+ files.push(fileRow);
661
+ }
662
+
663
+ loops.sort((a, b) => b.count - a.count || b.consecutive - a.consecutive);
664
+ for (const t of turns) {
665
+ if (t.durationMs === null && t.firstTs !== null && t.lastTs !== null)
666
+ t.durationMs = t.lastTs - t.firstTs;
667
+ delete t.firstTs;
668
+ delete t.lastTs;
669
+ }
670
+
671
+ let elided = 0;
672
+ let kept = timeline;
673
+ if (timeline.length > o.maxEvents) {
674
+ const half = Math.floor(o.maxEvents / 2);
675
+ elided = timeline.length - 2 * half;
676
+ kept = [...timeline.slice(0, half), ...timeline.slice(-half)];
677
+ }
678
+
679
+ return {
680
+ schema: "transcript-index/1",
681
+ files,
682
+ counts: {
683
+ turns: turns.length,
684
+ toolCalls: toolCalls.length,
685
+ loops: loops.length,
686
+ stalls: stalls.length,
687
+ idleGaps,
688
+ compactions: compactions.filter((c) => c.kind === "boundary").length,
689
+ subagents: subagents.length,
690
+ errors: errors.length,
691
+ malformedLines: files.reduce((a, f) => a + f.malformed, 0),
692
+ },
693
+ usage: { ...usage, partial: files.some((f) => f.usagePartial) },
694
+ turns,
695
+ loops,
696
+ stalls,
697
+ compactions,
698
+ subagents,
699
+ errors,
700
+ invocations,
701
+ toolCalls: toolCalls.map(({ group: _group, ts: _ts, ...c }) => c),
702
+ timeline: kept,
703
+ timelineElided: elided,
704
+ redaction: { total: R.total(), byKind: { ...R.byKind } },
705
+ };
706
+ }
707
+
708
+ function isPrompt(rec) {
709
+ if (rec.isMeta || rec.isCompactSummary || rec.isSidechain) return false;
710
+ const c = rec.message?.content;
711
+ if (Array.isArray(c) && c.some((b) => b?.type === "tool_result")) return false;
712
+ const t = textOf(c);
713
+ return Boolean(t.trim()) && !t.includes("<task-notification>");
714
+ }
715
+
716
+ // --- finding a run's transcripts -----------------------------------------------
717
+
718
+ function firstTimestamp(path) {
719
+ return (async () => {
720
+ for await (const row of readJsonl(path, { maxBytes: 1024 * 1024 })) {
721
+ const t = row.record ? tsOf(row.record) : null;
722
+ if (t !== null) return t;
723
+ }
724
+ return null;
725
+ })();
726
+ }
727
+
728
+ function readState(dir) {
729
+ for (const p of [join(dir, "agent-state.json"), join(dir, "artifacts", "agent-state.json")]) {
730
+ if (!existsSync(p)) continue;
731
+ const doc = readRegularJson(p, null);
732
+ if (doc && typeof doc === "object") return { path: p, doc };
733
+ }
734
+ return null;
735
+ }
736
+
737
+ function locateRun(id, root) {
738
+ const runs = listRuns(root);
739
+ const variants = new Set(taskIdVariants(id));
740
+ for (const r of runs) {
741
+ if (!variants.has(r.taskId)) continue;
742
+ const s = readState(r.dir);
743
+ return { run: r, state: s };
744
+ }
745
+ const short = String(id).replace(/^#/, "");
746
+ if (/^\d+$/.test(short)) {
747
+ for (const r of runs) {
748
+ const s = readState(r.dir);
749
+ if (s && Number(s.doc.shortId) === Number(short)) return { run: r, state: s };
750
+ }
751
+ }
752
+ return null;
753
+ }
754
+
755
+ async function windowMatches(dirs, start, end) {
756
+ const files = [];
757
+ for (const d of dirs) {
758
+ for (const f of listJsonl(d)) {
759
+ const p = join(d, f);
760
+ let mtime;
761
+ try {
762
+ mtime = statSync(p).mtimeMs;
763
+ } catch {
764
+ continue;
765
+ }
766
+ if (mtime < start) continue;
767
+ const first = await firstTimestamp(p);
768
+ if (first === null || first > end) continue;
769
+ files.push(p);
770
+ }
771
+ }
772
+ return files;
773
+ }
774
+
775
+ /** Every Copilot CLI session log under `root` (`<session>/events.jsonl`). */
776
+ function copilotLogs(root) {
777
+ let dirs;
778
+ try {
779
+ dirs = readdirSync(root, { withFileTypes: true });
780
+ } catch {
781
+ return [];
782
+ }
783
+ return dirs
784
+ .filter((e) => e.isDirectory())
785
+ .map((e) => join(root, e.name, COPILOT_LOG))
786
+ .filter((p) => existsSync(p))
787
+ .sort();
788
+ }
789
+
790
+ const CODEX_ROLLOUT = /^rollout-.*\.jsonl$/;
791
+
792
+ /** Every Codex rollout under `root` (`YYYY/MM/DD/rollout-*.jsonl`), at most four levels down. */
793
+ function codexRollouts(root, depth = 0) {
794
+ let entries;
795
+ try {
796
+ entries = readdirSync(root, { withFileTypes: true });
797
+ } catch {
798
+ return [];
799
+ }
800
+ const out = [];
801
+ for (const e of entries) {
802
+ const p = join(root, e.name);
803
+ if (e.isDirectory() && depth < 3) out.push(...codexRollouts(p, depth + 1));
804
+ else if (e.isFile() && CODEX_ROLLOUT.test(e.name)) out.push(p);
805
+ }
806
+ return out.sort();
807
+ }
808
+
809
+ /** The first timestamp and the starting cwd of a Copilot CLI or Codex session. */
810
+ async function sessionHead(path) {
811
+ let first = null;
812
+ let cwd = null;
813
+ let seen = 0;
814
+ for await (const row of readJsonl(path, { maxBytes: 1024 * 1024 })) {
815
+ if (!row.record) continue;
816
+ if (first === null) first = tsOf(row.record);
817
+ cwd = cwd ?? sessionCwd(row.record);
818
+ if ((first !== null && cwd !== null) || ++seen >= 8) break;
819
+ }
820
+ return { first, cwd };
821
+ }
822
+
823
+ const samePath = (p) => String(p).replace(/\/+$/, "");
824
+
825
+ async function hostWindowMatches(paths, cwds, start, end) {
826
+ const files = [];
827
+ for (const p of paths) {
828
+ let mtime;
829
+ try {
830
+ mtime = statSync(p).mtimeMs;
831
+ } catch {
832
+ continue;
833
+ }
834
+ if (mtime < start) continue;
835
+ const head = await sessionHead(p);
836
+ if (head.first === null || head.first > end) continue;
837
+ if (head.cwd === null || !cwds.has(samePath(head.cwd))) continue;
838
+ files.push(p);
839
+ }
840
+ return files;
841
+ }
842
+
843
+ /**
844
+ * The session files a run ran in, across Claude Code, Copilot CLI and Codex.
845
+ *
846
+ * A run launched with a session id (continuous mode, a client that set
847
+ * MULTI_AGENT_SESSION_ID) has it recorded in agent-state.json, and the file is
848
+ * `<sessionId>.jsonl` in whichever Claude Code project directory holds it,
849
+ * `<sessionId>/events.jsonl` under Copilot CLI's session-state, or the Codex
850
+ * rollout whose name ends in `-<sessionId>.jsonl`. An attended run records
851
+ * none, so the fallback is the sessions whose working directory was the
852
+ * worktree or the main checkout and whose time span overlaps the run's:
853
+ * started before the run finished, last written after it started. Claude Code
854
+ * keys its directories by the working directory; Copilot CLI and Codex record
855
+ * it in the session's opening record. That match is a heuristic and is
856
+ * reported as `time-window`.
857
+ *
858
+ * @returns {Promise<{taskId:string|null, runDir:string|null, matchedBy:"session"|"time-window"|null, files:string[], reason?:string}>}
859
+ */
860
+ export async function findRunTranscripts(
861
+ id,
862
+ {
863
+ logsRoot: root = logsRoot(),
864
+ projectsRoot = defaultProjectsRoot(),
865
+ copilotRoot = defaultCopilotRoot(),
866
+ codexRoot = defaultCodexRoot(),
867
+ } = {},
868
+ ) {
869
+ const hit = locateRun(id, root);
870
+ if (!hit)
871
+ return {
872
+ taskId: null,
873
+ runDir: null,
874
+ matchedBy: null,
875
+ files: [],
876
+ reason: "no run with this id",
877
+ };
878
+ const doc = hit.state?.doc || {};
879
+ const base = { taskId: doc.taskId || hit.run.taskId, runDir: hit.run.dir };
880
+ const none = (reason) => ({ ...base, matchedBy: null, files: [], reason });
881
+
882
+ let projectDirs = [];
883
+ try {
884
+ projectDirs = readdirSync(projectsRoot).map((d) => join(projectsRoot, d));
885
+ } catch {
886
+ // No Claude Code transcripts on this machine; the other hosts may have some.
887
+ }
888
+ const copilot = copilotLogs(copilotRoot);
889
+ const codex = codexRollouts(codexRoot);
890
+ if (!projectDirs.length && !copilot.length && !codex.length)
891
+ return none("no transcript directory");
892
+
893
+ if (typeof doc.sessionId === "string" && SAFE_SESSION_ID.test(doc.sessionId)) {
894
+ const sid = doc.sessionId;
895
+ const files = [
896
+ ...projectDirs.map((d) => join(d, `${sid}.jsonl`)).filter((p) => existsSync(p)),
897
+ ...copilot.filter((p) => basename(dirname(p)) === sid),
898
+ ...codex.filter((p) => basename(p).endsWith(`-${sid}.jsonl`)),
899
+ ];
900
+ if (files.length) return { ...base, matchedBy: "session", files };
901
+ }
902
+
903
+ const start = Date.parse(doc.startedAt || "");
904
+ let end = Date.parse(doc.finishedAt || "");
905
+ if (!Number.isFinite(end)) {
906
+ try {
907
+ end = statSync(hit.state.path).mtimeMs;
908
+ } catch {
909
+ end = Date.now();
910
+ }
911
+ }
912
+ const cwds = [doc.worktreePath, doc.projectRoot];
913
+ for (const p of Array.isArray(doc.projects) ? doc.projects : [])
914
+ cwds.push(p?.worktreePath, p?.projectRoot);
915
+ const known = cwds.filter((c) => typeof c === "string" && c);
916
+ const slugs = new Set(known.map(projectSlug));
917
+ const dirs = projectDirs.filter((d) => slugs.has(basename(d)));
918
+ if (!Number.isFinite(start) || (!dirs.length && !copilot.length && !codex.length))
919
+ return none("no session id recorded and no transcript directory for the run's checkout");
920
+
921
+ const files = [
922
+ ...(await windowMatches(dirs, start, end)),
923
+ ...(await hostWindowMatches([...copilot, ...codex], new Set(known.map(samePath)), start, end)),
924
+ ];
925
+ return files.length
926
+ ? { ...base, matchedBy: "time-window", files }
927
+ : none("no session overlaps the run's time window");
928
+ }
929
+
930
+ // --- rendering -----------------------------------------------------------------
931
+
932
+ const dur = (ms) => {
933
+ if (ms === null || ms === undefined || !Number.isFinite(ms)) return "-";
934
+ const s = Math.round(ms / 1000);
935
+ if (s < 60) return `${s}s`;
936
+ const m = Math.floor(s / 60);
937
+ if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
938
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
939
+ };
940
+ const k = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
941
+ const citeList = (ats, max = 6) => {
942
+ const shown = ats.slice(0, max).join(", ");
943
+ return ats.length > max ? `${shown}, ... (${ats.length - max} more)` : shown;
944
+ };
945
+
946
+ /** The index as compact text, one section per kind of finding. */
947
+ export function renderIndex(idx) {
948
+ const L = [];
949
+ const c = idx.counts;
950
+ const by = Object.entries(idx.redaction.byKind)
951
+ .sort((a, b) => b[1] - a[1])
952
+ .map(([kind, n]) => `${kind} ${n}`)
953
+ .join(", ");
954
+ L.push(
955
+ `transcript index - ${idx.files.length} file(s), ${c.turns} turn(s), ${c.toolCalls} tool call(s), ` +
956
+ `${c.malformedLines} malformed line(s) skipped, redacted ${idx.redaction.total} item(s)${by ? ` (${by})` : ""}`,
957
+ );
958
+ L.push(
959
+ `usage: in ${k(idx.usage.input)} / out ${k(idx.usage.output)} / cache read ${k(idx.usage.cacheRead)} / cache write ${k(idx.usage.cacheWrite)} over ${idx.usage.messages} message(s)` +
960
+ (idx.usage.partial
961
+ ? " - partial: a Copilot CLI session wrote no session.shutdown record, so only its output tokens are counted"
962
+ : ""),
963
+ );
964
+ L.push("", "files");
965
+ for (const f of idx.files) {
966
+ const flags = [
967
+ f.sidechain ? "subagent" : null,
968
+ f.malformed ? `malformed ${f.malformed} (${citeList(f.malformedAt, 4)})` : null,
969
+ f.oversized ? `oversized ${f.oversized}` : null,
970
+ f.truncated ? "truncated at the byte cap" : null,
971
+ f.unreadable ? "unreadable" : null,
972
+ f.usagePartial ? "usage partial (no session.shutdown)" : null,
973
+ ].filter(Boolean);
974
+ L.push(
975
+ ` ${f.name} ${f.format ?? "empty"} ${f.lines} lines${flags.length ? ` ${flags.join(" ")}` : ""}`,
976
+ );
977
+ }
978
+ L.push("", `turns (${idx.turns.length})`);
979
+ idx.turns.forEach((t, i) => {
980
+ const u = t.usage;
981
+ L.push(
982
+ ` #${i + 1} ${t.at} ${t.toolCalls} tool(s) ${t.errors} error(s) out ${k(u.output)} peak ctx ${k(u.peakContext)} ${dur(t.durationMs)} ${t.prompt ? `"${t.prompt}"` : "(no prompt)"}`,
983
+ );
984
+ });
985
+ L.push("", `loops - identical calls repeated (${idx.loops.length})`);
986
+ if (!idx.loops.length) L.push(" none");
987
+ for (const l of idx.loops)
988
+ L.push(
989
+ ` ${l.name} x${l.count} longest streak ${l.consecutive} errored ${l.errors} "${l.input}" at ${citeList(l.at)}`,
990
+ );
991
+ L.push(
992
+ "",
993
+ `stalls - gaps while working (${idx.stalls.length}; idle gaps between turns: ${idx.counts.idleGaps})`,
994
+ );
995
+ if (!idx.stalls.length) L.push(" none");
996
+ for (const s of idx.stalls)
997
+ L.push(
998
+ ` ${dur(s.gapMs)} ${s.from} -> ${s.to}${s.pendingTool ? ` running: ${s.pendingTool}` : ""}`,
999
+ );
1000
+ L.push("", `compactions (${idx.counts.compactions})`);
1001
+ if (!idx.compactions.length) L.push(" none");
1002
+ for (const m of idx.compactions)
1003
+ L.push(
1004
+ m.kind === "boundary"
1005
+ ? ` ${m.at} ${m.trigger} ${m.preTokens === null ? "?" : k(m.preTokens)} -> ${m.postTokens === null ? "?" : k(m.postTokens)} tokens`
1006
+ : ` ${m.at} summary turn`,
1007
+ );
1008
+ L.push("", `subagents (${idx.subagents.length})`);
1009
+ if (!idx.subagents.length) L.push(" none");
1010
+ for (const s of idx.subagents)
1011
+ L.push(
1012
+ ` ${s.at} ${s.agentType}${s.background ? " (background)" : ""} ${s.status ?? "no result"} ${dur(s.durationMs)}` +
1013
+ `${s.tokens !== null ? ` ${k(s.tokens)} tokens` : ""} "${s.description}"` +
1014
+ `${s.resultAt ? ` result ${s.resultAt}` : ""}${s.notifiedAt ? ` notified ${s.notifiedAt}` : ""}`,
1015
+ );
1016
+ L.push("", `errors and denials (${idx.errors.length})`);
1017
+ if (!idx.errors.length) L.push(" none");
1018
+ for (const e of idx.errors.slice(0, 40))
1019
+ L.push(` ${e.at} ${e.kind}${e.tool ? ` ${e.tool}` : ""} "${e.text}"`);
1020
+ if (idx.errors.length > 40) L.push(` ... ${idx.errors.length - 40} more`);
1021
+ L.push("", `invocations (${idx.invocations.length})`);
1022
+ if (!idx.invocations.length) L.push(" none");
1023
+ for (const v of idx.invocations) L.push(` ${v.at} ${v.kind} ${v.name}`);
1024
+ const slow = idx.toolCalls
1025
+ .filter((t) => Number.isFinite(t.durationMs))
1026
+ .sort((a, b) => b.durationMs - a.durationMs)
1027
+ .slice(0, 5);
1028
+ L.push("", "slowest tool calls");
1029
+ if (!slow.length) L.push(" none timed");
1030
+ for (const t of slow) L.push(` ${dur(t.durationMs)} ${t.at} ${t.name} "${t.input}"`);
1031
+ L.push(
1032
+ "",
1033
+ `timeline (${idx.timeline.length} event(s)${idx.timelineElided ? `, ${idx.timelineElided} in the middle elided` : ""})`,
1034
+ );
1035
+ for (const e of idx.timeline)
1036
+ L.push(
1037
+ ` ${e.at} ${e.role} ${e.kind}${e.name ? ` ${e.name}` : ""}${e.text ? ` "${e.text}"` : ""}`,
1038
+ );
1039
+ return `${L.join("\n")}\n`;
1040
+ }
1041
+
1042
+ // --- CLI -----------------------------------------------------------------------
1043
+
1044
+ const NUMERIC = {
1045
+ "--max-mb": "maxBytes",
1046
+ "--max-events": "maxEvents",
1047
+ "--loop-threshold": "loopThreshold",
1048
+ "--stall-minutes": "stallMinutes",
1049
+ "--excerpt": "excerpt",
1050
+ };
1051
+
1052
+ function parseArgs(argv) {
1053
+ const a = { files: [], json: false, locate: false, run: null, opts: {} };
1054
+ for (let i = 0; i < argv.length; i++) {
1055
+ const v = argv[i];
1056
+ if (v === "--json") a.json = true;
1057
+ else if (v === "--locate") a.locate = true;
1058
+ else if (v === "--no-subagents") a.opts.subagents = false;
1059
+ else if (v === "--run") a.run = argv[++i] ?? null;
1060
+ else if (NUMERIC[v]) {
1061
+ const n = Number(argv[++i]);
1062
+ if (!Number.isFinite(n) || n <= 0) return { error: `${v} takes a positive number` };
1063
+ a.opts[NUMERIC[v]] = v === "--max-mb" ? Math.floor(n * 1024 * 1024) : n;
1064
+ } else if (v.startsWith("--")) return { error: `unknown option ${v}` };
1065
+ else a.files.push(v);
1066
+ }
1067
+ if (a.run === "") a.run = null;
1068
+ if (!a.run && !a.files.length) return { error: "give transcript files or --run <id>" };
1069
+ if (a.locate && !a.run) return { error: "--locate needs --run" };
1070
+ return a;
1071
+ }
1072
+
1073
+ const tilde = (p) => {
1074
+ const h = homedir();
1075
+ return p && h && p.startsWith(`${h}/`) ? `~${p.slice(h.length)}` : p;
1076
+ };
1077
+
1078
+ async function main() {
1079
+ const a = parseArgs(process.argv.slice(2));
1080
+ if (a.error) {
1081
+ process.stderr.write(
1082
+ `transcript-index: ${a.error}\nusage: transcript-index.mjs <file.jsonl>... | --run <id> [--locate] [--json] [--no-subagents] [--max-mb N] [--max-events N] [--loop-threshold N] [--stall-minutes N] [--excerpt N]\n`,
1083
+ );
1084
+ process.exitCode = 2;
1085
+ return;
1086
+ }
1087
+ let files = a.files;
1088
+ let run = null;
1089
+ if (a.run) {
1090
+ run = await findRunTranscripts(a.run);
1091
+ if (a.locate) {
1092
+ const R = createRedactor();
1093
+ const out = {
1094
+ taskId: run.taskId ? R.clean(run.taskId) : null,
1095
+ runDir: run.runDir ? tilde(run.runDir) : null,
1096
+ matchedBy: run.matchedBy,
1097
+ files: run.files.map((f) => displayName(f)),
1098
+ reason: run.reason ?? null,
1099
+ };
1100
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
1101
+ process.exitCode = run.files.length ? 0 : 3;
1102
+ return;
1103
+ }
1104
+ files = run.files;
1105
+ }
1106
+ if (!files.length) {
1107
+ process.stderr.write(
1108
+ `transcript-index: nothing to read${run?.reason ? ` (${run.reason})` : ""}\n`,
1109
+ );
1110
+ process.exitCode = 3;
1111
+ return;
1112
+ }
1113
+ const idx = await indexTranscripts(files, a.opts);
1114
+ if (run) idx.run = { taskId: run.taskId, matchedBy: run.matchedBy };
1115
+ if (!idx.files.some((f) => f.lines > 0)) {
1116
+ process.stderr.write("transcript-index: nothing to read (empty or unreadable files)\n");
1117
+ process.exitCode = 3;
1118
+ return;
1119
+ }
1120
+ const out = a.json ? `${JSON.stringify(idx, null, 2)}\n` : renderIndex(idx);
1121
+ try {
1122
+ assertClean(out);
1123
+ } catch (err) {
1124
+ process.stderr.write(`transcript-index: ${err.message}; nothing was printed\n`);
1125
+ process.exitCode = 4;
1126
+ return;
1127
+ }
1128
+ process.stdout.write(out);
1129
+ }
1130
+
1131
+ if (invokedDirectly(import.meta.url)) runMain("transcript-index", main, { code: 2 });