diff --git a/docs/pr-876/01-index.png b/docs/pr-876/01-index.png new file mode 100644 index 000000000..dc2957b92 Binary files /dev/null and b/docs/pr-876/01-index.png differ diff --git a/docs/pr-876/02-summary.png b/docs/pr-876/02-summary.png new file mode 100644 index 000000000..dea9d8cb1 Binary files /dev/null and b/docs/pr-876/02-summary.png differ diff --git a/docs/pr-876/03-progress.png b/docs/pr-876/03-progress.png new file mode 100644 index 000000000..9dec3856b Binary files /dev/null and b/docs/pr-876/03-progress.png differ diff --git a/docs/pr-876/04-depgraph.png b/docs/pr-876/04-depgraph.png new file mode 100644 index 000000000..b1349dead Binary files /dev/null and b/docs/pr-876/04-depgraph.png differ diff --git a/docs/pr-876/05-metrics.png b/docs/pr-876/05-metrics.png new file mode 100644 index 000000000..bb8083030 Binary files /dev/null and b/docs/pr-876/05-metrics.png differ diff --git a/docs/pr-876/06-changelog.png b/docs/pr-876/06-changelog.png new file mode 100644 index 000000000..c79e00f2d Binary files /dev/null and b/docs/pr-876/06-changelog.png differ diff --git a/docs/pr-876/06-timeline.png b/docs/pr-876/06-timeline.png new file mode 100644 index 000000000..62d081703 Binary files /dev/null and b/docs/pr-876/06-timeline.png differ diff --git a/docs/pr-876/07-changelog.png b/docs/pr-876/07-changelog.png new file mode 100644 index 000000000..f279f6d95 Binary files /dev/null and b/docs/pr-876/07-changelog.png differ diff --git a/docs/pr-876/07-knowledge.png b/docs/pr-876/07-knowledge.png new file mode 100644 index 000000000..2e7e32952 Binary files /dev/null and b/docs/pr-876/07-knowledge.png differ diff --git a/docs/pr-876/08-knowledge.png b/docs/pr-876/08-knowledge.png new file mode 100644 index 000000000..14a4dd33b Binary files /dev/null and b/docs/pr-876/08-knowledge.png differ diff --git a/docs/pr-876/09-captures.png b/docs/pr-876/09-captures.png new file mode 100644 index 000000000..f3c29a40e Binary files /dev/null and b/docs/pr-876/09-captures.png differ diff --git a/docs/pr-876/10-artifacts.png b/docs/pr-876/10-artifacts.png new file mode 100644 index 000000000..7aab45ec9 Binary files /dev/null and b/docs/pr-876/10-artifacts.png differ diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 35cebd1ab..c29c4ab67 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -2334,6 +2334,55 @@ async function dispatchNextUnit( if (vizPrefs?.auto_visualize) { ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); } + // Auto-generate HTML report snapshot on milestone completion (default: on, disable with auto_report: false) + if (vizPrefs?.auto_report !== false) { + try { + const { loadVisualizerData } = await import("./visualizer-data.js"); + const { generateHtmlReport } = await import("./export-html.js"); + const { writeReportSnapshot, reportsDir } = await import("./reports.js"); + const { basename } = await import("node:path"); + const snapData = await loadVisualizerData(basePath); + const completedMs = snapData.milestones.find(m => m.id === currentMilestoneId); + const msTitle = completedMs?.title ?? currentMilestoneId; + const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; + const projName = basename(basePath); + const doneSlices = snapData.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); + const totalSlices = snapData.milestones.reduce((s, m) => s + m.slices.length, 0); + const outPath = writeReportSnapshot({ + basePath, + html: generateHtmlReport(snapData, { + projectName: projName, + projectPath: basePath, + gsdVersion, + milestoneId: currentMilestoneId, + indexRelPath: "index.html", + }), + milestoneId: currentMilestoneId, + milestoneTitle: msTitle, + kind: "milestone", + projectName: projName, + projectPath: basePath, + gsdVersion, + totalCost: snapData.totals?.cost ?? 0, + totalTokens: snapData.totals?.tokens.total ?? 0, + totalDuration: snapData.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: snapData.milestones.filter(m => m.status === "complete").length, + totalMilestones: snapData.milestones.length, + phase: snapData.phase, + }); + ctx.ui.notify( + `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, + "info", + ); + } catch (err) { + ctx.ui.notify( + `Report generation failed: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + } // Reset stuck detection for new milestone unitDispatchCount.clear(); unitRecoveryCount.clear(); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 447b977df..0f4315a68 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -625,7 +625,7 @@ function showHelp(ctx: ExtensionCommandContext): void { "", "MAINTENANCE", " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", - " /gsd export Export milestone/slice results [--json|--markdown]", + " /gsd export Export milestone/slice results [--json|--markdown|--html]", " /gsd cleanup Remove merged branches or snapshots [branches|snapshots]", " /gsd migrate Upgrade .gsd/ structures to new format", " /gsd remote Control remote auto-mode [slack|discord|status|disconnect]", diff --git a/src/resources/extensions/gsd/export-html.ts b/src/resources/extensions/gsd/export-html.ts new file mode 100644 index 000000000..98355477e --- /dev/null +++ b/src/resources/extensions/gsd/export-html.ts @@ -0,0 +1,1001 @@ +/** + * GSD HTML Report Generator + * + * Produces a single self-contained HTML file with: + * - Branding header (project name, path, GSD version, generated timestamp) + * - Project summary & overall progress + * - Health & configuration overview + * - Progress tree (milestones → slices → tasks, with critical path) + * - Slice dependency graph (SVG DAG per milestone) + * - Cost & token metrics (bar charts, phase/slice/model/tier breakdowns) + * - Execution timeline (chronological unit history) + * - Changelog (completed slice summaries + file modifications) + * - Knowledge base (rules, patterns, lessons) + * - Captures log + * - Milestone planning / discussion state + * + * No external dependencies — all CSS and JS is inlined. + * Printable to PDF from any browser. + * + * Design: Linear-inspired — restrained palette, geometric status, no emoji. + */ + +import type { + VisualizerData, + VisualizerMilestone, + VisualizerSlice, +} from './visualizer-data.js'; +import { formatDuration } from './history.js'; +import { formatCost, formatTokenCount } from './metrics.js'; + +// ─── Public API ──────────────────────────────────────────────────────────────── + +export interface HtmlReportOptions { + projectName: string; + projectPath: string; + gsdVersion: string; + milestoneId?: string; + indexRelPath?: string; +} + +export function generateHtmlReport( + data: VisualizerData, + opts: HtmlReportOptions, +): string { + const generated = new Date().toISOString(); + + const sections = [ + buildSummarySection(data, opts, generated), + buildHealthSection(data), + buildProgressSection(data), + buildDepGraphSection(data), + buildMetricsSection(data), + buildTimelineSection(data), + buildChangelogSection(data), + buildKnowledgeSection(data), + buildCapturesSection(data), + buildStatsSection(data), + buildDiscussionSection(data), + ]; + + const milestoneTag = opts.milestoneId + ? ` / ${esc(opts.milestoneId)}` + : ''; + + const backLink = opts.indexRelPath + ? `All Reports` + : ''; + + return ` + + + + +GSD Report — ${esc(opts.projectName)}${opts.milestoneId ? ` — ${esc(opts.milestoneId)}` : ''} + + + +
+
+
+ + v${esc(opts.gsdVersion)} +
+
+

${esc(opts.projectName)}${milestoneTag}

