Zion Boggan
repos/TreeTrace/src/cli.js
zionboggan.com ↗
809 lines · javascript
History for this file →
1
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
import { basename, join, resolve } from 'node:path';
3
import { discoverSessions } from './discover.js';
4
import { parseSessionFile, parsePlainTranscript } from './parse.js';
5
import { adaptFrom, autoAdapt, TOOLS } from './adapters/index.js';
6
import { classifyPrompts } from './extract.js';
7
import { buildTree } from './tree.js';
8
import { scanText, resolveFindings, applyDecisions, shadowScan, patchResiduals } from './redact.js';
9
import { renderMarkdown } from './render-md.js';
10
import { renderMermaid, isSummaryByDefault } from './render-mermaid.js';
11
import { renderJson } from './render-json.js';
12
import { renderHandoff } from './handoff.js';
13
import { renderReportMarkdown, renderTerminalSummary } from './report.js';
14
import {
15
  analyzeTree,
16
  renderFailuresJson,
17
  renderRejectionsJson,
18
  renderLessonsMarkdown,
19
  renderEvalsJsonl,
20
  renderMemoryMarkdown,
21
} from './analyze.js';
22
import { makeTitle } from './extract.js';
23
import { renderHallucinationsJson } from './hallucinate.js';
24
import { renderSecurityReport } from './security-report.js';
25
import { startMcpServer } from './mcp.js';
26
import { c, plural, truncate, TreetraceError, ExitCode } from './util.js';
27
 
28
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
29
 
30
const DETERMINISTIC_TIMESTAMP = '1970-01-01T00:00:00.000Z';
31
 
32
const HELP = `TreeTrace - turn AI coding sessions into regression-ready prompt lineage
33
 
34
Usage:
35
  treetrace                     auto-discover Claude Code sessions for this directory
36
  treetrace --file <path>...    parse specific session/transcript files
37
  treetrace --from <tool> --file <path>   import another tool's export
38
  treetrace --stdin             read a pasted transcript from stdin
39
  treetrace --report            write all artifacts and print the human report
40
  treetrace --handoff           print an agent-ready handoff brief to stdout
41
  treetrace --failures          write and print failure-analysis JSON
42
  treetrace --rejections        write and print rejection/refusal/decline JSON (v0.3)
43
  treetrace --lessons           write and print lessons Markdown
44
  treetrace --evals             write and print eval JSONL
45
  treetrace --memory            write and print compact agent memory
46
  treetrace --graph             write a branded Mermaid prompt-tree graph (PROMPT_TREE_GRAPH.md)
47
                                large projects auto-summarize; --full / --summary force a mode
48
  treetrace --security          print a security-focused report for this session
49
  treetrace --each              write one report bundle per session (+ INDEX manifest)
50
  treetrace mcp                 start a read-only MCP server over stdio
51
 
52
Options:
53
  --from <tool>         input format for --file: claude, codex, chatgpt, gemini,
54
                        copilot, grok, cursor, transcript (default: auto-detect)
55
  --dir <path>          project directory to trace (default: cwd)
56
  --out <file>          markdown output path (default: PROMPT_TREE.md)
57
  --report-file <file>  human report output path (default: TREETRACE_REPORT.md)
58
  --json                also print lineage JSON to stdout
59
  --analysis            write failure, lesson, eval, and memory artifacts
60
  --rejections          write and print .treetrace/rejections.json (v0.3)
61
  --titles-only         omit full prompt texts from the markdown tree
62
  --security            print a security-focused report and write hallucinations.json
63
  --mcp                 start a read-only MCP server over stdio (same as: treetrace mcp)
64
  --redact-auto         redact every detected secret without prompting
65
  --keep-git-shas       keep git object hashes (40/64-hex in a git context) instead of
66
                        redacting them as generic hex tokens; opt-in, still fail-closed
67
                        for any value that also matches a named secret rule
68
  --since <YYYY-MM-DD>  only include sessions active on/after this date
69
                        (timestamped sessions only; plain transcripts are excluded)
70
  --each                write one full report bundle per session into --out-dir,
71
                        plus INDEX.md and index.json manifests (batch / GRC mode;
72
                        auto-redacts each bundle, fails closed)
73
  --out-dir <path>      output root for --each (default: treetrace-reports/)
74
  --deterministic       pin the generation timestamp so re-running on the same
75
                        session produces byte-identical artifacts (reproducible
76
                        audit bundles; clean run-twice diffs)
77
  --quiet               suppress progress output
78
  --version, --help
79
 
80
Every export passes a redaction gate: detected secrets must be resolved
81
(redact/keep/edit) before anything is written. Outside a terminal, every
82
hit is redacted automatically - treetrace fails closed.
83
 
84
Exit codes: 0 ok, 1 generic error, 2 usage error, 3 nothing to trace,
85
4 redaction gate refused to write an unresolved secret.`;
86
 
