● Zion Boggan
repos/TreeTrace/src/cli.js
zionboggan.com ↗
810 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
  mixed: 'mixed',
382
};
383
 
384
export function sourceTypeFor(tool) {
385
  return SOURCE_TYPE_BY_TOOL[tool] || 'claude-code-jsonl';
386
}
387
 
388
function ingestText(from, text, label, log) {
389
  const sessions = adaptFrom(from, text, label);
390
  log(c.dim(`  read ${from} format from ${label}`));
391
  return { sessions, tool: from };
392
}
393
 
394
async function ingestFile(file, from, log) {
395
  if (from && from !== 'claude' && from !== 'transcript') {
396
    const text = readFileSync(file, 'utf8');
397
    return { sessions: adaptFrom(from, text, file), tool: from };
398
  }
399
  if (from === 'claude') {
400
    return { sessions: [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })], tool: 'claude' };
401
  }
402
  if (from === 'transcript') {
403
    return { sessions: [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))], tool: 'transcript' };
404
  }
405
 
406
  if (file.endsWith('.jsonl')) {
407
    const text = readFileSync(file, 'utf8');
408
    const adapted = autoAdapt(text, file);
409
    if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
410
      log(c.dim(`  detected ${adapted.tool} format in ${basename(file)}`));
411
      return { sessions: adapted.sessions, tool: adapted.tool };
412
    }
413
    return { sessions: [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })], tool: 'claude' };
414
  }
415
 
416
  if (file.endsWith('.json')) {
417
    const text = readFileSync(file, 'utf8');
418
    const adapted = autoAdapt(text, file);
419
    if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
420
      log(c.dim(`  detected ${adapted.tool} format in ${basename(file)}`));
421
      return { sessions: adapted.sessions, tool: adapted.tool };
422
    }
423
  }
424
 
425
  return { sessions: [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))], tool: 'transcript' };
426
}
427
 
428
function analysisArtifacts(ttDir, tree, renderOpts, projectDir) {
429
  return {
430
    failures: {
431
      label: 'failures.json',
432
      path: join(ttDir, 'failures.json'),
433
      text: JSON.stringify(renderFailuresJson(tree, renderOpts), null, 2),
434
    },
435
    rejections: {
436
      label: 'rejections.json',
437
      path: join(ttDir, 'rejections.json'),
438
      text: JSON.stringify(renderRejectionsJson(tree, renderOpts), null, 2),
439
    },
440
    hallucinations: {
441
      label: 'hallucinations.json',
442
      path: join(ttDir, 'hallucinations.json'),
443
      text: JSON.stringify(renderHallucinationsJson(tree, projectDir, renderOpts), null, 2),
444
    },
445
    lessons: {
446
      label: 'lessons.md',
447
      path: join(ttDir, 'lessons.md'),
448
      text: renderLessonsMarkdown(tree, renderOpts),
449
    },
450
    evals: {
451
      label: 'evals.jsonl',
452
      path: join(ttDir, 'evals.jsonl'),
453
      text: renderEvalsJsonl(tree, renderOpts),
454
    },
455
    memory: {
456
      label: 'agent-memory.md',
457
      path: join(ttDir, 'agent-memory.md'),
458
      text: renderMemoryMarkdown(tree, renderOpts),
459
    },
460
  };
461
}
462
 
463
function requestedArtifacts(opts, artifacts) {
464
  const requested = [];
465
  if (opts.failures) requested.push(artifacts.failures);
466
  if (opts.rejections) requested.push(artifacts.rejections);
467
  if (opts.lessons) requested.push(artifacts.lessons);
468
  if (opts.evals) requested.push(artifacts.evals);
469
  if (opts.memory) requested.push(artifacts.memory);
470
  if (opts.analysis && !requested.length) requested.push(...Object.values(artifacts));
471
  return requested;
472
}
473
 
