---
type: Fixed
pr: 2588
---
**`workstream progress` / `workstream status` / `workstream list` no longer report a workstream's CURRENT milestone as "milestone complete" / 100% while phases in that milestone are unstarted, in progress, or failing verification.** Three coupled defects in the shared inventory derivation are fixed. (1) The shipped signal was project-lifetime rather than milestone-scoped — `workstreamMilestoneShipped()` returned true if ANY `*-ROADMAP.md` snapshot existed or `SHIPPED` appeared anywhere in `ROADMAP.md`, and since every previously shipped milestone leaves a permanent collapsed `✅ … SHIPPED` block, any workstream that had ever shipped was pinned to "milestone complete" forever (an over-correction from #1913). It now requires the CURRENT version's archived `milestones/-ROADMAP.md` snapshot, or the current milestone's own ROADMAP line marked shipped; `REQUIREMENTS` snapshots are deliberately not accepted because they can be written at milestone start. (2) The completion percentage silently excluded phases declared for the current milestone but never scaffolded, while completed PRIOR-milestone phase directories inflated the numerator — both numerator and denominator are now scoped to the current milestone, whose phase set is read from the ROADMAP `## Progress` table (which lists phases with no directory) via the canonical `findTableWithColumns` parser, with the current version taken from the workstream `STATE.md` `milestone:` field rather than ROADMAP in-progress markers, which can be stale. (3) Phase completeness ignored the verification verdict — a phase with `SUMMARY` count ≥ `PLAN` count now counts as `in_progress` rather than `complete` when its verdict is an explicit failing one (`gaps_found`/`human_needed`); `missing`/`unknown`/`stale` are intentionally untouched so verifier-disabled projects do not regress to never-complete.
Two further denominator gaps are closed. A phase declared as a `## Progress` table row with **no `### Phase N` heading** was dropped by the heading-only count *even when other headings existed* (the regex counts 1 for a "1 heading + 1 table-only" roadmap), and milestone scoping could not cover it because a flat Progress table carries no per-phase milestone attribution — so greenfield and single-milestone projects kept the faulty count. When scoping cannot engage, the denominator is now the union of the Progress table's declared phase numbers and the phase directories, so neither source can shrink it. Separately, a sub-phase directory inserted mid-milestone (`30.1-…` under a table-declared phase 30) has no table row of its own and previously had no milestone attribution at all; it now inherits its parent phase's milestone and joins BOTH sides of the calculation — numerator-only would let `completed_phases` exceed a denominator that never counted it and cap back to 100%, reintroducing the reported defect. Attribution is one-directional (a sub-phase counts only when its parent is in the current milestone), so a follow-up created in a later milestone under an older parent is excluded rather than misattributed.
Membership and the denominator are derived from a single canonical phase-key surface, promoted to the phase-id owner module as `phaseKeyFromToken` / `phaseKeyFromDir` / `phaseKeyFromProse` / `parentPhaseKey` (previously a private pair in `state.cts`). Deriving one side of a comparison with a bespoke regex was itself a way to reproduce this issue: a padded `| 01. … |` table row never matched a `1-slug` directory, and a project-code-prefixed `PROJ-05-…` directory matched nothing at all — each silently zeroing or pinning the rollup while `phases[]` reported the opposite. Directory membership additionally consults `getMilestonePhaseFilter`, the module that owns milestone-phase filtering, which now accepts a workstream name so its `planningDir` resolution can target `.planning/workstreams//` (a loop over workstreams cannot express that through `GSD_WORKSTREAM`) and exposes `versionScoped` so its phase count is never mistaken for a current-milestone denominator on an unversioned roadmap. "Milestone shipped" detection likewise moved to that module as `isMilestoneShippedInRoadmap`: heading and `` lines only — a bullet such as `- [x] 03-01: ship the v2.0 login endpoint ✅` is prose about a phase, not a milestone verdict — with the version token boundary-matched so a shipped `v2.0.1` heading cannot close `v2.0`. A ROADMAP row whose Milestone cell is blank or malformed now stays in the denominator instead of vanishing from both sides, and a stale directory colliding on phase number with a current one (Bug #2445's scenario) counts once; the Builder asserts `completed_phases <= denominator` and throws rather than letting `Math.min` round a contradiction up to 100%. `getMilestonePhaseFilter` still applies its own internal phase-id normaliser for directory matching rather than routing through `phase-id.cts`; the two signals are OR'd, so a divergence can only widen membership, never narrow it — but they remain two normalisers, not one.
A **declared-but-empty current milestone** is scoped rather than treated as unscoped. `STATE.md`'s `milestone:` field updates the moment `/gsd-new-milestone` writes the heading, while the `## Progress` table and phase sections land later; in that window nothing attributes a phase to the current milestone, scoping switched off entirely, and the fallback counted the project's whole phase history as both numerator and denominator — reporting 100% for a milestone with no work done, the same symptom by a different route. Three witnesses now distinguish that state, each covering a ROADMAP shape the others miss: `getMilestonePhaseFilter` gained `versionSectionFound` (the milestone's section exists but declares no phases — `versionScoped` cannot answer this, because a located-but-empty section falls through to the zero-count pass-all degrade that resets it), the existing `missingExplicitVersion` (a versioned roadmap with no section for this version), and a Progress table attributing every row to another milestone. A ROADMAP that attributes no versions anywhere matches none of them — its rows parse unattributed and stay in the current milestone — so free-form legacy projects keep their whole-roadmap count instead of regressing to 0%. Within an empty milestone, membership inverts: a phase directory belongs unless another milestone's row claims it, so a phase scaffolded before the roadmap catches up is counted rather than dropped from both sides. Scoping is now stated by the caller (`milestoneScoped`) instead of inferred from `currentMilestonePhaseCount > 0`, which could not represent "scoped and legitimately zero-phase".
**`status` is cross-validated against the milestone's own artifacts, not asserted from the shipped marker alone.** Scoping the marker to the current milestone stopped a PRIOR milestone pinning `status` to "milestone complete", but the marker was still echoed as fact for the current one — so a single payload could report `status: "milestone complete"` beside `progress_percent: 67`, which is this issue's own symptom reached through `status`. The two shipped signals are now distinguished and cross-checked at different strengths, because one check cannot serve both. A `heading` signal (an operator-typed `✅ SHIPPED` in the LIVE roadmap) is refused when the milestone's completion ratio is short, which also catches phases declared but never scaffolded. A `snapshot` signal (`milestones/-ROADMAP.md`) is not gated on that ratio alone: the `milestone complete` run that writes it also moves the milestone's phase directories into `milestones/-phases/` while copying — never truncating — the live ROADMAP, so a CLEAN archive reads 0/N by construction and a bare ratio gate would strip "milestone complete" from every archived milestone in every project. But a phase directory still present under `phases/` means the archive is not clean — a phase was added or reopened after it, reachable because `milestone complete` does not advance `STATE.md`'s `milestone:` field — and once that is true the ratio is meaningful again, so the snapshot check is the conjunction of the two. The `legacy` project-lifetime fallback is ungated by signal, as before. The cross-check as a whole engages only when milestone scoping is active, for ALL three signals and not just `legacy`: with scoping off the denominator is the whole-roadmap count and membership is everything, so there is no current-milestone artifact set to check a current-milestone claim against. When a marker is refused, the `STATE.md` field is not accepted as a fallback claim of completion either — in this window it commonly asserts the same thing — so against contradicting artifacts neither source can report the milestone complete.
Greenfield roadmaps without a versioned Progress table, and projects whose current milestone version cannot be determined, keep the previous behaviour. (#2562)
**Behaviour change for consumers of the inventory JSON:** `roadmap_phase_count`, `completed_phases` and `progress_percent` now describe the workstream's CURRENT milestone rather than its lifetime, and there is no schema signal marking the change. Anything reading those fields — including `getOtherActiveWorkstreamInventories`, which filters completed workstreams out of the active list — sees real movement: a post-v1.0 workstream that reported `milestone complete` / 100% will now report its actual in-flight progress. The inventory also gains `milestone_shipped_unverified`: true when a shipped marker fired for the current milestone but its artifacts contradicted it. It is distinct from `status_conflict`, which continues to report only the derived-vs-`STATE.md`-field disagreement. `workstream list`, `workstream status` and `workstream progress` all project the new field, so a refused marker is visible at the CLI rather than collapsing silently into a fallback `status`.