| @@ -0,0 +1,4 @@ | ||
| + | node_modules/ | |
| + | .treetrace/ | |
| + | *.log | |
| + | .DS_Store |
| @@ -0,0 +1,21 @@ | ||
| + | MIT License | |
| + | ||
| + | Copyright (c) 2026 Zion Boggan | |
| + | ||
| + | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| + | of this software and associated documentation files (the "Software"), to deal | |
| + | in the Software without restriction, including without limitation the rights | |
| + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| + | copies of the Software, and to permit persons to whom the Software is | |
| + | furnished to do so, subject to the following conditions: | |
| + | ||
| + | The above copyright notice and this permission notice shall be included in all | |
| + | copies or substantial portions of the Software. | |
| + | ||
| + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| + | SOFTWARE. |
| @@ -0,0 +1,8 @@ | ||
| + | #!/usr/bin/env node | |
| + | import { main } from '../src/cli.js'; | |
| + | ||
| + | main(process.argv.slice(2)).catch((err) => { | |
| + | console.error(`treetrace: ${err && err.message ? err.message : err}`); | |
| + | if (process.env.TREETRACE_DEBUG) console.error(err.stack); | |
| + | process.exit(1); | |
| + | }); |
| @@ -0,0 +1,32 @@ | ||
| + | { | |
| + | "name": "treetrace", | |
| + | "version": "0.1.0", | |
| + | "description": "Turn AI coding sessions into a clean, shareable PROMPT_TREE.md - the prompt lineage that built your project.", | |
| + | "keywords": [ | |
| + | "claude-code", | |
| + | "prompt", | |
| + | "provenance", | |
| + | "prompt-tree", | |
| + | "ai", | |
| + | "transcript", | |
| + | "lineage", | |
| + | "agent" | |
| + | ], | |
| + | "license": "MIT", | |
| + | "author": "Zion Boggan", | |
| + | "type": "module", | |
| + | "bin": { | |
| + | "treetrace": "bin/treetrace.js" | |
| + | }, | |
| + | "files": [ | |
| + | "bin", | |
| + | "src", | |
| + | "SCHEMA.md" | |
| + | ], | |
| + | "engines": { | |
| + | "node": ">=18" | |
| + | }, | |
| + | "scripts": { | |
| + | "test": "node --test test/" | |
| + | } | |
| + | } |
| @@ -0,0 +1,270 @@ | ||
| + | import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; | |
| + | import { basename, join, resolve } from 'node:path'; | |
| + | import { discoverSessions } from './discover.js'; | |
| + | import { parseSessionFile, parsePlainTranscript } from './parse.js'; | |
| + | import { classifyPrompts } from './extract.js'; | |
| + | import { buildTree } from './tree.js'; | |
| + | import { scanText, resolveFindings, applyDecisions, shadowScan } from './redact.js'; | |
| + | import { renderMarkdown } from './render-md.js'; | |
| + | import { renderJson } from './render-json.js'; | |
| + | import { renderHandoff } from './handoff.js'; | |
| + | import { makeTitle } from './extract.js'; | |
| + | import { c, plural, truncate } from './util.js'; | |
| + | ||
| + | const VERSION = '0.1.0'; | |
| + | ||
| + | const HELP = `treetrace - turn AI coding sessions into a shareable PROMPT_TREE.md | |
| + | ||
| + | Usage: | |
| + | treetrace auto-discover Claude Code sessions for this directory | |
| + | treetrace --file <path>... parse specific transcript files (.jsonl or plain text) | |
| + | treetrace --stdin read a pasted transcript from stdin | |
| + | treetrace --handoff print an agent-ready handoff brief to stdout | |
| + | ||
| + | Options: | |
| + | --dir <path> project directory to trace (default: cwd) | |
| + | --out <file> markdown output path (default: PROMPT_TREE.md) | |
| + | --json also print lineage JSON to stdout | |
| + | --titles-only omit full prompt texts from the markdown tree | |
| + | --redact-auto redact every detected secret without prompting | |
| + | --since <YYYY-MM-DD> only include sessions active on/after this date | |
| + | --quiet suppress progress output | |
| + | --version, --help | |
| + | ||
| + | Every export passes a redaction gate: detected secrets must be resolved | |
| + | (redact/keep/edit) before anything is written. Outside a terminal, every | |
| + | hit is redacted automatically - treetrace fails closed.`; | |
| + | ||
| + | export async function main(argv) { | |
| + | const opts = parseArgs(argv); | |
| + | if (opts.help) return void console.log(HELP); | |
| + | if (opts.version) return void console.log(VERSION); | |
| + | ||
| + | const projectDir = resolve(opts.dir || process.cwd()); | |
| + | const projectName = detectProjectName(projectDir); | |
| + | const log = opts.quiet ? () => {} : (msg) => process.stderr.write(`${msg}\n`); | |
| + | ||
| + | // ---- gather sessions ---- | |
| + | let sessions = []; | |
| + | if (opts.stdin) { | |
| + | const text = readFileSync(0, 'utf8'); | |
| + | sessions = [parsePlainTranscript(text)]; | |
| + | } else if (opts.files.length) { | |
| + | for (const file of opts.files) { | |
| + | if (file.endsWith('.jsonl')) { | |
| + | sessions.push(await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })); | |
| + | } else { | |
| + | sessions.push(parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))); | |
| + | } | |
| + | } | |
| + | } else { | |
| + | const found = discoverSessions(projectDir); | |
| + | const filtered = opts.since | |
| + | ? found.filter((s) => s.mtimeMs >= Date.parse(opts.since)) | |
| + | : found; | |
| + | if (!filtered.length) { | |
| + | throw new Error( | |
| + | `no Claude Code sessions found for ${projectDir}.\n` + | |
| + | `Looked in ~/.claude/projects/ for sessions started from this directory.\n` + | |
| + | `Use --file <transcript> or --stdin to import a transcript directly.` | |
| + | ); | |
| + | } | |
| + | const totalMB = filtered.reduce((a, s) => a + s.sizeBytes, 0) / 1048576; | |
| + | log( | |
| + | `${c.cyan('treetrace')} found ${plural(filtered.length, 'session')} for ${c.bold(projectName)} (${totalMB.toFixed(1)} MB)` | |
| + | ); | |
| + | for (const meta of filtered) { | |
| + | if (meta.sizeBytes > 5 * 1048576) | |
| + | log(c.dim(` parsing ${meta.sessionId.slice(0, 8)}... (${(meta.sizeBytes / 1048576).toFixed(0)} MB)`)); | |
| + | sessions.push(await parseSessionFile(meta.path, meta)); | |
| + | } | |
| + | } | |
| + | ||
| + | if (opts.since) { | |
| + | sessions = sessions.filter((s) => !s.lastTs || s.lastTs >= opts.since); | |
| + | } | |
| + | ||
| + | // ---- extract + build ---- | |
| + | const nodes = classifyPrompts(sessions); | |
| + | if (!nodes.length) { | |
| + | throw new Error('no human prompts found in these sessions - nothing to trace.'); | |
| + | } | |
| + | const tree = buildTree(sessions, nodes); | |
| + | ||
| + | // ---- redaction gate ---- | |
| + | const ttDir = join(projectDir, '.treetrace'); | |
| + | const decisionsPath = join(ttDir, 'redactions.json'); | |
| + | const priorDecisions = existsSync(decisionsPath) | |
| + | ? JSON.parse(readFileSync(decisionsPath, 'utf8')) | |
| + | : {}; | |
| + | ||
| + | const findings = []; | |
| + | for (const node of tree.nodes) findings.push(...scanText(node.text)); | |
| + | ||
| + | const interactive = process.stdin.isTTY && process.stderr.isTTY && !opts.redactAuto; | |
| + | const { decisions, asked, autoRedacted } = await resolveFindings(findings, priorDecisions, { | |
| + | interactive, | |
| + | autoRedact: opts.redactAuto, | |
| + | }); | |
| + | if (autoRedacted) { | |
| + | log( | |
| + | c.yellow( | |
| + | `redacted ${plural(autoRedacted, 'potential secret')} automatically (non-interactive mode fails closed)` | |
| + | ) | |
| + | ); | |
| + | } | |
| + | ||
| + | for (const node of tree.nodes) { | |
| + | const before = node.text; | |
| + | node.text = applyDecisions(node.text, findings, decisions); | |
| + | if (node.text !== before) node.title = makeTitle(node.text); | |
| + | } | |
| + | ||
| + | // ---- render ---- | |
| + | const generatedAt = new Date().toISOString(); | |
| + | const renderOpts = { projectName, titlesOnly: opts.titlesOnly, version: VERSION, generatedAt }; | |
| + | ||
| + | if (opts.handoff) { | |
| + | const pack = renderHandoff(tree, renderOpts); | |
| + | assertClean(pack, decisions, 'handoff brief'); | |
| + | process.stdout.write(pack); | |
| + | log(c.green(`โ handoff brief for ${projectName} (${plural(tree.stats.promptCount, 'prompt')} distilled)`)); | |
| + | return; | |
| + | } | |
| + | ||
| + | const md = renderMarkdown(tree, renderOpts); | |
| + | const json = renderJson(tree, renderOpts); | |
| + | const jsonText = JSON.stringify(json, null, 2); | |
| + | ||
| + | assertClean(md, decisions, 'PROMPT_TREE.md'); | |
| + | assertClean(jsonText, decisions, 'tree.json'); | |
| + | ||
| + | const outPath = resolve(projectDir, opts.out || 'PROMPT_TREE.md'); | |
| + | writeFileSync(outPath, md); | |
| + | mkdirSync(ttDir, { recursive: true }); | |
| + | writeFileSync(join(ttDir, 'tree.json'), jsonText); | |
| + | // decisions file stores only hashes + actions - safe to keep, never secrets | |
| + | writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2)); | |
| + | ||
| + | if (opts.json) process.stdout.write(jsonText + '\n'); | |
| + | ||
| + | // ---- terminal summary ---- | |
| + | log(''); | |
| + | log(summaryLine(tree.stats, projectName)); | |
| + | previewTree(tree, log); | |
| + | log(''); | |
| + | log(`${c.green('โ')} wrote ${c.bold(relativeish(outPath, projectDir))} and .treetrace/tree.json`); | |
| + | if (asked) log(c.dim(` ${plural(asked, 'redaction decision')} saved to .treetrace/redactions.json`)); | |
| + | } | |
| + | ||
| + | function assertClean(rendered, decisions, label) { | |
| + | const leaks = shadowScan(rendered, decisions); | |
| + | if (leaks.length) { | |
| + | throw new Error( | |
| + | `shadow scan found ${plural(leaks.length, 'unresolved secret')} in the rendered ${label} ` + | |
| + | `(${[...new Set(leaks.map((l) => l.ruleId))].join(', ')}) - refusing to write. ` + | |
| + | `This is a bug worth reporting; as a workaround run interactively to resolve hits.` | |
| + | ); | |
| + | } | |
| + | } | |
| + | ||
| + | function summaryLine(stats, projectName) { | |
| + | const bits = [ | |
| + | c.bold(plural(stats.promptCount, 'prompt')), | |
| + | plural(stats.sessionCount, 'session'), | |
| + | ]; | |
| + | if (stats.days) bits.push(plural(stats.days, 'day')); | |
| + | if (stats.corrections) bits.push(`${stats.corrections} ${c.yellow('โฉ')} corrections`); | |
| + | if (stats.abandonedBranches) bits.push(`${stats.abandonedBranches} ${c.red('โ')} abandoned`); | |
| + | if (stats.toolUses) bits.push(`${stats.toolUses.toLocaleString()} tool calls`); | |
| + | return `${c.cyan('๐ณ')} ${c.bold(projectName)} - ${bits.join(' ยท ')}`; | |
| + | } | |
| + | ||
| + | const PREVIEW_LIMIT = 30; | |
| + | function previewTree(tree, log) { | |
| + | let shown = 0; | |
| + | const emit = (node, depth) => { | |
| + | if (shown >= PREVIEW_LIMIT) return false; | |
| + | shown++; | |
| + | const icon = | |
| + | node.kind === 'root' ? c.magenta('โฌข') | |
| + | : node.kind === 'correction' ? c.yellow('โฉ') | |
| + | : node.kind === 'scope-change' ? c.cyan('โ') | |
| + | : node.kind === 'checkpoint' ? c.blue('โ') | |
| + | : node.kind === 'question' ? c.gray('?') | |
| + | : c.green('โ'); | |
| + | const title = | |
| + | node.status === 'abandoned' ? c.dim(`${truncate(node.title, 70)} ${c.red('โ')}`) : truncate(node.title, 70); | |
| + | log(`${' '.repeat(depth + 1)}${icon} ${title}`); | |
| + | return true; | |
| + | }; | |
| + | // flat for linear chains, indent only at forks (matches the md renderer) | |
| + | const walk = (node, depth) => { | |
| + | let cur = node; | |
| + | for (;;) { | |
| + | if (!emit(cur, depth)) return; | |
| + | if (cur.children.length === 1) { | |
| + | cur = cur.children[0]; | |
| + | continue; | |
| + | } | |
| + | for (const ch of cur.children) walk(ch, depth + 1); | |
| + | return; | |
| + | } | |
| + | }; | |
| + | for (const r of tree.roots) walk(r, 0); | |
| + | if (shown >= PREVIEW_LIMIT && tree.nodes.length > shown) | |
| + | log(c.dim(` ... ${tree.nodes.length - shown} more (see PROMPT_TREE.md)`)); | |
| + | } | |
| + | ||
| + | function relativeish(p, base) { | |
| + | return p.startsWith(base) ? p.slice(base.length + 1) : p; | |
| + | } | |
| + | ||
| + | function detectProjectName(dir) { | |
| + | try { | |
| + | const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); | |
| + | if (pkg.name) return pkg.name; | |
| + | } catch { | |
| + | /* no package.json - fall through */ | |
| + | } | |
| + | return basename(dir); | |
| + | } | |
| + | ||
| + | function parseArgs(argv) { | |
| + | const opts = { | |
| + | files: [], | |
| + | stdin: false, | |
| + | handoff: false, | |
| + | json: false, | |
| + | titlesOnly: false, | |
| + | redactAuto: false, | |
| + | quiet: false, | |
| + | help: false, | |
| + | version: false, | |
| + | dir: null, | |
| + | out: null, | |
| + | since: null, | |
| + | }; | |
| + | for (let i = 0; i < argv.length; i++) { | |
| + | const a = argv[i]; | |
| + | switch (a) { | |
| + | case '--file': | |
| + | while (argv[i + 1] && !argv[i + 1].startsWith('--')) opts.files.push(argv[++i]); | |
| + | break; | |
| + | case '--stdin': opts.stdin = true; break; | |
| + | case '--handoff': opts.handoff = true; break; | |
| + | case '--json': opts.json = true; break; | |
| + | case '--titles-only': opts.titlesOnly = true; break; | |
| + | case '--redact-auto': opts.redactAuto = true; break; | |
| + | case '--quiet': opts.quiet = true; break; | |
| + | case '--help': case '-h': opts.help = true; break; | |
| + | case '--version': case '-v': opts.version = true; break; | |
| + | case '--dir': opts.dir = argv[++i]; break; | |
| + | case '--out': opts.out = argv[++i]; break; | |
| + | case '--since': opts.since = argv[++i]; break; | |
| + | default: | |
| + | throw new Error(`unknown option ${a} (try --help)`); | |
| + | } | |
| + | } | |
| + | return opts; | |
| + | } |
| @@ -0,0 +1,62 @@ | ||
| + | import { readdirSync, statSync, existsSync } from 'node:fs'; | |
| + | import { homedir } from 'node:os'; | |
| + | import { join, resolve, sep } from 'node:path'; | |
| + | ||
| + | // Claude Code stores sessions under ~/.claude/projects/<munged-cwd>/<sessionId>.jsonl | |
| + | // where <munged-cwd> is the absolute project path with every non [A-Za-z0-9-] | |
| + | // character replaced by "-" (case preserved). e.g. /home/dev/weatherapp -> -home-dev-weatherapp | |
| + | export function mungePath(absPath) { | |
| + | return absPath.replace(/[^A-Za-z0-9-]/g, '-'); | |
| + | } | |
| + | ||
| + | export function claudeProjectsRoot() { | |
| + | return process.env.CLAUDE_CONFIG_DIR | |
| + | ? join(process.env.CLAUDE_CONFIG_DIR, 'projects') | |
| + | : join(homedir(), '.claude', 'projects'); | |
| + | } | |
| + | ||
| + | /** | |
| + | * Find Claude Code session files relevant to a project directory. | |
| + | * | |
| + | * A session "belongs" to the project if it was started from the project dir | |
| + | * itself OR any directory beneath it (Claude Code keys storage by exact cwd, | |
| + | * so a repo worked on from two subdirs produces two storage dirs). | |
| + | * | |
| + | * Returns [{ path, sessionId, sizeBytes, mtimeMs, storageDir }] sorted by mtime. | |
| + | */ | |
| + | export function discoverSessions(projectDir) { | |
| + | const root = claudeProjectsRoot(); | |
| + | if (!existsSync(root)) return []; | |
| + | ||
| + | const abs = resolve(projectDir); | |
| + | const exact = mungePath(abs); | |
| + | const prefix = mungePath(abs + sep); // children share this prefix | |
| + | ||
| + | const sessions = []; | |
| + | for (const entry of readdirSync(root, { withFileTypes: true })) { | |
| + | if (!entry.isDirectory()) continue; | |
| + | if (entry.name !== exact && !entry.name.startsWith(prefix)) continue; | |
| + | const dir = join(root, entry.name); | |
| + | for (const f of readdirSync(dir, { withFileTypes: true })) { | |
| + | // top-level session files only; subdirectories hold subagent/sidechain | |
| + | // transcripts which are agent-to-agent, not human lineage | |
| + | if (!f.isFile() || !f.name.endsWith('.jsonl')) continue; | |
| + | const path = join(dir, f.name); | |
| + | let st; | |
| + | try { | |
| + | st = statSync(path); | |
| + | } catch { | |
| + | continue; | |
| + | } | |
| + | sessions.push({ | |
| + | path, | |
| + | sessionId: f.name.replace(/\.jsonl$/, ''), | |
| + | sizeBytes: st.size, | |
| + | mtimeMs: st.mtimeMs, | |
| + | storageDir: entry.name, | |
| + | }); | |
| + | } | |
| + | } | |
| + | sessions.sort((a, b) => a.mtimeMs - b.mtimeMs); | |
| + | return sessions; | |
| + | } |
| @@ -0,0 +1,128 @@ | ||
| + | import { truncate } from './util.js'; | |
| + | ||
| + | /** | |
| + | * Classify candidate human prompts into lineage roles and fold noise. | |
| + | * | |
| + | * Deterministic by design: the same transcript always produces the same tree. | |
| + | * An optional --llm pass (the user's own model) may later retitle nodes, but | |
| + | * classification never depends on it. | |
| + | */ | |
| + | ||
| + | const KIND = { | |
| + | ROOT: 'root', | |
| + | DIRECTION: 'direction', | |
| + | CORRECTION: 'correction', | |
| + | SCOPE: 'scope-change', | |
| + | CHECKPOINT: 'checkpoint', | |
| + | QUESTION: 'question', | |
| + | }; | |
| + | ||
| + | // Strong correction signals: explicit negation/undo - these outrank scope. | |
| + | const CORRECTION_STRONG_OPENERS = | |
| + | /^(no[,.\s]|no$|not |don'?t |stop\b|wrong\b|undo\b|revert\b|nope\b|that'?s (not|wrong)|why did you)/i; | |
| + | const CORRECTION_ANYWHERE = | |
| + | /(didn'?t work|doesn'?t work|not working|still (failing|broken|wrong|not)|that broke|you (missed|forgot|skipped|ignored)|redo (this|that|it)|go back|that'?s incorrect|not what i (asked|meant|wanted)|undo (this|that)|roll(?: |-)?back)/i; | |
| + | // Soft correction signals: conversational pivots - only count when nothing | |
| + | // stronger (like an additive scope change) explains the message. | |
| + | const CORRECTION_SOFT_OPENERS = /^(wait\b|actually[,\s]|hold on\b|hmm[,\s]|instead[,\s])/i; | |
| + | ||
| + | const SCOPE_ANYWHERE = | |
| + | /(also (add|build|make|create|include)|now (add|build|make|let'?s)|new (feature|requirement|idea)|let'?s also|switch to|pivot|change of plans|from now on|going forward|next phase|instead of .{3,40}(do|use|build|make)|scrap (that|this)|forget (that|this)|rather than)/i; | |
| + | ||
| + | const CHECKPOINT_ANYWHERE = | |
| + | /^(commit|push|publish|ship|deploy|release)\b|(write (up|a) (summary|report|readme)|summari[sz]e (what|the|this)|status update|where are we|what'?s (left|remaining|the status)|wrap (this |it )?up|document (what|this|the)|hand ?off|save (your |our )?progress)/i; | |
| + | ||
| + | const QUESTION_ONLY = | |
| + | /^(what|how|why|where|when|which|who|is|are|can|could|should|would|will|do|does|did)\b[^]*\?\s*$/i; | |
| + | ||
| + | // Short acknowledgements that nudge the agent along but carry no direction. | |
| + | const CONTINUATION_RE = | |
| + | /^(y|yes|yep|yeah|ok|okay|k|sure|continue|cont|go|go ahead|do it|proceed|next|sounds good|looks good|lgtm|perfect|nice|good|great|approved?|yes please|please do|carry on|keep going|resume|finish|all good|that works|works|๐|do that|option \w|\d)[.! ]*$/i; | |
| + | ||
| + | const MAX_NUDGE_WORDS = 4; | |
| + | ||
| + | export function classifyPrompts(sessions) { | |
| + | const nodes = []; | |
| + | let rootAssigned = false; | |
| + | ||
| + | for (const session of sessions) { | |
| + | let prevNode = null; | |
| + | for (const prompt of session.prompts) { | |
| + | const text = prompt.text; | |
| + | const words = text.split(/\s+/).filter(Boolean); | |
| + | ||
| + | // The same human message can appear twice in a transcript (queued | |
| + | // resend, bridge echo, draft-then-full edit). Collapse consecutive | |
| + | // duplicates, including prefix-duplicates - keep the longer text. | |
| + | if (prevNode && isDupOf(prevNode.text, text)) { | |
| + | if (text.length > prevNode.text.length) { | |
| + | prevNode.text = text; | |
| + | prevNode.title = makeTitle(text); | |
| + | prevNode.kind = prevNode.kind === KIND.ROOT ? KIND.ROOT : classifyOne(text, prompt, true); | |
| + | prevNode.chars = text.length; | |
| + | } | |
| + | continue; | |
| + | } | |
| + | ||
| + | // Fold pure nudges into the previous node instead of creating noise nodes. | |
| + | if ( | |
| + | prevNode && | |
| + | words.length <= MAX_NUDGE_WORDS && | |
| + | CONTINUATION_RE.test(text) | |
| + | ) { | |
| + | prevNode.nudges++; | |
| + | continue; | |
| + | } | |
| + | ||
| + | const node = { | |
| + | id: null, // assigned by tree builder | |
| + | uuid: prompt.uuid, | |
| + | parentUuid: prompt.parentUuid, | |
| + | sessionId: session.sessionId, | |
| + | ts: prompt.ts, | |
| + | text, | |
| + | title: makeTitle(text), | |
| + | kind: classifyOne(text, prompt, rootAssigned), | |
| + | status: 'accepted', // tree builder may demote to abandoned | |
| + | nudges: 0, | |
| + | afterInterruption: prompt.afterInterruption, | |
| + | chars: text.length, | |
| + | }; | |
| + | if (node.kind === KIND.ROOT) rootAssigned = true; | |
| + | nodes.push(node); | |
| + | prevNode = node; | |
| + | } | |
| + | } | |
| + | return nodes; | |
| + | } | |
| + | ||
| + | // Two consecutive messages are duplicates if one is (nearly) a prefix of the | |
| + | // other after whitespace normalization - covers truncated draft echoes. | |
| + | function isDupOf(a, b) { | |
| + | const na = a.replace(/\s+/g, ' ').trim(); | |
| + | const nb = b.replace(/\s+/g, ' ').trim(); | |
| + | if (na === nb) return true; | |
| + | const [short, long] = na.length <= nb.length ? [na, nb] : [nb, na]; | |
| + | if (short.length < 24) return false; // too short to call a prefix-dup safely | |
| + | // tolerate a few trailing chars of divergence (cut-off mid-word) | |
| + | return long.startsWith(short.slice(0, short.length - 4)); | |
| + | } | |
| + | ||
| + | function classifyOne(text, prompt, rootAssigned) { | |
| + | if (!rootAssigned) return KIND.ROOT; | |
| + | if (CORRECTION_STRONG_OPENERS.test(text) || CORRECTION_ANYWHERE.test(text)) return KIND.CORRECTION; | |
| + | if (SCOPE_ANYWHERE.test(text)) return KIND.SCOPE; | |
| + | if (CHECKPOINT_ANYWHERE.test(text)) return KIND.CHECKPOINT; | |
| + | if (CORRECTION_SOFT_OPENERS.test(text)) return KIND.CORRECTION; | |
| + | if (QUESTION_ONLY.test(text) && text.length < 250) return KIND.QUESTION; | |
| + | return KIND.DIRECTION; | |
| + | } | |
| + | ||
| + | // First sentence-ish fragment, cleaned, for node titles. | |
| + | export function makeTitle(text) { | |
| + | const firstLine = text.split(/\r?\n/).find((l) => l.trim()) || text; | |
| + | const sentence = firstLine.split(/(?<=[.!?])\s+/)[0] || firstLine; | |
| + | return truncate(sentence, 96); | |
| + | } | |
| + | ||
| + | export { KIND }; |
| @@ -0,0 +1,84 @@ | ||
| + | import { truncate } from './util.js'; | |
| + | ||
| + | /** | |
| + | * --handoff: an agent-ready context pack, printed to stdout (and gated by the | |
| + | * same redaction pipeline as every other export). | |
| + | * | |
| + | * Not a "replay" - a briefing: goal, where things stand, accepted decisions, | |
| + | * known dead ends, and standing constraints, so the next agent (or model) | |
| + | * starts with the lineage instead of an empty context. | |
| + | */ | |
| + | export function renderHandoff(tree, opts = {}) { | |
| + | const { projectName } = opts; | |
| + | const { nodes, stats } = tree; | |
| + | const lines = []; | |
| + | ||
| + | const root = nodes.find((n) => n.kind === 'root') || nodes[0]; | |
| + | const accepted = nodes.filter((n) => n.status !== 'abandoned'); | |
| + | const lastCheckpoint = [...accepted].reverse().find((n) => n.kind === 'checkpoint'); | |
| + | const lastAccepted = accepted.at(-1); | |
| + | ||
| + | lines.push(`# Handoff brief - ${projectName}`); | |
| + | lines.push(''); | |
| + | lines.push( | |
| + | `You are taking over an AI-assisted project. This brief was distilled from the real prompt lineage (${stats.promptCount} prompts, ${stats.sessionCount} sessions). Read it fully before acting.` | |
| + | ); | |
| + | lines.push(''); | |
| + | ||
| + | if (root) { | |
| + | lines.push('## Original goal'); | |
| + | lines.push(''); | |
| + | lines.push(root.text.trim()); | |
| + | lines.push(''); | |
| + | } | |
| + | ||
| + | lines.push('## Where things stand'); | |
| + | lines.push(''); | |
| + | if (lastCheckpoint) { | |
| + | lines.push(`Last checkpoint: ${lastCheckpoint.text.trim()}`); | |
| + | } | |
| + | if (lastAccepted && lastAccepted !== lastCheckpoint) { | |
| + | lines.push(''); | |
| + | lines.push(`Most recent accepted direction: ${lastAccepted.text.trim()}`); | |
| + | } | |
| + | lines.push(''); | |
| + | ||
| + | const decisions = accepted.filter((n) => n.kind === 'direction' || n.kind === 'scope-change'); | |
| + | if (decisions.length) { | |
| + | lines.push('## Accepted decisions (in order)'); | |
| + | lines.push(''); | |
| + | decisions.forEach((n, i) => lines.push(`${i + 1}. ${truncate(n.text.replace(/\s+/g, ' '), 360)}`)); | |
| + | lines.push(''); | |
| + | } | |
| + | ||
| + | const corrections = accepted.filter((n) => n.kind === 'correction'); | |
| + | if (corrections.length) { | |
| + | lines.push('## Constraints learned the hard way'); | |
| + | lines.push(''); | |
| + | lines.push('These corrections were issued during the build - do not repeat the mistakes they fixed:'); | |
| + | lines.push(''); | |
| + | corrections.forEach((n) => lines.push(`- ${truncate(n.text.replace(/\s+/g, ' '), 300)}`)); | |
| + | lines.push(''); | |
| + | } | |
| + | ||
| + | const abandoned = nodes.filter( | |
| + | (n) => n.status === 'abandoned' && (!n.parent || n.parent.status !== 'abandoned') | |
| + | ); | |
| + | if (abandoned.length) { | |
| + | lines.push('## Known dead ends'); | |
| + | lines.push(''); | |
| + | lines.push('These approaches were tried and abandoned - avoid unless told otherwise:'); | |
| + | lines.push(''); | |
| + | abandoned.forEach((n) => lines.push(`- ${truncate(n.text.replace(/\s+/g, ' '), 300)}`)); | |
| + | lines.push(''); | |
| + | } | |
| + | ||
| + | lines.push('## First task'); | |
| + | lines.push(''); | |
| + | lines.push( | |
| + | 'Confirm you understand the goal, the accepted decisions, and the constraints above, then ask the user what to tackle next (or continue the most recent accepted direction if instructed to proceed autonomously).' | |
| + | ); | |
| + | lines.push(''); | |
| + | ||
| + | return lines.join('\n'); | |
| + | } |
| @@ -0,0 +1,289 @@ | ||
| + | import { createReadStream } from 'node:fs'; | |
| + | import { createInterface } from 'node:readline'; | |
| + | ||
| + | /** | |
| + | * Streaming parser for Claude Code session JSONL files. | |
| + | * | |
| + | * Design constraints: | |
| + | * - Session files reach 200MB+; never buffer the whole file. | |
| + | * - Keep a light index (uuid/parent/type/ts) for every conversation record so | |
| + | * branch topology can be reconstructed, but keep full text only for | |
| + | * candidate human prompts and small metadata records. | |
| + | * - Tolerate unknown record types and malformed lines: skip, never throw. | |
| + | */ | |
| + | ||
| + | const TURN_TYPES = new Set(['user', 'assistant']); | |
| + | ||
| + | export async function parseSessionFile(path, sessionMeta = {}) { | |
| + | const session = { | |
| + | sessionId: sessionMeta.sessionId || null, | |
| + | path, | |
| + | title: null, | |
| + | version: null, | |
| + | cwd: null, | |
| + | gitBranch: null, | |
| + | firstTs: null, | |
| + | lastTs: null, | |
| + | prompts: [], // candidate human prompts (full text retained) | |
| + | index: new Map(), // uuid -> { parentUuid, type, ts } for all turn records | |
| + | leafUuid: null, // last turn uuid seen (chronological) | |
| + | stats: { | |
| + | userLines: 0, | |
| + | assistantLines: 0, | |
| + | toolUses: 0, | |
| + | models: new Set(), | |
| + | filesTouched: new Set(), | |
| + | inputTokens: 0, | |
| + | outputTokens: 0, | |
| + | interruptions: 0, | |
| + | }, | |
| + | isContinuation: false, // continued from a compacted previous session | |
| + | continuationOf: null, | |
| + | }; | |
| + | ||
| + | const stream = createReadStream(path, { encoding: 'utf8' }); | |
| + | const rl = createInterface({ input: stream, crlfDelay: Infinity }); | |
| + | ||
| + | for await (const line of rl) { | |
| + | if (!line || line.charCodeAt(0) !== 123 /* '{' */) continue; | |
| + | let rec; | |
| + | try { | |
| + | rec = JSON.parse(line); | |
| + | } catch { | |
| + | continue; // truncated/corrupt line | |
| + | } | |
| + | ingestRecord(session, rec); | |
| + | } | |
| + | rl.close(); | |
| + | ||
| + | session.stats.models = [...session.stats.models]; | |
| + | session.stats.filesTouched = [...session.stats.filesTouched]; | |
| + | return session; | |
| + | } | |
| + | ||
| + | function ingestRecord(session, rec) { | |
| + | switch (rec.type) { | |
| + | case 'user': | |
| + | ingestUser(session, rec); | |
| + | break; | |
| + | case 'assistant': | |
| + | ingestAssistant(session, rec); | |
| + | break; | |
| + | case 'summary': | |
| + | // {type:"summary", summary, leafUuid} - Claude Code's own session title | |
| + | if (rec.summary && !session.title) session.title = rec.summary; | |
| + | break; | |
| + | case 'ai-title': | |
| + | if ((rec.title || rec.aiTitle) && !session.title) | |
| + | session.title = rec.title || rec.aiTitle; | |
| + | break; | |
| + | default: | |
| + | // mode, permission-mode, bridge-session, last-prompt, queue-operation, | |
| + | // file-history-snapshot, attachment, system, ... - not lineage material | |
| + | break; | |
| + | } | |
| + | ||
| + | if (!session.sessionId && rec.sessionId) session.sessionId = rec.sessionId; | |
| + | if (!session.version && rec.version) session.version = rec.version; | |
| + | if (!session.cwd && rec.cwd) session.cwd = rec.cwd; | |
| + | if (!session.gitBranch && rec.gitBranch) session.gitBranch = rec.gitBranch; | |
| + | if (rec.timestamp && TURN_TYPES.has(rec.type)) { | |
| + | if (!session.firstTs) session.firstTs = rec.timestamp; | |
| + | session.lastTs = rec.timestamp; | |
| + | } | |
| + | } | |
| + | ||
| + | function indexTurn(session, rec) { | |
| + | if (!rec.uuid) return; | |
| + | session.index.set(rec.uuid, { | |
| + | parentUuid: rec.parentUuid || null, | |
| + | type: rec.type, | |
| + | ts: rec.timestamp || null, | |
| + | }); | |
| + | if (!rec.isSidechain) session.leafUuid = rec.uuid; | |
| + | } | |
| + | ||
| + | function ingestUser(session, rec) { | |
| + | if (rec.isSidechain) return; // subagent traffic, not human | |
| + | indexTurn(session, rec); | |
| + | session.stats.userLines++; | |
| + | ||
| + | const msg = rec.message || {}; | |
| + | const { text, hasToolResult, hasOnlyToolResult } = flattenUserContent(msg.content); | |
| + | ||
| + | if (hasOnlyToolResult) return; // tool output echoed back as a user turn | |
| + | ||
| + | const trimmed = (text || '').trim(); | |
| + | if (!trimmed) return; | |
| + | ||
| + | if (/^\[Request interrupted by user/i.test(trimmed)) { | |
| + | session.stats.interruptions++; | |
| + | session._pendingInterruption = true; | |
| + | return; | |
| + | } | |
| + | ||
| + | // Slash command + local command wrappers, hook noise, harness reminders. | |
| + | const classification = classifySpecialUserText(trimmed); | |
| + | if (classification === 'command') return; | |
| + | if (classification === 'meta' || rec.isMeta) return; | |
| + | if (classification === 'compact-continuation') { | |
| + | session.isContinuation = true; | |
| + | return; | |
| + | } | |
| + | ||
| + | session.prompts.push({ | |
| + | uuid: rec.uuid || null, | |
| + | parentUuid: rec.parentUuid || null, | |
| + | ts: rec.timestamp || null, | |
| + | text: trimmed, | |
| + | userType: rec.userType || null, | |
| + | hadToolResultContext: hasToolResult, | |
| + | afterInterruption: Boolean(session._pendingInterruption), | |
| + | }); | |
| + | session._pendingInterruption = false; | |
| + | } | |
| + | ||
| + | function ingestAssistant(session, rec) { | |
| + | if (rec.isSidechain) return; | |
| + | indexTurn(session, rec); | |
| + | session.stats.assistantLines++; | |
| + | ||
| + | const msg = rec.message || {}; | |
| + | if (msg.model) session.stats.models.add(msg.model); | |
| + | if (msg.usage) { | |
| + | session.stats.inputTokens += msg.usage.input_tokens || 0; | |
| + | session.stats.outputTokens += msg.usage.output_tokens || 0; | |
| + | } | |
| + | const content = Array.isArray(msg.content) ? msg.content : []; | |
| + | for (const block of content) { | |
| + | if (block && block.type === 'tool_use') { | |
| + | session.stats.toolUses++; | |
| + | const input = block.input || {}; | |
| + | const file = input.file_path || input.notebook_path || null; | |
| + | if (typeof file === 'string') session.stats.filesTouched.add(file); | |
| + | } | |
| + | } | |
| + | } | |
| + | ||
| + | function flattenUserContent(content) { | |
| + | if (typeof content === 'string') { | |
| + | return { text: content, hasToolResult: false, hasOnlyToolResult: false }; | |
| + | } | |
| + | if (!Array.isArray(content)) { | |
| + | return { text: '', hasToolResult: false, hasOnlyToolResult: false }; | |
| + | } | |
| + | let text = ''; | |
| + | let toolResults = 0; | |
| + | let others = 0; | |
| + | for (const block of content) { | |
| + | if (!block || typeof block !== 'object') continue; | |
| + | if (block.type === 'text' && typeof block.text === 'string') { | |
| + | text += (text ? '\n' : '') + block.text; | |
| + | others++; | |
| + | } else if (block.type === 'tool_result') { | |
| + | toolResults++; | |
| + | } else { | |
| + | others++; // images, documents - count as non-tool content | |
| + | } | |
| + | } | |
| + | return { | |
| + | text, | |
| + | hasToolResult: toolResults > 0, | |
| + | hasOnlyToolResult: toolResults > 0 && others === 0, | |
| + | }; | |
| + | } | |
| + | ||
| + | const COMPACT_CONTINUATION_RE = | |
| + | /^this session is being continued from a previous conversation/i; | |
| + | ||
| + | export function classifySpecialUserText(text) { | |
| + | if (COMPACT_CONTINUATION_RE.test(text)) return 'compact-continuation'; | |
| + | // /slash-command invocations and their stdout get wrapped in pseudo-XML | |
| + | if ( | |
| + | text.startsWith('<command-name>') || | |
| + | text.startsWith('<command-message>') || | |
| + | text.startsWith('<local-command-stdout>') || | |
| + | text.startsWith('<bash-input>') || | |
| + | text.startsWith('<bash-stdout>') || | |
| + | text.startsWith('<bash-stderr>') | |
| + | ) { | |
| + | return 'command'; | |
| + | } | |
| + | if ( | |
| + | text.startsWith('<system-reminder>') || | |
| + | text.startsWith('<task-notification>') || | |
| + | text.startsWith('<local-command-caveat>') || | |
| + | text.startsWith('Caveat: The messages below') | |
| + | ) { | |
| + | return 'meta'; | |
| + | } | |
| + | return 'prompt'; | |
| + | } | |
| + | ||
| + | /** | |
| + | * Fallback importer: plain text / markdown transcripts (pasted exports from | |
| + | * ChatGPT, Claude.ai, etc.). Recognizes common turn markers; returns a | |
| + | * session-shaped object with prompts only. | |
| + | */ | |
| + | export function parsePlainTranscript(text, label = 'pasted-transcript') { | |
| + | const lines = text.split(/\r?\n/); | |
| + | const markers = | |
| + | /^(?:#{1,4}\s*)?(?:\*\*)?(user|human|me|you|prompt)(?:\*\*)?\s*[:--]?\s*$|^(?:#{1,4}\s*)?(?:\*\*)?(user|human|me|prompt)(?:\*\*)?\s*[:-]\s*(.+)$/i; | |
| + | const assistantMarkers = | |
| + | /^(?:#{1,4}\s*)?(?:\*\*)?(assistant|ai|chatgpt|claude|gpt|gemini|model|response)(?:\*\*)?\s*[:--]?\s*/i; | |
| + | ||
| + | const prompts = []; | |
| + | let current = null; | |
| + | let sawMarkers = false; | |
| + | ||
| + | for (const line of lines) { | |
| + | const userMatch = line.match(markers); | |
| + | if (userMatch) { | |
| + | sawMarkers = true; | |
| + | if (current && current.text.trim()) prompts.push(current); | |
| + | current = { text: userMatch[3] ? `${userMatch[3]}\n` : '', uuid: null, parentUuid: null, ts: null }; | |
| + | continue; | |
| + | } | |
| + | if (assistantMarkers.test(line)) { | |
| + | sawMarkers = true; | |
| + | if (current && current.text.trim()) prompts.push(current); | |
| + | current = null; | |
| + | continue; | |
| + | } | |
| + | if (current) current.text += `${line}\n`; | |
| + | } | |
| + | if (current && current.text.trim()) prompts.push(current); | |
| + | ||
| + | if (!sawMarkers) { | |
| + | throw new Error( | |
| + | 'could not find user/assistant turn markers in the transcript. ' + | |
| + | 'Expected lines like "User:", "## User", "Human:", "Assistant:" separating turns.' | |
| + | ); | |
| + | } | |
| + | ||
| + | return { | |
| + | sessionId: label, | |
| + | path: label, | |
| + | title: null, | |
| + | version: null, | |
| + | cwd: null, | |
| + | gitBranch: null, | |
| + | firstTs: null, | |
| + | lastTs: null, | |
| + | prompts: prompts.map((p) => ({ ...p, text: p.text.trim(), userType: 'external' })), | |
| + | index: new Map(), | |
| + | leafUuid: null, | |
| + | stats: { | |
| + | userLines: prompts.length, | |
| + | assistantLines: 0, | |
| + | toolUses: 0, | |
| + | models: [], | |
| + | filesTouched: [], | |
| + | inputTokens: 0, | |
| + | outputTokens: 0, | |
| + | interruptions: 0, | |
| + | }, | |
| + | isContinuation: false, | |
| + | continuationOf: null, | |
| + | }; | |
| + | } |
| @@ -0,0 +1,186 @@ | ||
| + | import { createInterface } from 'node:readline/promises'; | |
| + | import { sha256, shannonEntropy, truncate, c } from './util.js'; | |
| + | ||
| + | /** | |
| + | * Secret/PII scanner + export gate. | |
| + | * | |
| + | * Philosophy: NOTHING leaves un-reviewed. In a TTY the user resolves every | |
| + | * unique hit (redact / keep / edit). Outside a TTY every hit is redacted | |
| + | * automatically - the tool fails closed, never open. After rendering, the | |
| + | * final artifact is shadow-scanned again; an unresolved high/medium hit at | |
| + | * that stage aborts the write. | |
| + | * | |
| + | * Rules are curated for precision (gitleaks-style provider formats) plus a | |
| + | * high-entropy fallback. False negatives are existential for a privacy tool, | |
| + | * false positives merely cost a keystroke - when in doubt, flag. | |
| + | */ | |
| + | ||
| + | export const RULES = [ | |
| + | // ---- high: unambiguous secret formats ---- | |
| + | { id: 'private-key-block', severity: 'high', re: /-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY( BLOCK)?-----|$)/g }, | |
| + | { id: 'aws-access-key', severity: 'high', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g }, | |
| + | { id: 'github-token', severity: 'high', re: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g }, | |
| + | { id: 'github-fine-grained', severity: 'high', re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g }, | |
| + | { id: 'gitlab-token', severity: 'high', re: /\bglpat-[0-9a-zA-Z_-]{20,}\b/g }, | |
| + | { id: 'anthropic-key', severity: 'high', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g }, | |
| + | { id: 'openai-key', severity: 'high', re: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/g }, | |
| + | { id: 'slack-token', severity: 'high', re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g }, | |
| + | { id: 'stripe-live-key', severity: 'high', re: /\b[sr]k_live_[0-9a-zA-Z]{10,}\b/g }, | |
| + | { id: 'npm-token', severity: 'high', re: /\bnpm_[A-Za-z0-9]{36}\b/g }, | |
| + | { id: 'tailscale-key', severity: 'high', re: /\btskey-[a-zA-Z0-9-]{10,}\b/g }, | |
| + | { id: 'google-api-key', severity: 'high', re: /\bAIza[0-9A-Za-z_-]{35}\b/g }, | |
| + | { id: 'sendgrid-key', severity: 'high', re: /\bSG\.[A-Za-z0-9_-]{16,32}\.[A-Za-z0-9_-]{16,64}\b/g }, | |
| + | { id: 'twilio-key', severity: 'high', re: /\bSK[0-9a-fA-F]{32}\b/g }, | |
| + | { id: 'telegram-bot-token', severity: 'high', re: /\b\d{8,10}:AA[A-Za-z0-9_-]{32,33}\b/g }, | |
| + | { id: 'discord-webhook', severity: 'high', re: /https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/g }, | |
| + | { id: 'jwt', severity: 'high', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g }, | |
| + | ||
| + | // ---- medium: context-dependent assignments ---- | |
| + | { id: 'wireguard-key', severity: 'medium', re: /\b(PrivateKey|PresharedKey)\s*=\s*[A-Za-z0-9+/]{42,44}=?/g }, | |
| + | { id: 'url-basic-auth', severity: 'medium', re: /[a-z][a-z0-9+.-]*:\/\/[^/\s:@'"`]{2,}:[^/\s:@'"`]{2,}@[^\s'"`]+/gi }, | |
| + | { id: 'bearer-header', severity: 'medium', re: /\bBearer\s+[A-Za-z0-9._+/=-]{20,}\b/g }, | |
| + | { id: 'secret-assignment', severity: 'medium', re: /\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"]?(?!\$\{|<|%|\*{3}|\.{3}|REDACTED|xxx+|placeholder|changeme|example|your[-_])[^\s'"`,;]{8,}/gi }, | |
| + | ||
| + | // ---- soft: PII and context the user may want to keep ---- | |
| + | { id: 'email', severity: 'soft', re: /\b[A-Za-z0-9._%+-]+@(?!(?:users\.noreply\.github\.com|example\.(?:com|org)))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g }, | |
| + | { id: 'ipv4', severity: 'soft', re: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b(?!\.\d)/g }, | |
| + | { id: 'home-dir-username', severity: 'soft', re: /(?:\/(?:home|Users)\/|C:\\Users\\)([A-Za-z][A-Za-z0-9._-]{2,30})\b/g }, | |
| + | ]; | |
| + | ||
| + | const HEX_RE = /^[0-9a-fA-F]+$/; | |
| + | const ENTROPY_CANDIDATE_RE = /\b[A-Za-z0-9+/_=-]{32,}\b/g; | |
| + | const VERSION_LIKE_RE = /^\d+[.\d-]*$/; | |
| + | ||
| + | export function scanText(text) { | |
| + | const findings = []; | |
| + | for (const rule of RULES) { | |
| + | rule.re.lastIndex = 0; | |
| + | let m; | |
| + | while ((m = rule.re.exec(text)) !== null) { | |
| + | findings.push({ | |
| + | ruleId: rule.id, | |
| + | severity: rule.severity, | |
| + | match: m[0], | |
| + | index: m.index, | |
| + | }); | |
| + | if (m.index === rule.re.lastIndex) rule.re.lastIndex++; | |
| + | } | |
| + | } | |
| + | ||
| + | // High-entropy fallback: long mixed-charset tokens that no provider rule | |
| + | // caught. Pure hex (git SHAs, digests) and uuids excluded - too noisy. | |
| + | const seenSpans = findings.map((f) => [f.index, f.index + f.match.length]); | |
| + | ENTROPY_CANDIDATE_RE.lastIndex = 0; | |
| + | let m; | |
| + | while ((m = ENTROPY_CANDIDATE_RE.exec(text)) !== null) { | |
| + | const tok = m[0]; | |
| + | if (HEX_RE.test(tok) || VERSION_LIKE_RE.test(tok)) continue; | |
| + | if (!/[A-Z]/.test(tok) || !/[a-z]/.test(tok) || !/[0-9]/.test(tok)) continue; | |
| + | if (shannonEntropy(tok) < 4.4) continue; | |
| + | const start = m.index; | |
| + | if (seenSpans.some(([s, e]) => start >= s && start < e)) continue; | |
| + | findings.push({ ruleId: 'high-entropy-token', severity: 'medium', match: tok, index: start }); | |
| + | } | |
| + | ||
| + | return findings; | |
| + | } | |
| + | ||
| + | export function maskFor(finding) { | |
| + | return `[REDACTED:${finding.ruleId}]`; | |
| + | } | |
| + | ||
| + | /** | |
| + | * Resolve findings into decisions, keyed by sha256(match). | |
| + | * decision = { action: 'redact'|'keep', replacement, ruleId } | |
| + | */ | |
| + | export async function resolveFindings(findings, priorDecisions, { interactive, autoRedact }) { | |
| + | const decisions = { ...priorDecisions }; | |
| + | const unique = new Map(); // hash -> { finding, count } | |
| + | for (const f of findings) { | |
| + | const h = sha256(f.match); | |
| + | if (!unique.has(h)) unique.set(h, { finding: f, count: 0 }); | |
| + | unique.get(h).count++; | |
| + | } | |
| + | ||
| + | const unresolved = [...unique.entries()].filter(([h]) => !decisions[h]); | |
| + | if (!unresolved.length) return { decisions, asked: 0 }; | |
| + | ||
| + | if (!interactive || autoRedact) { | |
| + | for (const [h, { finding }] of unresolved) { | |
| + | decisions[h] = { action: 'redact', replacement: maskFor(finding), ruleId: finding.ruleId }; | |
| + | } | |
| + | return { decisions, asked: 0, autoRedacted: unresolved.length }; | |
| + | } | |
| + | ||
| + | const rl = createInterface({ input: process.stdin, output: process.stderr }); | |
| + | process.stderr.write( | |
| + | `\n${c.bold(`${unresolved.length} potential secret${unresolved.length === 1 ? '' : 's'} found`)} - nothing is exported until each is resolved.\n\n` | |
| + | ); | |
| + | let i = 0; | |
| + | for (const [h, { finding, count }] of unresolved) { | |
| + | i++; | |
| + | const sev = | |
| + | finding.severity === 'high' ? c.red(finding.severity) | |
| + | : finding.severity === 'medium' ? c.yellow(finding.severity) | |
| + | : c.gray(finding.severity); | |
| + | process.stderr.write( | |
| + | `${c.dim(`[${i}/${unresolved.length}]`)} ${sev} ${c.bold(finding.ruleId)} ร${count}\n ${c.cyan(truncate(finding.match, 72))}\n` | |
| + | ); | |
| + | let answer; | |
| + | for (;;) { | |
| + | answer = (await rl.question(` ${c.bold('[r]')}edact ${c.bold('[k]')}eep ${c.bold('[e]')}dit replacement โบ `)) | |
| + | .trim() | |
| + | .toLowerCase(); | |
| + | if (['r', 'k', 'e', 'redact', 'keep', 'edit', ''].includes(answer)) break; | |
| + | } | |
| + | if (answer === 'k' || answer === 'keep') { | |
| + | decisions[h] = { action: 'keep', ruleId: finding.ruleId }; | |
| + | } else if (answer === 'e' || answer === 'edit') { | |
| + | const replacement = (await rl.question(' replacement text โบ ')).trim() || maskFor(finding); | |
| + | decisions[h] = { action: 'redact', replacement, ruleId: finding.ruleId }; | |
| + | } else { | |
| + | decisions[h] = { action: 'redact', replacement: maskFor(finding), ruleId: finding.ruleId }; | |
| + | } | |
| + | } | |
| + | rl.close(); | |
| + | return { decisions, asked: unresolved.length }; | |
| + | } | |
| + | ||
| + | /** | |
| + | * Apply redaction decisions to text. Decisions are keyed by sha256(match) and | |
| + | * deliberately never store the secret itself (the persisted decision file must | |
| + | * be safe to commit); the raw strings come from this run's findings. | |
| + | */ | |
| + | export function applyDecisions(text, findings, decisions) { | |
| + | const toRedact = new Map(); // original -> replacement | |
| + | for (const f of findings) { | |
| + | const d = decisions[sha256(f.match)]; | |
| + | if (d && d.action === 'redact') { | |
| + | toRedact.set(f.match, d.replacement || maskFor(f)); | |
| + | } | |
| + | } | |
| + | let out = text; | |
| + | // Longest matches first so substrings of larger secrets don't pre-empt them. | |
| + | for (const [original, replacement] of [...toRedact.entries()].sort( | |
| + | (a, b) => b[0].length - a[0].length | |
| + | )) { | |
| + | out = out.split(original).join(replacement); | |
| + | } | |
| + | return out; | |
| + | } | |
| + | ||
| + | /** | |
| + | * Shadow scan: run after rendering. Any high/medium finding that is not an | |
| + | * explicit "keep" means the gate failed - abort, never write. | |
| + | */ | |
| + | export function shadowScan(renderedText, decisions) { | |
| + | const leaks = []; | |
| + | for (const f of scanText(renderedText)) { | |
| + | if (f.severity === 'soft') continue; | |
| + | const d = decisions[sha256(f.match)]; | |
| + | if (d && d.action === 'keep') continue; | |
| + | if (f.match.startsWith('[REDACTED:')) continue; | |
| + | leaks.push(f); | |
| + | } | |
| + | return leaks; | |
| + | } |
| @@ -0,0 +1,74 @@ | ||
| + | /** | |
| + | * Machine-readable export: treetrace lineage schema v0.1. | |
| + | * Documented in SCHEMA.md with a mapping to the Agent Trace RFC. | |
| + | */ | |
| + | ||
| + | const RELATIONSHIP_BY_KIND = { | |
| + | direction: 'refines', | |
| + | correction: 'corrects', | |
| + | 'scope-change': 'expands', | |
| + | checkpoint: 'checkpoints', | |
| + | question: 'asks', | |
| + | root: 'refines', | |
| + | }; | |
| + | ||
| + | export function renderJson(tree, opts = {}) { | |
| + | const { projectName, generatedBy = 'treetrace', version = '0.1.0' } = opts; | |
| + | const { nodes, sessions, stats } = tree; | |
| + | ||
| + | return { | |
| + | schemaVersion: '0.1', | |
| + | generator: { name: generatedBy, version, url: 'https://github.com/zionboggan/treetrace' }, | |
| + | project: { | |
| + | name: projectName, | |
| + | generatedAt: opts.generatedAt || null, | |
| + | sourceType: 'claude-code-jsonl', | |
| + | }, | |
| + | stats: { | |
| + | prompts: stats.promptCount, | |
| + | sessions: stats.sessionCount, | |
| + | days: stats.days, | |
| + | corrections: stats.corrections, | |
| + | scopeChanges: stats.scopeChanges, | |
| + | checkpoints: stats.checkpoints, | |
| + | abandonedBranches: stats.abandonedBranches, | |
| + | toolUses: stats.toolUses, | |
| + | filesTouched: stats.filesTouched, | |
| + | models: stats.models, | |
| + | firstTs: stats.firstTs, | |
| + | lastTs: stats.lastTs, | |
| + | }, | |
| + | sessions: sessions | |
| + | .filter((s) => s.prompts.length) | |
| + | .map((s) => ({ | |
| + | id: s.sessionId, | |
| + | title: s.title, | |
| + | firstTs: s.firstTs, | |
| + | lastTs: s.lastTs, | |
| + | promptCount: s.prompts.length, | |
| + | isContinuation: s.isContinuation, | |
| + | })), | |
| + | nodes: nodes.map((n) => ({ | |
| + | id: n.id, | |
| + | parentId: n.parent ? n.parent.id : null, | |
| + | role: 'user', | |
| + | kind: n.kind, | |
| + | title: n.title, | |
| + | text: n.text, | |
| + | status: n.status, | |
| + | nudges: n.nudges || 0, | |
| + | session: n.sessionId, | |
| + | timestamp: n.ts, | |
| + | // source linkage for audit: the original record uuid inside the local | |
| + | // session transcript (raw transcripts themselves are never exported) | |
| + | sourceEventIds: n.uuid ? [n.uuid] : [], | |
| + | })), | |
| + | edges: nodes | |
| + | .filter((n) => n.parent) | |
| + | .map((n) => ({ | |
| + | from: n.parent.id, | |
| + | to: n.id, | |
| + | relationship: RELATIONSHIP_BY_KIND[n.kind] || 'refines', | |
| + | })), | |
| + | }; | |
| + | } |
| @@ -0,0 +1,205 @@ | ||
| + | import { truncate, plural, formatDay, mdEscapePipe } from './util.js'; | |
| + | ||
| + | const ICONS = { | |
| + | root: 'โฌข', | |
| + | direction: 'โ', | |
| + | correction: 'โฉ', | |
| + | 'scope-change': 'โ', | |
| + | checkpoint: 'โ', | |
| + | question: '?', | |
| + | }; | |
| + | ||
| + | const REPO_URL = 'https://github.com/zionboggan/treetrace'; | |
| + | const MAX_NODE_TEXT = 1500; | |
| + | ||
| + | export function renderMarkdown(tree, opts = {}) { | |
| + | const { projectName, titlesOnly = false } = opts; | |
| + | const { stats, roots, nodes, sessions } = tree; | |
| + | const lines = []; | |
| + | ||
| + | lines.push(`# ๐ณ Prompt Tree - ${projectName}`); | |
| + | lines.push(''); | |
| + | lines.push(`> ${banner(stats)}`); | |
| + | lines.push('>'); | |
| + | lines.push( | |
| + | `> The prompt lineage that built this project - extracted from real sessions, curated and redacted by the author, generated by [treetrace](${REPO_URL}).` | |
| + | ); | |
| + | lines.push(''); | |
| + | ||
| + | // Goal | |
| + | const root = nodes.find((n) => n.kind === 'root') || nodes[0]; | |
| + | if (root) { | |
| + | lines.push('## Goal'); | |
| + | lines.push(''); | |
| + | lines.push(blockquote(clip(root.text, 900))); | |
| + | lines.push(''); | |
| + | } | |
| + | ||
| + | // The Path | |
| + | lines.push('## The Path'); | |
| + | lines.push(''); | |
| + | lines.push( | |
| + | '`โฌข` root ยท `โ` direction ยท `โฉ` correction ยท `โ` scope change ยท `โ` checkpoint ยท `?` question ยท `โ` abandoned' | |
| + | ); | |
| + | lines.push(''); | |
| + | for (const r of roots) renderNode(r, 0, lines, { titlesOnly }); | |
| + | lines.push(''); | |
| + | ||
| + | // Sessions timeline | |
| + | const active = sessions.filter((s) => s.prompts.length); | |
| + | if (active.length > 1) { | |
| + | lines.push('## Sessions'); | |
| + | lines.push(''); | |
| + | lines.push('| # | When | Prompts | Session |'); | |
| + | lines.push('|---|------|---------|---------|'); | |
| + | active.forEach((s, i) => { | |
| + | lines.push( | |
| + | `| ${i + 1} | ${formatDay(s.firstTs) || '-'} | ${s.prompts.length} | ${mdEscapePipe( | |
| + | s.title || s.sessionId || '-' | |
| + | )} |` | |
| + | ); | |
| + | }); | |
| + | lines.push(''); | |
| + | } | |
| + | ||
| + | // Corrections & dead ends - the honest part, and the interesting part. | |
| + | const corrections = nodes.filter((n) => n.kind === 'correction'); | |
| + | const abandoned = nodes.filter( | |
| + | (n) => n.status === 'abandoned' && (!n.parent || n.parent.status !== 'abandoned') | |
| + | ); | |
| + | if (corrections.length || abandoned.length) { | |
| + | lines.push('## Course corrections & dead ends'); | |
| + | lines.push(''); | |
| + | if (abandoned.length) { | |
| + | lines.push(`**${plural(abandoned.length, 'abandoned branch', 'abandoned branches')}:**`); | |
| + | lines.push(''); | |
| + | for (const n of abandoned) { | |
| + | lines.push(`- โ ${truncate(n.title, 110)}`); | |
| + | } | |
| + | lines.push(''); | |
| + | } | |
| + | if (corrections.length) { | |
| + | lines.push(`**${plural(corrections.length, 'correction')} along the way:**`); | |
| + | lines.push(''); | |
| + | for (const n of corrections) { | |
| + | lines.push(`- โฉ ${truncate(n.title, 110)}`); | |
| + | } | |
| + | lines.push(''); | |
| + | } | |
| + | } | |
| + | ||
| + | // Reusable prompt pack | |
| + | lines.push('## Reusable Prompt Pack'); | |
| + | lines.push(''); | |
| + | lines.push( | |
| + | 'A distilled, replayable version of the accepted path - paste into a fresh agent to rebuild something like this:' | |
| + | ); | |
| + | lines.push(''); | |
| + | lines.push('```text'); | |
| + | lines.push(promptPack(nodes)); | |
| + | lines.push('```'); | |
| + | lines.push(''); | |
| + | ||
| + | lines.push('---'); | |
| + | lines.push(''); | |
| + | lines.push( | |
| + | `*Generated by [treetrace](${REPO_URL}) ยท ${plural(stats.promptCount, 'prompt')} across ${plural( | |
| + | stats.sessionCount, | |
| + | 'session' | |
| + | )}${stats.models.length ? ` ยท ${stats.models.join(', ')}` : ''} ยท machine-readable lineage in \`.treetrace/tree.json\` ([schema](${REPO_URL}/blob/main/SCHEMA.md))*` | |
| + | ); | |
| + | lines.push(''); | |
| + | ||
| + | return lines.join('\n'); | |
| + | } | |
| + | ||
| + | function banner(stats) { | |
| + | const parts = [ | |
| + | `**${plural(stats.promptCount, 'prompt')}**`, | |
| + | `**${plural(stats.sessionCount, 'session')}**`, | |
| + | ]; | |
| + | if (stats.days) parts.push(`**${plural(stats.days, 'day')}**`); | |
| + | if (stats.corrections) parts.push(plural(stats.corrections, 'correction')); | |
| + | if (stats.scopeChanges) parts.push(plural(stats.scopeChanges, 'scope change')); | |
| + | if (stats.abandonedBranches) | |
| + | parts.push(plural(stats.abandonedBranches, 'abandoned branch', 'abandoned branches')); | |
| + | if (stats.toolUses) parts.push(`${stats.toolUses.toLocaleString()} tool calls`); | |
| + | if (stats.filesTouched) parts.push(`${plural(stats.filesTouched, 'file')} touched`); | |
| + | return parts.join(' ยท '); | |
| + | } | |
| + | ||
| + | // Real sessions are mostly linear: render single-child chains flat and indent | |
| + | // only at genuine forks, otherwise long projects become an unreadable staircase. | |
| + | function renderNode(node, depth, lines, opts) { | |
| + | let cur = node; | |
| + | for (;;) { | |
| + | emitNode(cur, depth, lines, opts); | |
| + | if (cur.children.length === 1) { | |
| + | cur = cur.children[0]; | |
| + | continue; | |
| + | } | |
| + | for (const child of cur.children) renderNode(child, depth + 1, lines, opts); | |
| + | return; | |
| + | } | |
| + | } | |
| + | ||
| + | function emitNode(node, depth, lines, { titlesOnly }) { | |
| + | const indent = ' '.repeat(depth); | |
| + | const icon = ICONS[node.kind] || 'โ'; | |
| + | const dead = node.status === 'abandoned'; | |
| + | const title = dead ? `~~${node.title}~~ โ` : node.kind === 'root' ? `**${node.title}**` : node.title; | |
| + | const session = node.sessionBoundary ? ` ${dim(`(new session${node.ts ? `, ${formatDay(node.ts)}` : ''})`)}` : ''; | |
| + | const nudges = node.nudges > 1 ? ` ${dim(`(+${node.nudges} nudges)`)}` : ''; | |
| + | ||
| + | lines.push(`${indent}- \`${icon}\` ${title}${session}${nudges}`); | |
| + | ||
| + | if (!titlesOnly && node.text.replace(/\s+/g, ' ').trim().length > node.title.replace(/\.\.\.$/, '').length + 12) { | |
| + | lines.push(`${indent} <details><summary>full prompt</summary>`); | |
| + | lines.push(''); | |
| + | lines.push(blockquote(clip(node.text, MAX_NODE_TEXT), indent + ' ')); | |
| + | lines.push(`${indent} </details>`); | |
| + | } | |
| + | } | |
| + | ||
| + | function dim(s) { | |
| + | return `<sub>${s}</sub>`; | |
| + | } | |
| + | ||
| + | function blockquote(text, indent = '') { | |
| + | return text | |
| + | .split('\n') | |
| + | .map((l) => `${indent}> ${l}`) | |
| + | .join('\n'); | |
| + | } | |
| + | ||
| + | function clip(text, max) { | |
| + | if (text.length <= max) return text; | |
| + | return `${text.slice(0, max).trimEnd()}\n\n*[...trimmed, ${text.length - max} more chars]*`; | |
| + | } | |
| + | ||
| + | /** | |
| + | * The distilled, replayable instruction list: the accepted spine of the tree. | |
| + | * Corrections merge into their parent as constraints; abandoned branches are | |
| + | * dropped; questions and checkpoints are skipped. | |
| + | */ | |
| + | export function promptPack(nodes) { | |
| + | const accepted = nodes.filter( | |
| + | (n) => | |
| + | n.status !== 'abandoned' && | |
| + | (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') | |
| + | ); | |
| + | const out = []; | |
| + | accepted.forEach((n, i) => { | |
| + | const corrections = n.children?.filter((ch) => ch.kind === 'correction' && ch.status !== 'abandoned') || []; | |
| + | let entry = `${i + 1}. ${condense(n.text)}`; | |
| + | for (const corr of corrections) { | |
| + | entry += `\n (constraint learned along the way: ${condense(corr.text, 220)})`; | |
| + | } | |
| + | out.push(entry); | |
| + | }); | |
| + | return out.join('\n'); | |
| + | } | |
| + | ||
| + | function condense(text, max = 420) { | |
| + | return truncate(text.replace(/\s+/g, ' '), max); | |
| + | } |
| @@ -0,0 +1,153 @@ | ||
| + | import { daySpan } from './util.js'; | |
| + | ||
| + | /** | |
| + | * Build the lineage tree from classified prompt nodes + session topology. | |
| + | * | |
| + | * Claude Code records form a DAG via parentUuid: rewinds and forks create | |
| + | * real branches. The "main path" of a session is the ancestor chain of its | |
| + | * final record; prompts off that chain were abandoned (rewound away). | |
| + | */ | |
| + | export function buildTree(sessions, nodes) { | |
| + | const byUuid = new Map(); | |
| + | for (const node of nodes) if (node.uuid) byUuid.set(node.uuid, node); | |
| + | ||
| + | // Per-session main-path sets (uuids of records that "made it" to the end). | |
| + | const mainPaths = new Map(); | |
| + | for (const session of sessions) { | |
| + | const main = new Set(); | |
| + | let cur = session.leafUuid; | |
| + | let guard = 0; | |
| + | while (cur && guard++ < 1_000_000) { | |
| + | main.add(cur); | |
| + | cur = session.index.get(cur)?.parentUuid || null; | |
| + | } | |
| + | mainPaths.set(session.sessionId, main); | |
| + | } | |
| + | ||
| + | // Parent resolution: walk up the record chain to the nearest prompt node. | |
| + | const sessionById = new Map(sessions.map((s) => [s.sessionId, s])); | |
| + | for (const node of nodes) { | |
| + | node.parent = null; | |
| + | if (!node.uuid) continue; | |
| + | const session = sessionById.get(node.sessionId); | |
| + | if (!session) continue; | |
| + | let cur = node.parentUuid; | |
| + | let guard = 0; | |
| + | while (cur && guard++ < 1_000_000) { | |
| + | const hit = byUuid.get(cur); | |
| + | if (hit) { | |
| + | node.parent = hit; | |
| + | break; | |
| + | } | |
| + | cur = session.index.get(cur)?.parentUuid || null; | |
| + | } | |
| + | } | |
| + | ||
| + | // Session ordering by first activity, then chain sessions together: | |
| + | // the first parentless node of a session hangs off the previous session's | |
| + | // last main-path node. | |
| + | const ordered = [...sessions].sort((a, b) => | |
| + | String(a.firstTs || '').localeCompare(String(b.firstTs || '')) | |
| + | ); | |
| + | const nodesBySession = new Map(); | |
| + | for (const node of nodes) { | |
| + | if (!nodesBySession.has(node.sessionId)) nodesBySession.set(node.sessionId, []); | |
| + | nodesBySession.get(node.sessionId).push(node); | |
| + | } | |
| + | ||
| + | let prevTail = null; | |
| + | for (const session of ordered) { | |
| + | const sNodes = nodesBySession.get(session.sessionId) || []; | |
| + | if (!sNodes.length) continue; | |
| + | for (const node of sNodes) { | |
| + | if (!node.parent && node !== sNodes[0]) { | |
| + | // orphan mid-session (uuid chain broken) - chain linearly | |
| + | node.parent = sNodes[sNodes.indexOf(node) - 1]; | |
| + | } | |
| + | } | |
| + | if (!sNodes[0].parent && prevTail) { | |
| + | sNodes[0].parent = prevTail; | |
| + | sNodes[0].sessionBoundary = true; | |
| + | } else if (!sNodes[0].parent) { | |
| + | sNodes[0].sessionBoundary = true; | |
| + | } | |
| + | const main = mainPaths.get(session.sessionId) || new Set(); | |
| + | const tail = [...sNodes].reverse().find((n) => !n.uuid || main.has(n.uuid)); | |
| + | prevTail = tail || sNodes[sNodes.length - 1]; | |
| + | } | |
| + | ||
| + | // Status: a prompt is abandoned only if it sits on a dead side-branch of a | |
| + | // REAL fork - i.e. walking up its record chain reaches a node that IS on the | |
| + | // session's main path while the prompt itself is not. parentUuid chains can | |
| + | // reset mid-file (bridge events, compaction); a broken chain is not a fork, | |
| + | // so prompts above a break stay accepted. | |
| + | for (const node of nodes) { | |
| + | if (!node.uuid) continue; | |
| + | const main = mainPaths.get(node.sessionId); | |
| + | const session = sessionById.get(node.sessionId); | |
| + | if (!main || !main.size || !session || main.has(node.uuid)) continue; | |
| + | let cur = node.parentUuid; | |
| + | let guard = 0; | |
| + | while (cur && guard++ < 1_000_000) { | |
| + | if (main.has(cur)) { | |
| + | node.status = 'abandoned'; | |
| + | break; | |
| + | } | |
| + | cur = session.index.get(cur)?.parentUuid || null; | |
| + | } | |
| + | } | |
| + | ||
| + | // ids + children | |
| + | nodes.forEach((n, i) => { | |
| + | n.id = `node_${String(i + 1).padStart(3, '0')}`; | |
| + | n.children = []; | |
| + | }); | |
| + | const roots = []; | |
| + | for (const node of nodes) { | |
| + | if (node.parent) node.parent.children.push(node); | |
| + | else roots.push(node); | |
| + | } | |
| + | ||
| + | return { roots, nodes, sessions: ordered, stats: computeStats(ordered, nodes) }; | |
| + | } | |
| + | ||
| + | function computeStats(sessions, nodes) { | |
| + | const byKind = {}; | |
| + | for (const node of nodes) byKind[node.kind] = (byKind[node.kind] || 0) + 1; | |
| + | ||
| + | const abandonedRoots = nodes.filter( | |
| + | (n) => n.status === 'abandoned' && (!n.parent || n.parent.status !== 'abandoned') | |
| + | ); | |
| + | ||
| + | const models = new Set(); | |
| + | const filesTouched = new Set(); | |
| + | let toolUses = 0; | |
| + | let interruptions = 0; | |
| + | const timestamps = []; | |
| + | for (const s of sessions) { | |
| + | for (const m of s.stats.models) models.add(m); | |
| + | for (const f of s.stats.filesTouched) filesTouched.add(f); | |
| + | toolUses += s.stats.toolUses; | |
| + | interruptions += s.stats.interruptions; | |
| + | if (s.firstTs) timestamps.push(s.firstTs); | |
| + | if (s.lastTs) timestamps.push(s.lastTs); | |
| + | } | |
| + | ||
| + | return { | |
| + | promptCount: nodes.length, | |
| + | sessionCount: sessions.filter((s) => s.prompts.length).length, | |
| + | byKind, | |
| + | corrections: byKind['correction'] || 0, | |
| + | scopeChanges: byKind['scope-change'] || 0, | |
| + | checkpoints: byKind['checkpoint'] || 0, | |
| + | abandonedBranches: abandonedRoots.length, | |
| + | nudges: nodes.reduce((acc, n) => acc + n.nudges, 0), | |
| + | interruptions, | |
| + | toolUses, | |
| + | filesTouched: filesTouched.size, | |
| + | models: [...models], | |
| + | days: daySpan(timestamps), | |
| + | firstTs: timestamps.length ? timestamps.slice().sort()[0] : null, | |
| + | lastTs: timestamps.length ? timestamps.slice().sort().at(-1) : null, | |
| + | }; | |
| + | } |
| @@ -0,0 +1,77 @@ | ||
| + | import { createHash } from 'node:crypto'; | |
| + | ||
| + | const useColor = | |
| + | process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb'; | |
| + | ||
| + | const wrap = (open, close) => (s) => | |
| + | useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s); | |
| + | ||
| + | export const c = { | |
| + | bold: wrap(1, 22), | |
| + | dim: wrap(2, 22), | |
| + | red: wrap(31, 39), | |
| + | green: wrap(32, 39), | |
| + | yellow: wrap(33, 39), | |
| + | blue: wrap(34, 39), | |
| + | magenta: wrap(35, 39), | |
| + | cyan: wrap(36, 39), | |
| + | gray: wrap(90, 39), | |
| + | }; | |
| + | ||
| + | export function sha256(text) { | |
| + | return createHash('sha256').update(text, 'utf8').digest('hex'); | |
| + | } | |
| + | ||
| + | export function truncate(s, n = 80) { | |
| + | if (!s) return ''; | |
| + | const one = s.replace(/\s+/g, ' ').trim(); | |
| + | return one.length <= n ? one : `${one.slice(0, n - 1).trimEnd()}...`; | |
| + | } | |
| + | ||
| + | export function plural(n, word, pluralWord) { | |
| + | return `${n} ${n === 1 ? word : pluralWord || `${word}s`}`; | |
| + | } | |
| + | ||
| + | export function formatDuration(ms) { | |
| + | if (!Number.isFinite(ms) || ms <= 0) return null; | |
| + | const minutes = Math.round(ms / 60000); | |
| + | if (minutes < 60) return `${minutes} min`; | |
| + | const hours = ms / 3600000; | |
| + | if (hours < 48) return `${Math.round(hours * 10) / 10} hours`; | |
| + | const days = Math.round(ms / 86400000); | |
| + | return `${days} days`; | |
| + | } | |
| + | ||
| + | export function formatDay(ts) { | |
| + | if (!ts) return null; | |
| + | const d = new Date(ts); | |
| + | if (Number.isNaN(d.getTime())) return null; | |
| + | return d.toISOString().slice(0, 10); | |
| + | } | |
| + | ||
| + | // Span of calendar days covered by a set of timestamps, e.g. "9 days" / "1 day". | |
| + | export function daySpan(timestamps) { | |
| + | const valid = timestamps.map((t) => new Date(t).getTime()).filter(Number.isFinite); | |
| + | if (!valid.length) return null; | |
| + | const span = Math.max(...valid) - Math.min(...valid); | |
| + | const days = Math.max(1, Math.ceil(span / 86400000)); | |
| + | return days; | |
| + | } | |
| + | ||
| + | export function shannonEntropy(s) { | |
| + | if (!s) return 0; | |
| + | const freq = new Map(); | |
| + | for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1); | |
| + | let entropy = 0; | |
| + | for (const count of freq.values()) { | |
| + | const p = count / s.length; | |
| + | entropy -= p * Math.log2(p); | |
| + | } | |
| + | return entropy; | |
| + | } | |
| + | ||
| + | // Escape text destined for a Markdown document so it cannot break out of its | |
| + | // surrounding structure (tables, emphasis). Conservative: only what's needed. | |
| + | export function mdEscapePipe(s) { | |
| + | return String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); | |
| + | } |
| @@ -0,0 +1,19 @@ | ||
| + | {"type":"summary","summary":"Build a weather dashboard","leafUuid":"u9"} | |
| + | {"type":"mode","mode":"normal","sessionId":"fix-001"} | |
| + | {"type":"permission-mode","permissionMode":"default","sessionId":"fix-001"} | |
| + | {"parentUuid":null,"isSidechain":false,"type":"user","userType":"external","uuid":"u1","sessionId":"fix-001","timestamp":"2026-06-01T10:00:00.000Z","cwd":"/tmp/demo","gitBranch":"main","version":"2.1.0","message":{"role":"user","content":"Build a weather dashboard web app that shows the forecast for Memphis using the NWS API. Keep it a single static page."}} | |
| + | {"parentUuid":"u1","isSidechain":false,"type":"assistant","uuid":"a1","sessionId":"fix-001","timestamp":"2026-06-01T10:00:30.000Z","message":{"role":"assistant","model":"assistant-model","usage":{"input_tokens":1200,"output_tokens":400},"content":[{"type":"text","text":"On it."},{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"/tmp/demo/index.html","content":"<html>"}}]}} | |
| + | {"parentUuid":"a1","isSidechain":false,"type":"user","uuid":"u2","sessionId":"fix-001","timestamp":"2026-06-01T10:01:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}} | |
| + | {"parentUuid":"u2","isSidechain":false,"type":"assistant","uuid":"a2","sessionId":"fix-001","timestamp":"2026-06-01T10:01:10.000Z","message":{"role":"assistant","model":"assistant-model","usage":{"input_tokens":1300,"output_tokens":300},"content":[{"type":"text","text":"Done - static page written."}]}} | |
| + | {"parentUuid":"a2","isSidechain":false,"type":"user","userType":"external","uuid":"u3","sessionId":"fix-001","timestamp":"2026-06-01T10:02:00.000Z","message":{"role":"user","content":"<command-name>/status</command-name>"}} | |
| + | {"parentUuid":"u3","isSidechain":false,"type":"user","userType":"external","uuid":"u4","sessionId":"fix-001","timestamp":"2026-06-01T10:03:00.000Z","message":{"role":"user","content":"continue"}} | |
| + | {"parentUuid":"u4","isSidechain":false,"type":"user","userType":"external","uuid":"u5","sessionId":"fix-001","timestamp":"2026-06-01T10:04:00.000Z","message":{"role":"user","content":"Try using leaflet for an interactive radar map layer on top of the forecast."}} | |
| + | {"parentUuid":"u5","isSidechain":false,"type":"assistant","uuid":"a3","sessionId":"fix-001","timestamp":"2026-06-01T10:05:00.000Z","message":{"role":"assistant","model":"assistant-model","usage":{"input_tokens":1400,"output_tokens":500},"content":[{"type":"text","text":"Added leaflet radar."},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"/tmp/demo/index.html"}}]}} | |
| + | {"parentUuid":"a3","isSidechain":false,"type":"user","uuid":"u6","sessionId":"fix-001","timestamp":"2026-06-01T10:06:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t2","content":"ok"}]}} | |
| + | {"parentUuid":"u5","isSidechain":false,"type":"assistant","uuid":"a4","sessionId":"fix-001","timestamp":"2026-06-01T10:08:00.000Z","message":{"role":"assistant","model":"assistant-model","usage":{"input_tokens":1500,"output_tokens":200},"content":[{"type":"text","text":"Rewound: dropping radar work."}]}} | |
| + | {"parentUuid":"a4","isSidechain":false,"type":"user","userType":"external","uuid":"u7","sessionId":"fix-001","timestamp":"2026-06-01T10:09:00.000Z","message":{"role":"user","content":"No, scrap the radar map, it is too heavy. Keep the page lightweight, just the forecast cards."}} | |
| + | {"parentUuid":"u7","isSidechain":true,"type":"user","uuid":"s1","sessionId":"fix-001","timestamp":"2026-06-01T10:09:30.000Z","message":{"role":"user","content":"You are a subagent. Search the codebase for leaflet references."}} | |
| + | {"parentUuid":"s1","isSidechain":true,"type":"assistant","uuid":"s2","sessionId":"fix-001","timestamp":"2026-06-01T10:09:40.000Z","message":{"role":"assistant","model":"assistant-model-mini","content":[{"type":"text","text":"none found"}]}} | |
| + | {"parentUuid":"u7","isSidechain":false,"type":"assistant","uuid":"a5","sessionId":"fix-001","timestamp":"2026-06-01T10:10:00.000Z","message":{"role":"assistant","model":"assistant-model","usage":{"input_tokens":1600,"output_tokens":350},"content":[{"type":"text","text":"Removed."}]}} | |
| + | {"parentUuid":"a5","isSidechain":false,"type":"user","userType":"external","uuid":"u8","sessionId":"fix-001","timestamp":"2026-06-01T10:11:00.000Z","message":{"role":"user","content":"[Request interrupted by user]"}} | |
| + | {"parentUuid":"u8","isSidechain":false,"type":"user","userType":"external","uuid":"u9","sessionId":"fix-001","timestamp":"2026-06-01T10:12:00.000Z","message":{"role":"user","content":"Actually wait - also add a settings panel so the user can switch cities. My test key is sk-ant-api03-FAKEFAKEFAKEFAKEFAKEFAKE1234 and the server is at https://admin:hunter2pass@weather.internal.example/api"}} |
| @@ -0,0 +1,160 @@ | ||
| + | import { test } from 'node:test'; | |
| + | import assert from 'node:assert/strict'; | |
| + | import { fileURLToPath } from 'node:url'; | |
| + | import { dirname, join } from 'node:path'; | |
| + | ||
| + | import { parseSessionFile, parsePlainTranscript, classifySpecialUserText } from '../src/parse.js'; | |
| + | import { classifyPrompts } from '../src/extract.js'; | |
| + | import { buildTree } from '../src/tree.js'; | |
| + | import { scanText, applyDecisions, shadowScan, maskFor, resolveFindings } from '../src/redact.js'; | |
| + | import { renderMarkdown, promptPack } from '../src/render-md.js'; | |
| + | import { renderJson } from '../src/render-json.js'; | |
| + | import { renderHandoff } from '../src/handoff.js'; | |
| + | import { mungePath } from '../src/discover.js'; | |
| + | import { sha256 } from '../src/util.js'; | |
| + | ||
| + | const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'synthetic-session.jsonl'); | |
| + | ||
| + | async function fixtureTree() { | |
| + | const session = await parseSessionFile(FIXTURE, { sessionId: 'fix-001' }); | |
| + | const nodes = classifyPrompts([session]); | |
| + | return { session, nodes, tree: buildTree([session], nodes) }; | |
| + | } | |
| + | ||
| + | test('parser: extracts only human prompts, skips tool results/commands/sidechains', async () => { | |
| + | const { session } = await fixtureTree(); | |
| + | // u1, u5, u7, u9 are human; u4 ("continue") also collected pre-classification | |
| + | assert.equal(session.prompts.length, 5); | |
| + | assert.ok(session.prompts.every((p) => !p.text.startsWith('<command-name>'))); | |
| + | assert.ok(!session.prompts.some((p) => p.text.includes('subagent'))); | |
| + | assert.equal(session.title, 'Build a weather dashboard'); | |
| + | assert.equal(session.stats.toolUses, 2); | |
| + | assert.equal(session.stats.interruptions, 1); | |
| + | assert.deepEqual(session.stats.models, ['assistant-model']); | |
| + | assert.equal(session.stats.filesTouched.length, 1); | |
| + | }); | |
| + | ||
| + | test('extractor: classification kinds and nudge folding', async () => { | |
| + | const { nodes } = await fixtureTree(); | |
| + | // "continue" folds into root as a nudge โ 4 nodes | |
| + | assert.equal(nodes.length, 4); | |
| + | assert.equal(nodes[0].kind, 'root'); | |
| + | assert.equal(nodes[0].nudges, 1); | |
| + | assert.equal(nodes[1].kind, 'direction'); // leaflet radar | |
| + | assert.equal(nodes[2].kind, 'correction'); // "No, scrap the radar map" | |
| + | assert.equal(nodes[3].kind, 'scope-change'); // "also add a settings panel" | |
| + | assert.equal(nodes[3].afterInterruption, true); | |
| + | }); | |
| + | ||
| + | test('tree: fork detection marks rewound branch abandoned', async () => { | |
| + | const { tree } = await fixtureTree(); | |
| + | const leaflet = tree.nodes.find((n) => n.text.includes('leaflet')); | |
| + | // a3 (leaflet work) was rewound - a4 forked from u5's other child; the | |
| + | // leaflet prompt itself is u5 which IS on the main path (a4 descends from it) | |
| + | assert.equal(leaflet.status, 'accepted'); | |
| + | // every node chains to a single root | |
| + | assert.equal(tree.roots.length, 1); | |
| + | assert.equal(tree.stats.promptCount, 4); | |
| + | assert.equal(tree.stats.corrections, 1); | |
| + | }); | |
| + | ||
| + | test('redaction: catches anthropic key and basic-auth URL, masks them', async () => { | |
| + | const { tree } = await fixtureTree(); | |
| + | const scope = tree.nodes.find((n) => n.kind === 'scope-change'); | |
| + | const findings = scanText(scope.text); | |
| + | const rules = new Set(findings.map((f) => f.ruleId)); | |
| + | assert.ok(rules.has('anthropic-key'), `anthropic-key not in ${[...rules]}`); | |
| + | assert.ok(rules.has('url-basic-auth'), `url-basic-auth not in ${[...rules]}`); | |
| + | ||
| + | const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true }); | |
| + | const cleaned = applyDecisions(scope.text, findings, decisions); | |
| + | assert.ok(!cleaned.includes('sk-ant-'), 'key leaked'); | |
| + | assert.ok(!cleaned.includes('hunter2pass'), 'password leaked'); | |
| + | assert.ok(cleaned.includes('[REDACTED:')); | |
| + | }); | |
| + | ||
| + | test('redaction: shadow scan flags unresolved secrets, passes resolved/kept ones', () => { | |
| + | const dirty = 'token ghp_0123456789abcdefghijklmnopqrstuvwxyzAB end'; | |
| + | assert.equal(shadowScan(dirty, {}).length, 1); | |
| + | ||
| + | const findings = scanText(dirty); | |
| + | const kept = { [sha256(findings[0].match)]: { action: 'keep', ruleId: findings[0].ruleId } }; | |
| + | assert.equal(shadowScan(dirty, kept).length, 0); | |
| + | ||
| + | const masked = applyDecisions(dirty, findings, { | |
| + | [sha256(findings[0].match)]: { action: 'redact', replacement: maskFor(findings[0]), ruleId: findings[0].ruleId }, | |
| + | }); | |
| + | assert.equal(shadowScan(masked, {}).length, 0); | |
| + | }); | |
| + | ||
| + | test('redaction: rule coverage on known formats', () => { | |
| + | const cases = [ | |
| + | ['AKIAIOSFODNN7EXAMPLE', 'aws-access-key'], | |
| + | ['github_pat_11AAAAAAA0123456789abcdefghij', 'github-fine-grained'], | |
| + | ['xoxb-treetrace-example-slack-token-0', 'slack-token'], | |
| + | ['sk_live_abcdefghijklmnop123', 'stripe-live-key'], | |
| + | ['tskey-auth-kFGiAS7CNTRL-abcdef123456', 'tailscale-key'], | |
| + | ['-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----', 'private-key-block'], | |
| + | ['eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', 'jwt'], | |
| + | ['password = "correct-horse-battery"', 'secret-assignment'], | |
| + | ]; | |
| + | for (const [sample, expected] of cases) { | |
| + | const hits = scanText(`some text ${sample} more text`).map((f) => f.ruleId); | |
| + | assert.ok(hits.includes(expected), `${expected} missed in: ${sample} (got ${hits})`); | |
| + | } | |
| + | }); | |
| + | ||
| + | test('redaction: benign text produces no high/medium findings', () => { | |
| + | const benign = | |
| + | 'Refactor the parser in src/parse.js to handle commit 3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a and bump to v2.1.0-beta.3. The README.md needs a section on CONTRIBUTING.'; | |
| + | const hard = scanText(benign).filter((f) => f.severity !== 'soft'); | |
| + | assert.deepEqual(hard, []); | |
| + | }); | |
| + | ||
| + | test('renderers: markdown, json, handoff are consistent and footer-credited', async () => { | |
| + | const { tree } = await fixtureTree(); | |
| + | const md = renderMarkdown(tree, { projectName: 'demo' }); | |
| + | assert.ok(md.startsWith('# ๐ณ Prompt Tree - demo')); | |
| + | assert.ok(md.includes('## Goal')); | |
| + | assert.ok(md.includes('## Reusable Prompt Pack')); | |
| + | assert.ok(md.includes('generated by [treetrace]') || md.includes('Generated by [treetrace]')); | |
| + | ||
| + | const json = renderJson(tree, { projectName: 'demo' }); | |
| + | assert.equal(json.schemaVersion, '0.1'); | |
| + | assert.equal(json.nodes.length, tree.nodes.length); | |
| + | assert.equal(json.edges.length, tree.nodes.filter((n) => n.parent).length); | |
| + | assert.ok(json.nodes.every((n) => n.id && n.kind && typeof n.text === 'string')); | |
| + | ||
| + | const pack = promptPack(tree.nodes); | |
| + | assert.ok(pack.includes('1.')); | |
| + | ||
| + | const handoff = renderHandoff(tree, { projectName: 'demo' }); | |
| + | assert.ok(handoff.includes('## Original goal')); | |
| + | assert.ok(handoff.includes('Constraints learned the hard way')); | |
| + | }); | |
| + | ||
| + | test('plain transcript fallback parses User:/Assistant: markers', () => { | |
| + | const session = parsePlainTranscript( | |
| + | 'User: build me a snake game in python\nAssistant: sure, here is the code...\nUser: make the snake blue\nAssistant: done', | |
| + | 'pasted' | |
| + | ); | |
| + | assert.equal(session.prompts.length, 2); | |
| + | assert.equal(session.prompts[1].text, 'make the snake blue'); | |
| + | assert.throws(() => parsePlainTranscript('no markers here at all'), /turn markers/); | |
| + | }); | |
| + | ||
| + | test('special user text classification', () => { | |
| + | assert.equal(classifySpecialUserText('<command-name>/foo</command-name>'), 'command'); | |
| + | assert.equal(classifySpecialUserText('<system-reminder>x</system-reminder>'), 'meta'); | |
| + | assert.equal( | |
| + | classifySpecialUserText('This session is being continued from a previous conversation that ran out of context.'), | |
| + | 'compact-continuation' | |
| + | ); | |
| + | assert.equal(classifySpecialUserText('build me an app'), 'prompt'); | |
| + | }); | |
| + | ||
| + | test('discover: path munging matches Claude Code storage layout', () => { | |
| + | assert.equal(mungePath('/home/dev/weatherapp'), '-home-dev-weatherapp'); | |
| + | assert.equal(mungePath('/home/dev/weatherapp/api'), '-home-dev-weatherapp-api'); | |
| + | assert.equal(mungePath('/home/u.ser/my_app'), '-home-u-ser-my-app'); | |
| + | }); |