87
export async function main(argv) {
88
  const opts = parseArgs(argv);
89
  if (opts.help) return void console.log(HELP);
90
  if (opts.version) return void console.log(VERSION);
91
  if (opts.mcp) return await startMcpServer({ argv, version: VERSION });
92
 
93
  const projectDir = resolve(opts.dir || process.cwd());
94
  const projectName = detectProjectName(projectDir);
95
  const log = opts.quiet ? () => {} : (msg) => process.stderr.write(`${msg}\n`);
96
 
97
  if (opts.each) return await runEach(opts, projectDir, projectName, log);
98
 
99
  const { tree, decisions, asked, sourceTool } = await loadRedactedTree(opts, projectDir, projectName, log);
100
 
101
  const ttDir = join(projectDir, '.treetrace');
102
  const decisionsPath = join(ttDir, 'redactions.json');
103
 
104
  const generatedAt = opts.deterministic ? DETERMINISTIC_TIMESTAMP : new Date().toISOString();
105
  const renderOpts = { projectName, titlesOnly: opts.titlesOnly, version: VERSION, generatedAt, sourceType: sourceTypeFor(sourceTool) };
106
 
107
  if (opts.handoff) {
108
    let pack = renderHandoff(tree, renderOpts);
109
    pack = assertClean(pack, decisions, 'handoff brief', opts.redactAuto);
110
    if (Object.keys(decisions).length) {
111
      mkdirSync(ttDir, { recursive: true });
112
      writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2));
113
    }
114
    process.stdout.write(pack);
115
    log(c.green(`✓ handoff brief for ${projectName} (${plural(tree.stats.promptCount, 'prompt')} distilled)`));
116
    return;
117
  }
118
 
119
  if (opts.security) {
120
    let securityReport = renderSecurityReport(tree, projectDir, renderOpts);
121
    let hallucinationsText = JSON.stringify(renderHallucinationsJson(tree, projectDir, renderOpts), null, 2);
122
    securityReport = assertClean(securityReport, decisions, 'security report', opts.redactAuto);
123
    hallucinationsText = assertClean(hallucinationsText, decisions, 'hallucinations.json', opts.redactAuto);
124
    mkdirSync(projectDir, { recursive: true });
125
    mkdirSync(ttDir, { recursive: true });
126
    writeFileSync(join(ttDir, 'hallucinations.json'), hallucinationsText);
127
    writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2));
128
    process.stdout.write(securityReport);
129
    log(c.green(`✓ security report for ${projectName}; wrote .treetrace/hallucinations.json`));
130
    return;
131
  }
132
 
133
  if (opts.graph) {
134
    const skippedByGraph = [];
135
    if (opts.report) skippedByGraph.push('--report');
136
    if (opts.analysis) skippedByGraph.push('--analysis');
137
    if (opts.failures) skippedByGraph.push('--failures');
138
    if (opts.rejections) skippedByGraph.push('--rejections');
139
    if (opts.lessons) skippedByGraph.push('--lessons');
140
    if (opts.evals) skippedByGraph.push('--evals');
141
    if (opts.memory) skippedByGraph.push('--memory');
142
    if (skippedByGraph.length) {
143
      log(
144
        `note: graph mode is terminal -- ${skippedByGraph.join(', ')} output${skippedByGraph.length > 1 ? 's were' : ' was'} not written`
145
      );
146
    }
147
    const graphOpts = { ...renderOpts, summary: opts.graphSummary, full: opts.graphFull };
148
    const mermaid = renderMermaid(tree, graphOpts);
149
    const summarized =
150
      graphOpts.summary === true ||
151
      (graphOpts.full !== true && isSummaryByDefault(tree));
152
    let graphDoc = wrapMermaidDoc(mermaid, projectName, summarized);
153
    const graphPath = resolve(projectDir, opts.out || 'PROMPT_TREE_GRAPH.md');
154
    graphDoc = assertClean(graphDoc, decisions, 'PROMPT_TREE_GRAPH.md', opts.redactAuto);
155
    mkdirSync(projectDir, { recursive: true });
156
    mkdirSync(ttDir, { recursive: true });
157
    writeFileSync(graphPath, graphDoc);
158
    writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2));
159
    process.stdout.write(graphDoc);
160
    log(c.green(`✓ prompt-tree graph for ${projectName} -> ${relativeish(graphPath, projectDir)}`));
161
    return;
162
  }
163
 
164
  let md = renderMarkdown(tree, renderOpts);
165
  const json = renderJson(tree, renderOpts);
166
  let jsonText = JSON.stringify(json, null, 2);
167
  const artifacts = analysisArtifacts(ttDir, tree, renderOpts, projectDir);
168
  const outPath = resolve(projectDir, opts.out || 'PROMPT_TREE.md');
169
  const reportPath = resolve(projectDir, opts.reportFile || 'TREETRACE_REPORT.md');