+ ${esc(opts.projectPath)} +
+
+ ${backLink} +
${formatDateLong(generated)}
+
+
+
+ +
+${sections.join('\n')} +
+ + + +`; +} + +// ─── Section: Summary ───────────────────────────────────────────────────────── + +function buildSummarySection( + data: VisualizerData, + _opts: HtmlReportOptions, + _generated: string, +): string { + const t = data.totals; + const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); + const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); + const doneMilestones = data.milestones.filter(m => m.status === 'complete').length; + const activeMilestone = data.milestones.find(m => m.status === 'active'); + const pct = totalSlices > 0 ? Math.round((doneSlices / totalSlices) * 100) : 0; + + const act = data.agentActivity; + const kv = [ + kvi('Milestones', `${doneMilestones}/${data.milestones.length}`), + kvi('Slices', `${doneSlices}/${totalSlices}`), + kvi('Phase', data.phase), + t ? kvi('Cost', formatCost(t.cost)) : '', + t ? kvi('Tokens', formatTokenCount(t.tokens.total)) : '', + t ? kvi('Duration', formatDuration(t.duration)) : '', + t ? kvi('Tool calls', String(t.toolCalls)) : '', + t ? kvi('Units', String(t.units)) : '', + data.remainingSliceCount > 0 ? kvi('Remaining', String(data.remainingSliceCount)) : '', + act ? kvi('Rate', `${act.completionRate.toFixed(1)}/hr`) : '', + ].filter(Boolean).join(''); + + const activeInfo = activeMilestone ? (() => { + const active = activeMilestone.slices.find(s => s.active); + if (!active) return ''; + return `
+ Executing ${esc(activeMilestone.id)}/${esc(active.id)} — ${esc(active.title)} +
`; + })() : ''; + + const activityHtml = act?.active ? ` +
+ + ${esc(act.currentUnit?.type ?? '')} + ${esc(act.currentUnit?.id ?? '')} + ${formatDuration(act.elapsed)} elapsed +
` : ''; + + return section('summary', 'Summary', ` +
${kv}
+
+
+ ${pct}% +
+ ${activeInfo} + ${activityHtml} + `); +} + +// ─── Section: Health ────────────────────────────────────────────────────────── + +function buildHealthSection(data: VisualizerData): string { + const h = data.health; + const t = data.totals; + + const rows: string[] = []; + rows.push(hRow('Token profile', h.tokenProfile)); + if (h.budgetCeiling !== undefined) { + const spent = t?.cost ?? 0; + const pct = (spent / h.budgetCeiling) * 100; + const status = pct > 90 ? 'warn' : pct > 75 ? 'caution' : 'ok'; + rows.push(hRow( + 'Budget ceiling', + `${formatCost(h.budgetCeiling)} (${formatCost(spent)} spent, ${pct.toFixed(0)}% used)`, + status, + )); + } + rows.push(hRow( + 'Truncation rate', + `${h.truncationRate.toFixed(1)}% per unit (${t?.totalTruncationSections ?? 0} total)`, + h.truncationRate > 20 ? 'warn' : h.truncationRate > 10 ? 'caution' : 'ok', + )); + rows.push(hRow( + 'Continue-here rate', + `${h.continueHereRate.toFixed(1)}% per unit (${t?.continueHereFiredCount ?? 0} total)`, + h.continueHereRate > 15 ? 'warn' : h.continueHereRate > 8 ? 'caution' : 'ok', + )); + if (h.tierSavingsLine) rows.push(hRow('Routing savings', h.tierSavingsLine)); + rows.push(hRow('Tool calls', String(h.toolCalls))); + rows.push(hRow('Messages', `${h.assistantMessages} assistant / ${h.userMessages} user`)); + + const tierRows = h.tierBreakdown.length > 0 ? ` +

Tier breakdown

+ + + + ${h.tierBreakdown.map(tb => + ` + + ` + ).join('')} + +
TierUnitsCostTokens
${esc(tb.tier)}${tb.units}${formatCost(tb.cost)}${formatTokenCount(tb.tokens.total)}
` : ''; + + return section('health', 'Health', ` + ${rows.join('')}
+ ${tierRows} + `); +} + +// ─── Section: Progress ──────────────────────────────────────────────────────── + +function buildProgressSection(data: VisualizerData): string { + if (data.milestones.length === 0) { + return section('progress', 'Progress', '

No milestones found.

'); + } + + const critMS = new Set(data.criticalPath.milestonePath); + const critSL = new Set(data.criticalPath.slicePath); + + const msHtml = data.milestones.map(ms => { + const doneCount = ms.slices.filter(s => s.done).length; + const onCrit = critMS.has(ms.id); + const sliceHtml = ms.slices.length > 0 + ? ms.slices.map(sl => buildSliceRow(sl, critSL, data)).join('') + : '

No slices in roadmap yet.

'; + + return ` +
+ + + ${esc(ms.id)} + ${esc(ms.title)} + ${doneCount}/${ms.slices.length} + ${onCrit ? 'critical path' : ''} + ${ms.dependsOn.length > 0 ? `needs ${ms.dependsOn.map(esc).join(', ')}` : ''} + +
${sliceHtml}
+
`; + }).join(''); + + return section('progress', 'Progress', msHtml); +} + +function buildSliceRow(sl: VisualizerSlice, critSL: Set, data: VisualizerData): string { + const onCrit = critSL.has(sl.id); + const ver = data.sliceVerifications.find(v => v.sliceId === sl.id); + const slack = data.criticalPath.sliceSlack.get(sl.id); + const status = sl.done ? 'complete' : sl.active ? 'active' : 'pending'; + + const taskHtml = sl.tasks.length > 0 ? ` + ` : ''; + + const tags = [ + ...(ver?.provides ?? []).map(p => `provides: ${esc(p)}`), + ...(ver?.requires ?? []).map(r => `requires: ${esc(r.provides)}`), + ].join(''); + + const keyDecisions = ver?.keyDecisions?.length + ? `
Decisions
` + : ''; + + const patterns = ver?.patternsEstablished?.length + ? `
Patterns
` + : ''; + + const verifBadge = ver?.verificationResult + ? `
+ ${ver.blockerDiscovered ? 'Blocker: ' : ''}${esc(ver.verificationResult)} +
` + : ''; + + return ` +
+ + + ${esc(sl.id)} + ${esc(sl.title)} + ${esc(sl.risk || '?')} + ${sl.depends.length > 0 ? `${sl.depends.map(esc).join(', ')}` : ''} + ${onCrit ? 'critical' : ''} + ${slack !== undefined && slack > 0 ? `+${slack} slack` : ''} + +
+ ${tags ? `
${tags}
` : ''} + ${verifBadge} + ${keyDecisions} + ${patterns} + ${taskHtml} +
+
`; +} + +// ─── Section: Dependency Graph ──────────────────────────────────────────────── + +function buildDepGraphSection(data: VisualizerData): string { + const hasSlices = data.milestones.some(ms => ms.slices.length > 0); + if (!hasSlices) return section('depgraph', 'Dependencies', '

No slices to graph.

'); + + const hasDeps = data.milestones.some(ms => ms.slices.some(s => s.depends.length > 0)); + if (!hasDeps) return section('depgraph', 'Dependencies', '

No dependencies defined.

'); + + const svgs = data.milestones + .filter(ms => ms.slices.length > 0) + .map(ms => buildMilestoneDepSVG(ms, data)) + .filter(Boolean) + .join(''); + + return section('depgraph', 'Dependencies', svgs); +} + +function buildMilestoneDepSVG(ms: VisualizerMilestone, data: VisualizerData): string { + const slices = ms.slices; + if (slices.length === 0) return ''; + + const critSL = new Set(data.criticalPath.slicePath); + const slMap = new Map(slices.map(s => [s.id, s])); + + const layerMap = new Map(); + const inDeg = new Map(); + for (const s of slices) inDeg.set(s.id, 0); + for (const s of slices) { + for (const dep of s.depends) { + if (slMap.has(dep)) inDeg.set(s.id, (inDeg.get(s.id) ?? 0) + 1); + } + } + + const visited = new Set(); + const q: string[] = []; + for (const [id, d] of inDeg) { + if (d === 0) { q.push(id); visited.add(id); layerMap.set(id, 0); } + } + + while (q.length > 0) { + const node = q.shift()!; + for (const s of slices) { + if (!s.depends.includes(node)) continue; + const newDeg = (inDeg.get(s.id) ?? 1) - 1; + inDeg.set(s.id, newDeg); + layerMap.set(s.id, Math.max(layerMap.get(s.id) ?? 0, (layerMap.get(node) ?? 0) + 1)); + if (newDeg === 0 && !visited.has(s.id)) { visited.add(s.id); q.push(s.id); } + } + } + for (const s of slices) if (!layerMap.has(s.id)) layerMap.set(s.id, 0); + + const maxLayer = Math.max(...[...layerMap.values()]); + const byLayer = new Map(); + for (const [id, layer] of layerMap) { + const arr = byLayer.get(layer) ?? []; + arr.push(id); + byLayer.set(layer, arr); + } + + const NW = 130, NH = 40, CGAP = 56, RGAP = 14, PAD = 20; + let maxRows = 0; + for (let c = 0; c <= maxLayer; c++) maxRows = Math.max(maxRows, (byLayer.get(c) ?? []).length); + const totalH = PAD * 2 + maxRows * NH + Math.max(0, maxRows - 1) * RGAP; + const totalW = PAD * 2 + (maxLayer + 1) * NW + maxLayer * CGAP; + + const pos = new Map(); + for (let col = 0; col <= maxLayer; col++) { + const ids = byLayer.get(col) ?? []; + const colH = ids.length * NH + Math.max(0, ids.length - 1) * RGAP; + const startY = (totalH - colH) / 2; + ids.forEach((id, i) => pos.set(id, { x: PAD + col * (NW + CGAP), y: startY + i * (NH + RGAP) })); + } + + const edges = slices.flatMap(sl => sl.depends.flatMap(dep => { + if (!pos.has(dep) || !pos.has(sl.id)) return []; + const f = pos.get(dep)!, t = pos.get(sl.id)!; + const x1 = f.x + NW, y1 = f.y + NH / 2; + const x2 = t.x, y2 = t.y + NH / 2; + const mx = (x1 + x2) / 2; + const crit = critSL.has(sl.id) && critSL.has(dep); + return [``]; + })); + + const nodes = slices.map(sl => { + const p = pos.get(sl.id); + if (!p) return ''; + const crit = critSL.has(sl.id); + const sc = sl.done ? 'n-done' : sl.active ? 'n-active' : 'n-pending'; + return ` + + ${esc(truncStr(sl.id, 18))} + ${esc(truncStr(sl.title, 18))} + ${esc(sl.id)}: ${esc(sl.title)} + `; + }); + + const legend = `
+ done + active + pending +
`; + + return ` +
+

