From c9f63f8e93587824536053356dd931a302eff7d0 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 17:17:50 -0500 Subject: [PATCH] feat: add /gsd export --html --all for retrospective milestone reports When --all is passed alongside --html, generates a report snapshot for every milestone that doesn't already have one in the reports index. This fills the progression timeline with cards for completed milestones that were finished before the HTML report feature existed. - Deduplicates against existing reports.json entries to avoid duplicates - Tags completed milestones with kind "milestone", active with "manual" - Tracks cumulative slice/milestone progress per snapshot for the index - Adds --html and --html --all to export autocomplete suggestions - Updates help text to show [--all] flag --- src/resources/extensions/gsd/commands.ts | 4 +- src/resources/extensions/gsd/export.ts | 117 ++++++++++++++---- .../gsd/tests/export-html-all.test.ts | 105 ++++++++++++++++ 3 files changed, 197 insertions(+), 29 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/export-html-all.test.ts diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 90f84720d..9b2ee15dc 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -182,7 +182,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts[0] === "export" && parts.length <= 2) { const flagPrefix = parts[1] ?? ""; - return ["--json", "--markdown"] + return ["--json", "--markdown", "--html", "--html --all"] .filter((f) => f.startsWith(flagPrefix)) .map((f) => ({ value: `export ${f}`, label: f })); } @@ -631,7 +631,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|--html]", + " /gsd export Export milestone/slice results [--json|--markdown|--html] [--all]", " /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.ts b/src/resources/extensions/gsd/export.ts index f4a23c080..c7f9bc290 100644 --- a/src/resources/extensions/gsd/export.ts +++ b/src/resources/extensions/gsd/export.ts @@ -98,43 +98,106 @@ export function writeExportFile( export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise { // HTML report — delegates to the full visualizer-data pipeline if (args.includes("--html")) { + const generateAll = args.includes("--all"); try { const { loadVisualizerData } = await import("./visualizer-data.js"); const { generateHtmlReport } = await import("./export-html.js"); - const { writeReportSnapshot, reportsDir } = await import("./reports.js"); + const { writeReportSnapshot, loadReportsIndex } = 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", + const doneMilestones = data.milestones.filter(m => m.status === "complete").length; + + const htmlOpts = { 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", - ); + indexRelPath: "index.html", + }; + + if (generateAll) { + // Generate a report snapshot for every milestone + const existing = loadReportsIndex(basePath); + const existingIds = new Set(existing?.entries.map(e => e.milestoneId) ?? []); + + const targets = data.milestones.filter(m => !existingIds.has(m.id)); + if (targets.length === 0) { + ctx.ui.notify( + "All milestones already have report snapshots. Run without --all to create a new snapshot for the active milestone.", + "info", + ); + return; + } + + const html = generateHtmlReport(data, htmlOpts); + const paths: string[] = []; + + for (const ms of targets) { + const msSlicesDone = ms.slices.filter(sl => sl.done).length; + const msSlicesTotal = ms.slices.length; + + // Accumulate project-wide progress up to and including this milestone + const msIdx = data.milestones.indexOf(ms); + let cumulativeDone = 0; + let cumulativeTotal = 0; + for (let i = 0; i <= msIdx; i++) { + cumulativeDone += data.milestones[i].slices.filter(sl => sl.done).length; + cumulativeTotal += data.milestones[i].slices.length; + } + + const outPath = writeReportSnapshot({ + basePath, + html, + milestoneId: ms.id, + milestoneTitle: ms.title, + kind: ms.status === "complete" ? "milestone" : "manual", + projectName: projName, + projectPath: basePath, + gsdVersion, + totalCost: data.totals?.cost ?? 0, + totalTokens: data.totals?.tokens.total ?? 0, + totalDuration: data.totals?.duration ?? 0, + doneSlices: cumulativeDone, + totalSlices: cumulativeTotal, + doneMilestones: data.milestones.slice(0, msIdx + 1).filter(m => m.status === "complete").length, + totalMilestones: data.milestones.length, + phase: ms.status === "complete" ? "complete" : data.phase, + }); + paths.push(bn(outPath)); + } + + ctx.ui.notify( + `Generated ${paths.length} report snapshot${paths.length !== 1 ? "s" : ""}:\n${paths.map(p => ` ${p}`).join("\n")}\nBrowse all reports: .gsd/reports/index.html`, + "success", + ); + } else { + // Single report for the active milestone (existing behavior) + 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, htmlOpts), + 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, + 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)}`, diff --git a/src/resources/extensions/gsd/tests/export-html-all.test.ts b/src/resources/extensions/gsd/tests/export-html-all.test.ts new file mode 100644 index 000000000..ccd5ef4bf --- /dev/null +++ b/src/resources/extensions/gsd/tests/export-html-all.test.ts @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Test: --all flag generates snapshots for milestones not yet in the index + +test("handleExport --html --all generates reports for milestones missing from the index", async () => { + // We test the export logic indirectly by verifying the flag parsing + // and the deduplication logic via loadReportsIndex + milestone filtering + const { loadReportsIndex } = await import("../reports.js"); + + const tmp = join(tmpdir(), `gsd-export-all-test-${Date.now()}`); + const gsdDir = join(tmp, ".gsd"); + const reportsDir = join(gsdDir, "reports"); + mkdirSync(reportsDir, { recursive: true }); + + // No existing reports — loadReportsIndex returns null + const noIndex = loadReportsIndex(tmp); + assert.equal(noIndex, null, "empty reports dir should return null index"); + + // Write a reports.json with M001 already present + const index = { + version: 1, + projectName: "test-project", + projectPath: tmp, + gsdVersion: "2.27.0", + entries: [ + { + filename: "M001-2026-01-01T00-00-00.html", + generatedAt: "2026-01-01T00:00:00.000Z", + milestoneId: "M001", + milestoneTitle: "First Milestone", + label: "M001: First Milestone", + kind: "milestone", + totalCost: 0.5, + totalTokens: 10000, + totalDuration: 60000, + doneSlices: 3, + totalSlices: 3, + doneMilestones: 1, + totalMilestones: 3, + phase: "complete", + }, + ], + }; + writeFileSync(join(reportsDir, "reports.json"), JSON.stringify(index), "utf-8"); + + // Now loadReportsIndex should find M001 + const loaded = loadReportsIndex(tmp); + assert.ok(loaded, "should load existing reports index"); + assert.equal(loaded.entries.length, 1); + assert.equal(loaded.entries[0].milestoneId, "M001"); + + // Simulate the deduplication logic from handleExport --all + const existingIds = new Set(loaded.entries.map(e => e.milestoneId)); + const allMilestones = [ + { id: "M001", title: "First Milestone", status: "complete" }, + { id: "M002", title: "Second Milestone", status: "complete" }, + { id: "M003", title: "Third Milestone", status: "active" }, + ]; + + const targets = allMilestones.filter(m => !existingIds.has(m.id)); + assert.equal(targets.length, 2, "should skip M001 and target M002 + M003"); + assert.equal(targets[0].id, "M002"); + assert.equal(targets[1].id, "M003"); + + // Cleanup + rmSync(tmp, { recursive: true, force: true }); +}); + +test("handleExport --html --all sets milestone kind based on status", async () => { + const completeMilestone = { id: "M001", status: "complete" }; + const activeMilestone = { id: "M002", status: "active" }; + + // Logic from the implementation + const completeKind = completeMilestone.status === "complete" ? "milestone" : "manual"; + const activeKind = activeMilestone.status === "complete" ? "milestone" : "manual"; + + assert.equal(completeKind, "milestone", "completed milestones get kind 'milestone'"); + assert.equal(activeKind, "manual", "active milestones get kind 'manual'"); +}); + +test("export completions include --html and --html --all", async () => { + const { registerGSDCommand } = await import("../commands.js"); + + const commands = new Map(); + const pi = { + registerCommand(name: string, options: any) { commands.set(name, options); }, + registerTool() {}, + registerShortcut() {}, + on() {}, + sendMessage() {}, + }; + + registerGSDCommand(pi as any); + const gsd = commands.get("gsd"); + assert.ok(gsd, "should register /gsd command"); + + const completions = gsd.getArgumentCompletions("export --"); + const labels = completions.map((c: any) => c.label); + assert.ok(labels.includes("--html"), "completions should include --html"); + assert.ok(labels.includes("--html --all"), "completions should include --html --all"); +});