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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 15:50:57 -04:00 committed by GitHub
parent a725fa2d9d
commit 341a211be2
5 changed files with 199 additions and 34 deletions

View file

@ -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<TestMilestone> & { 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");
});

View file

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

View file

@ -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

View file

@ -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(

View file

@ -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[]
}