diff --git a/src/resources/extensions/gsd/tests/milestone-status-authoritative.test.ts b/src/resources/extensions/gsd/tests/milestone-status-authoritative.test.ts new file mode 100644 index 000000000..94fdcf3c0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/milestone-status-authoritative.test.ts @@ -0,0 +1,116 @@ +/** + * Bug #2807: Web roadmap derives milestone status from slice heuristics + * instead of authoritative GSD milestone state. + * + * getMilestoneStatus() should prefer the authoritative `status` field on + * WorkspaceMilestoneTarget (populated from the engine registry) rather + * than inferring status from slice completion flags. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getMilestoneStatus } from "../../../../../web/lib/workspace-status.ts"; + +// Inline type to avoid importing .tsx (not compiled to .js by test pipeline) +interface TestMilestone { + id: string; + title: string; + roadmapPath?: string; + status?: "complete" | "active" | "pending" | "parked"; + validationVerdict?: "pass" | "needs-attention" | "needs-remediation"; + slices: Array<{ id: string; title: string; done: boolean; tasks: Array<{ id: string; title: string; done: boolean }> }>; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeMilestone(overrides: Partial & { id: string }): TestMilestone { + return { + title: overrides.id, + roadmapPath: undefined, + slices: [], + ...overrides, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +test("getMilestoneStatus returns authoritative 'complete' even when slices are not all done", () => { + const milestone = makeMilestone({ + id: "M001", + status: "complete", + slices: [ + { id: "S01", title: "Slice 1", done: true, tasks: [] }, + { id: "S02", title: "Slice 2", done: false, tasks: [] }, // not done + ], + }); + // Before the fix, this would return "in-progress" because not all slices are done. + // After the fix, it should return "done" because authoritative status is "complete". + assert.equal(getMilestoneStatus(milestone, {}), "done"); +}); + +test("getMilestoneStatus returns authoritative 'active' regardless of slice state", () => { + const milestone = makeMilestone({ + id: "M002", + status: "active", + slices: [ + { id: "S01", title: "Slice 1", done: true, tasks: [] }, + { id: "S02", title: "Slice 2", done: true, tasks: [] }, + ], + }); + // Before the fix, this would return "done" because all slices are done. + // After the fix, it should return "in-progress" because authoritative status is "active". + assert.equal(getMilestoneStatus(milestone, {}), "in-progress"); +}); + +test("getMilestoneStatus returns 'pending' for authoritative 'pending' even when some slices done", () => { + const milestone = makeMilestone({ + id: "M003", + status: "pending", + slices: [ + { id: "S01", title: "Slice 1", done: true, tasks: [] }, + { id: "S02", title: "Slice 2", done: false, tasks: [] }, + ], + }); + // Before the fix, this would return "in-progress" because some slices are done. + // After the fix, it should return "pending". + assert.equal(getMilestoneStatus(milestone, {}), "pending"); +}); + +test("getMilestoneStatus maps 'parked' to 'pending' item status", () => { + const milestone = makeMilestone({ + id: "M004", + status: "parked", + slices: [ + { id: "S01", title: "Slice 1", done: true, tasks: [] }, + ], + }); + // Parked milestones should render as pending in the UI + assert.equal(getMilestoneStatus(milestone, {}), "pending"); +}); + +test("getMilestoneStatus falls back to heuristic when no authoritative status", () => { + // Backward compatibility: milestones without the status field should + // still work using the old slice-based heuristic. + const milestone = makeMilestone({ + id: "M005", + slices: [ + { id: "S01", title: "Slice 1", done: true, tasks: [] }, + { id: "S02", title: "Slice 2", done: true, tasks: [] }, + ], + }); + assert.equal(getMilestoneStatus(milestone, {}), "done"); +}); + +test("getMilestoneStatus exposes validationVerdict on milestone target", () => { + const milestone = makeMilestone({ + id: "M006", + status: "complete", + validationVerdict: "needs-attention", + slices: [ + { id: "S01", title: "Slice 1", done: true, tasks: [] }, + ], + }); + // The milestone should have the validationVerdict field available + assert.equal(milestone.validationVerdict, "needs-attention"); + // And status should still be "done" + assert.equal(getMilestoneStatus(milestone, {}), "done"); +}); diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts index 8b270662b..28fa95df1 100644 --- a/src/resources/extensions/gsd/workspace-index.ts +++ b/src/resources/extensions/gsd/workspace-index.ts @@ -11,6 +11,7 @@ import { resolveTasksDir, } from "./paths.js"; import { deriveState } from "./state.js"; +import { extractVerdict } from "./verdict-parser.js"; import { milestoneIdSort, findMilestoneIds } from "./guided-flow.js"; import type { RiskLevel } from "./types.js"; import { getSliceBranchName, detectWorktreeName } from "./worktree.js"; @@ -42,6 +43,10 @@ export interface WorkspaceMilestoneTarget { id: string; title: string; roadmapPath?: string; + /** Authoritative milestone lifecycle status from the GSD state registry. */ + status?: "complete" | "active" | "pending" | "parked"; + /** Milestone validation verdict, when validation has been performed. */ + validationVerdict?: "pass" | "needs-attention" | "needs-remediation"; slices: WorkspaceSliceTarget[]; } @@ -192,6 +197,31 @@ export async function indexWorkspace(basePath: string, opts: IndexWorkspaceOptio phase: state.phase, }; + // Enrich milestones with authoritative status from state registry (#2807) + if (state.registry) { + const registryMap = new Map(state.registry.map(e => [e.id, e])); + for (const milestone of milestones) { + const entry = registryMap.get(milestone.id); + if (entry) { + milestone.status = entry.status; + } + } + } + + // Populate validationVerdict from VALIDATION files (#2807) + for (const milestone of milestones) { + const validationPath = resolveMilestoneFile(basePath, milestone.id, "VALIDATION"); + if (validationPath) { + const validationContent = await loadFile(validationPath); + if (validationContent) { + const verdict = extractVerdict(validationContent); + if (verdict === "pass" || verdict === "needs-attention" || verdict === "needs-remediation") { + milestone.validationVerdict = verdict; + } + } + } + } + const scopes: WorkspaceScopeTarget[] = [{ scope: "project", label: "project", kind: "project" }]; for (const milestone of milestones) { scopes.push({ scope: milestone.id, label: `${milestone.id}: ${milestone.title}`, kind: "milestone" }); diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index 123b914f8..7c0d1d399 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -125,37 +125,7 @@ export interface BridgeRuntimeSnapshot { lastError: BridgeLastError | null } -export interface WorkspaceTaskTarget { - id: string - title: string - done: boolean - planPath?: string - summaryPath?: string -} - -export type RiskLevel = "low" | "medium" | "high" - -export interface WorkspaceSliceTarget { - id: string - title: string - done: boolean - planPath?: string - summaryPath?: string - uatPath?: string - tasksDir?: string - branch?: string - risk?: RiskLevel - depends?: string[] - demo?: string - tasks: WorkspaceTaskTarget[] -} - -export interface WorkspaceMilestoneTarget { - id: string - title: string - roadmapPath?: string - slices: WorkspaceSliceTarget[] -} +export type { WorkspaceTaskTarget, RiskLevel, WorkspaceSliceTarget, WorkspaceMilestoneTarget } from "./workspace-types.js" export interface WorkspaceScopeTarget { scope: string diff --git a/web/lib/workspace-status.ts b/web/lib/workspace-status.ts index 7fffa498c..7578b0042 100644 --- a/web/lib/workspace-status.ts +++ b/web/lib/workspace-status.ts @@ -2,7 +2,7 @@ import type { WorkspaceMilestoneTarget, WorkspaceSliceTarget, WorkspaceTaskTarget, -} from "./gsd-workspace-store" +} from "./workspace-types.js" export type ItemStatus = "done" | "in-progress" | "pending" @@ -10,13 +10,27 @@ export function getMilestoneStatus( milestone: WorkspaceMilestoneTarget, active: { milestoneId?: string }, ): ItemStatus { - if (milestone.slices.length > 0 && milestone.slices.every((slice) => slice.done)) { + // Prefer authoritative milestone status from GSD state registry (#2807) + if (milestone.status) { + switch (milestone.status) { + case "complete": + return "done" + case "active": + return "in-progress" + case "pending": + case "parked": + return "pending" + } + } + + // Fallback: infer from slice completion (legacy / no status field) + if (milestone.slices.length > 0 && milestone.slices.every((slice: WorkspaceSliceTarget) => slice.done)) { return "done" } if (active.milestoneId === milestone.id) { return "in-progress" } - return milestone.slices.some((slice) => slice.done) ? "in-progress" : "pending" + return milestone.slices.some((slice: WorkspaceSliceTarget) => slice.done) ? "in-progress" : "pending" } export function getSliceStatus( diff --git a/web/lib/workspace-types.ts b/web/lib/workspace-types.ts new file mode 100644 index 000000000..5cfa99450 --- /dev/null +++ b/web/lib/workspace-types.ts @@ -0,0 +1,35 @@ +export interface WorkspaceTaskTarget { + id: string + title: string + done: boolean + planPath?: string + summaryPath?: string +} + +export type RiskLevel = "low" | "medium" | "high" + +export interface WorkspaceSliceTarget { + id: string + title: string + done: boolean + planPath?: string + summaryPath?: string + uatPath?: string + tasksDir?: string + branch?: string + risk?: RiskLevel + depends?: string[] + demo?: string + tasks: WorkspaceTaskTarget[] +} + +export interface WorkspaceMilestoneTarget { + id: string + title: string + roadmapPath?: string + /** Authoritative milestone lifecycle status from the GSD state registry. */ + status?: "complete" | "active" | "pending" | "parked" + /** Milestone validation verdict, when validation has been performed. */ + validationVerdict?: "pass" | "needs-attention" | "needs-remediation" + slices: WorkspaceSliceTarget[] +}