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