170
  let report = renderReportMarkdown(tree, renderOpts);
171
 
172
  const requested = requestedArtifacts(opts, artifacts);
173
  if (requested.length && !opts.report) {
174
    for (const artifact of requested) {
175
      artifact.text = assertClean(artifact.text, decisions, artifact.label, opts.redactAuto);
176
    }
177
    mkdirSync(projectDir, { recursive: true });
178
    mkdirSync(ttDir, { recursive: true });
179
    for (const artifact of requested) writeFileSync(artifact.path, artifact.text);
180
    writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2));
181
    if (requested.length === 1) {
182
      process.stdout.write(requested[0].text);
183
    } else {
184
      process.stdout.write(requested.map((a) => `# ${a.label}\n\n${a.text}`).join('\n'));
185
    }
186
    log(c.green(`wrote ${requested.map((a) => relativeish(a.path, projectDir)).join(', ')}`));
187
    return;
188
  }
189
 
190
  md = assertClean(md, decisions, 'PROMPT_TREE.md', opts.redactAuto);
191
  jsonText = assertClean(jsonText, decisions, 'tree.json', opts.redactAuto);
192
  for (const artifact of Object.values(artifacts)) {
193
    artifact.text = assertClean(artifact.text, decisions, artifact.label, opts.redactAuto);
194
  }
195
  report = assertClean(report, decisions, 'TREETRACE_REPORT.md', opts.redactAuto);
196
 
197
  mkdirSync(projectDir, { recursive: true });
198
  mkdirSync(ttDir, { recursive: true });
199
  writeFileSync(outPath, md);
200
  writeFileSync(reportPath, report);
201
  writeFileSync(join(ttDir, 'tree.json'), jsonText);
202
  for (const artifact of Object.values(artifacts)) writeFileSync(artifact.path, artifact.text);
203
 
204
  writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2));
205
 
206
  if (opts.json) process.stdout.write(jsonText + '\n');
207
  if (opts.report) process.stdout.write(report);
208
 
209
  log('');
210
  log(summaryLine(tree.stats, projectName));
211
  log(renderTerminalSummary(tree, renderOpts).trimEnd());
212
  previewTree(tree, log);
213
  log('');
214
  log(
215
    `${c.green('ok')} wrote ${c.bold(relativeish(reportPath, projectDir))}, ${c.bold(relativeish(outPath, projectDir))}, .treetrace/tree.json, and analysis artifacts`
216
  );
217
  if (!opts.report) log(c.dim('  run `treetrace --report` to print the human report in this terminal'));
218
  if (asked) log(c.dim(`  ${plural(asked, 'redaction decision')} saved to .treetrace/redactions.json`));
219
}
220
 
221
export async function collectSessions(opts, projectDir, projectName, log = () => {}) {
222
  let sessions = [];
223
  let sourceTool = 'claude';
224
  if (opts.stdin) {
225
    const text = readFileSync(0, 'utf8');
226
    if (opts.from && opts.from !== 'transcript') {
227
      const { sessions: adapted, tool } = ingestText(opts.from, text, 'stdin', log);
228
      sessions = adapted;
229
      sourceTool = tool;
230
    } else {
231
      sessions = [parsePlainTranscript(text)];
232
      sourceTool = 'transcript';
233
    }
234
    for (const s of sessions) s.sourceTool = sourceTool;
235
  } else if (opts.files.length) {
236
    const tools = new Set();
237
    for (const file of opts.files) {
238
      const { sessions: fileSessions, tool } = await ingestFile(file, opts.from, log);
239
      for (const s of fileSessions) s.sourceTool = tool;
240
      sessions.push(...fileSessions);
241
      tools.add(tool);
242
    }
243
    sourceTool = tools.size === 1 ? [...tools][0] : 'mixed';
244
  } else {
245
    const found = discoverSessions(projectDir);
246
    const filtered = opts.since
247
      ? found.filter((s) => s.mtimeMs >= Date.parse(opts.since))
248
      : found;
249
    if (!filtered.length) {
250
      throw new TreetraceError(
251
        `no Claude Code sessions found for ${projectDir}.\n` +
252
          `Looked in ~/.claude/projects/ for sessions started from this directory.\n` +
253
          `Use --file <transcript> or --stdin to import a transcript directly.`,
254
        ExitCode.NO_DATA
255
      );
256
    }
257
    const totalMB = filtered.reduce((a, s) => a + s.sizeBytes, 0) / 1048576;
258
    log(
259
      `${c.cyan('treetrace')} found ${plural(filtered.length, 'session')} for ${c.bold(projectName)} (${totalMB.toFixed(1)} MB)`
260
    );
261
    for (const meta of filtered) {
262
      if (meta.sizeBytes > 5 * 1048576)
263
        log(c.dim(`  parsing ${meta.sessionId.slice(0, 8)}... (${(meta.sizeBytes / 1048576).toFixed(0)} MB)`));
264
      const parsed = await parseSessionFile(meta.path, meta);
265
      parsed.sourceTool = 'claude';
266
      sessions.push(parsed);
267
    }
268
  }
269
 
270
  if (opts.since) {
271
    sessions = sessions.filter((s) => s.lastTs && s.lastTs >= opts.since);
272
    if (!sessions.length) {
273
      throw new TreetraceError(
274
        `no sessions on or after ${opts.since}. --since only applies to timestamped sessions; ` +
275
          `plain transcripts carry no timestamps and are excluded when --since is set.`,
276
        ExitCode.NO_DATA
277
      );
278
    }
279
  }
280
 
281
  return { sessions, sourceTool };
282
}
283
 