474
async function runEach(opts, projectDir, projectName, log) {
475
  const { sessions, sourceTool } = await collectSessions(opts, projectDir, projectName, log);
476
  const outRoot = resolve(projectDir, opts.outDir || 'treetrace-reports');
477
  const generatedAt = opts.deterministic ? DETERMINISTIC_TIMESTAMP : new Date().toISOString();
478
  const manifest = [];
479
  const usedLabels = new Set();
480
  let idx = 0;
481
  for (const session of sessions) {
482
    idx++;
483
    let built;
484
    try {
485
      built = await treeFromSessions([session], opts, projectDir, log, { forceAuto: true });
486
    } catch (err) {
487
      if (err instanceof TreetraceError && err.exitCode === ExitCode.NO_DATA) {
488
        log(c.dim(`  skip ${session.sessionId || `session-${idx}`}: nothing to trace`));
489
        continue;
490
      }
491
      throw err;
492
    }
493
    const { tree, decisions } = built;
494
    const sessionTool = session.sourceTool || sourceTool;
495
    const label = uniqueLabel(session.sessionId, idx, usedLabels);
496
    const targetDir = join(outRoot, label);
497
    const renderOpts = {
498
      projectName,
499
      titlesOnly: opts.titlesOnly,
500
      version: VERSION,
501
      generatedAt,
502
      sourceType: sourceTypeFor(sessionTool),
503
    };
504
    writeBundle(targetDir, tree, decisions, renderOpts, projectDir);
505
    manifest.push(summarizeSession(label, session, tree, sessionTool, targetDir, projectDir));
506
    log(c.green(`✓ ${label} · ${plural(tree.stats.promptCount, 'prompt')} -> ${relativeish(targetDir, projectDir)}`));
507
  }
508
  if (!manifest.length) {
509
    throw new TreetraceError('no sessions produced a report (nothing to trace).', ExitCode.NO_DATA);
510
  }
511
  writeManifest(outRoot, manifest, projectName, generatedAt);
512
  log('');
513
  log(`${c.green('ok')} wrote ${plural(manifest.length, 'session report')} to ${c.bold(relativeish(outRoot, projectDir))} (see INDEX.md)`);
514
}
515
 
516
function uniqueLabel(sessionId, idx, used) {
517
  let base = String(sessionId || `session-${idx}`).replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '');
518
  if (!base) base = `session-${idx}`;
519
  if (base.length > 64) base = base.slice(0, 64);
520
  let label = base;
521
  let n = 2;
522
  while (used.has(label)) label = `${base}-${n++}`;
523
  used.add(label);
524
  return label;
525
}
526
 
527
function writeBundle(targetDir, tree, decisions, renderOpts, projectDir) {
528
  const ttDir = join(targetDir, '.treetrace');
529
  let md = renderMarkdown(tree, renderOpts);
530
  let jsonText = JSON.stringify(renderJson(tree, renderOpts), null, 2);
531
  const artifacts = analysisArtifacts(ttDir, tree, renderOpts, projectDir);
532
  let report = renderReportMarkdown(tree, renderOpts);
533
  md = assertClean(md, decisions, 'PROMPT_TREE.md', true);
534
  jsonText = assertClean(jsonText, decisions, 'tree.json', true);
535
  for (const artifact of Object.values(artifacts)) {
536
    artifact.text = assertClean(artifact.text, decisions, artifact.label, true);
537
  }
538
  report = assertClean(report, decisions, 'TREETRACE_REPORT.md', true);
539
  mkdirSync(targetDir, { recursive: true });
540
  mkdirSync(ttDir, { recursive: true });
541
  writeFileSync(join(targetDir, 'PROMPT_TREE.md'), md);
542
  writeFileSync(join(targetDir, 'TREETRACE_REPORT.md'), report);
543
  writeFileSync(join(ttDir, 'tree.json'), jsonText);
544
  for (const artifact of Object.values(artifacts)) writeFileSync(artifact.path, artifact.text);
545
  writeFileSync(join(ttDir, 'redactions.json'), JSON.stringify(decisions, null, 2));
546
}
547
 
548
function summarizeSession(label, session, tree, sourceTool, targetDir, projectDir) {
549
  const s = tree.stats;
550
  const summary = (tree.analysis && tree.analysis.summary) || analyzeTree(tree).summary;
551
  const secEntry = (summary.topFailureTypes || []).find((t) => t.type === 'security_or_privacy_risk');
552
  return {
553
    label,
554
    sessionId: session.sessionId || null,
555
    source: sourceTool,
556
    prompts: s.promptCount,
557
    corrections: s.corrections || 0,
558
    abandonedBranches: s.abandonedBranches || 0,
559
    rejections: s.rejections || 0,
560
    securityFlags: secEntry ? secEntry.count : 0,
561
    failureSignals: summary.totalFailureSignals || 0,
562
    correctionChains: summary.correctionChains || 0,
563
    models: s.models || [],
564
    firstTs: s.firstTs || null,
565
    lastTs: s.lastTs || null,
566
    dir: relativeish(targetDir, projectDir),
567
  };
568
}
569
 
570
function writeManifest(outRoot, manifest, projectName, generatedAt) {
571
  const totals = {
572
    prompts: manifest.reduce((a, m) => a + (m.prompts || 0), 0),
573
    corrections: manifest.reduce((a, m) => a + (m.corrections || 0), 0),
574
    rejections: manifest.reduce((a, m) => a + (m.rejections || 0), 0),
575
    securityFlags: manifest.reduce((a, m) => a + (m.securityFlags || 0), 0),
576
    failureSignals: manifest.reduce((a, m) => a + (m.failureSignals || 0), 0),
577
  };
578
  const indexJson = {
579
    schemaVersion: 1,
580
    project: projectName,
581
    generatedAt,
582
    sessionCount: manifest.length,
583
    totals,
584
    sessions: manifest,
585
  };
586
  mkdirSync(outRoot, { recursive: true });
587
  writeFileSync(join(outRoot, 'index.json'), JSON.stringify(indexJson, null, 2));
588
  writeFileSync(join(outRoot, 'INDEX.md'), renderManifestMarkdown(indexJson));
589
}
590
 
