diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 4ef9ce1c1..c785bf2c0 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -36,6 +36,15 @@ import { writeUnitRuntimeRecord } from "../unit-runtime.js"; // ─── generateMilestoneReport ────────────────────────────────────────────────── +/** + * Resolve the base path for milestone reports. + * Prefers originalBasePath (project root) over basePath (which may be a worktree). + * Exported for testing as _resolveReportBasePath. + */ +export function _resolveReportBasePath(s: Pick): string { + return s.originalBasePath || s.basePath; +} + /** * Generate and write an HTML milestone report snapshot. * Extracted from the milestone-transition block in autoLoop. @@ -50,13 +59,15 @@ async function generateMilestoneReport( const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "../reports.js"); const { basename } = await import("node:path"); - const snapData = await loadVisualizerData(s.basePath); + const reportBasePath = _resolveReportBasePath(s); + + const snapData = await loadVisualizerData(reportBasePath); const completedMs = snapData.milestones.find( (m: { id: string }) => m.id === milestoneId, ); const msTitle = completedMs?.title ?? milestoneId; const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; - const projName = basename(s.basePath); + const projName = basename(reportBasePath); const doneSlices = snapData.milestones.reduce( (acc: number, m: { slices: { done: boolean }[] }) => acc + m.slices.filter((sl: { done: boolean }) => sl.done).length, @@ -67,10 +78,10 @@ async function generateMilestoneReport( 0, ); const outPath = writeReportSnapshot({ - basePath: s.basePath, + basePath: reportBasePath, html: generateHtmlReport(snapData, { projectName: projName, - projectPath: s.basePath, + projectPath: reportBasePath, gsdVersion, milestoneId, indexRelPath: "index.html", @@ -79,7 +90,7 @@ async function generateMilestoneReport( milestoneTitle: msTitle, kind: "milestone", projectName: projName, - projectPath: s.basePath, + projectPath: reportBasePath, gsdVersion, totalCost: snapData.totals?.cost ?? 0, totalTokens: snapData.totals?.tokens.total ?? 0, diff --git a/src/resources/extensions/gsd/tests/milestone-report-path.test.ts b/src/resources/extensions/gsd/tests/milestone-report-path.test.ts new file mode 100644 index 000000000..8ab7c1571 --- /dev/null +++ b/src/resources/extensions/gsd/tests/milestone-report-path.test.ts @@ -0,0 +1,51 @@ +/** + * milestone-report-path.test.ts — Regression test for milestone report path resolution. + * + * When running in a worktree, milestone reports must be written to the + * original project root (originalBasePath), not the worktree path (basePath). + * + * Covers: _resolveReportBasePath from auto/phases.ts + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { _resolveReportBasePath } from "../auto/phases.ts"; + +describe("_resolveReportBasePath", () => { + test("uses originalBasePath when set (worktree scenario)", () => { + const session = { + originalBasePath: "/projects/my-app", + basePath: "/projects/my-app/.claude/worktrees/agent-abc123", + }; + + assert.equal(_resolveReportBasePath(session), "/projects/my-app"); + }); + + test("falls back to basePath when originalBasePath is empty", () => { + const session = { + originalBasePath: "", + basePath: "/projects/my-app", + }; + + assert.equal(_resolveReportBasePath(session), "/projects/my-app"); + }); + + test("falls back to basePath when originalBasePath is undefined", () => { + const session = { + originalBasePath: undefined as unknown as string, + basePath: "/projects/my-app", + }; + + assert.equal(_resolveReportBasePath(session), "/projects/my-app"); + }); + + test("uses originalBasePath even when basePath differs", () => { + const session = { + originalBasePath: "/home/user/repo", + basePath: "/tmp/worktree-xyz", + }; + + assert.equal(_resolveReportBasePath(session), "/home/user/repo"); + }); +});