284
export async function treeFromSessions(sessions, opts, projectDir, log = () => {}, { forceAuto = false } = {}) {
285
  const nodes = classifyPrompts(sessions);
286
  if (!nodes.length) {
287
    throw new TreetraceError('no human prompts found in these sessions, nothing to trace.', ExitCode.NO_DATA);
288
  }
289
  const tree = buildTree(sessions, nodes);
290
 
291
  const ttDir = join(projectDir, '.treetrace');
292
  const decisionsPath = join(ttDir, 'redactions.json');
293
  let priorDecisions = {};
294
  if (existsSync(decisionsPath)) {
295
    try {
296
      priorDecisions = JSON.parse(readFileSync(decisionsPath, 'utf8'));
297
    } catch {
298
      priorDecisions = {};
299
    }
300
  }
301
 
302
  const ACTION_FIELDS = ['command', 'file', 'input'];
303
  const findings = [];
304
  for (const node of tree.nodes) {
305
    findings.push(...scanText(node.text));
306
    for (const action of node.actions || []) {
307
      for (const field of ACTION_FIELDS) {
308
        if (typeof action[field] === 'string') findings.push(...scanText(action[field]));
309
      }
310
    }
311
    if (Array.isArray(node.rejections)) {
312
      for (const r of node.rejections) {
313
        if (typeof r.evidence === 'string') findings.push(...scanText(r.evidence));
314
      }
315
    }
316
  }
317
 
318
  const interactive = !forceAuto && process.stdin.isTTY && process.stderr.isTTY && !opts.redactAuto;
319
  const { decisions, asked, autoRedacted, overriddenKeeps, autoKeptGitShas } = await resolveFindings(findings, priorDecisions, {
320
    interactive,
321
    autoRedact: forceAuto || opts.redactAuto,
322
    keepGitShas: opts.keepGitShas,
323
  });
324
  if (overriddenKeeps) {
325
    log(
326
      c.yellow(
327
        `re-redacted ${plural(overriddenKeeps, 'prior keep decision')} in non-interactive mode (keep is only honored in an interactive session)`
328
      )
329
    );
330
  }
331
  if (autoRedacted) {
332
    log(
333
      c.yellow(
334
        `redacted ${plural(autoRedacted, 'potential secret')} automatically (non-interactive mode fails closed)`
335
      )
336
    );
337
  }
338
  if (autoKeptGitShas) {
339
    log(c.dim(`kept ${plural(autoKeptGitShas, 'git object hash')} as non-secret (--keep-git-shas)`));
340
  }
341
 
342
  for (const node of tree.nodes) {
343
    const before = node.text;
344
    node.text = applyDecisions(node.text, findings, decisions);
345
    if (node.text !== before) node.title = makeTitle(node.text);
346
    for (const action of node.actions || []) {
347
      for (const field of ACTION_FIELDS) {
348
        if (typeof action[field] === 'string') {
349
          action[field] = applyDecisions(action[field], findings, decisions);
350
        }
351
      }
352
    }
353
    if (Array.isArray(node.rejections)) {
354
      for (const r of node.rejections) {
355
        if (typeof r.evidence === 'string') {
356
          r.evidence = applyDecisions(r.evidence, findings, decisions);
357
        }
358
      }
359
    }
360
  }
361
  analyzeTree(tree);
362
 
363
  return { tree, decisions, asked };
364
}
365
 
366
export async function loadRedactedTree(opts, projectDir, projectName, log = () => {}, { forceAuto = false } = {}) {
367
  const { sessions, sourceTool } = await collectSessions(opts, projectDir, projectName, log);
368
  const { tree, decisions, asked } = await treeFromSessions(sessions, opts, projectDir, log, { forceAuto });
369
  return { tree, decisions, asked, sourceTool };
370
}
371
 
372
const SOURCE_TYPE_BY_TOOL = {
373
  claude: 'claude-code-jsonl',
374
  codex: 'codex-rollout',
375
  chatgpt: 'chatgpt-export',
376
  gemini: 'gemini-cli',
377
  copilot: 'copilot-chat',
378
  cursor: 'cursor-export',
379
  grok: 'grok-cli',
380
  transcript: 'transcript',
381
};
382
 
