| 1 | import { truncate, escapeMdTags } from './util.js'; |
| 2 | import { analyzeTree, isStrategicDirection, latestByTime } from './analyze.js'; |
| 3 | |
| 4 | export function renderHandoff(tree, opts = {}) { |
| 5 | const { projectName } = opts; |
| 6 | const { nodes, stats } = tree; |
| 7 | const analysis = analyzeTree(tree); |
| 8 | const lines = []; |
| 9 | |
| 10 | const root = nodes.find((n) => n.kind === 'root') || nodes[0]; |
| 11 | const accepted = nodes.filter((n) => n.status !== 'abandoned'); |
| 12 | const lastCheckpoint = latestByTime(accepted.filter((n) => n.kind === 'checkpoint')); |
| 13 | const lastAccepted = latestByTime(accepted); |
| 14 | |
| 15 | lines.push(`# Handoff brief: ${escapeMdTags(projectName)}`); |
| 16 | lines.push(`${stats.promptCount} ${plural(stats.promptCount, 'prompt')} · ${stats.sessionCount} ${plural(stats.sessionCount, 'session')}`); |
| 17 | lines.push(''); |
| 18 | |
| 19 | if (root) { |
| 20 | lines.push('## Original goal'); |
| 21 | lines.push(''); |
| 22 | lines.push(escapeMdTags(root.text.trim())); |
| 23 | lines.push(''); |
| 24 | } |
| 25 | |
| 26 | lines.push('## Where things stand'); |
| 27 | lines.push(''); |
| 28 | if (lastCheckpoint) { |
| 29 | lines.push(`Last checkpoint: ${escapeMdTags(lastCheckpoint.text.trim())}`); |
| 30 | if (lastAccepted && lastAccepted !== lastCheckpoint) lines.push(''); |
| 31 | } |
| 32 | if (lastAccepted && lastAccepted !== lastCheckpoint) { |
| 33 | lines.push(`Most recent accepted direction: ${escapeMdTags(lastAccepted.text.trim())}`); |
| 34 | } |
| 35 | lines.push(''); |
| 36 | |
| 37 | const decisions = accepted.filter( |
| 38 | (n) => (n.kind === 'direction' || n.kind === 'scope-change') && isStrategicDirection(n) |
| 39 | ); |
| 40 | if (decisions.length) { |
| 41 | lines.push('## Accepted decisions'); |
| 42 | lines.push(''); |
| 43 | decisions.forEach((n, i) => lines.push(`${i + 1}. ${escapeMdTags(truncate(n.text.replace(/\s+/g, ' '), 360))}`)); |
| 44 | lines.push(''); |
| 45 | } |
| 46 | |
| 47 | const corrections = accepted.filter((n) => n.kind === 'correction'); |
| 48 | if (corrections.length) { |
| 49 | lines.push('## Constraints'); |
| 50 | lines.push(''); |
| 51 | corrections.forEach((n) => lines.push(`- ${escapeMdTags(truncate(n.text.replace(/\s+/g, ' '), 300))}`)); |
| 52 | lines.push(''); |
| 53 | } |
| 54 | |
| 55 | const abandoned = nodes.filter( |
| 56 | (n) => n.status === 'abandoned' && (!n.parent || n.parent.status !== 'abandoned') |
| 57 | ); |
| 58 | if (abandoned.length) { |
| 59 | lines.push('## Dead ends'); |
| 60 | lines.push(''); |
| 61 | abandoned.forEach((n) => lines.push(`- ${escapeMdTags(truncate(n.text.replace(/\s+/g, ' '), 300))}`)); |
| 62 | lines.push(''); |
| 63 | } |
| 64 | |
| 65 | if (analysis.lessons.length) { |
| 66 | lines.push('## Lessons'); |
| 67 | lines.push(''); |
| 68 | analysis.lessons.slice(0, 6).forEach((lesson) => { |
| 69 | lines.push(`- ${escapeMdTags(lesson.title)}: ${escapeMdTags(truncate(compactLessonText(lesson.text), 320))}`); |
| 70 | }); |
| 71 | lines.push(''); |
| 72 | } |
| 73 | |
| 74 | return lines.join('\n'); |
| 75 | } |
| 76 | |
| 77 | function plural(count, singular) { |
| 78 | return count === 1 ? singular : `${singular}s`; |
| 79 | } |
| 80 | |
| 81 | function compactLessonText(text) { |
| 82 | const normalized = String(text || '').replace(/\s+/g, ' ').trim(); |
| 83 | const evidenceAt = normalized.indexOf('Specifically:'); |
| 84 | return evidenceAt === -1 ? normalized : normalized.slice(evidenceAt + 'Specifically:'.length).trim(); |
| 85 | } |