591
function renderManifestMarkdown(index) {
592
  const lines = [];
593
  lines.push(`# TreeTrace session reports: ${index.project}`);
594
  lines.push('');
595
  lines.push(
596
    `${index.sessionCount} sessions · ${index.totals.prompts} prompts · ${index.totals.corrections} corrections · ` +
597
      `${index.totals.rejections} rejections · ${index.totals.securityFlags} security flags`
598
  );
599
  lines.push('');
600
  lines.push('| Session | Source | Prompts | Corrections | Rejections | Security | Report |');
601
  lines.push('|---|---|---|---|---|---|---|');
602
  for (const m of index.sessions) {
603
    lines.push(
604
      `| ${m.label} | ${m.source} | ${m.prompts} | ${m.corrections} | ${m.rejections} | ${m.securityFlags} | [report](${m.label}/TREETRACE_REPORT.md) |`
605
    );
606
  }
607
  lines.push('');
608
  return lines.join('\n');
609
}
610
 
611
export function assertClean(rendered, decisions, label, autoRedact = false) {
612
  if (autoRedact) {
613
    return patchResiduals(rendered, decisions);
614
  }
615
  const leaks = shadowScan(rendered, decisions);
616
  if (leaks.length) {
617
    throw new TreetraceError(
618
      `shadow scan found ${plural(leaks.length, 'unresolved secret')} in the rendered ${label} ` +
619
        `(${[...new Set(leaks.map((l) => l.ruleId))].join(', ')}). Refusing to write. ` +
620
        `This is a bug worth reporting; as a workaround run interactively to resolve hits.`,
621
      ExitCode.WOULD_LEAK
622
    );
623
  }
624
  return rendered;
625
}
626
 
627
export function wrapMermaidDoc(mermaid, projectName, summarized = false) {
628
  const intro = summarized
629
    ? [
630
        'Goal at the top, the steered progression of prompts as the bright spine, with',
631
        'abandoned explorations and routine intermediate steps folded into dim count stubs',
632
        'so the whole project still reads at a glance. Renders on GitHub and any Mermaid',
633
        'viewer. Pass --full for every node.',
634
      ]
635
    : [
636
        'Goal at the top, the winning progression of prompts as the bright spine, abandoned',
637
        'explorations as dimmed dotted side detours. Renders on GitHub and any Mermaid viewer.',
638
      ];
639
  return [
640
    `# Prompt Tree Graph: ${projectName}`,
641
    '',
642
    ...intro,
643
    '',
644
    '```mermaid',
645
    mermaid,
646
    '```',
647
    '',
648
  ].join('\n');
649
}
650
 
651
function summaryLine(stats, projectName) {
652
  const bits = [
653
    c.bold(plural(stats.promptCount, 'prompt')),
654
    plural(stats.sessionCount, 'session'),
655
  ];
656
  if (stats.days) bits.push(plural(stats.days, 'day'));
657
  if (stats.corrections) bits.push(`${stats.corrections} ${c.yellow('↩')} corrections`);
658
  if (stats.abandonedBranches) bits.push(`${stats.abandonedBranches} ${c.red('✗')} abandoned`);
659
  if (stats.toolUses) bits.push(`${stats.toolUses.toLocaleString()} tool calls`);
660
  return `${c.cyan('🌳')} ${c.bold(projectName)} · ${bits.join(' · ')}`;
661
}
662
 
663
const PREVIEW_LIMIT = 30;
664
function previewTree(tree, log) {
665
  let shown = 0;
666
  const emit = (node, depth) => {
667
    if (shown >= PREVIEW_LIMIT) return false;
668
    shown++;
669
    const icon =
670
      node.kind === 'root' ? c.magenta('⬢')
671
      : node.kind === 'correction' ? c.yellow('↩')
672
      : node.kind === 'scope-change' ? c.cyan('⚑')
673
      : node.kind === 'checkpoint' ? c.blue('◆')
674
      : node.kind === 'question' ? c.gray('?')
675
      : c.green('→');
676
    const title =
677
      node.status === 'abandoned' ? c.dim(`${truncate(node.title, 70)} ${c.red('✗')}`) : truncate(node.title, 70);
678
    log(`${'  '.repeat(depth + 1)}${icon} ${title}`);
679
    return true;
680
  };
681
 
682
  const walk = (node, depth) => {
683
    let cur = node;
684
    for (;;) {
685
      if (!emit(cur, depth)) return;
686
      if (cur.children.length === 1) {
687
        cur = cur.children[0];
688
        continue;
689
      }
690
      for (const ch of cur.children) walk(ch, depth + 1);
691
      return;
692
    }
693
  };
694
  for (const r of tree.roots) walk(r, 0);
695
  if (shown >= PREVIEW_LIMIT && tree.nodes.length > shown)
696
    log(c.dim(`  ... ${tree.nodes.length - shown} more (see PROMPT_TREE.md)`));
697
}
698
 
