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)}` : ''}
+
+
+
+
+
+
+${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}
+
+ ${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
+
+ | Tier | Units | Cost | Tokens |
+
+ ${h.tierBreakdown.map(tb =>
+ `| ${esc(tb.tier)} |
+ ${tb.units} | ${formatCost(tb.cost)} |
+ ${formatTokenCount(tb.tokens.total)} |
`
+ ).join('')}
+
+
` : '';
+
+ return section('health', 'Health', `
+
+ ${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 ? `
+
+ ${sl.tasks.map(t => `
+ -
+
+ ${esc(t.id)}
+ ${esc(t.title)}
+ ${t.estimate ? `${esc(t.estimate)}` : ''}
+
`).join('')}
+
` : '';
+
+ const tags = [
+ ...(ver?.provides ?? []).map(p => `provides: ${esc(p)}`),
+ ...(ver?.requires ?? []).map(r => `requires: ${esc(r.provides)}`),
+ ].join('');
+
+ const keyDecisions = ver?.keyDecisions?.length
+ ? `Decisions${ver.keyDecisions.map(d => `- ${esc(d)}
`).join('')}
`
+ : '';
+
+ const patterns = ver?.patternsEstablished?.length
+ ? `Patterns${ver.patternsEstablished.map(p => `- ${esc(p)}
`).join('')}
`
+ : '';
+
+ 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}
+
+
+
+
`;
+}
+
+// ─── 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', `
+ `);
+}
+
+// ─── 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 `
+
+
+ ${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}
+
+ | ID | Scope | Rule |
+ ${k.rules.map(r => `| ${esc(r.id)} | ${esc(r.scope)} | ${esc(r.content)} |
`).join('')}
+
` : '';
+
+ const patternsHtml = k.patterns.length > 0 ? `
+ Patterns ${k.patterns.length}
+
+ | ID | Pattern |
+ ${k.patterns.map(p => `| ${esc(p.id)} | ${esc(p.content)} |
`).join('')}
+
` : '';
+
+ const lessonsHtml = k.lessons.length > 0 ? `
+ Lessons ${k.lessons.length}
+
+ | ID | Lesson |
+ ${k.lessons.map(l => `| ${esc(l.id)} | ${esc(l.content)} |
`).join('')}
+
` : '';
+
+ 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}`, `
+ `);
+}
+
+// ─── Section: Stats ───────────────────────────────────────────────────────────
+
+function buildStatsSection(data: VisualizerData): string {
+ const s = data.stats;
+
+ const missingHtml = s.missingCount > 0 ? `
+ Missing changelogs ${s.missingCount}
+
+ | Milestone | Slice | Title |
+
+ ${s.missingSlices.map(sl => `| ${esc(sl.milestoneId)} | ${esc(sl.sliceId)} | ${esc(sl.title)} |
`).join('')}
+ ${s.missingCount > s.missingSlices.length
+ ? `| and ${s.missingCount - s.missingSlices.length} more |
`
+ : ''}
+
+
` : '';
+
+ const updatedHtml = s.updatedCount > 0 ? `
+ Recently completed ${s.updatedCount}
+
+ | Milestone | Slice | Title | Completed |
+ ${s.updatedSlices.map(sl => `
+ | ${esc(sl.milestoneId)} | ${esc(sl.sliceId)} | ${esc(sl.title)} | ${sl.completedAt ? formatDateShort(sl.completedAt) : ''} |
`).join('')}
+
+
` : '';
+
+ 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', `
+
+ | ID | Milestone | State | Context | Draft | Updated |
+ ${rows}
+
`);
+}
+
+// ─── Primitives ────────────────────────────────────────────────────────────────
+
+function section(id: string, title: string, body: string): string {
+ return `\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)}
+
+
`;
+ }).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)}
+
+
+ ${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)}
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
'}
+
+
+
+
+
+
+`;
+}
+
+// ─── 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 `
+
+
+
+ ${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}
+}
+`;