383
function sourceTypeFor(tool) {
384
  return SOURCE_TYPE_BY_TOOL[tool] || 'claude-code-jsonl';
385
}
386
 
387
function ingestText(from, text, label, log) {
388
  const sessions = adaptFrom(from, text, label);
389
  log(c.dim(`  read ${from} format from ${label}`));
390
  return { sessions, tool: from };
391
}
392
 
393
async function ingestFile(file, from, log) {
394
  if (from && from !== 'claude' && from !== 'transcript') {
395
    const text = readFileSync(file, 'utf8');
396
    return { sessions: adaptFrom(from, text, file), tool: from };
397
  }
398
  if (from === 'claude') {
399
    return { sessions: [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })], tool: 'claude' };
400
  }
401
  if (from === 'transcript') {
402
    return { sessions: [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))], tool: 'transcript' };
403
  }
404
 
405
  if (file.endsWith('.jsonl')) {
406
    const text = readFileSync(file, 'utf8');
407
    const adapted = autoAdapt(text, file);
408
    if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
409
      log(c.dim(`  detected ${adapted.tool} format in ${basename(file)}`));
410
      return { sessions: adapted.sessions, tool: adapted.tool };
411
    }
412
    return { sessions: [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })], tool: 'claude' };
413
  }
414
 
415
  if (file.endsWith('.json')) {
416
    const text = readFileSync(file, 'utf8');
417
    const adapted = autoAdapt(text, file);
418
    if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
419
      log(c.dim(`  detected ${adapted.tool} format in ${basename(file)}`));
420
      return { sessions: adapted.sessions, tool: adapted.tool };
421
    }
422
  }
423
 
424
  return { sessions: [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))], tool: 'transcript' };
425
}
426
 
427
function analysisArtifacts(ttDir, tree, renderOpts, projectDir) {
428
  return {
429
    failures: {
430
      label: 'failures.json',
431
      path: join(ttDir, 'failures.json'),
432
      text: JSON.stringify(renderFailuresJson(tree, renderOpts), null, 2),
433
    },
434
    rejections: {
435
      label: 'rejections.json',
436
      path: join(ttDir, 'rejections.json'),
437
      text: JSON.stringify(renderRejectionsJson(tree, renderOpts), null, 2),
438
    },
439
    hallucinations: {
440
      label: 'hallucinations.json',
441
      path: join(ttDir, 'hallucinations.json'),
442
      text: JSON.stringify(renderHallucinationsJson(tree, projectDir, renderOpts), null, 2),
443
    },
444
    lessons: {
445
      label: 'lessons.md',
446
      path: join(ttDir, 'lessons.md'),
447
      text: renderLessonsMarkdown(tree, renderOpts),
448
    },
449
    evals: {
450
      label: 'evals.jsonl',
451
      path: join(ttDir, 'evals.jsonl'),
452
      text: renderEvalsJsonl(tree, renderOpts),
453
    },
454
    memory: {
455
      label: 'agent-memory.md',
456
      path: join(ttDir, 'agent-memory.md'),
457
      text: renderMemoryMarkdown(tree, renderOpts),
458
    },
459
  };
460
}
461
 
462
function requestedArtifacts(opts, artifacts) {
463
  const requested = [];
464
  if (opts.failures) requested.push(artifacts.failures);
465
  if (opts.rejections) requested.push(artifacts.rejections);
466
  if (opts.lessons) requested.push(artifacts.lessons);
467
  if (opts.evals) requested.push(artifacts.evals);
468
  if (opts.memory) requested.push(artifacts.memory);
469
  if (opts.analysis && !requested.length) requested.push(...Object.values(artifacts));
470
  return requested;
471
}
472
 
