From 341a211be2468b8e23ff86ba5138f2f85097feba Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 15:50:57 -0400 Subject: [PATCH] fix: use authoritative milestone status in web roadmap (#2807) (#3258) * fix: use authoritative milestone status in web roadmap instead of slice heuristics (#2807) The roadmap view was deriving milestone status from slice completion flags, which disagrees with the actual GSD state model when milestones have lifecycle states (complete/active/pending/parked) or validation verdicts that differ from what slice progress implies. Add status and validationVerdict fields to WorkspaceMilestoneTarget, populate them from the state registry and VALIDATION files, and update getMilestoneStatus() to prefer the authoritative status with a fallback to the old heuristic for backward compatibility. Co-Authored-By: Claude Opus 4.6 * fix: add .js import extension and slice type annotations in workspace-status Fixes TS2835 (missing .js extension for NodeNext resolution) and TS7006 (implicit any on slice callback parameters) that caused CI build failure. Co-Authored-By: Claude Opus 4.6 * fix: extract workspace types to .ts file to avoid jsx resolution error Move WorkspaceTaskTarget, WorkspaceSliceTarget, WorkspaceMilestoneTarget, and RiskLevel to workspace-types.ts so that workspace-status.ts (a plain .ts file) can import them without requiring --jsx. The .tsx store file re-exports the types for backward compatibility. Fixes TS6142 in CI for PR #3258. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../milestone-status-authoritative.test.ts | 116 ++++++++++++++++++ .../extensions/gsd/workspace-index.ts | 30 +++++ web/lib/gsd-workspace-store.tsx | 32 +---- web/lib/workspace-status.ts | 20 ++- web/lib/workspace-types.ts | 35 ++++++ 5 files changed, 199 insertions(+), 34 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/milestone-status-authoritative.test.ts create mode 100644 web/lib/workspace-types.ts 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[] +}