--- type: Added pr: 2867 --- **Bracket-style phase IDs (`[GSD.02] 05: Name`) are now recognized on the read path** — `roadmap`, `validate` and `state` previously matched only the `Phase N:` spelling and the `NN-name` directory shape, so on a project with `phase_id_convention: "bracket"` every phase was invisible: counts fell back to the on-disk directory listing, `get-phase` reported not-found, every `GSD.02-05-slug` directory was reported malformed, and a completed milestone was warned to have unstarted phases. What changes: - Milestone scoping recognizes the ADR-canonical `## [GSD.02] Foundation` heading, including the version-less form (no `vN.N`, no status emoji), at any heading level through `###`, and across a milestone split over two headings in either order. A sibling milestone's phases and directories are excluded either way. - Phase directories resolve, so each bracket phase reports its real `disk_status`, `plan_count` and `summary_count` instead of `no_directory` and zeros, and `completed_phases`, `total_plans` and the progress percent count the whole milestone. `state sync` scopes its own disk scan the same way, so the percent it writes to STATE.md agrees with the read path. - Bracket-sentinel milestones (`[GSD.999]` icebox and `[GSD.00]` pre-milestone) and the reserved `999` phase token are excluded from phase counts, while retired phases leave the denominator. `validate consistency` and `validate health` now agree on bracket icebox entries instead of one flagging what the other excludes. The deliberately asymmetric phase-0 behavior is detailed below. - `missing_phase_details` classifies each checklist entry on its own bracket. Two entries sharing a phase token across different brackets previously shared one verdict, decided by which was written first, so a real phase listed under an icebox entry's token was silently dropped from the report. - `phase_id_convention` resolves against the workstream being read. A workstream that declares its own convention is no longer overridden by the root config, and `--workstream foo` now agrees with `GSD_WORKSTREAM=foo`; workstream progress rollups resolve the convention instead of assuming legacy, so a bracket workstream's phase count comes from its ROADMAP rather than falling back to its directory count. - `validate health` gains an advisory W021 for opted-in projects: one sub-check flags a phase whose bracket milestone disagrees with its enclosing section, the other flags a phase heading not yet migrated to bracket form. - `state validate` resolves bracket phase directories, so its drift scan actually runs on a bracket project instead of reporting `no phase directory matches` — and `valid: false` — for a directory that is plainly on disk. - The `roadmap milestone-scope` probe and the `phase add` / `add-batch` / `insert` milestone-scope guard both read bracket headings. Blind, the probe reported an empty phase set on a bracket ROADMAP before *and* after a write, so the edit-phase rollback check could never fire; and the guard accepted a description embedding `## [GSD.09] Name` — a heading that carries none of the legacy milestone markers yet terminates the window on an opted-in project, silently dropping every later phase out of the milestone scope. A project that has not opted in is unaffected in both cases. Every widened read engages only when the resolved `phase_id_convention` is `bracket`; a project that has not opted in compiles the same patterns it did before. `"bracket"` is a read-path opt-in until the migrator and write path land — valid values are documented in `docs/CONFIGURATION.md`. Two consequences of landing on top of #3185 are worth stating. First, #3185 moved the legacy heading counter onto the canonical sentinel predicate, which drops a `### Phase 0:` or `### Phase 0.5:` heading from `total_phases`; combined with this PR's mid-migration guard, that legacy-spelled heading is counted on a bracket project and not counted on one that has not opted in. A bracket-spelled bare `0` or `0.x` remains excluded. The bracket counter keeps the narrower `^0\b` rule deliberately, because the canonical predicate also swallows decimal phase IDs such as `00.1`, which is a real phase rather than milestone 0. Second, the shared phase-directory enumerator keeps its `phaseIdConvention = null` destructure default (`phase-locator.cts:375`), and `null` means "resolved, and not bracket" — the lazy resolve-from-config fires only on `undefined` (`roadmap-parser.cts:941`, `:1928`, whose own comment records that "explicit null still means 'resolved and non-bracket'"). Only four of its seventeen call sites thread a resolved convention: `milestone complete` (`milestone.cts:782`) and `state`'s three (`state.cts:979`, `:2303`, `:4672`). The remaining thirteen omit it and therefore still enumerate on the legacy reading — including `progress` (`roadmap.cts:745`), `stats` (`commands.cts:1947`, `:2302`), `phase list` and the init manager view (`init.cts`), and `state sync`'s own scope probe (`state.cts:4795`). On a bracket project those surfaces receive an unscoped or legacy-scoped directory set, not a bracket-scoped one, and their per-entry rendering is likewise unconverted (`cmdProgressRender`'s directory regex, `cmdStats`'s convention-less `extractPhaseToken(dir)` call, the init manager view's `Phase`-literal heading pattern). Widening those call sites is display- and command-surface work deferred to the epic's later PRs; this slice does not claim them. The archival and milestone-completion paths DO reach the widened enumerator, and deliberately so: `milestone complete` (`cmdMilestoneComplete`) and `state update-progress` (`cmdStateUpdateProgress`) both call the same shared `listMilestonePhaseDirs` this PR widens, and both now resolve and thread `phase_id_convention` explicitly at their call sites rather than relying on the enumerator's own lazy resolve-from-config default. On a project that has opted into `"bracket"`, `milestone complete` archives the milestone's real bracket-declared phase directories — the same set the read path already reports — instead of failing to recognize them; a `null` / `milestone-prefixed` project's archived set is unchanged. `state update-progress`'s reported and written percent was already correctly scoped (it derives from `buildStateFrontmatter`, which threads its own resolved convention independently); the explicit thread at its own enumerator call is single-derivation hygiene, not a behavior change, and is documented as such in-line (mutation-tested: reverting only this thread leaves every existing assertion on this command green, because the enumerator's own lazy resolve-from-config default answers the same question the explicit thread does). `cmdMilestoneComplete`'s enumerated set is pinned by a test (`tests/adr-612-bracket-phase-counting.test.cjs`, the round-11 BLOCKER block) so a future regression to the pass-all-degrade legacy reading cannot silently move what a bracket project's `milestone complete` archives without failing a test. `state update-progress`'s own call site is pinned differently, matching what it actually gates: not the reported percent, but the #3233 zero-plans no-op — a bracket milestone whose declared phases carry no plans on disk stays a no-op only when a directory that plainly does not belong to the milestone window is correctly excluded from this call site's enumerated set; swept in by a pass-all degrade, the no-op stops firing. (#2761) One more disk-side fix lands alongside the above. `listMilestonePhaseDirs`'s sentinel filter (`isSentinelPhaseId`) treated a bare, untagged phase directory under `phase_id_convention: "bracket"` (`0-bootstrap`, no `{CODE}.{MM}-` prefix) as sentinel milestone 0 by falling through to the legacy leading-int rule — silently dropping a real, on-disk, milestone-declared phase directory from `completed_phases`. This mirrors, on the disk side, the exact defect class the heading-side counters (`countRoadmapPhaseHeadings`, `scanMilestonePhaseIds`) already guard against for the identical bare/untagged shape: under bracket convention, milestone 0 is expressed only via an explicit bracket tag, so an untagged leading `0` is a real phase token, not a sentinel. The `999`/icebox reading stays universal. Legacy and milestone-prefixed projects are unaffected (this call site's `convention` argument is only ever `'bracket'` or unset).