473
async function runEach(opts, projectDir, projectName, log) {
474
  const { sessions, sourceTool } = await collectSessions(opts, projectDir, projectName, log);
475
  const outRoot = resolve(projectDir, opts.outDir || 'treetrace-reports');
476
  const generatedAt = opts.deterministic ? DETERMINISTIC_TIMESTAMP : new Date().toISOString();
477
  const manifest = [];
478
  const usedLabels = new Set();
479
  let idx = 0;
480
  for (const session of sessions) {
481
    idx++;
482
    let built;
483
    try {
484
      built = await treeFromSessions([session], opts, projectDir, log, { forceAuto: true });
485
    } catch (err) {
486
      if (err instanceof TreetraceError && err.code === ExitCode.NO_DATA) {
487
        log(c.dim(`  skip ${session.sessionId || `session-${idx}`}: nothing to trace`));
488
        continue;
489
      }
490
      throw err;
491
    }
492
    const { tree, decisions } = built;
493
    const sessionTool = session.sourceTool || sourceTool;
494
    const label = uniqueLabel(session.sessionId, idx, usedLabels);
495
    const targetDir = join(outRoot, label);
496
    const renderOpts = {
497
      projectName,
498
      titlesOnly: opts.titlesOnly,
499
      version: VERSION,
500
      generatedAt,
501
      sourceType: sourceTypeFor(sessionTool),
502
    };
503
    writeBundle(targetDir, tree, decisions, renderOpts, projectDir);
504
    manifest.push(summarizeSession(label, session, tree, sessionTool, targetDir, projectDir));
505
    log(c.green(`✓ ${label} · ${plural(tree.stats.promptCount, 'prompt')} -> ${relativeish(targetDir, projectDir)}`));
506
  }
507
  if (!manifest.length) {
508
    throw new TreetraceError('no sessions produced a report (nothing to trace).', ExitCode.NO_DATA);
509
  }
510
  writeManifest(outRoot, manifest, projectName, generatedAt);
511
  log('');
512
  log(`${c.green('ok')} wrote ${plural(manifest.length, 'session report')} to ${c.bold(relativeish(outRoot, projectDir))} (see INDEX.md)`);
513
}
514
 
515
function uniqueLabel(sessionId, idx, used) {
516
  let base = String(sessionId || `session-${idx}`).replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '');
517
  if (!base) base = `session-${idx}`;
518
  if (base.length > 64) base = base.slice(0, 64);
519
  let label = base;
520
  let n = 2;
521
  while (used.has(label)) label = `${base}-${n++}`;
522
  used.add(label);
523
  return label;
524
}
525
 
526
function writeBundle(targetDir, tree, decisions, renderOpts, projectDir) {
527
  const ttDir = join(targetDir, '.treetrace');
528
  let md = renderMarkdown(tree, renderOpts);
529
  let jsonText = JSON.stringify(renderJson(tree, renderOpts), null, 2);
530
  const artifacts = analysisArtifacts(ttDir, tree, renderOpts, projectDir);
531
  let report = renderReportMarkdown(tree, renderOpts);
532
  md = assertClean(md, decisions, 'PROMPT_TREE.md', true);
533
  jsonText = assertClean(jsonText, decisions, 'tree.json', true);
534
  for (const artifact of Object.values(artifacts)) {
535
    artifact.text = assertClean(artifact.text, decisions, artifact.label, true);
536
  }
537
  report = assertClean(report, decisions, 'TREETRACE_REPORT.md', true);
538
  mkdirSync(targetDir, { recursive: true });
539
  mkdirSync(ttDir, { recursive: true });
540
  writeFileSync(join(targetDir, 'PROMPT_TREE.md'), md);
541
  writeFileSync(join(targetDir, 'TREETRACE_REPORT.md'), report);
542
  writeFileSync(join(ttDir, 'tree.json'), jsonText);
543
  for (const artifact of Object.values(artifacts)) writeFileSync(artifact.path, artifact.text);
544
  writeFileSync(join(ttDir, 'redactions.json'), JSON.stringify(decisions, null, 2));
545
}
546
 
547
function summarizeSession(label, session, tree, sourceTool, targetDir, projectDir) {
548
  const s = tree.stats;
549
  const summary = (tree.analysis && tree.analysis.summary) || analyzeTree(tree).summary;
550
  const secEntry = (summary.topFailureTypes || []).find((t) => t.type === 'security_or_privacy_risk');
551
  return {
552
    label,
553
    sessionId: session.sessionId || null,
554
    source: sourceTool,
555
    prompts: s.promptCount,
556
    corrections: s.corrections || 0,
557
    abandonedBranches: s.abandonedBranches || 0,
558
    rejections: s.rejections || 0,
559
    securityFlags: secEntry ? secEntry.count : 0,
560
    failureSignals: summary.totalFailureSignals || 0,
561
    correctionChains: summary.correctionChains || 0,
562
    models: s.models || [],
563
    firstTs: s.firstTs || null,
564
    lastTs: s.lastTs || null,
565
    dir: relativeish(targetDir, projectDir),
566
  };
567
}
568
 
569
function writeManifest(outRoot, manifest, projectName, generatedAt) {
570
  const totals = {
571
    prompts: manifest.reduce((a, m) => a + (m.prompts || 0), 0),
572
    corrections: manifest.reduce((a, m) => a + (m.corrections || 0), 0),
573
    rejections: manifest.reduce((a, m) => a + (m.rejections || 0), 0),
574
    securityFlags: manifest.reduce((a, m) => a + (m.securityFlags || 0), 0),
575
    failureSignals: manifest.reduce((a, m) => a + (m.failureSignals || 0), 0),
576
  };
577
  const indexJson = {
578
    schemaVersion: 1,
579
    project: projectName,
580
    generatedAt,
581
    sessionCount: manifest.length,
582
    totals,
583
    sessions: manifest,
584
  };
585
  mkdirSync(outRoot, { recursive: true });
586
  writeFileSync(join(outRoot, 'index.json'), JSON.stringify(indexJson, null, 2));
587
  writeFileSync(join(outRoot, 'INDEX.md'), renderManifestMarkdown(indexJson));
588
}
589
 