${esc(ms.id)}: ${esc(ms.title)}

+ ${legend} +
+ + + + + + + + + + ${edges.join('')} + ${nodes.join('')} + +
+
`; +} + +// ─── Section: Metrics ───────────────────────────────────────────────────────── + +function buildMetricsSection(data: VisualizerData): string { + if (!data.totals) return section('metrics', 'Metrics', '

No metrics data yet.

'); + const t = data.totals; + + const grid = [ + kvi('Total cost', formatCost(t.cost)), + kvi('Total tokens', formatTokenCount(t.tokens.total)), + kvi('Input', formatTokenCount(t.tokens.input)), + kvi('Output', formatTokenCount(t.tokens.output)), + kvi('Cache read', formatTokenCount(t.tokens.cacheRead)), + kvi('Cache write', formatTokenCount(t.tokens.cacheWrite)), + kvi('Duration', formatDuration(t.duration)), + kvi('Units', String(t.units)), + kvi('Tool calls', String(t.toolCalls)), + kvi('Truncations', String(t.totalTruncationSections)), + ].join(''); + + const tokenBreakdown = buildTokenBreakdown(t.tokens); + + const phaseRow = data.byPhase.length > 0 ? ` +
+ ${buildBarChart('Cost by phase', data.byPhase.map(p => ({ + label: p.phase, value: p.cost, display: formatCost(p.cost), sub: `${p.units} units`, + })))} + ${buildBarChart('Tokens by phase', data.byPhase.map(p => ({ + label: p.phase, value: p.tokens.total, display: formatTokenCount(p.tokens.total), sub: formatCost(p.cost), + })))} +
` : ''; + + const sliceModelRow = (data.bySlice.length > 0 || data.byModel.length > 0) ? ` +
+ ${data.bySlice.length > 0 ? buildBarChart('Cost by slice', data.bySlice.map(s => ({ + label: s.sliceId, value: s.cost, display: formatCost(s.cost), + sub: `${s.units} units`, + }))) : ''} + ${data.byModel.length > 0 ? buildBarChart('Cost by model', data.byModel.map(m => ({ + label: shortModel(m.model), value: m.cost, display: formatCost(m.cost), + sub: `${m.units} units`, + }))) : ''} +
` : ''; + + return section('metrics', 'Metrics', ` +
${grid}
+ ${tokenBreakdown} + ${phaseRow} + ${sliceModelRow} + `); +} + +function buildTokenBreakdown(tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }): string { + if (tokens.total === 0) return ''; + const segs = [ + { label: 'Input', value: tokens.input, cls: 'seg-1' }, + { label: 'Output', value: tokens.output, cls: 'seg-2' }, + { label: 'Cache read', value: tokens.cacheRead, cls: 'seg-3' }, + { label: 'Cache write', value: tokens.cacheWrite, cls: 'seg-4' }, + ].filter(s => s.value > 0); + + const bars = segs.map(s => { + const pct = (s.value / tokens.total) * 100; + return `
`; + }).join(''); + + const legend = segs.map(s => { + const pct = ((s.value / tokens.total) * 100).toFixed(1); + return `${s.label}: ${formatTokenCount(s.value)} (${pct}%)`; + }).join(''); + + return ` +
+

Token breakdown

+
${bars}
+
${legend}
+
`; +} + +interface BarEntry { label: string; value: number; display: string; sub?: string; color?: number } + +const CHART_COLORS = 6; + +function buildBarChart(title: string, entries: BarEntry[]): string { + if (entries.length === 0) return ''; + const max = Math.max(...entries.map(e => e.value), 1); + const rows = entries.map((e, i) => { + const pct = (e.value / max) * 100; + const ci = e.color ?? i; + return ` +
+
${esc(truncStr(e.label, 22))}
+
+
${esc(e.display)}
+
+ ${e.sub ? `
${esc(e.sub)}
` : ''}`; + }).join(''); + return `

${esc(title)}