699
function relativeish(p, base) {
700
  return p.startsWith(base) ? p.slice(base.length + 1) : p;
701
}
702
 
703
export function detectProjectName(dir) {
704
  try {
705
    const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
706
    if (pkg.name) return pkg.name;
707
  } catch {
708
 
709
  }
710
  return basename(dir);
711
}
712
 
713
export function parseArgs(argv) {
714
  const opts = {
715
    files: [],
716
    stdin: false,
717
    report: false,
718
    handoff: false,
719
    json: false,
720
    analysis: false,
721
    failures: false,
722
    rejections: false,
723
    lessons: false,
724
    evals: false,
725
    memory: false,
726
    graph: false,
727
    graphSummary: false,
728
    graphFull: false,
729
    security: false,
730
    mcp: false,
731
    titlesOnly: false,
732
    redactAuto: false,
733
    keepGitShas: false,
734
    quiet: false,
735
    help: false,
736
    version: false,
737
    each: false,
738
    deterministic: false,
739
    from: null,
740
    dir: null,
741
    out: null,
742
    outDir: null,
743
    reportFile: null,
744
    since: null,
745
  };
746
  let i = 0;
747
  const requireValue = (flag) => {
748
    const next = argv[i + 1];
749
    if (next === undefined || next.startsWith('--')) {
750
      throw new TreetraceError(`${flag} requires a value`, ExitCode.USAGE);
751
    }
752
    return argv[++i];
753
  };
754
  for (; i < argv.length; i++) {
755
    const a = argv[i];
756
    switch (a) {
757
      case '--file':
758
        if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
759
          throw new TreetraceError('--file requires at least one path', ExitCode.USAGE);
760
        }
761
        while (argv[i + 1] && !argv[i + 1].startsWith('--')) opts.files.push(argv[++i]);
762
        break;
763
      case '--stdin': opts.stdin = true; break;
764
      case '--report': opts.report = true; break;
765
      case '--handoff': opts.handoff = true; break;
766
      case '--json': opts.json = true; break;
767
      case '--analysis': opts.analysis = true; break;
768
      case '--failures': opts.failures = true; break;
769
      case '--rejections': opts.rejections = true; break;
770
      case '--lessons': opts.lessons = true; break;
771
      case '--evals': opts.evals = true; break;
772
      case '--memory': opts.memory = true; break;
773
      case '--graph': case '--mermaid': opts.graph = true; break;
774
      case '--summary': opts.graph = true; opts.graphSummary = true; break;
775
      case '--full': opts.graph = true; opts.graphFull = true; break;
776
      case '--security': opts.security = true; break;
777
      case 'mcp': case '--mcp': opts.mcp = true; break;
778
      case '--titles-only': opts.titlesOnly = true; break;
779
      case '--redact-auto': opts.redactAuto = true; break;
780
      case '--keep-git-shas': opts.keepGitShas = true; break;
781
      case '--quiet': opts.quiet = true; break;
782
      case '--deterministic': opts.deterministic = true; break;
783
      case '--each': opts.each = true; break;
784
      case '--out-dir': opts.outDir = requireValue('--out-dir'); break;
785
      case '--help': case '-h': opts.help = true; break;
786
      case '--version': case '-v': opts.version = true; break;
787
      case '--from':
788
        opts.from = requireValue('--from');
789
        if (!TOOLS.includes(opts.from)) {
790
          throw new TreetraceError(`unknown --from value "${opts.from}" (expected one of: ${TOOLS.join(', ')})`, ExitCode.USAGE);
791
        }
792
        break;
793
      case '--dir': opts.dir = requireValue('--dir'); break;
794
      case '--out': opts.out = requireValue('--out'); break;
795
      case '--report-file': opts.reportFile = requireValue('--report-file'); break;
796
      case '--since':
797
        opts.since = requireValue('--since');
798
        if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(opts.since) || Number.isNaN(Date.parse(opts.since))) {
799
          throw new TreetraceError(`--since expects a date like YYYY-MM-DD (got "${opts.since}")`, ExitCode.USAGE);
800
        }
801
        break;
802
      default:
803
        throw new TreetraceError(`unknown option ${a} (try --help)`, ExitCode.USAGE);
804
    }
805
  }
806
  if (opts.stdin && opts.from === 'claude') {
807
    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);
808
  }
809
  return opts;
810
}