590
function renderManifestMarkdown(index) {
591
  const lines = [];
592
  lines.push(`# TreeTrace session reports: ${index.project}`);
593
  lines.push('');
594
  lines.push(
595
    `${index.sessionCount} sessions · ${index.totals.prompts} prompts · ${index.totals.corrections} corrections · ` +
596
      `${index.totals.rejections} rejections · ${index.totals.securityFlags} security flags`
597
  );
598
  lines.push('');
599
  lines.push('| Session | Source | Prompts | Corrections | Rejections | Security | Report |');
600
  lines.push('|---|---|---|---|---|---|---|');
601
  for (const m of index.sessions) {
602
    lines.push(
603
      `| ${m.label} | ${m.source} | ${m.prompts} | ${m.corrections} | ${m.rejections} | ${m.securityFlags} | [report](${m.label}/TREETRACE_REPORT.md) |`
604
    );
605
  }
606
  lines.push('');
607
  return lines.join('\n');
608
}
609
 
610
export function assertClean(rendered, decisions, label, autoRedact = false) {
611
  if (autoRedact) {
612
    return patchResiduals(rendered, decisions);
613
  }
614
  const leaks = shadowScan(rendered, decisions);
615
  if (leaks.length) {
616
    throw new TreetraceError(
617
      `shadow scan found ${plural(leaks.length, 'unresolved secret')} in the rendered ${label} ` +
618
        `(${[...new Set(leaks.map((l) => l.ruleId))].join(', ')}). Refusing to write. ` +
619
        `This is a bug worth reporting; as a workaround run interactively to resolve hits.`,
620
      ExitCode.WOULD_LEAK
621
    );
622
  }
623
  return rendered;
624
}
625
 
626
export function wrapMermaidDoc(mermaid, projectName, summarized = false) {
627
  const intro = summarized
628
    ? [
629
        'Goal at the top, the steered progression of prompts as the bright spine, with',
630
        'abandoned explorations and routine intermediate steps folded into dim count stubs',
631
        'so the whole project still reads at a glance. Renders on GitHub and any Mermaid',
632
        'viewer. Pass --full for every node.',
633
      ]
634
    : [
635
        'Goal at the top, the winning progression of prompts as the bright spine, abandoned',
636
        'explorations as dimmed dotted side detours. Renders on GitHub and any Mermaid viewer.',
637
      ];
638
  return [
639
    `# Prompt Tree Graph: ${projectName}`,
640
    '',
641
    ...intro,
642
    '',
643
    '```mermaid',
644
    mermaid,
645
    '```',
646
    '',
647
  ].join('\n');
648
}
649
 
650
function summaryLine(stats, projectName) {
651
  const bits = [
652
    c.bold(plural(stats.promptCount, 'prompt')),
653
    plural(stats.sessionCount, 'session'),
654
  ];
655
  if (stats.days) bits.push(plural(stats.days, 'day'));
656
  if (stats.corrections) bits.push(`${stats.corrections} ${c.yellow('↩')} corrections`);
657
  if (stats.abandonedBranches) bits.push(`${stats.abandonedBranches} ${c.red('✗')} abandoned`);
658
  if (stats.toolUses) bits.push(`${stats.toolUses.toLocaleString()} tool calls`);
659
  return `${c.cyan('🌳')} ${c.bold(projectName)} · ${bits.join(' · ')}`;
660
}
661
 
662
const PREVIEW_LIMIT = 30;
663
function previewTree(tree, log) {
664
  let shown = 0;
665
  const emit = (node, depth) => {
666
    if (shown >= PREVIEW_LIMIT) return false;
667
    shown++;
668
    const icon =
669
      node.kind === 'root' ? c.magenta('⬢')
670
      : node.kind === 'correction' ? c.yellow('↩')
671
      : node.kind === 'scope-change' ? c.cyan('⚑')
672
      : node.kind === 'checkpoint' ? c.blue('◆')
673
      : node.kind === 'question' ? c.gray('?')
674
      : c.green('→');
675
    const title =
676
      node.status === 'abandoned' ? c.dim(`${truncate(node.title, 70)} ${c.red('✗')}`) : truncate(node.title, 70);
677
    log(`${'  '.repeat(depth + 1)}${icon} ${title}`);
678
    return true;
679
  };
680
 
681
  const walk = (node, depth) => {
682
    let cur = node;
683
    for (;;) {
684
      if (!emit(cur, depth)) return;
685
      if (cur.children.length === 1) {
686
        cur = cur.children[0];
687
        continue;
688
      }
689
      for (const ch of cur.children) walk(ch, depth + 1);
690
      return;
691
    }
692
  };
693
  for (const r of tree.roots) walk(r, 0);
694
  if (shown >= PREVIEW_LIMIT && tree.nodes.length > shown)
695
    log(c.dim(`  ... ${tree.nodes.length - shown} more (see PROMPT_TREE.md)`));
696
}
697
 