${rows}
`; +} + +// ─── Section: Timeline ──────────────────────────────────────────────────────── + +function buildTimelineSection(data: VisualizerData): string { + if (data.units.length === 0) return section('timeline', 'Timeline', '

No units executed yet.

'); + + const sorted = [...data.units].sort((a, b) => a.startedAt - b.startedAt); + const maxCost = Math.max(...sorted.map(u => u.cost), 0.01); + + const rows = sorted.map((u, i) => { + const dur = u.finishedAt > 0 ? formatDuration(u.finishedAt - u.startedAt) : 'running'; + // Cost heatmap: subtle red background for expensive rows + const intensity = Math.min(u.cost / maxCost, 1); + const heatStyle = intensity > 0.15 ? ` style="background:rgba(239,68,68,${(intensity * 0.15).toFixed(3)})"` : ''; + return ` + + ${i + 1} + ${esc(u.type)} + ${esc(u.id)} + ${esc(shortModel(u.model))} + ${formatDateShort(new Date(u.startedAt).toISOString())} + ${dur} + ${formatCost(u.cost)} + ${formatTokenCount(u.tokens.total)} + ${u.toolCalls} + ${u.tier ?? ''} + ${u.modelDowngraded ? 'routed' : ''} + ${(u.truncationSections ?? 0) > 0 ? u.truncationSections : ''} + ${u.continueHereFired ? 'yes' : ''} + `; + }).join(''); + + return section('timeline', 'Timeline', ` +
+ + + + + + + ${rows} +
#TypeIDModelStartedDurationCostTokensToolsTierRoutedTruncCHF
+
`); +} + +// ─── Section: Changelog ─────────────────────────────────────────────────────── + +function buildChangelogSection(data: VisualizerData): string { + if (data.changelog.entries.length === 0) return section('changelog', 'Changelog', '

No completed slices yet.

'); + + const entries = data.changelog.entries.map(e => { + const filesHtml = e.filesModified.length > 0 ? ` +
+ ${e.filesModified.length} file${e.filesModified.length !== 1 ? 's' : ''} modified +
    + ${e.filesModified.map(f => `
  • ${esc(f.path)}${f.description ? ` — ${esc(f.description)}` : ''}
  • `).join('')} +
+
` : ''; + + const ver = data.sliceVerifications.find(v => v.sliceId === e.sliceId); + const decisionsHtml = ver?.keyDecisions?.length ? ` +
Decisions +
    ${ver.keyDecisions.map(d => `
  • ${esc(d)}
  • `).join('')}
+
` : ''; + + return ` +
+
+ ${esc(e.milestoneId)}/${esc(e.sliceId)} + ${esc(e.title)} + ${e.completedAt ? `${formatDateShort(e.completedAt)}` : ''} +
+ ${e.oneLiner ? `

${esc(e.oneLiner)}

` : ''} + ${decisionsHtml} + ${filesHtml} +
`; + }).join(''); + + return section('changelog', `Changelog ${data.changelog.entries.length}`, entries); +} + +// ─── Section: Knowledge ─────────────────────────────────────────────────────── + +function buildKnowledgeSection(data: VisualizerData): string { + const k = data.knowledge; + if (!k.exists) return section('knowledge', 'Knowledge', '

No KNOWLEDGE.md found.

'); + const total = k.rules.length + k.patterns.length + k.lessons.length; + if (total === 0) return section('knowledge', 'Knowledge', '

KNOWLEDGE.md exists but no entries parsed.

'); + + const rulesHtml = k.rules.length > 0 ? ` +

Rules ${k.rules.length}

+ + + ${k.rules.map(r => ``).join('')} +
IDScopeRule
${esc(r.id)}${esc(r.scope)}${esc(r.content)}
` : ''; + + const patternsHtml = k.patterns.length > 0 ? ` +

Patterns ${k.patterns.length}

+ + + ${k.patterns.map(p => ``).join('')} +
IDPattern
${esc(p.id)}${esc(p.content)}
` : ''; + + const lessonsHtml = k.lessons.length > 0 ? ` +

Lessons ${k.lessons.length}

+ + + ${k.lessons.map(l => ``).join('')} +
IDLesson
${esc(l.id)}${esc(l.content)}
` : ''; + + return section('knowledge', `Knowledge ${total}`, `${rulesHtml}${patternsHtml}${lessonsHtml}`); +} + +// ─── Section: Captures ──────────────────────────────────────────────────────── + +function buildCapturesSection(data: VisualizerData): string { + const c = data.captures; + if (c.totalCount === 0) return section('captures', 'Captures', '

No captures recorded.

'); + + const badge = c.pendingCount > 0 + ? `${c.pendingCount} pending` + : `all triaged`; + + const rows = c.entries.map(e => ` + + ${formatDateShort(new Date(e.timestamp).toISOString())} + ${esc(e.status)} + ${e.classification ?? ''} + ${e.resolution ?? ''} + ${esc(e.text)} + ${e.rationale ?? ''} + ${e.resolvedAt ? formatDateShort(e.resolvedAt) : ''} + ${e.executed !== undefined ? (e.executed ? 'yes' : 'no') : ''} + `).join(''); + + return section('captures', `Captures ${badge}`, ` +
+ + + ${rows} +
CapturedStatusClassResolutionTextRationaleResolvedExecuted
+
`); +} + +// ─── Section: Stats ─────────────────────────────────────────────────────────── + +function buildStatsSection(data: VisualizerData): string { + const s = data.stats; + + const missingHtml = s.missingCount > 0 ? ` +

Missing changelogs ${s.missingCount}

+ + + + ${s.missingSlices.map(sl => ``).join('')} + ${s.missingCount > s.missingSlices.length + ? `` + : ''} + +
MilestoneSliceTitle
${esc(sl.milestoneId)}${esc(sl.sliceId)}${esc(sl.title)}
and ${s.missingCount - s.missingSlices.length} more
` : ''; + + const updatedHtml = s.updatedCount > 0 ? ` +

Recently completed ${s.updatedCount}

+ + + ${s.updatedSlices.map(sl => ` + `).join('')} + +
MilestoneSliceTitleCompleted
${esc(sl.milestoneId)}${esc(sl.sliceId)}${esc(sl.title)}${sl.completedAt ? formatDateShort(sl.completedAt) : ''}
` : ''; + + if (!missingHtml && !updatedHtml) { + return section('stats', 'Artifacts', '

All artifacts accounted for.

'); + } + + return section('stats', 'Artifacts', `${missingHtml}${updatedHtml}`); +} + +// ─── Section: Discussion ────────────────────────────────────────────────────── + +function buildDiscussionSection(data: VisualizerData): string { + if (data.discussion.length === 0) return section('discussion', 'Planning', '

No milestones.

'); + + const rows = data.discussion.map(d => ` + + ${esc(d.milestoneId)} + ${esc(d.title)} + ${d.state} + ${d.hasContext ? 'yes' : ''} + ${d.hasDraft ? 'draft' : ''} + ${d.lastUpdated ? formatDateShort(d.lastUpdated) : ''} + `).join(''); + + return section('discussion', 'Planning', ` + + + ${rows} +
IDMilestoneStateContextDraftUpdated
`); +} + +// ─── Primitives ──────────────────────────────────────────────────────────────── + +function section(id: string, title: string, body: string): string { + return `\n
\n

${title}

