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"); +});