fix(#2562): scope workstream progress/status to the current milestone (#2588)

* fix(#2562): scope workstream progress/status to the current milestone

`workstream progress` / `workstream status` / `workstream list` share one
derivation that could report a workstream's CURRENT milestone as
"milestone complete" / 100% while phases in that milestone were unstarted,
in progress, or failing verification. Three coupled defects:

1. The shipped signal was project-lifetime, not milestone-scoped:
   workstreamMilestoneShipped() returned true if ANY *-ROADMAP.md snapshot
   existed or "SHIPPED" appeared anywhere in ROADMAP.md. Every prior shipped
   milestone leaves a permanent collapsed <summary>✅ … SHIPPED</summary>
   block, so any post-v1.0 workstream was pinned to "milestone complete"
   forever (over-correction from #1913).
2. The denominator dropped declared-but-unscaffolded phases, and completed
   PRIOR-milestone phase directories inflated the numerator, letting
   progress_percent round to 100 while real work remained.
3. Phase completeness ignored the VERIFICATION verdict — SUMMARY >= PLAN
   count alone marked a phase complete even with a human_needed verdict.

Fix: derive both numerator and denominator from artifacts scoped to the
current milestone. The current version comes from the workstream STATE.md
`milestone:` field (ROADMAP in-progress markers can be stale); the ROADMAP
`## Progress` table maps every phase — including dirless ones — to its
milestone, and the matching set is both the denominator and the directory
membership filter. The shipped signal now requires the CURRENT version's
archived ROADMAP snapshot (REQUIREMENTS snapshots are not accepted; they can
be written at milestone start) or the current milestone's own line marked
shipped. Phases with an explicit failing verdict (gaps_found/human_needed)
count as in_progress; missing/unknown/stale are left untouched so
verifier-disabled projects do not regress to never-complete.

Greenfield roadmaps with no versioned Progress table, and projects whose
current version cannot be determined, keep the prior behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#2562): add changeset for workstream milestone-scoping fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#2562): parse the Progress table via findTableWithColumns

The ad-hoc pipe-table regex tripped the local/no-adhoc-markdown-parsing
ESLint rule. Use the canonical markdown-table helper instead: the
milestone-grouped RoadmapProgress variant is located by its required
`Phase` + `Milestone` columns and cells are addressed by column NAME,
so the parser tolerates column reordering and injected columns. The
`flat` variant (no Milestone column) yields no attribution, which is
the intended fallback to legacy counting.

Behaviour is unchanged: verified against a real multi-workstream project
(same status/percent/phase and plan counts before and after).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2562): count table-only phases in the unscoped denominator

Addresses the reporter's repro detail: a phase declared as a `## Progress`
table row with no `### Phase N` heading is missed by countRoadmapPhases
EVEN WHEN other headings exist — the heading regex counts 1 for a
"1 heading + 1 table-only" roadmap — not just in the zero-heading fallback
path. Milestone scoping did not cover this, because a flat Progress table
(no Milestone column) carries no per-phase attribution, so greenfield and
single-milestone projects kept the old heading-only denominator and the
declared phase silently vanished from it.

When milestone 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. Verified against the reporter's minimal
fixture (phase 1: 1 PLAN + 1 SUMMARY + gaps_found; phase 2: table row only,
no heading, no dir), which now reports 0/2 at 0% across all four
table/STATE permutations instead of 1/1 at 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2562): attribute dir-only sub-phases to their parent's milestone

A sub-phase directory inserted mid-milestone (e.g. `30.1-…` under a
table-declared phase 30) usually has no ROADMAP Progress-table row, so it
had no milestone attribution and was scoped out of the rollup entirely —
its completed work was invisible and it could never hold the percentage
below 100.

It now inherits its parent phase's milestone and joins BOTH sides of the
calculation. Both sides is the load-bearing part: adding it to the
numerator alone would let completed_phases exceed a denominator that never
counted it, cap back to 100% via Math.min, and reintroduce exactly the
defect this issue reports. A regression test pins that failure mode (all
declared phases complete + an in-progress dir-only sub-phase → 75%, not
100%).

Attribution is deliberately 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 —
conservative (under-count) rather than falsely inflating.

Verified on a real project: the reported workstream moves from 2/6 (33%)
to 3/7 (43%), the 3/7 being the honest figure — a completed sub-phase that
was previously invisible now counts, and so does its plan total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#2562): describe the denominator + sub-phase fixes in the changeset

The fragment was written at the first commit and only covered the three
original defects. Bring it up to date with what actually ships: the
table-only-phase denominator union (heading-only counting dropped a
declared phase even when other headings existed) and sub-phase milestone
inheritance across both sides of the calculation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#2562): promote the canonical phase-key surface to phase-id

state.cts kept `phaseKeyFromToken`/`phaseKeyFromDir` private, so every other
module that had to compare two independently-derived phase references — a
ROADMAP table cell against a phase directory, say — wrote its own regex. That
is the defect class #2562 reports: a padded `01` and an unpadded `1-slug` land
in different key spaces and the comparison silently yields nothing.

Move the pair to the phase-id owner module and add `phaseKeyFromProse` (for
ROADMAP/STATE prose, markdown emphasis stripped) and `parentPhaseKey` (a
sub-phase's parent). state.cts imports them; its call sites are unchanged.

* fix(#2562): own milestone-shipped detection and accept a workstream scope

Three changes to the module that owns milestone parsing, so its consumers stop
reimplementing it:

- `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark
  THIS milestone shipped" from heading and `<summary>` lines only. A bullet that
  merely names the version (`- [x] 03-01: ship the v2.0 login endpoint`) is prose
  about a phase, not a milestone verdict. The version token is boundary-matched
  with `(?![\w.-])` — `\b` does not bound it, since `.` is a non-word character,
  so a shipped `v2.0.1` heading would otherwise close `v2.0`.
- `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional
  trailing workstream name and thread it to `planningDir(cwd, ws)`. A caller
  iterating workstreams cannot set `GSD_WORKSTREAM` per iteration, which is what
  the existing resolution falls back to. Omitted, resolution is unchanged.
- `getMilestonePhaseFilter` exposes `versionScoped`, true only when the phase set
  really is one milestone's. On an unversioned roadmap `phaseCount` spans the
  project's lifetime and must not be read as a current-milestone denominator.

The closed/active milestone-marker patterns were kept in three byte-identical
copies; they are hoisted to module scope as one `isClosedMilestoneHeading`.

* fix(#2562): derive membership and denominator from one phase-key space

The milestone scoping added earlier in this PR derived the ROADMAP table key and
the phase-directory key with two different regexes, and dropped rows it could not
attribute. Each of those was another way to reproduce the symptom this issue
reports — a rollup contradicting its own `phases[]` listing:

- a padded `| 01. … |` row never matched a `1-slug` directory (and a bespoke
  `^0*(\d+…)` never matched `PROJ-05-…` at all), so phases fell out of the
  milestone entirely and the percentage collapsed or pinned;
- a blank or malformed Milestone cell deleted the phase from BOTH sides, letting
  an unstarted phase vanish and the remainder round to 100%;
- shipped detection scanned bullets, so any checkmarked line naming the version
  closed the milestone;
- the numerator counted per-directory while the denominator counted distinct
  phases, so a stale same-numbered directory (Bug #2445's scenario) pushed
  `completed_phases` past the denominator, where `Math.min` capped it to 100%
  and hid the unstarted phase.

Both sides now key off the phase-id owner module (`phaseKeyFromDir` /
`phaseKeyFromProse`), directory membership additionally consults
`getMilestonePhaseFilter` when that filter is genuinely version-scoped, and the
denominator is the union of the roadmap's declarations with the member
directories' keys — so `completed_phases <= denominator` holds by construction.
The Builder asserts it and throws; the `Math.min` cap survives only on the legacy
unscoped path, where the denominator is a heading count that cannot bound the
numerator. An unattributable row degrades over-inclusively (kept, never dropped),
matching the degrade direction roadmap-parser already commits to.

* test(#2562): boundary coverage for each milestone-scoping reproduction

One test per way the scoping could still report "milestone complete"/100% while
phases are incomplete: zero-padded rows vs padded dirs (and the mirror),
project-code-prefixed dirs, a blank/malformed Milestone cell, a checkmarked
bullet naming the version, a shipped `v2.0.1` heading against a current `v2.0`,
and a stale same-numbered directory. Plus the current milestone's own shipped
heading (the signal must survive the boundary fix), the Builder's
numerator-above-denominator throw, a parity check that every non-`passed`
verifier status blocks completeness, and a guard that scoping reads the
workstream's ROADMAP rather than the project root's.

Reverting only `src/` reddens six of them.

* docs(#2562): record the milestone-scoped semantics and its consumer impact

CONTEXT.md: the Workstream Inventory Module's completion fields now describe the
current milestone, not the workstream's lifetime; phase-id owns the canonical
phase-key surface; roadmap-parser owns milestone shipped/active classification
and takes an optional workstream scope.

Changeset: name the behaviour change explicitly — `roadmap_phase_count`,
`completed_phases` and `progress_percent` change meaning with no schema signal,
and `getOtherActiveWorkstreamInventories` filters on the derived status, so
consumers see real movement.

* fix(#2562): collapse every zero-padding spelling to one phase key

A property test over the key surface — table cell and directory decorated
INDEPENDENTLY, which is the point — found a divergence neither review named:
`padStart(2, '0')` is a no-op once the input is already ≥2 characters, so `5`
normalised to `05` while `005` stayed `005`. A `| 5. … |` row and a `005-slug`
directory therefore never compared equal, which is the same failure mode as the
padded-vs-unpadded blocker, one level down.

The strip belongs in `phaseKeyFromToken`, not in `normalizePhaseName`: applying
it to the latter regressed multi-decimal leading-zero plan IDs (`001.10-PLAN.md`
capture + wave assignment), which rely on its verbatim rendering. Confining it
to the key surface fixes the comparison and leaves rendering untouched.

Also tightens `isMilestoneShippedInRoadmap`'s patterns to anchored,
complementary character classes so an untrusted ROADMAP cannot drive
backtracking, and makes the project-code test discriminating — it previously
passed pre-fix, because an unresolvable key collapsed scoping to the whole
roadmap and happened to land on the same number. It now carries a
prior-milestone directory that a collapse would wrongly admit.

* fix(#2562): prefer the milestone-attributing Progress table; pin the seams

Three gaps the earlier self-check missed:

- Both RoadmapProgress variants carry a `Plans Complete` column, so probing it
  first picked a FLAT table appearing earlier in the document over the
  milestone-grouped one that actually carries the attribution. Every row came
  back unattributed, was treated as current-milestone, and silently re-admitted
  prior-milestone phases. The attributing shape is probed first; flipping the
  order reddens the new test.
- `lint-phase-id-drift` exempts phase-id.cts by design, so it is silent on
  `phaseKeyFromToken`'s own segment strip by construction — not evidence. Its
  interaction with `stripProjectCodePrefix` (which runs AFTER) is pinned across
  project codes and hyphenated ids, including the pre-existing `M1-46-6` vs
  `M1-46-6-rs` asymmetry, which is `extractPhaseToken`'s #2043/#2232 slug-word
  rule and not something to "fix" by accident.
- `listWorkstreamInventories` loops every workstream with no try/catch, so a
  REACHABLE Builder-invariant throw would take down `workstream list`/`status`/
  `progress` for all of them. The invariant test only exercised the pure Builder
  with hand-built inputs. A test now drives `inspectWorkstream` over every
  adversarial shape at once (prior-milestone dirs, three colliding duplicates,
  a dirless declaration, an unattributed row, a project-code prefix, a dir-only
  sub-phase) and asserts it does not throw and the invariant holds — so the
  throw stays a contract assertion for external callers, not a runtime path.

Also covers the active-marker-wins rule (`## v2.0 — 🚧 IN PROGRESS … ✅` must not
mark shipped), which nothing exercised.

* fix(#2562): scope a declared-but-empty current milestone instead of falling back to history

The review's open MAJOR. `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, `scoped` went false, and the fallback counted the project's ENTIRE
phase history as both numerator and denominator — a milestone with zero work
done reported 100% off its predecessors'. That is #2562's own symptom reached by
a different precondition, and none of the 16 tests covered it.

Reproduced first, four ROADMAP shapes, at `inspectWorkstream` rather than the
Builder — the Builder takes the scoping decision as an input, so a Builder-level
test proves it honours a flag, not that the derivation sets it. Three of the
four reported 2/2 100% with no phase of the current milestone begun.

Which signal witnesses the state depends on the ROADMAP's shape, and no single
one covers all three:

- `## v3.0` exists but declares no phases. `getMilestonePhaseFilter` DOES locate
  the section and sets `versionScoped`, then the zero-phase pass-all degrade
  resets it to false — erasing the only evidence the milestone exists. Neither
  existing flag survives that path, so this adds `versionSectionFound`, set
  beside `versionScoped` and deliberately preserved through the degrade.
- No section for this version at all, in a roadmap that versions its others —
  the existing `missingExplicitVersion`, already exposed and tested.
- Unversioned headings, but a Progress table attributing every row elsewhere:
  neither filter flag fires and the table is the only witness.

A ROADMAP that attributes NO versions anywhere matches none of them, which is
the point. Its rows parse with `version: null`, land in `currentMilestoneKeys`,
and never reach the new branch. `readCurrentMilestoneVersion` returns a non-null
version for very nearly every project (`getMilestoneInfo` defaults to `v1.0`),
so keying off `currentVersion` alone would have zeroed out every free-form
legacy project — the condition looks fussy for that reason. A test pins it.

Within an empty milestone, membership inverts: a directory belongs unless
another milestone's row claims it. Excluding everything would have dropped a
phase scaffolded before the roadmap caught up from BOTH sides of the rollup, and
hiding real work is the same class of defect as inventing it — this codebase
degrades over-inclusive, never under.

Scoping is now stated by the caller (`milestoneScoped`) rather than inferred
from `currentMilestonePhaseCount > 0`. That inference was the root cause: it
cannot represent a milestone that is scoped AND legitimately zero-phase, so the
Builder read "no phases yet" as "no scoping" and reopened the whole-history
path. The count-derived value stays the default for callers that say nothing.

A regression test also pins that a zero denominator does not trip the Builder's
`completed_phases <= denominator` throw, since `listWorkstreamInventories` has
no try/catch and a crash on every freshly-declared milestone would be worse than
a wrong percentage.

The changeset and CONTEXT.md no longer claim membership is derived in "ONE" /
"a SINGLE" phase-key space. `getMilestonePhaseFilter` still runs its own
`normalizePhaseIdSegments`; the signals are OR'd so a divergence can only widen
membership, but two normalisers coexist and the docs now say so.

* fix(#2562): cross-validate the shipped marker against the milestone's artifacts

`status: "milestone complete"` was asserted from the shipped marker alone, so a
single payload could report it beside `progress_percent: 67` — this issue's own
symptom, reached through `status` rather than the percentage.

The marker is now a claim checked against the milestone's own artifacts, and the
two signals are checked at DIFFERENT strengths because one check cannot serve
both. A `heading` marker (operator-typed, live ROADMAP) is refused on a short
completion ratio, which also catches phases declared but never scaffolded. A
`snapshot` marker is NOT ratio-gated: `milestone complete` moves the milestone's
phase dirs into `milestones/<version>-phases/` (milestone.cts:755-762) while
copying — never truncating — the live ROADMAP (:671-674), so a correctly
archived milestone reads 0/N by construction and a ratio gate would strip
`milestone complete` from every archived milestone. It is refused instead when
an in-milestone phase dir is still live and unfinished, reachable because
`milestone complete` does not advance STATE's `milestone:` field
(state-transition.cts:1335 vs :1224). `legacy` stays ungated — only reachable
when scoping is off.

A refused marker does not fall through to a STATE field claiming the same thing;
against contradicting artifacts neither source may report completion. The
refusal surfaces as `milestone_shipped_unverified` rather than staying silent,
distinct from `status_conflict` (derived-vs-field only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* test(#2562): pin both marker strengths, the archived guard, and the owner modules

workstream-inventory: four tests, all four red against the prior src and green
with it. A live-ROADMAP SHIPPED heading over an incomplete milestone is refused;
an archived snapshot SURVIVES its phase dirs being moved out (the regression the
obvious single ratio-gate would cause — swapping the snapshot branch to that
gate reddens this AND the pre-existing `CURRENT-version snapshot marks the
milestone complete` at :321); an archived snapshot is refused once a phase is
reopened under it; and a refused marker is not re-asserted by a STATE field
claiming the same.

roadmap-parser: `isMilestoneShippedInRoadmap` gets unit coverage at its owner
module rather than only through the inventory that consumes it, plus two
characterisation tests for `getMilestonePhaseFilter`'s legacy call surface —
omitting the new trailing `ws` param is indistinguishable from `undefined`/`null`,
and the `GSD_WORKSTREAM` env fallback still resolves. These characterise the
call surface; they do not stand in for coverage of its individual callers.

phase-id: the `phaseKeyFrom*` / `parentPhaseKey` one-key-space contract, incl. a
property that padding a directory number never changes its key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* docs(#2562): record the two-strength shipped cross-check + milestone_shipped_unverified

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* fix(#2562): refuse an archived snapshot on a DIRTY archive, not just a live-unfinished dir

The snapshot arm checked `liveIncompletePhases > 0`, which misses the shape
@davesienkowski reproduced: a COMPLETE live dir beside a phase declared in the
Progress table with no directory. Nothing is live-and-unfinished, the marker
sails through, and `cmdWorkstreamProgress` returns
`{"status":"milestone complete","progress_percent":50}` — the reported symptom
verbatim, from one payload. Reproduced at 483a3ba30 before changing anything.

His diagnosis is the right one and better than mine: an in-milestone directory
outliving the archive means the archive is not CLEAN, and once that is true the
completion ratio is meaningful again. So the check is the conjunction — any live
in-milestone dir AND `completedPhases < effectivePhaseCount`. That strictly
subsumes the old predicate (an incomplete member dir is in the denominator and
not the numerator, so the ratio is always short when one exists) and leaves the
clean-archive guard green, since a clean archive has no live dirs at all.

Also corrects the module comment: the `scoped &&` prefix ungates all three
signals, not just `legacy`. That is correct behaviour — unscoped, 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 —
but the comment claimed otherwise. And the `milestone.cts` citations were ~28
lines stale after the rebase; they are now :700-702 (copy) and :783-790 (move),
re-verified against this head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* fix(#2562): project milestone_shipped_unverified from list, status and progress

The inventory carried the field and every renderer dropped it — `workstream.cts`
was not in this PR's diff at all — so at the CLI a refused marker looked exactly
like no marker: a fallback `status` and nothing saying one was seen and rejected.
That is the silent collapse this issue is about, reintroduced one layer up, and
it made the changeset's "visible rather than silent" claim false at every
surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* test(#2562): pin the dirty-archive shape and the CLI projection

Five tests, all five red against the prior src and green with it.

The reviewer's repro at the builder: an archived snapshot with a COMPLETE live
dir beside a dirless declared phase must be refused, and status must not
contradict the percentage.

Four at the CLI via runGsdTools, the surface that was dropping the field rather
than the builder that already had it: `workstream progress`/`status`/`list` each
project `milestone_shipped_unverified: true` for that workstream, and a clean
archive still reports `false` with `status: "milestone complete"`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* docs(#2562): correct the snapshot check, the scoped-only caveat and the CLI claim

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Adnan
2026-08-02 04:06:43 +01:00
committed by GitHub
parent f4d6747d21
commit 137f3fbb9c
12 changed files with 1842 additions and 82 deletions

View File

@@ -568,6 +568,69 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean {
return false;
}
// ─── Canonical phase KEY surface (#2562) ─────────────────────────────────────
//
// A phase "key" is the padding-, case- and project-code-insensitive identity of
// a phase, for use as a Map/Set key when two independently-derived phase
// references (a ROADMAP table cell and a phase directory name, say) must be
// compared. Promoted here from a local pair in state.cts (#2445) so every
// consumer derives BOTH sides of a comparison from the SAME function — deriving
// one side with a bespoke regex is the #2562 defect class (a `01` table cell
// never matching a `1-slug` directory, silently zeroing a rollup).
/**
* Canonical key for an already-extracted phase TOKEN (`"5"`, `"05"`, `"005"`,
* `"12A"`, `"30.1"`, `"PROJ-05"`). Padding- and case-insensitive: every
* spelling of a number collapses to one key.
*
* Leading zeros are stripped per hyphen-separated segment BEFORE
* `normalizePhaseName` pads to the 2-digit convention. Padding alone is not a
* normalisation — `padStart(2)` is a no-op once the input is already ≥2
* characters, so `5` yielded `05` while `005` stayed `005` and the two never
* compared equal. The strip is deliberately confined to this key surface:
* `normalizePhaseName` itself is a RENDERING function whose verbatim treatment
* of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment.
* Arithmetic is avoided (`parseInt` would lose precision on a long digit run).
*/
function phaseKeyFromToken(token: unknown): string {
const stripped = String(token)
.split('-')
.map(segment => segment.replace(/^0+(?=\d)/, ''))
.join('-');
return normalizePhaseName(stripped).toUpperCase();
}
/**
* Canonical key for a phase DIRECTORY name (`"05-schedule-8"` → `"05"`,
* `"PROJ-5-x"` → `"05"`, `"30.1-follow-up"` → `"30.1"`).
*/
function phaseKeyFromDir(dirName: string): string {
return phaseKeyFromToken(extractPhaseToken(dirName));
}
/**
* Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table
* cell (`"30. Schedule 8 rollout"`, `"**05.1 Follow-up**"`) or a STATE.md
* `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not
* mistaken for a non-phase. Returns null when the value does not BEGIN with a
* phase token (`parsePhaseFromProse` anchoring, #2111).
*/
function phaseKeyFromProse(value: string | null | undefined): string | null {
if (value == null) return null;
const { phase } = parsePhaseFromProse(String(value).replace(/[*_`~]/g, ''));
return phase === null ? null : phaseKeyFromToken(phase);
}
/**
* The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a
* top-level phase. A sub-phase directory inserted mid-milestone frequently has
* no ROADMAP row of its own and inherits its parent's milestone (#2562).
*/
function parentPhaseKey(key: string): string | null {
const dot = key.indexOf('.');
return dot === -1 ? null : key.slice(0, dot);
}
// ─── #2121 canonical surface (ADR-2121) ──────────────────────────────────────
/**
@@ -711,6 +774,10 @@ export = {
comparePhaseNum,
extractPhaseToken,
phaseTokenMatches,
phaseKeyFromToken,
phaseKeyFromDir,
phaseKeyFromProse,
parentPhaseKey,
parsePhaseFromProse,
stripConfiguredProjectCodePrefix,
isForeignPrefixedPhaseQuery,

View File

@@ -41,6 +41,18 @@ import type { HeadingToken } from './markdown-sectionizer.cjs';
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
/**
* Markers that classify a MILESTONE HEADING (or `<summary>`) as closed/shipped
* versus still active. Hoisted to module scope in #2562 — three call sites
* (`extractCurrentMilestone`, `currentMilestoneRawRanges`,
* `isMilestoneShippedInRoadmap`) previously kept byte-identical copies.
*/
const MILESTONE_CLOSED_MARKER_PATTERN = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
function isClosedMilestoneHeading(headingText: string): boolean {
return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
}
/**
* Strip shipped milestone content wrapped in <details> blocks.
*/
@@ -49,14 +61,58 @@ function stripShippedMilestones(content: string): string {
}
/**
* Extract the current milestone section from ROADMAP.md by positive lookup.
* #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
*
* Scoped deliberately narrowly, because a false positive here reproduces the
* exact symptom #2562 reports ("milestone complete" while phases are unstarted):
*
* - Only a MILESTONE HEADING (`^#{1,3}` that is not a `Phase N:` heading) or a
* `<summary>` line can carry the signal. A bullet or checklist item that
* merely NAMES the version (`- [x] 03-01: ship the v2.0 login endpoint ✅`)
* is prose about a phase, not a milestone verdict, and is ignored.
* - The version token is boundary-matched with `(?![\w.-])` (mirrors the #730
* sub-milestone boundary at `extractCurrentMilestone`), so `v2.0` does not
* match inside `v2.0.1` — `\b` alone would, since `.` is a non-word char.
* - Shipped/active classification reuses the same marker patterns the milestone
* sectioniser uses, so an in-progress marker on the line always wins.
*
* Both patterns are anchored and use only complementary character classes
* (`[^\n]`, `[^<]`, `[^>]`) with no overlapping alternation, so matching stays
* linear in the ROADMAP's length — an untrusted ROADMAP cannot drive backtracking.
*/
function extractCurrentMilestone(content: string, cwd?: string): string {
function isMilestoneShippedInRoadmap(content: string, version: string): boolean {
const boundedVersion = `${escapeRegex(version)}(?![\\w.-])`;
const candidates = [
// A milestone heading: `## v2.0 Launch — ✅ SHIPPED`.
new RegExp(`^#{1,3}[^\\S\\n]+(?!Phase\\s+\\S)[^\\n]*${boundedVersion}[^\\n]*$`, 'gmi'),
// A collapsed shipped block's own summary: `<summary>✅ v2.0 … SHIPPED</summary>`.
new RegExp(`<summary[^>]*>[^<]*${boundedVersion}[^<]*<\\/summary>`, 'gi'),
];
for (const pattern of candidates) {
for (const match of content.matchAll(pattern)) {
if (isClosedMilestoneHeading(match[0])) return true;
}
}
return false;
}
/**
* Extract the current milestone section from ROADMAP.md by positive lookup.
*
* @param content - ROADMAP.md content.
* @param cwd - Project working directory, used to read the companion STATE.md
* for the current `milestone:` version.
* @param ws - #2562: workstream name, so the companion STATE.md is read from
* `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
* default) preserves the prior `planningDir(cwd)` resolution exactly,
* including its `GSD_WORKSTREAM` env fallback.
*/
function extractCurrentMilestone(content: string, cwd?: string, ws?: string | null): string {
if (!cwd) return stripShippedMilestones(content);
let version: string | null = null;
try {
const statePath = path.join(planningDir(cwd), 'STATE.md');
const statePath = path.join(planningDir(cwd, ws), 'STATE.md');
const stateRaw = platformReadSync(statePath);
if (stateRaw !== null) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
@@ -113,9 +169,7 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
const allMatches = headingMatches;
const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
const isClosed = (h: string) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
const isClosed = isClosedMilestoneHeading;
const firstMatch = allMatches[0];
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
@@ -519,6 +573,26 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
phaseCount: number;
missingExplicitVersion: boolean;
/**
* #2562: true only when `versionOverride` was supplied AND a matching
* milestone section was located, i.e. the phase set really is scoped to that
* one milestone. False for the whole-roadmap (unversioned) shape, where
* `phaseCount` spans the project's lifetime and must NOT be read as a
* current-milestone denominator.
*/
versionScoped: boolean;
/**
* #2562: true when `versionOverride`'s milestone section was LOCATED in the
* ROADMAP, independent of whether it turned out to declare any phases.
* `versionScoped` cannot answer that question — a located-but-empty section
* falls through to the zero-count pass-all filter below, which resets
* `versionScoped` to false, making "milestone absent" and "milestone present
* but not yet populated" indistinguishable. They are not the same state: the
* second is a real, empty current milestone, and a caller that treats it as
* "unscoped" silently reports the project's whole phase history as if it were
* the current milestone's.
*/
versionSectionFound: boolean;
};
/**
@@ -533,15 +607,23 @@ type MilestonePhaseFilter = ((dirName: string) => boolean) & {
* free-form ROADMAPs that lack versioned milestone headings. When absent or
* any other value, the warning is suppressed — legacy/default projects must
* never see spurious warnings.
* @param ws - #2562: workstream name, so the ROADMAP/STATE pair is read from
* `.planning/workstreams/<ws>/` instead of the project root. Required by any
* caller that iterates workstreams (it cannot set `GSD_WORKSTREAM` per
* iteration). Omitted (the default) preserves the prior `planningDir(cwd)`
* resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
* pre-#2562 call site is unaffected.
*/
function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, phaseIdConvention?: string | null): MilestonePhaseFilter {
function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, phaseIdConvention?: string | null, ws?: string | null): MilestonePhaseFilter {
const milestonePhaseNums = new Set<string>();
let missingExplicitVersion = false;
let versionScoped = false;
let versionSectionFound = false;
try {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapPath = path.join(planningDir(cwd, ws), 'ROADMAP.md');
const roadmapContent = platformReadSync(roadmapPath);
if (roadmapContent === null) throw new Error('missing');
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
let roadmap = extractCurrentMilestone(roadmapContent, cwd, ws);
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
@@ -578,6 +660,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
missingExplicitVersion = true;
}
} else {
versionScoped = true;
versionSectionFound = true;
const sectionStart = sectionMatch.index!;
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = sectionStart + sectionMatch[0].length;
@@ -632,17 +716,25 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
const passAll = (() => true) as unknown as MilestonePhaseFilter;
passAll.phaseCount = 0;
passAll.missingExplicitVersion = missingExplicitVersion;
passAll.versionScoped = false;
// #2562: preserved through the pass-all degrade precisely BECAUSE
// `versionScoped` is reset here — this is the only surviving evidence that
// the current milestone exists in the ROADMAP and simply has no phases yet.
passAll.versionSectionFound = versionSectionFound;
return passAll;
}
const normalized = new Set(
[...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase())
);
function normalizePhaseIdSegments(id: string): string {
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
}
// #2562: derive BOTH sides of every membership comparison from
// normalizePhaseIdSegments. This set previously inlined a byte-identical
// second copy of that logic — the drift-prone shape this issue is about.
const normalized = new Set(
[...milestonePhaseNums].map(n => normalizePhaseIdSegments(n).toLowerCase())
);
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
// #2043: milestone-prefixed sub-phase components must be zero-padded — so a
// single-digit slug word after the phase
@@ -672,6 +764,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
}
(isDirInMilestone as MilestonePhaseFilter).phaseCount = milestonePhaseNums.size;
(isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion;
(isDirInMilestone as MilestonePhaseFilter).versionScoped = versionScoped;
(isDirInMilestone as MilestonePhaseFilter).versionSectionFound = versionSectionFound;
return isDirInMilestone as MilestonePhaseFilter;
}
@@ -717,9 +811,7 @@ function currentMilestoneRawRanges(
const headingMatches = [...content.matchAll(sectionPattern)];
if (headingMatches.length === 0) return null;
const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
const isClosed = (h: string) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
const isClosed = isClosedMilestoneHeading;
const firstMatch = headingMatches[0];
const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
const sectionStart = selected.index ?? 0;
@@ -764,6 +856,7 @@ function currentMilestoneRawRanges(
export = {
stripShippedMilestones,
extractCurrentMilestone,
isMilestoneShippedInRoadmap,
replaceInCurrentMilestone,
getRoadmapPhaseInternal,
getMilestoneInfo,

View File

@@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
@@ -1526,25 +1526,11 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void {
// ─── State Frontmatter Sync ──────────────────────────────────────────────────
/**
* Canonical key for matching a ROADMAP phase token against an on-disk phase
* directory: normalizePhaseName collapses padding/case, strips the project-code
* prefix, and handles decimals/letter-suffixes/milestone-prefixed IDs, so
* "Phase 4"/"Phase 04"/dir "04-delta" and "Phase PROJ-42"/dir "PROJ-42-foo"
* each map to one key. For a directory, extract its phase token first.
*
* Stripping the project-code prefix is GSD's canonical phase identity (a
* project_code is a display prefix; normalizePhaseName / phaseTokenMatches treat
* `CK-01` and `01` as the same phase, which is what lets a prefixed dir match a
* bare ROADMAP token). A consistent project uses one scheme, so a bare numeric
* and a same-suffix project-code phase never coexist in one milestone.
*/
function phaseKeyFromToken(token: string): string {
return normalizePhaseName(token).toUpperCase();
}
function phaseKeyFromDir(dir: string): string {
return phaseKeyFromToken(extractPhaseToken(dir));
}
// `phaseKeyFromToken` / `phaseKeyFromDir` — the canonical key for matching a
// ROADMAP phase token against an on-disk phase directory — moved to the
// phase-id owner module in #2562 so every consumer derives BOTH sides of a
// phase comparison from the same function (see phase-id.cts). Imported at the
// top of this file; call sites below are unchanged.
/**
* Extract the set of retired/folded phase keys from a ROADMAP milestone scope

View File

@@ -15,6 +15,15 @@ function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
/**
* #2562: verification verdicts that DISQUALIFY a phase from `complete`, even
* when its SUMMARY count meets its PLAN count. Deliberately scoped to the two
* EXPLICIT failing verdicts the verifier emits — `missing`/`unknown` (verifier
* off / not yet run) and `stale` (mtime-derived, #2348) are intentionally left
* untouched so verifier-disabled projects do not regress to never-complete.
*/
const FAILING_VERIFICATION_STATUSES = new Set<string>(['gaps_found', 'human_needed']);
export function isCompletedInventory(status: unknown): boolean {
const s = (typeof status === 'string'
? status
@@ -29,6 +38,28 @@ export interface PhaseFilesCount {
directory: string;
planCount: number;
summaryCount: number;
/**
* #2562: the directory's canonical phase key (`phaseKeyFromDir`). Two stale
* same-numbered directories (`05-x` alongside `5-x-old` — Bug #2445's
* scenario) share a key and must count ONCE in the rollup, or the numerator
* outgrows a denominator that counts distinct phases. Absent → the directory
* name is its own key (no de-duplication).
*/
phaseKey?: string;
/** #2562: directory mtime, the Bug #2445 tie-break when two directories share a phase key. */
mtimeMs?: number;
/**
* #2562: whether this phase directory belongs to the CURRENT milestone.
* Only meaningful when milestone scoping is active (see
* `currentMilestonePhaseCount`); undefined/true otherwise.
*/
inMilestone?: boolean;
/**
* #2562: the phase's `*-VERIFICATION.md` verdict (`readVerificationStatus`).
* A phase with SUMMARY count ≥ PLAN count but a failing verdict
* (`gaps_found`/`human_needed`) must NOT count as complete.
*/
verificationStatus?: string;
}
export interface PhaseStatus {
@@ -50,6 +81,20 @@ export interface StateProjection {
last_activity: string | null | undefined;
}
/**
* Which authoritative signal claimed the current milestone is shipped.
*
* - `snapshot` — `milestones/<version>-ROADMAP.md` exists. Written by
* `milestone complete`; the strongest signal a tool can produce.
* - `heading` — the LIVE ROADMAP carries a shipped marker for the current
* milestone. Operator-typed prose; the weakest signal.
* - `legacy` — the over-broad project-lifetime fallback used only when the
* current milestone version cannot be determined (#1913 protection for
* malformed/legacy projects).
* - `null` — no shipped signal.
*/
export type MilestoneShippedSignal = 'snapshot' | 'heading' | 'legacy' | null;
export interface BuildWorkstreamInventoryInputs {
name: string;
projectDir: string;
@@ -67,7 +112,35 @@ export interface BuildWorkstreamInventoryInputs {
* "milestone complete" regardless of the mutable STATE.md `Status` field,
* so a stale field can never report a shipped workstream as executing (#1913).
*/
milestoneShipped: boolean;
milestoneShipped?: boolean;
/**
* #2562 review: WHICH shipped signal fired, so the builder can cross-validate
* it against the milestone's own artifacts at the right strength. The two
* signals are not interchangeable — see the `shippedContradicted` block below.
* Supersedes the `milestoneShipped` boolean; a caller passing only the boolean
* is treated as `'legacy'` (ungated), preserving pre-review behavior.
*/
milestoneShippedSignal?: MilestoneShippedSignal;
/**
* #2562: number of phases the CURRENT milestone declares in the ROADMAP
* `## Progress` table (including phases declared but never scaffolded). When
* > 0, milestone scoping is active: only phases whose directory belongs to
* the current milestone (`PhaseFilesCount.inMilestone`) feed the completion
* rollup, and this value — not `roadmapPhaseCount` — is the denominator, so
* completed prior-milestone phases can never inflate the percentage to 100.
* 0 disables scoping and preserves the legacy `roadmapPhaseCount` behavior.
*/
currentMilestonePhaseCount?: number;
/**
* #2562: whether milestone scoping is active, stated by the caller rather than
* inferred from `currentMilestonePhaseCount > 0`. The two are not equivalent:
* a current milestone that is declared but not yet populated is scoped AND has
* zero phases, and inferring from the count alone reads it as "unscoped" —
* which silently falls back to counting the project's entire phase history as
* the current milestone's, reporting 100% for a milestone with no work done.
* Defaults to the count-derived value so pre-#2562 callers are unaffected.
*/
milestoneScoped?: boolean;
}
export interface WorkstreamInventory {
@@ -76,10 +149,25 @@ export interface WorkstreamInventory {
active: boolean;
files: WorkstreamFilesExist;
status: string;
/** Whether `status` was derived from an authoritative signal ("derived") or taken verbatim from the STATE.md field ("field"). */
/**
* Whether `status` was derived ("derived") or taken verbatim from the STATE.md
* field ("field"). `"derived"` covers TWO cases and does NOT imply
* `status === 'milestone complete'`: a shipped signal fired and was accepted,
* OR a shipped signal was refused by the artifacts and the STATE field claimed
* completion anyway, so `status` was derived DOWN to `'in_progress'`. Check
* `milestone_shipped_unverified` to tell them apart.
*/
status_source: 'field' | 'derived';
/** True when the derived status disagrees with the STATE.md `Status` field (the field is stale). */
status_conflict: boolean;
/**
* #2562 review: a shipped signal fired for the current milestone but the
* milestone's own artifacts contradict it, so the claim was NOT trusted.
* Distinct from `status_conflict`, which reports the derived-vs-STATE-field
* disagreement. Without this the rejection would be silent: `status` falls
* back to the field and no output says a shipped marker was seen and refused.
*/
milestone_shipped_unverified: boolean;
current_phase: string | null | undefined;
last_activity: string | null | undefined;
phases: PhaseStatus[];
@@ -103,46 +191,170 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs)
stateProjection,
filesExist,
milestoneShipped,
milestoneShippedSignal,
currentMilestonePhaseCount = 0,
milestoneScoped,
} = inputs;
// A caller that passes only the legacy boolean states THAT a signal fired but
// not which one. Treat it as `legacy` — the ungated strength — so pre-review
// callers keep their exact behavior rather than silently acquiring a new gate.
const shippedSignal: MilestoneShippedSignal =
milestoneShippedSignal !== undefined ? milestoneShippedSignal : (milestoneShipped ? 'legacy' : null);
const milestoneShippedResolved = shippedSignal !== null;
// #2562: when scoping is active, prior-milestone phase directories are
// excluded from the completion rollup and the denominator. The caller states
// this; the count-derived default is the pre-#2562 fallback for callers that
// do not, and cannot represent a scoped-but-empty current milestone.
const scoped = milestoneScoped ?? currentMilestonePhaseCount > 0;
// Index counts by directory for O(1) lookup during sort/iteration
const countsMap = new Map<string, { planCount: number; summaryCount: number }>();
const countsMap = new Map<string, PhaseFilesCount>();
for (const entry of phaseFilesCounts) {
countsMap.set(entry.directory, { planCount: entry.planCount, summaryCount: entry.summaryCount });
countsMap.set(entry.directory, entry);
}
// #2562 / Bug #2445: pick ONE directory per phase key for the rollup. Stale
// same-numbered directories left over from a prior milestone would otherwise
// each add to the numerator while the denominator counts distinct phases —
// pushing completed_phases past it, where the old `Math.min` cap silently
// rounded the result up to 100% and hid an unstarted phase. Newest-on-disk
// wins, mirroring state.cts's #2445 de-duplication.
const rollupDirByKey = new Map<string, string>();
for (const dir of [...phaseDirNames].sort()) {
const entry = countsMap.get(dir);
if (scoped && entry?.inMilestone === false) continue;
const key = entry?.phaseKey ?? dir;
const incumbent = rollupDirByKey.get(key);
if (incumbent === undefined) {
rollupDirByKey.set(key, dir);
continue;
}
const incumbentMtime = countsMap.get(incumbent)?.mtimeMs ?? 0;
if ((entry?.mtimeMs ?? 0) > incumbentMtime) rollupDirByKey.set(key, dir);
}
const rollupDirs = new Set(rollupDirByKey.values());
const phases: PhaseStatus[] = [];
let completedPhases = 0;
let totalPlans = 0;
let completedPlans = 0;
// #2562 review: in-milestone phase directories still present under `phases/`,
// whatever their status. A CLEAN archive has none — `milestone complete` moves
// them all out — so this counts exactly the phases that outlived the archive,
// which is what distinguishes "archived" from "archived, then reopened".
// Deliberately NOT "…and unfinished": a complete live dir beside a declared
// but never-scaffolded phase is a dirty archive too, and the dirless phase has
// no directory to inspect.
let liveInMilestonePhases = 0;
for (const dir of [...phaseDirNames].sort()) {
const counts = countsMap.get(dir) ?? { planCount: 0, summaryCount: 0 };
const counts = countsMap.get(dir);
const planCount = counts?.planCount ?? 0;
const summaryCount = counts?.summaryCount ?? 0;
// #2562: SUMMARY≥PLAN parity is necessary but not sufficient — a phase whose
// verification verdict is an explicit failing one is still in progress.
const verificationStatus = counts?.verificationStatus ?? 'missing';
const summariesMeetPlans = summaryCount >= planCount && planCount > 0;
const status: 'complete' | 'in_progress' | 'pending' =
counts.summaryCount >= counts.planCount && counts.planCount > 0
summariesMeetPlans && !FAILING_VERIFICATION_STATUSES.has(verificationStatus)
? 'complete'
: counts.planCount > 0
: planCount > 0
? 'in_progress'
: 'pending';
totalPlans += counts.planCount;
completedPlans += Math.min(counts.summaryCount, counts.planCount);
if (status === 'complete') completedPhases++;
// #2562: only current-milestone phases feed the rollup when scoping is on,
// and only one directory per phase key (see rollupDirs above).
const countsTowardMilestone = (!scoped || counts?.inMilestone !== false) && rollupDirs.has(dir);
if (countsTowardMilestone) {
totalPlans += planCount;
completedPlans += Math.min(summaryCount, planCount);
if (status === 'complete') completedPhases++;
liveInMilestonePhases++;
}
phases.push({
directory: dir,
status,
plan_count: counts.planCount,
summary_count: counts.summaryCount,
plan_count: planCount,
summary_count: summaryCount,
});
}
// #2562: the denominator is the current milestone's declared phase count when
// scoping is active (catches phases declared but never scaffolded), else the
// legacy whole-roadmap heading count.
const effectivePhaseCount = scoped ? currentMilestonePhaseCount : roadmapPhaseCount;
// #2562 invariant: the numerator counts de-duplicated in-milestone phase keys
// and the denominator counts the union of those keys with the roadmap's own
// declarations, so the numerator can never exceed it. Raising the numerator
// above the denominator means the two sides were derived in different key
// spaces — the defect class this issue is about. The old `Math.min(100, …)`
// capped that away and reported 100%; this makes it fail loudly instead.
if (scoped && completedPhases > effectivePhaseCount) {
throw new Error(
`workstream inventory invariant violated for "${name}": completed_phases (${completedPhases}) ` +
`exceeds the current-milestone denominator (${effectivePhaseCount}). The completion numerator and ` +
`denominator were derived in different phase-key spaces.`
);
}
// #1913: derive status from authoritative shipped signals rather than trusting
// the mutable STATE.md `Status` field. When a shipped signal is present, the
// workstream is "milestone complete" regardless of a stale field value.
//
// #2562 review: a shipped signal is a CLAIM, and a claim its own milestone's
// artifacts contradict must not be echoed as fact — the defect class this issue
// is about reaches `status`, not just `progress_percent`. The two signals need
// DIFFERENT cross-checks; one check for both regresses the commonest shape:
//
// - `heading` — operator-typed marker in the LIVE roadmap. Nothing has been
// archived, so every phase the milestone declares should be on disk and
// complete. Gate on the full ratio, which also catches the
// declared-but-never-scaffolded phases that have no directory to inspect.
// - `snapshot` — `milestones/<version>-ROADMAP.md`. The `milestone complete`
// run that writes it also MOVES the milestone's phase directories into
// `milestones/<version>-phases/` (milestone.cts:783-790) while COPYING —
// never truncating — the live ROADMAP (:700-702), so its Progress rows
// survive. A CLEAN archive therefore reads 0/N by construction, and gating
// it on the ratio alone would strip `milestone complete` from every
// archived milestone. But a live in-milestone directory means the archive
// is NOT clean — a phase was added or reopened after it, reachable because
// `milestone complete` does not advance STATE's `milestone:` field
// (state-transition.cts:83, :1335); only `/gsd-new-milestone` does (:1224).
// Once any in-milestone directory is live the ratio IS meaningful again, so
// the check is the conjunction. Requiring the live directory to itself be
// unfinished was too narrow: it let a complete live dir alongside a
// declared-but-unscaffolded phase reproduce the reported symptom, since a
// dirless phase has nothing to inspect.
//
// The whole cross-check is scoped-only, and NOT because of the signal: when
// scoping is off, `effectivePhaseCount` is the whole-roadmap count and
// membership is everything, so there is no current-milestone artifact set to
// check a current-milestone claim against. `legacy` is additionally ungated by
// signal — it is the fallback for an unknown milestone version, which is
// exactly when scoping cannot engage either.
const fieldStatus = stateProjection.status;
const useDerived = milestoneShipped;
const status = useDerived ? 'milestone complete' : fieldStatus;
const status_source: 'field' | 'derived' = useDerived ? 'derived' : 'field';
const status_conflict = useDerived && !isCompletedInventory(fieldStatus);
const shippedContradicted = scoped && (
shippedSignal === 'heading'
? completedPhases < effectivePhaseCount
: shippedSignal === 'snapshot'
? liveInMilestonePhases > 0 && completedPhases < effectivePhaseCount
: false
);
const useDerived = milestoneShippedResolved && !shippedContradicted;
// Refusing the claim does not make the STATE field a safe fallback: it is
// operator-written and in this window it commonly ALSO reads "milestone
// complete", which would re-report the refused claim through the other door.
// Against contradicting artifacts, NEITHER source may assert completion.
const artifactOverride = shippedContradicted && isCompletedInventory(fieldStatus);
const status = useDerived
? 'milestone complete'
: artifactOverride
? 'in_progress'
: fieldStatus;
const status_source: 'field' | 'derived' = useDerived || artifactOverride ? 'derived' : 'field';
const status_conflict = (useDerived && !isCompletedInventory(fieldStatus)) || artifactOverride;
return {
name,
@@ -156,17 +368,22 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs)
status,
status_source,
status_conflict,
milestone_shipped_unverified: shippedContradicted,
current_phase: stateProjection.current_phase,
last_activity: stateProjection.last_activity,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
roadmap_phase_count: roadmapPhaseCount,
roadmap_phase_count: effectivePhaseCount,
total_plans: totalPlans,
completed_plans: completedPlans,
// The `Math.min` cap is unreachable under milestone scoping (the invariant
// above throws first) and survives only for the legacy unscoped path, where
// the denominator is a roadmap heading count that a caller cannot guarantee
// bounds the numerator.
progress_percent:
roadmapPhaseCount > 0
? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100))
effectivePhaseCount > 0
? Math.min(100, Math.round((completedPhases / effectivePhaseCount) * 100))
: 0,
};
}

View File

@@ -24,8 +24,18 @@ import planScan = require('./plan-scan.cjs');
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths, planningRoot, getActiveWorkstream } = planningWorkspace;
import { stateExtractField } from './state-document.cjs';
import { findTableWithColumns } from './markdown-table.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
import verificationMod = require('./verification.cjs');
const { readVerificationStatus } = verificationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseIdMod = require('./phase-id.cjs');
const { phaseKeyFromDir, phaseKeyFromProse, parentPhaseKey } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestonePhaseFilter, isMilestoneShippedInRoadmap } = roadmapParserMod;
import { buildWorkstreamInventory, isCompletedInventory } from './workstream-inventory-builder.cjs';
import type { WorkstreamInventory, StateProjection } from './workstream-inventory-builder.cjs';
import type { WorkstreamInventory, StateProjection, MilestoneShippedSignal } from './workstream-inventory-builder.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -62,6 +72,128 @@ function countRoadmapPhases(roadmapPath: string, fallbackCount: number): number
}
}
interface RoadmapProgressRow {
/** Canonical phase key (`phaseKeyFromProse`) — the SAME key space as `phaseKeyFromDir`. */
key: string;
/** `vX.Y` when the row attributes the phase to a milestone; null when the cell is absent, blank or malformed. */
version: string | null;
}
/**
* #2562: parse the ROADMAP `## Progress` table into canonical phase keys with
* their milestone attribution, e.g. `| 30. Name | v10.0 | 1/3 | … |` →
* `{ key: '30', version: 'v10.0' }`. The table is the authoritative per-phase
* milestone attribution and — crucially — lists phases declared but never
* scaffolded (no directory), which a directory-only scan misses. `Plans
* Complete` is present in BOTH RoadmapProgress variants, so this matches the
* flat (no Milestone column → every `version` null) and milestone-grouped
* shapes alike.
*
* Row keys come from `phaseKeyFromProse`, the same owner-module derivation
* `phaseKeyFromDir` uses for directories, so a `| 01. … |` row and a `1-slug`
* directory cannot land in different key spaces (the padding-asymmetry defect).
*/
function parseRoadmapProgressRows(roadmapPath: string): RoadmapProgressRow[] {
let content: string;
try {
content = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
return []; /* no roadmap */
}
// Milestone-ATTRIBUTING shape first. Both shapes carry `Plans Complete`, so
// probing that column first would pick a flat table appearing earlier in the
// document over a milestone-grouped one later — every row would come back
// unattributed and be treated as current-milestone, silently over-including.
const table = findTableWithColumns(content, ['Phase', 'Milestone'])
?? findTableWithColumns(content, ['Phase', 'Plans Complete']);
if (!table) return [];
const rows: RoadmapProgressRow[] = [];
for (const row of table.rows) {
const key = phaseKeyFromProse(row['Phase']);
if (key === null) continue;
const cell = (row['Milestone'] ?? '').trim();
rows.push({ key, version: /^v\d+(?:\.\d+)+$/.test(cell) ? cell : null });
}
return rows;
}
/**
* #2562: the workstream's CURRENT milestone version, read from the STATE.md
* `milestone:` frontmatter field (the reliable per-workstream signal — the
* ROADMAP's own in-progress markers can be stale, e.g. a lingering 🚧 on an
* already-shipped milestone). Falls back to the ROADMAP in-progress heading
* marker only when STATE has no field.
*/
function readCurrentMilestoneVersion(statePath: string, roadmapPath: string): string | null {
try {
const m = fs.readFileSync(statePath, 'utf-8').match(/^milestone:\s*["']?(v\d+(?:\.\d+)+)["']?/m);
if (m) return m[1];
} catch {
/* no state */
}
try {
const rm = fs.readFileSync(roadmapPath, 'utf-8').match(/(?:🚧|🔄)\s*\*\*(v\d+(?:\.\d+)+)\b/);
if (rm) return rm[1];
} catch {
/* no roadmap */
}
return null;
}
/**
* #2562: does the CURRENT milestone's own ROADMAP heading carry a shipped
* marker? Delegated to `roadmap-parser`, the module that owns milestone-heading
* classification: heading/`<summary>` lines only (never a bullet that merely
* names the version), version-token boundary-matched so `v2.0` does not match
* inside `v2.0.1`, and in-progress markers win. Scoped to the current version,
* so a prior milestone's collapsed `<details><summary>✅ … SHIPPED</summary>`
* block can never mark the current milestone complete.
*/
function currentMilestoneHeadingShipped(roadmapPath: string, version: string): boolean {
try {
return isMilestoneShippedInRoadmap(fs.readFileSync(roadmapPath, 'utf-8'), version);
} catch {
return false; /* no roadmap */
}
}
/**
* Legacy pre-#2562 shipped detection: ANY archived milestone snapshot OR a
* SHIPPED marker anywhere in the ROADMAP. Over-broad (project-lifetime, not
* milestone-scoped) — retained ONLY as the fallback when the current milestone
* version cannot be determined (malformed/legacy STATE.md with no `milestone:`
* field), so those projects keep #1913's stale-field protection.
*/
function legacyMilestoneShipped(roadmapPath: string, planningBase: string): boolean {
try {
const milestonesDir = path.join(planningBase, 'milestones');
for (const entry of fs.readdirSync(milestonesDir, { withFileTypes: true })) {
if (entry.isFile() && /-ROADMAP\.md$/i.test(entry.name)) return true;
}
} catch {
/* no milestones archive dir */
}
try {
if (/SHIPPED/i.test(fs.readFileSync(roadmapPath, 'utf-8'))) return true;
} catch {
/* no roadmap */
}
return false;
}
/**
* #2562: directory mtime, used only to break a duplicate-phase-key tie in the
* rollup (keep the more recently touched directory — the Bug #2445 rule). 0 on
* a stat failure, which loses the tie rather than throwing.
*/
function phaseDirMtime(phaseDir: string): number {
try {
return fs.statSync(phaseDir).mtimeMs;
} catch {
return 0;
}
}
function countPhaseFiles(phaseDir: string): PhaseFileCounts {
const scan = planScan(phaseDir);
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
@@ -85,27 +217,39 @@ function readStateProjection(statePath: string): StateProjection {
}
/**
* #1913: detect an authoritative shipped signal for a workstream so the
* inventory status is never trusted from the mutable STATE.md `Status` field
* alone. Returns true when EITHER an archived milestone snapshot is present
* under `<planningBase>/milestones/` OR the workstream ROADMAP carries a
* SHIPPED marker — both are hard to desync, unlike the hand-maintained field.
* #1913 + #2562: detect an authoritative shipped signal for a workstream's
* CURRENT milestone, so the inventory status is never trusted from the mutable
* STATE.md `Status` field alone (#1913) yet is never pinned to "milestone
* complete" by a PRIOR milestone's shipped marker (#2562).
*
* When the current milestone version is known, the signal is scoped to it:
* an archived snapshot `milestones/<version>-ROADMAP.md` (the canonical
* "milestone shipped" artifact) OR the current milestone's own ROADMAP line
* marked shipped. When the version cannot be determined, we fall back to the
* over-broad legacy detection to preserve #1913's protection for those
* (malformed/legacy) projects.
*
* Returns WHICH signal fired, not merely that one did. The two differ in how
* much they can be trusted and therefore in how the builder cross-validates
* them against the milestone's own artifacts — see the `shippedContradicted`
* block in `workstream-inventory-builder.cts`. Collapsing them to a boolean is
* what forced a single completeness check to serve two incompatible shapes.
*/
function workstreamMilestoneShipped(roadmapPath: string, planningBase: string): boolean {
try {
const milestonesDir = path.join(planningBase, 'milestones');
for (const entry of fs.readdirSync(milestonesDir, { withFileTypes: true })) {
if (entry.isFile() && /-ROADMAP\.md$/i.test(entry.name)) return true;
}
} catch {
/* no milestones archive dir */
function workstreamShippedSignal(
roadmapPath: string,
planningBase: string,
currentVersion: string | null,
): MilestoneShippedSignal {
if (!currentVersion) {
return legacyMilestoneShipped(roadmapPath, planningBase) ? 'legacy' : null;
}
try {
if (/SHIPPED/i.test(fs.readFileSync(roadmapPath, 'utf-8'))) return true;
} catch {
/* no roadmap */
}
return false;
// Canonical shipped artifact: the archived ROADMAP snapshot of the CURRENT
// milestone (`vX.Y-ROADMAP.md`), written at milestone close. REQUIREMENTS
// snapshots are intentionally NOT accepted — they can be written at milestone
// START (requirements-locked), so they do not imply shipped.
const snapshot = path.join(planningBase, 'milestones', `${currentVersion}-ROADMAP.md`);
if (fs.existsSync(snapshot)) return 'snapshot';
return currentMilestoneHeadingShipped(roadmapPath, currentVersion) ? 'heading' : null;
}
function sortWorkstreamInventories(inventories: WorkstreamInventory[], activeWorkstreamName: string | null): WorkstreamInventory[] {
@@ -127,12 +271,139 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream
const p = planningPaths(cwd, name);
const phaseDirNames = readSubdirectories(p.phases);
// Collect per-phase file counts
// #2562: scope progress to the CURRENT milestone. Membership and the
// denominator are derived in ONE key space (`phaseKeyFromDir` /
// `phaseKeyFromProse`, both from the phase-id owner module) so the two sides
// of the rollup cannot disagree.
const currentVersion = readCurrentMilestoneVersion(p.state, p.roadmap);
const progressRows = parseRoadmapProgressRows(p.roadmap);
// Phase keys the ROADMAP attributes to the current milestone. A row whose
// Milestone cell is blank or malformed is INCLUDED rather than dropped: a
// phase we cannot attribute must still be visible to the rollup. Dropping it
// from both sides was the silent-deletion defect — it let an unstarted phase
// vanish and the percentage round to 100. Over-inclusive-never-under is the
// degrade direction this codebase already commits to for unparseable roadmap
// input (see the getMilestonePhaseFilter catch in roadmap-parser.cts).
const currentMilestoneKeys = new Set<string>();
if (currentVersion) {
for (const row of progressRows) {
if (row.version === null || row.version === currentVersion) currentMilestoneKeys.add(row.key);
}
}
// Roadmap-heading membership, from the module that OWNS milestone-phase
// filtering. Consulted only when it is genuinely scoped to a single milestone
// (`versionScoped`); the unversioned whole-roadmap shape spans the project's
// lifetime and would re-admit prior-milestone phases — the very defect here.
const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, null, name);
const headingScoped = headingFilter.versionScoped && headingFilter.phaseCount > 0;
// Phase keys the ROADMAP attributes to some OTHER milestone. A row carrying an
// explicit version that is not the current one is a positive claim by a prior
// (or future) milestone — the only reliable evidence that a phase does NOT
// belong to the current one.
const claimedElsewhere = new Set<string>();
for (const row of progressRows) {
if (row.version !== null && row.version !== currentVersion) claimedElsewhere.add(row.key);
}
// #2562: the current milestone is DECLARED but nothing attributes a phase to
// it yet — the window right after `/gsd-new-milestone`, where STATE.md's
// `milestone:` field updates the moment the heading lands but the Progress
// table and phase sections have not caught up.
//
// Treating that as "unscoped" was a hole in the original fix: scoping switched
// off entirely and the fallback below counted the project's ENTIRE phase
// history as both numerator and denominator, so a workstream whose current
// milestone had zero phases done reported 100% off its predecessors' work.
// That is the very symptom #2562 reports, reached by a different route.
//
// Three independent signals witness it; ANY of them is enough, and each covers
// a ROADMAP shape the others miss:
// - `versionSectionFound` — the milestone's own section exists but declares
// no phases (heading-only ROADMAPs, and the common `## v3.0` stub).
// - `missingExplicitVersion` — the ROADMAP versions its milestones but has
// no section for this one at all.
// - a Progress table that attributes every row elsewhere (`claimedElsewhere`
// non-empty while `currentMilestoneKeys` is empty).
// A ROADMAP that attributes NO versions anywhere matches none of them: its
// rows parse with `version: null`, land in `currentMilestoneKeys`, and never
// reach here. That is deliberate — for a free-form legacy project the
// whole-roadmap count IS the current milestone, and `readCurrentMilestoneVersion`
// hands back a non-null version for almost every project, so keying off
// `currentVersion` alone would regress every one of them to 0%.
const currentMilestoneDeclaredEmpty =
currentVersion !== null &&
currentMilestoneKeys.size === 0 &&
!headingScoped &&
(headingFilter.versionSectionFound || headingFilter.missingExplicitVersion || claimedElsewhere.size > 0);
// A dir-only phase joins the current milestone when the roadmap names it, or
// when it is a sub-phase (`30.1-…`) of a phase the roadmap names — sub-phases
// inserted mid-milestone rarely get a row of their own. Membership feeds BOTH
// the numerator and (via `milestoneKeys` below) the denominator, so a member
// can never exceed the denominator that counts it.
const scoped = currentMilestoneKeys.size > 0 || headingScoped || currentMilestoneDeclaredEmpty;
const isDirInCurrentMilestone = (dir: string): boolean => {
if (!scoped) return true;
const key = phaseKeyFromDir(dir);
if (currentMilestoneKeys.has(key)) return true;
const parent = parentPhaseKey(key);
if (parent !== null && currentMilestoneKeys.has(parent)) return true;
// An empty current milestone has no roadmap declarations to match against,
// so membership inverts: a directory belongs UNLESS another milestone claims
// it. A phase scaffolded before the roadmap caught up would otherwise vanish
// from both sides of the rollup — under-reporting, the direction this
// codebase never degrades in.
if (currentMilestoneDeclaredEmpty) {
const parentKey = parentPhaseKey(key);
return !claimedElsewhere.has(key) && (parentKey === null || !claimedElsewhere.has(parentKey));
}
return headingScoped && headingFilter(dir);
};
// Collect per-phase file counts (+ canonical key, milestone membership,
// verification verdict). `phaseKey` lets the builder de-duplicate stale
// same-numbered directories (Bug #2445's scenario) in the rollup.
const phaseFilesCounts = phaseDirNames.map(dir => {
const counts = countPhaseFiles(path.join(p.phases, dir));
return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount };
const phaseDir = path.join(p.phases, dir);
const counts = countPhaseFiles(phaseDir);
return {
directory: dir,
phaseKey: phaseKeyFromDir(dir),
mtimeMs: phaseDirMtime(phaseDir),
planCount: counts.planCount,
summaryCount: counts.summaryCount,
inMilestone: isDirInCurrentMilestone(dir),
verificationStatus: readVerificationStatus(phaseDir).status,
};
});
// The denominator is the union of what the roadmap DECLARES for the current
// milestone (including never-scaffolded phases) and the keys of the member
// directories (including dir-only sub-phases). One key space, so
// `completed_phases <= denominator` holds by construction rather than by a
// `Math.min` cap that hid the inconsistency.
const milestoneKeys = new Set(currentMilestoneKeys);
for (const entry of phaseFilesCounts) {
if (entry.inMilestone) milestoneKeys.add(entry.phaseKey);
}
const currentMilestonePhaseCount = scoped
? Math.max(milestoneKeys.size, headingScoped ? headingFilter.phaseCount : 0)
: 0;
// Unscoped fallback: the denominator must STILL count phases the ROADMAP
// declares in its Progress table but never scaffolded — the heading-only
// count drops them, even when other headings exist. Union the declared rows
// with the phase directories so neither source can silently shrink it.
let fallbackPhaseCount = countRoadmapPhases(p.roadmap, phaseDirNames.length);
if (!scoped && progressRows.length > 0) {
const union = new Set(progressRows.map(row => row.key));
for (const entry of phaseFilesCounts) union.add(entry.phaseKey);
fallbackPhaseCount = union.size;
}
return buildWorkstreamInventory({
name,
projectDir: cwd,
@@ -140,14 +411,19 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream
phaseDirNames,
activeWorkstreamName: activeWorkstreamName ?? '',
phaseFilesCounts,
roadmapPhaseCount: countRoadmapPhases(p.roadmap, phaseDirNames.length),
roadmapPhaseCount: fallbackPhaseCount,
currentMilestonePhaseCount,
// Stated, not inferred from the count: a declared-but-empty current
// milestone is legitimately scoped AND legitimately zero-phase, and the
// builder cannot tell those apart from `currentMilestonePhaseCount` alone.
milestoneScoped: scoped,
stateProjection: readStateProjection(p.state),
filesExist: {
roadmap: fs.existsSync(p.roadmap),
state: fs.existsSync(p.state),
requirements: fs.existsSync(p.requirements),
},
milestoneShipped: workstreamMilestoneShipped(p.roadmap, p.planning),
milestoneShippedSignal: workstreamShippedSignal(p.roadmap, p.planning, currentVersion),
});
}

View File

@@ -235,6 +235,10 @@ function cmdWorkstreamList(cwd: string, raw: boolean): void {
has_roadmap: ws.files.roadmap,
has_state: ws.files.state,
status: ws.status,
// #2562: a refused shipped marker must reach the surface. Projecting
// `status` without it renders the refusal invisible at the CLI, which is
// the silent-collapse defect this issue is about.
milestone_shipped_unverified: ws.milestone_shipped_unverified,
current_phase: ws.current_phase,
phase_count: ws.phase_count,
completed_phases: ws.completed_phases,
@@ -272,6 +276,7 @@ function cmdWorkstreamStatus(cwd: string, name: string | null | undefined, raw:
phase_count: inv.phase_count,
completed_phases: inv.completed_phases,
status: inv.status,
milestone_shipped_unverified: inv.milestone_shipped_unverified,
current_phase: inv.current_phase,
last_activity: inv.last_activity,
}, raw, undefined);
@@ -386,6 +391,7 @@ function cmdWorkstreamProgress(cwd: string, raw: boolean): void {
name: ws.name,
active: ws.active,
status: ws.status,
milestone_shipped_unverified: ws.milestone_shipped_unverified,
current_phase: ws.current_phase ?? null,
phases: `${ws.completed_phases}/${ws.roadmap_phase_count}`,
plans: `${ws.completed_plans}/${ws.total_plans}`,