\n ${body}\n
`; +} + +function kvi(label: string, value: string): string { + return `
${esc(value)}${esc(label)}
`; +} + +function hRow(label: string, value: string, status?: 'ok' | 'caution' | 'warn'): string { + const cls = status ? ` class="h-${status}"` : ''; + return `${esc(label)}${esc(value)}`; +} + +function shortModel(m: string) { return m.replace(/^claude-/, '').replace(/^anthropic\//, ''); } +function truncStr(s: string, n: number) { return s.length > n ? s.slice(0, n - 1) + '\u2026' : s; } + +function formatDateLong(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }); + } catch { return iso; } +} + +function formatDateShort(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch { return iso; } +} + +function esc(s: string | undefined | null): string { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +// ─── CSS ─────────────────────────────────────────────────────────────────────── +// Linear-inspired: restrained palette, one accent, no emoji, no gradients. + +const CSS = ` +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg-0:#0f1115;--bg-1:#16181d;--bg-2:#1e2028;--bg-3:#272a33; + --border-1:#2b2e38;--border-2:#3b3f4c; + --text-0:#ededef;--text-1:#a1a1aa;--text-2:#71717a; + --accent:#5e6ad2;--accent-subtle:rgba(94,106,210,.12); + --ok:#22c55e;--ok-subtle:rgba(34,197,94,.12);--warn:#ef4444;--caution:#eab308; + /* Chart palette — 6 hues for bar charts */ + --c0:#5e6ad2;--c1:#e5796d;--c2:#14b8a6;--c3:#a78bfa;--c4:#f59e0b;--c5:#10b981; + /* Token breakdown — 4 distinct hues */ + --tk-input:#5e6ad2;--tk-output:#e5796d;--tk-cache-r:#2dd4bf;--tk-cache-w:#64748b; + --font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; + --mono:'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,monospace; +} +html{scroll-behavior:smooth;font-size:13px} +body{background:var(--bg-0);color:var(--text-0);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5px;border-radius:3px} +.mono{font-family:var(--mono);font-size:12px} +.muted{color:var(--text-2)} +.accent{color:var(--accent)} +.sep{color:var(--border-2);margin:0 4px} +.empty{color:var(--text-2);padding:8px 0;font-size:13px} +.indent{padding-left:12px} +.num{font-variant-numeric:tabular-nums;text-align:right} + +/* Status dots — geometric, no emoji */ +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;vertical-align:middle} +.dot-sm{width:6px;height:6px} +.dot-complete{background:var(--ok);opacity:.6} +.dot-active{background:var(--accent)} +.dot-pending{background:transparent;border:1.5px solid var(--border-2)} + +/* Header */ +header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:200} +.header-inner{display:flex;align-items:center;gap:16px;max-width:1280px;margin:0 auto} +.branding{display:flex;align-items:baseline;gap:6px;flex-shrink:0} +.logo{font-size:18px;font-weight:800;letter-spacing:-.5px;color:var(--text-0)} +.version{font-size:10px;color:var(--text-2);font-family:var(--mono)} +.header-meta{flex:1;min-width:0} +.header-meta h1{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.header-path{font-size:11px;color:var(--text-2);font-family:var(--mono);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.header-right{text-align:right;flex-shrink:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px} +.generated{font-size:11px;color:var(--text-2)} +.back-link{font-size:12px;color:var(--text-1)} +.back-link:hover{color:var(--accent)} + +/* TOC nav */ +.toc{background:var(--bg-1);border-bottom:1px solid var(--border-1);overflow-x:auto} +.toc ul{display:flex;list-style:none;max-width:1280px;margin:0 auto;padding:0 32px} +.toc a{display:inline-block;padding:8px 12px;color:var(--text-2);font-size:12px;font-weight:500;border-bottom:2px solid transparent;transition:color .12s,border-color .12s;white-space:nowrap;text-decoration:none} +.toc a:hover{color:var(--text-0);border-bottom-color:var(--border-2)} +.toc a.active{color:var(--text-0);border-bottom-color:var(--accent)} + +/* Layout */ +main{max-width:1280px;margin:0 auto;padding:32px;display:flex;flex-direction:column;gap:48px} +section{scroll-margin-top:82px} +section>h2{font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-1);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-1);display:flex;align-items:center;gap:8px} +h3{font-size:13px;font-weight:600;color:var(--text-1);margin:20px 0 8px} +.count{font-size:11px;font-weight:500;color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} +.count-warn{color:var(--caution)} + +/* KV grid (stats/metrics) */ +.kv-grid{display:flex;flex-wrap:wrap;gap:1px;background:var(--border-1);border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:16px} +.kv{background:var(--bg-1);padding:10px 16px;display:flex;flex-direction:column;gap:2px;min-width:110px;flex:1} +.kv-val{font-size:18px;font-weight:600;color:var(--text-0);font-variant-numeric:tabular-nums} +.kv-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px} + +/* Progress bar */ +.progress-wrap{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.progress-track{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.progress-fill{height:100%;background:var(--accent);border-radius:2px} +.progress-label{font-size:12px;font-weight:600;color:var(--text-1);min-width:40px;text-align:right} +.active-info{font-size:12px;color:var(--text-1);margin-bottom:4px} +.activity-line{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-1);padding:6px 0} + +/* Tables */ +.tbl{width:100%;border-collapse:collapse;font-size:12px} +.tbl th{color:var(--text-2);font-weight:500;padding:6px 12px;text-align:left;border-bottom:1px solid var(--border-1);font-size:11px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap} +.tbl td{padding:6px 12px;border-bottom:1px solid var(--border-1);vertical-align:top} +.tbl tr:last-child td{border-bottom:none} +.tbl tbody tr:hover td{background:var(--accent-subtle)} +.tbl-kv td:first-child{color:var(--text-2);width:180px} +.table-scroll{overflow-x:auto;border:1px solid var(--border-1);border-radius:4px} +.table-scroll .tbl{border:none} + +/* Health */ +.h-ok td:first-child{color:var(--text-1)} +.h-caution td{color:var(--caution)} +.h-warn td{color:var(--warn)} + +/* Labels */ +.label{font-size:10px;font-weight:500;color:var(--accent);text-transform:uppercase;letter-spacing:.4px} +.risk{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.3px;flex-shrink:0} +.risk-low{color:var(--text-2)} +.risk-medium{color:var(--caution)} +.risk-high{color:var(--warn)} +.risk-unknown{color:var(--text-2)} + +/* Tags */ +.tag-row{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px} +.tag{font-size:11px;font-family:var(--mono);color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} + +/* Verification */ +.verif{font-size:12px;color:var(--text-1);padding:4px 0;margin-bottom:6px} +.verif-blocker{color:var(--warn)} + +/* Detail blocks */ +.detail-block{font-size:12px;color:var(--text-2);margin-bottom:6px} +.detail-label{font-weight:600;color:var(--text-1);display:block;margin-bottom:2px} +.detail-block ul{padding-left:16px;margin-top:2px} +.detail-block li{margin-bottom:1px} + +/* Progress tree */ +.ms-block{border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:8px} +.ms-summary{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;list-style:none;background:var(--bg-1);user-select:none;font-size:13px} +.ms-summary:hover{background:var(--bg-2)} +.ms-summary::-webkit-details-marker{display:none} +.ms-id{font-weight:600} +.ms-title{flex:1;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ms-body{padding:6px 12px 8px 24px;display:flex;flex-direction:column;gap:4px} + +.sl-block{border:1px solid var(--border-1);border-radius:3px;overflow:hidden} +.sl-summary{display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;list-style:none;background:var(--bg-2);font-size:12px;user-select:none} +.sl-summary:hover{background:var(--bg-3)} +.sl-summary::-webkit-details-marker{display:none} +.sl-crit{border-left:2px solid var(--accent)} +.sl-deps::before{content:'\\2190 ';color:var(--border-2)} +.sl-detail{padding:8px 12px;background:var(--bg-0);border-top:1px solid var(--border-1)} + +.task-list{list-style:none;padding:4px 0 0;display:flex;flex-direction:column;gap:2px} +.task-row{display:flex;align-items:center;gap:6px;font-size:12px;padding:3px 6px;border-radius:2px} + +/* Dep graph */ +.dep-block{margin-bottom:28px} +.dep-legend{display:flex;gap:14px;font-size:12px;color:var(--text-2);margin-bottom:8px;align-items:center} +.dep-legend span{display:flex;align-items:center;gap:4px} +.dep-wrap{overflow-x:auto;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:16px} +.dep-svg{display:block} +.edge{fill:none;stroke:var(--border-2);stroke-width:1.5} +.edge-crit{stroke:var(--accent);stroke-width:2} +.node rect{fill:var(--bg-2);stroke:var(--border-2);stroke-width:1} +.n-done rect{fill:var(--ok-subtle);stroke:rgba(34,197,94,.4)} +.n-active rect{fill:var(--accent-subtle);stroke:var(--accent)} +.n-crit rect{stroke:var(--accent)!important;stroke-width:1.5!important} +.n-id{font-family:var(--mono);font-size:10px;fill:var(--text-1);font-weight:600;text-anchor:middle} +.n-title{font-size:9px;fill:var(--text-2);text-anchor:middle} +.n-active .n-id{fill:var(--accent)} + +/* Metrics */ +.token-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px;margin-bottom:16px} +.token-bar{display:flex;height:16px;border-radius:2px;overflow:hidden;gap:1px;margin-bottom:8px} +.tseg{height:100%;min-width:2px} +.seg-1{background:var(--tk-input)} +.seg-2{background:var(--tk-output)} +.seg-3{background:var(--tk-cache-r)} +.seg-4{background:var(--tk-cache-w)} +.token-legend{display:flex;flex-wrap:wrap;gap:12px} +.leg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-2)} +.leg-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0} +.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px} +@media(max-width:860px){.chart-row{grid-template-columns:1fr}} +.chart-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px} +.bar-row{display:grid;grid-template-columns:120px 1fr 68px;align-items:center;gap:6px;margin-bottom:2px} +.bar-lbl{font-size:12px;color:var(--text-2);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bar-track{height:14px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.bar-fill{height:100%;border-radius:2px;background:var(--c0)} +.bar-c0{background:var(--c0)}.bar-c1{background:var(--c1)}.bar-c2{background:var(--c2)} +.bar-c3{background:var(--c3)}.bar-c4{background:var(--c4)}.bar-c5{background:var(--c5)} +.bar-val{font-size:11px;font-variant-numeric:tabular-nums;color:var(--text-1)} +.bar-sub{font-size:10px;color:var(--text-2);padding-left:128px;margin-bottom:6px} + +/* Changelog */ +.cl-entry{border-bottom:1px solid var(--border-1);padding:12px 0} +.cl-entry:last-child{border-bottom:none} +.cl-header{display:flex;align-items:center;gap:8px;margin-bottom:4px} +.cl-title{flex:1;font-weight:500} +.cl-date{margin-left:auto;white-space:nowrap} +.cl-liner{font-size:13px;color:var(--text-1);margin-bottom:6px} +.files-detail summary{font-size:12px;cursor:pointer} +.file-list{list-style:none;padding-left:10px;margin-top:4px;display:flex;flex-direction:column;gap:2px} +.file-list li{font-size:12px;color:var(--text-1)} + +/* Footer */ +footer{border-top:1px solid var(--border-1);padding:20px 32px;margin-top:40px} +.footer-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)} + +/* Print */ +@media print{ + header,nav.toc{position:static} + body{background:#fff;color:#1a1a1a} + :root{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5;--ok:#16a34a;--ok-subtle:rgba(22,163,74,.08);--c0:#4f46e5;--c1:#dc2626;--c2:#0d9488;--c3:#7c3aed;--c4:#d97706;--c5:#059669;--tk-input:#4f46e5;--tk-output:#dc2626;--tk-cache-r:#0d9488;--tk-cache-w:#64748b} + section{page-break-inside:avoid} + .table-scroll{overflow:visible} +} +`; + +// ─── JS ──────────────────────────────────────────────────────────────────────── + +const JS = ` +(function(){ + const sections=document.querySelectorAll('section[id]'); + const links=document.querySelectorAll('.toc a'); + if(!sections.length||!links.length)return; + const obs=new IntersectionObserver(entries=>{ + for(const e of entries){ + if(!e.isIntersecting)continue; + for(const l of links)l.classList.remove('active'); + const a=document.querySelector('.toc a[href="#'+e.target.id+'"]'); + if(a)a.classList.add('active'); + } + },{rootMargin:'-10% 0px -80% 0px',threshold:0}); + for(const s of sections)obs.observe(s); +})(); +`; diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts index 7a5202bd2..f4a23c080 100644 --- a/src/resources/extensions/gsd/export.ts +++ b/src/resources/extensions/gsd/export.ts @@ -93,9 +93,57 @@ export function writeExportFile( } /** - * Export session/milestone data to JSON or markdown. + * Export session/milestone data to JSON, markdown, or HTML. */ export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise { + // HTML report — delegates to the full visualizer-data pipeline + if (args.includes("--html")) { + try { + const { loadVisualizerData } = await import("./visualizer-data.js"); + const { generateHtmlReport } = await import("./export-html.js"); + const { writeReportSnapshot, reportsDir } = await import("./reports.js"); + const { basename: bn } = await import("node:path"); + const data = await loadVisualizerData(basePath); + const projName = basename(basePath); + const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; + const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); + const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); + const outPath = writeReportSnapshot({ + basePath, + html: generateHtmlReport(data, { + projectName: projName, + projectPath: basePath, + gsdVersion, + indexRelPath: "index.html", + }), + milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual", + milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "", + kind: "manual", + projectName: projName, + projectPath: basePath, + gsdVersion, + totalCost: data.totals?.cost ?? 0, + totalTokens: data.totals?.tokens.total ?? 0, + totalDuration: data.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: data.milestones.filter(m => m.status === "complete").length, + totalMilestones: data.milestones.length, + phase: data.phase, + }); + ctx.ui.notify( + `HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`, + "success", + ); + } catch (err) { + ctx.ui.notify( + `HTML export failed: ${err instanceof Error ? err.message : String(err)}`, + "error", + ); + } + return; + } + const format = args.includes("--json") ? "json" : "markdown"; const ledger = getLedger(); diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index aa050186b..762318493 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -75,6 +75,7 @@ const KNOWN_PREFERENCE_KEYS = new Set([ "token_profile", "phases", "auto_visualize", + "auto_report", "parallel", "verification_commands", "verification_auto_fix", @@ -175,6 +176,8 @@ export interface GSDPreferences { token_profile?: TokenProfile; phases?: PhaseSkipPreferences; auto_visualize?: boolean; + /** Generate HTML report snapshot after each milestone completion. Default: true. Set false to disable. */ + auto_report?: boolean; parallel?: import("./types.js").ParallelConfig; verification_commands?: string[]; verification_auto_fix?: boolean; diff --git a/src/resources/extensions/gsd/reports.ts b/src/resources/extensions/gsd/reports.ts new file mode 100644 index 000000000..c31d73bff --- /dev/null +++ b/src/resources/extensions/gsd/reports.ts @@ -0,0 +1,510 @@ +/** + * GSD Reports Registry + * + * Manages .gsd/reports/ — the persistent progression log of HTML snapshots. + * + * Layout: + * .gsd/reports/ + * reports.json lightweight metadata index (never re-parses HTML) + * index.html auto-regenerated on every new snapshot + * M001-20260101T120000.html per-milestone snapshot + * final-20260201T090000.html full-project final snapshot + * + * Auto-triggered: after each milestone completion (when auto_report: true). + * Manual: /gsd export --html + */ + +import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { gsdRoot } from './paths.js'; +import { formatCost, formatTokenCount } from './metrics.js'; +import { formatDuration } from './history.js'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ReportEntry { + /** Filename relative to the reports/ dir, e.g. "M001-20260101T120000.html" */ + filename: string; + /** ISO timestamp when this report was generated */ + generatedAt: string; + /** Milestone ID this snapshot covers, or "final" for a full-project snapshot */ + milestoneId: string | 'final'; + /** Milestone title at snapshot time */ + milestoneTitle: string; + /** Human-readable label shown in the index */ + label: string; + /** Snapshot kind */ + kind: 'milestone' | 'manual' | 'final'; + // Metrics at snapshot time — for the index progression view + totalCost: number; + totalTokens: number; + totalDuration: number; + doneSlices: number; + totalSlices: number; + doneMilestones: number; + totalMilestones: number; + phase: string; +} + +export interface ReportsIndex { + version: 1; + projectName: string; + projectPath: string; + gsdVersion: string; + entries: ReportEntry[]; +} + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +export function reportsDir(basePath: string): string { + return join(gsdRoot(basePath), 'reports'); +} + +function reportsIndexPath(basePath: string): string { + return join(reportsDir(basePath), 'reports.json'); +} + +function reportsHtmlIndexPath(basePath: string): string { + return join(reportsDir(basePath), 'index.html'); +} + +// ─── Registry ───────────────────────────────────────────────────────────────── + +export function loadReportsIndex(basePath: string): ReportsIndex | null { + const p = reportsIndexPath(basePath); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf-8')) as ReportsIndex; + } catch { + return null; + } +} + +function saveReportsIndex(basePath: string, index: ReportsIndex): void { + const dir = reportsDir(basePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(reportsIndexPath(basePath), JSON.stringify(index, null, 2) + '\n', 'utf-8'); +} + +// ─── Write a report snapshot ────────────────────────────────────────────────── + +export interface WriteReportSnapshotArgs { + basePath: string; + html: string; + milestoneId: string | 'final'; + milestoneTitle: string; + kind: 'milestone' | 'manual' | 'final'; + projectName: string; + projectPath: string; + gsdVersion: string; + // metrics + totalCost: number; + totalTokens: number; + totalDuration: number; + doneSlices: number; + totalSlices: number; + doneMilestones: number; + totalMilestones: number; + phase: string; +} + +/** + * Write a report snapshot to .gsd/reports/, update reports.json, regenerate index.html. + * Returns the path of the written report file. + */ +export function writeReportSnapshot(args: WriteReportSnapshotArgs): string { + const dir = reportsDir(args.basePath); + mkdirSync(dir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const prefix = args.milestoneId === 'final' ? 'final' : args.milestoneId; + const filename = `${prefix}-${timestamp}.html`; + const filePath = join(dir, filename); + + writeFileSync(filePath, args.html, 'utf-8'); + + // Load or init registry + const existing = loadReportsIndex(args.basePath); + const index: ReportsIndex = existing ?? { + version: 1, + projectName: args.projectName, + projectPath: args.projectPath, + gsdVersion: args.gsdVersion, + entries: [], + }; + + // Keep metadata fresh + index.projectName = args.projectName; + index.projectPath = args.projectPath; + index.gsdVersion = args.gsdVersion; + + const label = args.milestoneId === 'final' + ? 'Final Report' + : `${args.milestoneId}: ${args.milestoneTitle}`; + + const entry: ReportEntry = { + filename, + generatedAt: new Date().toISOString(), + milestoneId: args.milestoneId, + milestoneTitle: args.milestoneTitle, + label, + kind: args.kind, + totalCost: args.totalCost, + totalTokens: args.totalTokens, + totalDuration: args.totalDuration, + doneSlices: args.doneSlices, + totalSlices: args.totalSlices, + doneMilestones: args.doneMilestones, + totalMilestones: args.totalMilestones, + phase: args.phase, + }; + + index.entries.push(entry); + saveReportsIndex(args.basePath, index); + regenerateHtmlIndex(args.basePath, index); + + return filePath; +} + +// ─── HTML Index Generator ───────────────────────────────────────────────────── + +export function regenerateHtmlIndex(basePath: string, index: ReportsIndex): void { + const html = buildIndexHtml(index); + writeFileSync(reportsHtmlIndexPath(basePath), html, 'utf-8'); +} + +function buildIndexHtml(index: ReportsIndex): string { + const { projectName, projectPath, gsdVersion, entries } = index; + const generated = new Date().toISOString(); + + // Sort oldest → newest for the progression timeline + const sorted = [...entries].sort( + (a, b) => new Date(a.generatedAt).getTime() - new Date(b.generatedAt).getTime() + ); + + const latestEntry = sorted[sorted.length - 1]; + const overallPct = latestEntry + ? (latestEntry.totalSlices > 0 + ? Math.round((latestEntry.doneSlices / latestEntry.totalSlices) * 100) + : 0) + : 0; + + // TOC: group by milestone + const milestoneGroups = new Map(); + for (const e of sorted) { + const key = e.milestoneId; + const arr = milestoneGroups.get(key) ?? []; + arr.push(e); + milestoneGroups.set(key, arr); + } + + const tocHtml = [...milestoneGroups.entries()].map(([mid, group]) => { + const links = group.map(e => + `
  • ${formatDateShort(e.generatedAt)} ${e.kind}
  • ` + ).join(''); + return ` +
    +
    ${esc(mid === 'final' ? 'Final' : mid)}
    +
      ${links}
    +
    `; + }).join(''); + + // Progression cards + const cardHtml = sorted.map((e, i) => { + const pct = e.totalSlices > 0 ? Math.round((e.doneSlices / e.totalSlices) * 100) : 0; + const isLatest = i === sorted.length - 1; + + // Delta vs previous + let deltaHtml = ''; + if (i > 0) { + const prev = sorted[i - 1]; + const dCost = e.totalCost - prev.totalCost; + const dSlices = e.doneSlices - prev.doneSlices; + const dMillestones = e.doneMilestones - prev.doneMilestones; + const parts: string[] = []; + if (dCost > 0) parts.push(`+${formatCost(dCost)}`); + if (dSlices > 0) parts.push(`+${dSlices} slice${dSlices !== 1 ? 's' : ''}`); + if (dMillestones > 0) parts.push(`+${dMillestones} milestone${dMillestones !== 1 ? 's' : ''}`); + if (parts.length > 0) { + deltaHtml = `
    ${parts.map(p => `${esc(p)}`).join('')}
    `; + } + } + + return ` + +
    + ${esc(e.label)} + ${e.kind} +
    +
    ${formatDateShort(e.generatedAt)}
    +
    +
    +
    +
    + ${pct}% +
    +
    + ${esc(formatCost(e.totalCost))} + ${esc(formatTokenCount(e.totalTokens))} + ${esc(formatDuration(e.totalDuration))} + ${e.doneSlices}/${e.totalSlices} slices +
    + ${deltaHtml} + ${isLatest ? '
    Latest
    ' : ''} +
    `; + }).join(''); + + // Cost progression mini-chart (inline SVG sparkline) + const sparklineSvg = sorted.length > 1 ? buildCostSparkline(sorted) : ''; + + // Summary of latest state + const summaryHtml = latestEntry ? ` +
    +
    ${formatCost(latestEntry.totalCost)}Total Cost
    +
    ${formatTokenCount(latestEntry.totalTokens)}Total Tokens
    +
    ${formatDuration(latestEntry.totalDuration)}Duration
    +
    ${latestEntry.doneSlices}/${latestEntry.totalSlices}Slices
    +
    ${latestEntry.doneMilestones}/${latestEntry.totalMilestones}Milestones
    +
    ${entries.length}Reports
    +
    +
    +
    + ${overallPct}% complete +
    ` : '

    No reports generated yet.

    '; + + return ` + + + + +GSD Reports — ${esc(projectName)} + + + +
    +
    +
    + + v${esc(gsdVersion)} +
    +
    +

    ${esc(projectName)} Reports

    + ${esc(projectPath)} +
    +
    + Updated + ${formatDateShort(generated)} +
    +
    +
    + +
    + + + + +
    +
    +

    Project Overview

    + ${summaryHtml} + ${sparklineSvg ? `

    Cost Progression

    ${sparklineSvg}
    ` : ''} +
    + +
    +

    Progression ${entries.length}

    + ${sorted.length > 0 + ? `
    ${cardHtml}
    ` + : '

    No reports generated yet. Run /gsd export --html or enable auto_report: true.

    '} +
    +
    +
    + +
    +
    + GSD v${esc(gsdVersion)} + + ${esc(projectName)} + + ${esc(projectPath)} + + Updated ${formatDateShort(generated)} +
    +
    + +`; +} + +// ─── Cost sparkline (inline SVG) ────────────────────────────────────────────── + +function buildCostSparkline(entries: ReportEntry[]): string { + const costs = entries.map(e => e.totalCost); + const maxCost = Math.max(...costs, 0.001); + const W = 600, H = 60, PAD = 12; + const xStep = entries.length > 1 ? (W - PAD * 2) / (entries.length - 1) : W - PAD * 2; + + const points = costs.map((c, i) => { + const x = PAD + i * xStep; + const y = PAD + (1 - c / maxCost) * (H - PAD * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + + const dots = costs.map((c, i) => { + const x = PAD + i * xStep; + const y = PAD + (1 - c / maxCost) * (H - PAD * 2); + return ` + ${esc(entries[i].label)} — ${formatCost(c)} + `; + }).join(''); + + // Labels at start and end + const startLabel = formatCost(costs[0]); + const endLabel = formatCost(costs[costs.length - 1]); + + return ` +
    + + + ${dots} + ${esc(startLabel)} + ${esc(endLabel)} + +
    + ${entries.map((e, i) => { + const x = (PAD + i * xStep) / W * 100; + return `${esc(e.milestoneId === 'final' ? 'final' : e.milestoneId)}`; + }).join('')} +
    +
    `; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatDateShort(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch { return iso; } +} + +function esc(s: string | number | undefined | null): string { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +// ─── Index CSS ──────────────────────────────────────────────────────────────── + +const INDEX_CSS = ` +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg-0:#0f1115;--bg-1:#16181d;--bg-2:#1e2028;--bg-3:#272a33; + --border-1:#2b2e38;--border-2:#3b3f4c; + --text-0:#ededef;--text-1:#a1a1aa;--text-2:#71717a; + --accent:#5e6ad2;--accent-subtle:rgba(94,106,210,.12); + --font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; + --mono:'JetBrains Mono','Fira Code',ui-monospace,monospace; +} +html{font-size:13px} +body{background:var(--bg-0);color:var(--text-0);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +h2{font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-1);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-1)} +h3{font-size:13px;font-weight:600;color:var(--text-1);margin:16px 0 8px} +code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5px;border-radius:3px} +.empty{color:var(--text-2);font-size:13px;padding:8px 0} +.count{font-size:11px;font-weight:500;color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} + +/* Header */ +header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:100} +.hdr-inner{display:flex;align-items:center;gap:16px;max-width:1280px;margin:0 auto} +.branding{display:flex;align-items:baseline;gap:6px;flex-shrink:0} +.logo{font-size:18px;font-weight:800;letter-spacing:-.5px;color:var(--text-0)} +.ver{font-size:10px;color:var(--text-2);font-family:var(--mono)} +.hdr-meta{flex:1;min-width:0} +.hdr-meta h1{font-size:15px;font-weight:600} +.hdr-subtitle{color:var(--text-2);font-weight:400;font-size:13px;margin-left:4px} +.hdr-path{font-size:11px;color:var(--text-2);font-family:var(--mono);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.hdr-right{text-align:right;flex-shrink:0} +.gen-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;display:block} +.gen{font-size:11px;color:var(--text-1)} + +/* Layout */ +.layout{display:grid;grid-template-columns:200px 1fr;gap:0;max-width:1280px;margin:0 auto;min-height:calc(100vh - 120px)} + +/* Sidebar */ +.sidebar{background:var(--bg-1);border-right:1px solid var(--border-1);padding:20px 14px;position:sticky;top:52px;height:calc(100vh - 52px);overflow-y:auto} +.sidebar-title{font-size:10px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px} +.toc-group{margin-bottom:14px} +.toc-group-label{font-size:11px;font-weight:600;color:var(--text-1);margin-bottom:3px;font-family:var(--mono)} +.toc-group ul{list-style:none;display:flex;flex-direction:column;gap:1px} +.toc-group li{display:flex;align-items:center;gap:6px} +.toc-group a{font-size:11px;color:var(--text-2);padding:2px 4px;border-radius:3px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.toc-group a:hover{background:var(--bg-2);color:var(--text-0);text-decoration:none} +.toc-kind{font-size:9px;color:var(--text-2);font-family:var(--mono);flex-shrink:0} + +/* Main */ +main{padding:28px;display:flex;flex-direction:column;gap:40px} + +/* Overview */ +.idx-summary{display:flex;flex-wrap:wrap;gap:1px;background:var(--border-1);border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:16px} +.idx-stat{background:var(--bg-1);padding:10px 16px;display:flex;flex-direction:column;gap:2px;min-width:100px;flex:1} +.idx-val{font-size:18px;font-weight:600;color:var(--text-0);font-variant-numeric:tabular-nums} +.idx-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px} +.idx-progress{display:flex;align-items:center;gap:10px;margin-top:10px} +.idx-bar-track{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.idx-bar-fill{height:100%;background:var(--accent);border-radius:2px} +.idx-pct{font-size:12px;font-weight:600;color:var(--text-1);min-width:40px;text-align:right} + +/* Sparkline */ +.sparkline-wrap{margin-top:20px} +.sparkline{position:relative} +.spark-svg{display:block;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;overflow:visible;max-width:100%} +.spark-line{stroke:var(--accent);stroke-width:1.5;fill:none} +.spark-dot{fill:var(--accent);stroke:var(--bg-1);stroke-width:2;cursor:pointer} +.spark-dot:hover{r:4;fill:var(--text-0)} +.spark-lbl{font-size:10px;fill:var(--text-2);font-family:var(--mono)} +.spark-axis{display:flex;position:relative;height:18px;margin-top:2px} +.spark-tick{position:absolute;transform:translateX(-50%);font-size:9px;color:var(--text-2);font-family:var(--mono);white-space:nowrap} + +/* Report cards */ +.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px} +.report-card{ + display:flex;flex-direction:column;gap:6px; + background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px; + padding:14px;text-decoration:none;color:var(--text-0); + transition:border-color .12s; +} +.report-card:hover{border-color:var(--accent);text-decoration:none} +.card-latest{border-color:var(--accent)} +.card-top{display:flex;align-items:center;gap:8px} +.card-label{flex:1;font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.card-kind{font-size:10px;color:var(--text-2);font-family:var(--mono);flex-shrink:0} +.card-date{font-size:11px;color:var(--text-2)} +.card-progress{display:flex;align-items:center;gap:6px} +.card-bar-track{flex:1;height:3px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.card-bar-fill{height:100%;background:var(--accent);border-radius:2px} +.card-pct{font-size:11px;color:var(--text-2);min-width:30px;text-align:right} +.card-stats{display:flex;gap:8px;flex-wrap:wrap} +.card-stats span{font-size:11px;color:var(--text-2);font-variant-numeric:tabular-nums} +.card-delta{display:flex;gap:4px;flex-wrap:wrap} +.card-delta span{font-size:10px;color:var(--text-1);font-family:var(--mono)} +.card-latest-badge{display:none} + +/* Footer */ +footer{border-top:1px solid var(--border-1);padding:16px 32px} +.ftr-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)} +.ftr-sep{color:var(--border-2)} + +@media(max-width:768px){ + .layout{grid-template-columns:1fr} + .sidebar{position:static;height:auto;border-right:none;border-bottom:1px solid var(--border-1)} +} +@media print{ + .sidebar{display:none} + header{position:static} + body{background:#fff;color:#1a1a1a} + :root{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5} +} +`;