698
function relativeish(p, base) {
699
  return p.startsWith(base) ? p.slice(base.length + 1) : p;
700
}
701
 
702
export function detectProjectName(dir) {
703
  try {
704
    const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
705
    if (pkg.name) return pkg.name;
706
  } catch {
707
 
708
  }
709
  return basename(dir);
710
}
711
 
712
export function parseArgs(argv) {
713
  const opts = {
714
    files: [],
715
    stdin: false,
716
    report: false,
717
    handoff: false,
718
    json: false,
719
    analysis: false,
720
    failures: false,
721
    rejections: false,
722
    lessons: false,
723
    evals: false,
724
    memory: false,
725
    graph: false,
726
    graphSummary: false,
727
    graphFull: false,
728
    security: false,
729
    mcp: false,
730
    titlesOnly: false,
731
    redactAuto: false,
732
    keepGitShas: false,
733
    quiet: false,
734
    help: false,
735
    version: false,
736
    each: false,
737
    deterministic: false,
738
    from: null,
739
    dir: null,
740
    out: null,
741
    outDir: null,
742
    reportFile: null,
743
    since: null,
744
  };
745
  let i = 0;
746
  const requireValue = (flag) => {
747
    const next = argv[i + 1];
748
    if (next === undefined || next.startsWith('--')) {
749
      throw new TreetraceError(`${flag} requires a value`, ExitCode.USAGE);
750
    }
751
    return argv[++i];
752
  };
753
  for (; i < argv.length; i++) {
754
    const a = argv[i];
755
    switch (a) {
756
      case '--file':
757
        if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
758
          throw new TreetraceError('--file requires at least one path', ExitCode.USAGE);
759
        }
760
        while (argv[i + 1] && !argv[i + 1].startsWith('--')) opts.files.push(argv[++i]);
761
        break;
762
      case '--stdin': opts.stdin = true; break;
763
      case '--report': opts.report = true; break;
764
      case '--handoff': opts.handoff = true; break;
765
      case '--json': opts.json = true; break;
766
      case '--analysis': opts.analysis = true; break;
767
      case '--failures': opts.failures = true; break;
768
      case '--rejections': opts.rejections = true; break;
769
      case '--lessons': opts.lessons = true; break;
770
      case '--evals': opts.evals = true; break;
771
      case '--memory': opts.memory = true; break;
772
      case '--graph': case '--mermaid': opts.graph = true; break;
773
      case '--summary': opts.graph = true; opts.graphSummary = true; break;
774
      case '--full': opts.graph = true; opts.graphFull = true; break;
775
      case '--security': opts.security = true; break;
776
      case 'mcp': case '--mcp': opts.mcp = true; break;
777
      case '--titles-only': opts.titlesOnly = true; break;
778
      case '--redact-auto': opts.redactAuto = true; break;
779
      case '--keep-git-shas': opts.keepGitShas = true; break;
780
      case '--quiet': opts.quiet = true; break;
781
      case '--deterministic': opts.deterministic = true; break;
782
      case '--each': opts.each = true; break;
783
      case '--out-dir': opts.outDir = requireValue('--out-dir'); break;
784
      case '--help': case '-h': opts.help = true; break;
785
      case '--version': case '-v': opts.version = true; break;
786
      case '--from':
787
        opts.from = requireValue('--from');
788
        if (!TOOLS.includes(opts.from)) {
789
          throw new TreetraceError(`unknown --from value "${opts.from}" (expected one of: ${TOOLS.join(', ')})`, ExitCode.USAGE);
790
        }
791
        break;
792
      case '--dir': opts.dir = requireValue('--dir'); break;
793
      case '--out': opts.out = requireValue('--out'); break;
794
      case '--report-file': opts.reportFile = requireValue('--report-file'); break;
795
      case '--since':
796
        opts.since = requireValue('--since');
797
        if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(opts.since) || Number.isNaN(Date.parse(opts.since))) {
798
          throw new TreetraceError(`--since expects a date like YYYY-MM-DD (got "${opts.since}")`, ExitCode.USAGE);
799
        }
800
        break;
801
      default:
802
        throw new TreetraceError(`unknown option ${a} (try --help)`, ExitCode.USAGE);
803
    }
804
  }
805
  if (opts.stdin && opts.from === 'claude') {
806
    throw new TreetraceError('--stdin cannot be combined with --from claude: Claude Code JSONL sessions are read from files. Use --file, or omit --from to paste a plain transcript.', ExitCode.USAGE);
807
  }
808
  return opts;
809
}