Repository: gsd-build/gsd-2 (2026-03-30T16:13:48Z)

Issue:
Web roadmap derives milestone status from slice heuristics instead of authoritative GSD milestone state
## Summary

The web Roadmap view derives milestone status from slice completion heuristics instead of using the authoritative milestone status from GSD state. This causes incorrect status rendering for milestones whose lifecycle state and validation verdict differ.

## Problem

In the current web UI:

- `web/components/gsd/roadmap.tsx` calls `getMilestoneStatus()` from `web/lib/workspace-status.ts`
- `getMilestoneStatus()` infers milestone state using only:
  - whether all slices are done
  - whether the milestone is the active milestone
  - whether any slice is done

Current logic:

```ts
export function getMilestoneStatus(milestone, active) {
  if (milestone.slices.length > 0 && milestone.slices.every((slice) => slice.done)) {
    return "done"
  }
  if (active.milestoneId === milestone.id) {
    return "in-progress"
  }
  return milestone.slices.some((slice) => slice.done) ? "in-progress" : "pending"
}
```

This is not aligned with GSD's actual milestone model.

## Why this is wrong

GSD milestone state has milestone-level semantics that are separate from slice completion:

- lifecycle status: `complete` / `active` / `pending` / `parked`
- validation verdict: `pass` / `needs-attention` / `needs-remediation`

The roadmap page currently does not consume milestone-level status at all. Instead, it reconstructs a UI state from slice progress.

That means milestones can render incorrectly when:

- the milestone is complete but validation is `needs-attention`
- the milestone has summary / validation artifacts that represent a terminal state
- milestone state and slice completion are not perfectly representable by the three inferred UI buckets

## Root cause

The web workspace index does not expose milestone-level status.

`src/resources/extensions/gsd/workspace-index.ts` currently returns milestone entries shaped roughly like:

```ts
{
  id,
  title,
  roadmapPath,
  slices,
}
```

It does **not** include:

- authoritative milestone status
- validation verdict
- summary existence / terminal milestone information

So the roadmap UI has no choice except to guess.

## Expected behavior

The Roadmap view should render milestone status from authoritative milestone state, not from slice heuristics.

At minimum, it should consume milestone lifecycle status from the engine/index layer.

Ideally, the UI contract should include both:

- `status: complete | active | pending | parked`
- `validationVerdict?: pass | needs-attention | needs-remediation`

## Suggested fix

### Option A (preferred)
Extend `WorkspaceMilestoneTarget` in `src/resources/extensions/gsd/workspace-index.ts` and `web/lib/gsd-workspace-store.tsx` to include milestone-level state:

```ts
interface WorkspaceMilestoneTarget {
  id: string
  title: string
  roadmapPath?: string
  status: "complete" | "active" | "pending" | "parked"
  validationVerdict?: "pass" | "needs-attention" | "needs-remediation"
  slices: WorkspaceSliceTarget[]
}
```

Populate `status` from `deriveState(basePath).registry`, and populate `validationVerdict` from milestone validation data.

Then update `web/components/gsd/roadmap.tsx` to render from those fields directly.

### Option B (partial)
If you want a smaller first step, at least add milestone `status` to the workspace index and make `getMilestoneStatus()` prefer that value before falling back to the current heuristic.

## Relevant files

- `web/components/gsd/roadmap.tsx`
- `web/lib/workspace-status.ts`
- `src/resources/extensions/gsd/workspace-index.ts`
- `web/lib/gsd-workspace-store.tsx`
- `src/resources/extensions/gsd/state.ts` (authoritative state source)
- `src/resources/extensions/gsd/visualizer-data.ts` / `web/lib/visualizer-types.ts` (already carry milestone-level status in another path)

## Impact

This makes the Roadmap view disagree with the actual GSD state model and can mislead users about milestone progress/completion semantics.

In practice, milestones with different validation outcomes can appear indistinguishable or be rendered with the wrong status emphasis in the Roadmap page.



Discussion:
## Forensic Evidence: DB Not Opened in Web Subprocess

Additional root-cause analysis from a forensic investigation on a project where M001-M006 are all `status: "complete"` in the DB, but the web UI sidebar shows them as pending/in-progress.

### The subprocess gap

The web API routes (`/api/live-state`, `/api/visualizer`) spawn a Node.js subprocess to call `indexWorkspace()` / `loadVisualizerData()`. The subprocess eval code is:

```javascript
const mod = await import(pathToFileURL(process.env.GSD_WORKSPACE_MODULE).href);
const result = await mod.indexWorkspace(process.env.GSD_WORKSPACE_BASE);
process.stdout.write(JSON.stringify(result));
```

**No `openDatabase()` call happens anywhere in this subprocess.** The DB module (`gsd-db.js`) keeps `currentDb = null` unless `openDatabase(path)` is explicitly called. In the normal GSD runtime, this happens in `bootstrap/dynamic-tools.js` or `auto-start.js` — but neither runs in the web subprocess.

Result: `isDbAvailable()` returns `false` → `workspace-index.js:80` falls back to file-based ROADMAP parsing → slice completion depends entirely on regex matching against markdown.

### File parser misses

The file-based fallback in `roadmap-slices.js:76-78` uses:

```javascript
const done = /\[x\]/i.test(line) ||
    /\bdone\b/.test(fullRow) ||
    /\bcomplete(?:d)?\b/.test(fullRow);
```

This misses:
- `✅` emoji without accompanying "done"/"complete" text
- `⬜` emoji (treated as not-done, but milestone is actually complete in DB)
- `[ ]` unchecked checkboxes on milestones that completed via DB status update without toggling the markdown checkbox

### DB verification

```
M001 (complete): 3 slices, 3 complete in DB
M002 (complete): 5 slices, 5 complete in DB
M003 (complete): 3 slices, 3 complete in DB  ← ROADMAP had [ ] unchecked
M004 (complete): 2 slices, 2 complete in DB  ← ROADMAP had ✅ without "done" text on S02
M005 (complete): 3 slices, 3 complete in DB  ← ROADMAP had ⬜ emoji
M006 (complete): 2 slices, 2 complete in DB  ← ROADMAP had ✅ without "done" text
M007-z1pf4d (active): 4 slices, 0 complete
```

### Impact on PR #2834

PR #2834 adds `status` from `deriveState().registry` to the workspace index output. However, `deriveState()` in the subprocess also checks `isDbAvailable()` — if it returns `false`, state derivation also falls back to file parsing (via `_deriveStateImpl`). The `deriveState` file-based path may handle completion detection differently (checking for SUMMARY files, etc.), but the core issue remains: **the DB is the authoritative source and the subprocess can't access it.**

The most robust fix would be to open the DB in the subprocess before calling `indexWorkspace()`, either by:
1. Adding `openDatabase(join(basePath, ".gsd", "gsd.db"))` to the subprocess eval script
2. Having `workspace-index.js` call `openDatabase()` itself at the start of `indexWorkspace()`

### Environment
- GSD version: 2.54.0
- Project: monorepo with 7 milestones (6 complete, 1 active)
- DB engine: better-sqlite3 via `gsd-db.js`

---
*Auto-generated by `/gsd forensics`*




Patch:
diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts
index 8b270662b8..28fa95df1c 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 a34d91cf15..997769b3cf 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 7fffa498c5..7578b0042b 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 0000000000..5cfa994508
--- /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[]
+}


Test patch:
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 0000000000..94fdcf3c07
--- /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<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");
+});