| 1 | import { analyzeTree, latestByTime, renderRejectionsJson } from './analyze.js'; |
| 2 | import { plural, truncate, escapeMd } from './util.js'; |
| 3 | import { REPO_URL } from './config.js'; |
| 4 | |
| 5 | export function renderReportMarkdown(tree, opts = {}) { |
| 6 | const projectName = opts.projectName || 'project'; |
| 7 | const generatedAt = opts.generatedAt || new Date().toISOString(); |
| 8 | const analysis = analyzeTree(tree); |
| 9 | const lines = []; |
| 10 | |
| 11 | lines.push(`# TreeTrace Report - ${escapeMd(projectName)}`); |
| 12 | lines.push(''); |
| 13 | lines.push(`Generated: ${generatedAt}`); |
| 14 | lines.push(''); |
| 15 | |
| 16 | lines.push('## Session summary'); |
| 17 | lines.push(''); |
| 18 | const { promptCount, rawPromptCount } = tree.stats; |
| 19 | const foldedTurns = (rawPromptCount || promptCount) - promptCount; |
| 20 | const tc = analysis.summary.tierCounts || { verified: 0, high: 0, confirmed: 0, inferred: 0 }; |
| 21 | const promptPart = |
| 22 | foldedTurns > 0 |
| 23 | ? `Prompts: ${promptCount} (from ${rawPromptCount} raw turns)` |
| 24 | : `Prompts: ${promptCount}`; |
| 25 | const sessionParts = [ |
| 26 | promptPart, |
| 27 | `Sessions: ${tree.stats.sessionCount}`, |
| 28 | tree.stats.days ? `Span: ${plural(tree.stats.days, 'day')}` : null, |
| 29 | tree.stats.toolUses ? `Tool calls: ${tree.stats.toolUses.toLocaleString()}` : null, |
| 30 | tree.stats.filesTouched ? `Files touched: ${tree.stats.filesTouched}` : null, |
| 31 | ].filter(Boolean); |
| 32 | lines.push(`- ${sessionParts.join(' ')}`); |
| 33 | lines.push( |
| 34 | `- Failure signals: ${analysis.summary.totalFailureSignals} (verified ${tc.verified}, high ${tc.high || 0}, confirmed ${tc.confirmed}, inferred ${tc.inferred})` |
| 35 | ); |
| 36 | if (tree.stats.corrections) lines.push(`- Corrections: ${tree.stats.corrections}`); |
| 37 | if (tree.stats.abandonedBranches) lines.push(`- Abandoned branches: ${tree.stats.abandonedBranches}`); |
| 38 | if (tree.stats.rejections) { |
| 39 | const byKind = tree.stats.rejectionsByKind || {}; |
| 40 | const breakdown = Object.entries(byKind) |
| 41 | .sort((a, b) => b[1] - a[1]) |
| 42 | .map(([k, v]) => `${k.replace(/_/g, ' ')}: ${v}`) |
| 43 | .join(', '); |
| 44 | lines.push(`- Rejections: ${tree.stats.rejections}${breakdown ? ` (${breakdown})` : ''}`); |
| 45 | } |
| 46 | { |
| 47 | const allModels = [...new Set([ |
| 48 | ...(tree.stats.models || []), |
| 49 | ...(analysis.summary.models || []), |
| 50 | ])].filter(Boolean); |
| 51 | if (allModels.length) lines.push(`- Models seen: ${allModels.join(', ')}`); |
| 52 | } |
| 53 | if (analysis.summary.thinkingBlocks) { |
| 54 | lines.push(`- Reasoning blocks captured: ${analysis.summary.thinkingBlocks}`); |
| 55 | } |
| 56 | lines.push(`- Eval candidates: ${analysis.summary.evalCandidates}`); |
| 57 | lines.push(`- Lessons: ${analysis.summary.lessons}`); |
| 58 | lines.push(''); |
| 59 | |
| 60 | lines.push('## Output map'); |
| 61 | lines.push(''); |
| 62 | lines.push('| File | Purpose |'); |
| 63 | lines.push('|------|---------|'); |
| 64 | lines.push('| `TREETRACE_REPORT.md` | this file |'); |
| 65 | lines.push('| `PROMPT_TREE.md` | prompt lineage + replay pack |'); |
| 66 | lines.push('| `.treetrace/tree.json` | canonical schema |'); |
| 67 | lines.push('| `.treetrace/failures.json` | labels + correction chains |'); |
| 68 | lines.push('| `.treetrace/rejections.json` | typed rejections/refusals/declines (v0.3) |'); |
| 69 | lines.push('| `.treetrace/hallucinations.json` | unresolved references |'); |
| 70 | lines.push('| `.treetrace/lessons.md` | correction memory |'); |
| 71 | lines.push('| `.treetrace/evals.jsonl` | regression eval cases |'); |
| 72 | lines.push('| `.treetrace/agent-memory.md` | next-agent memory pack |'); |
| 73 | lines.push(''); |
| 74 | |
| 75 | if (analysis.failures.length) { |
| 76 | lines.push('## Failure signals'); |
| 77 | lines.push(''); |
| 78 | for (const { type, count } of analysis.summary.topFailureTypes) { |
| 79 | lines.push(`- ${type}: ${count}`); |
| 80 | } |
| 81 | lines.push(''); |
| 82 | for (const failure of analysis.failures.slice(0, 8)) { |
| 83 | const meta = [failure.tier, confidencePct(failure.confidence), failure.model].filter(Boolean).join(', '); |
| 84 | const nodeId = failure.firstSeenNodeId ? ` [${failure.firstSeenNodeId}]` : ''; |
| 85 | const evidence = failure.evidence ? ` Evidence: ${escapeMd(truncate(failure.evidence, 180))}` : ''; |
| 86 | lines.push(`- ${failure.id}${nodeId} (${failure.type}, ${meta}): ${escapeMd(failure.summary)}${evidence}`); |
| 87 | } |
| 88 | if (analysis.failures.length > 8) { |
| 89 | lines.push(`- ... ${analysis.failures.length - 8} more in .treetrace/failures.json`); |
| 90 | } |
| 91 | lines.push(''); |
| 92 | } |
| 93 | |
| 94 | const securityTrail = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk'); |
| 95 | if (securityTrail.length) { |
| 96 | const rank = { verified: 4, high: 3, confirmed: 2, inferred: 1 }; |
| 97 | securityTrail.sort((a, b) => (rank[b.tier] || 0) - (rank[a.tier] || 0)); |
| 98 | lines.push('## Security audit trail'); |
| 99 | lines.push(''); |
| 100 | for (const f of securityTrail.slice(0, 12)) { |
| 101 | const tag = f.tier === 'inferred' ? 'stated intent' : f.tier; |
| 102 | const nodeId = f.firstSeenNodeId ? ` [${f.firstSeenNodeId}]` : ''; |
| 103 | lines.push(`- (${tag})${nodeId} ${escapeMd(f.evidence)}${f.model ? ` (${f.model})` : ''}`); |
| 104 | } |
| 105 | lines.push(''); |
| 106 | } |
| 107 | |
| 108 | if (analysis.correctionChains && analysis.correctionChains.length) { |
| 109 | lines.push('## Correction chains'); |
| 110 | lines.push(''); |
| 111 | lines.push('Failure turns that received a human correction, with resolution status.'); |
| 112 | lines.push(''); |
| 113 | for (const chain of analysis.correctionChains.slice(0, 10)) { |
| 114 | const resolved = chain.resolvedNodeId ? ` -> resolved [${chain.resolvedNodeId}]` : ' -> unresolved'; |
| 115 | lines.push(`- ${chain.id} (${chain.failureType}, ${chain.confidence}): failure [${chain.failureNodeId}] -> correction [${chain.correctionNodeId}]${resolved}`); |
| 116 | } |
| 117 | if (analysis.correctionChains.length > 10) { |
| 118 | lines.push(`- ... ${analysis.correctionChains.length - 10} more in .treetrace/failures.json`); |
| 119 | } |
| 120 | lines.push(''); |
| 121 | } |
| 122 | |
| 123 | const rejectionsView = renderRejectionsJson(tree, opts); |
| 124 | if (rejectionsView.summary.total) { |
| 125 | lines.push('## Rejections'); |
| 126 | lines.push(''); |
| 127 | lines.push('Typed rejection / refusal / decline events captured on the session. Each one is also surfaced as a failure signal of the mapped type.'); |
| 128 | lines.push(''); |
| 129 | const byKind = rejectionsView.summary.byKind || {}; |
| 130 | const breakdown = Object.entries(byKind) |
| 131 | .sort((a, b) => b[1] - a[1]) |
| 132 | .map(([k, v]) => `${k.replace(/_/g, ' ')} (${v})`) |
| 133 | .join(', '); |
| 134 | lines.push(`- Total: ${rejectionsView.summary.total}${breakdown ? ` - ${breakdown}` : ''}`); |
| 135 | lines.push(''); |
| 136 | for (const r of rejectionsView.rejections.slice(0, 12)) { |
| 137 | const nodeId = r.nodeId ? ` [${r.nodeId}]` : ''; |
| 138 | const pct = `${Math.round((r.confidence || 0) * 100)}%`; |
| 139 | const ev = r.evidence ? ` - ${escapeMd(truncate(r.evidence, 160))}` : ''; |
| 140 | lines.push(`- (${r.kind}, ${pct})${nodeId}${ev}`); |
| 141 | } |
| 142 | if (rejectionsView.rejections.length > 12) { |
| 143 | lines.push(`- ... ${rejectionsView.rejections.length - 12} more in .treetrace/rejections.json`); |
| 144 | } |
| 145 | lines.push(''); |
| 146 | } |
| 147 | |
| 148 | lines.push('## Artifacts'); |
| 149 | lines.push(''); |
| 150 | lines.push('See: `PROMPT_TREE.md` · `.treetrace/lessons.md` · `.treetrace/agent-memory.md` · handoff: run `treetrace --handoff`'); |
| 151 | |
| 152 | lines.push('---'); |
| 153 | lines.push(`Generated by [treetrace](${REPO_URL})${opts.version ? ` v${opts.version}` : ''}.`); |
| 154 | lines.push(''); |
| 155 | |
| 156 | return lines.join('\n'); |
| 157 | } |
| 158 | |
| 159 | export function renderTerminalSummary(tree, opts = {}) { |
| 160 | const projectName = opts.projectName || 'project'; |
| 161 | const analysis = analyzeTree(tree); |
| 162 | const accepted = tree.nodes.filter((n) => n.status !== 'abandoned'); |
| 163 | const lastAccepted = latestByTime(accepted); |
| 164 | const lines = []; |
| 165 | |
| 166 | lines.push(`TreeTrace summary - ${projectName}`); |
| 167 | lines.push(''); |
| 168 | lines.push( |
| 169 | `${plural(tree.stats.promptCount, 'prompt')} across ${plural(tree.stats.sessionCount, 'session')} ` + |
| 170 | `| ${analysis.summary.totalFailureSignals} failure signals ` + |
| 171 | `| ${analysis.summary.lessons} lessons ` + |
| 172 | `| ${analysis.summary.evalCandidates} eval candidates` |
| 173 | ); |
| 174 | if (lastAccepted) { |
| 175 | lines.push(''); |
| 176 | lines.push(`Latest accepted direction: ${truncate(lastAccepted.text.replace(/\s+/g, ' '), 280)}`); |
| 177 | } |
| 178 | if (analysis.lessons.length) { |
| 179 | lines.push(''); |
| 180 | lines.push('Top lessons:'); |
| 181 | for (const lesson of analysis.lessons.slice(0, 3)) { |
| 182 | lines.push(`- ${truncate(lesson.text.replace(/\s+/g, ' '), 240)}`); |
| 183 | } |
| 184 | } |
| 185 | lines.push(''); |
| 186 | lines.push('Human report: TREETRACE_REPORT.md'); |
| 187 | lines.push('Stream it in the terminal with: treetrace --report'); |
| 188 | lines.push(''); |
| 189 | |
| 190 | return lines.join('\n'); |
| 191 | } |
| 192 | |
| 193 | function confidencePct(confidence) { |
| 194 | return `${Math.round(confidence * 100)}%`; |
| 195 | } |