Files
msd-core/tests/adr-612-bracket-phase-counting.test.cjs
BeeHiggs 1a358ce0fd feat(#2761): bracket-tolerant read path — roadmap/validate/verify/state recognize bracket ids (epic #612 PR-2) (#2867)
* feat(#2761): gated heading-intro selection + one bracket identity grammar

Foundation. Two owner-level changes plus a federated convention resolver; no
reader consumes them yet.

1. GATED SELECTION, not an ungated widening.

   Widening every heading matcher requires the claim "no legacy ROADMAP contains
   a `[CODE.MM]` bracket followed by a digit", and that is false:
   `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` and
   `### [ISO.8601] 2026:` are ordinary headings, and a widened reader claims each
   as a phase — moving phase_count and total_phases and adding W006 on projects
   that never opted in. No narrowing rescues it: the premise is about documents
   we do not control.

   `phaseHeadingPrefixSrcFor(baseline, convention, capturing?)` selects the
   pattern SOURCE at construction time. A project whose resolved
   `phase_id_convention` is not exactly 'bracket' compiles the same source string
   it compiled before. `baseline` is explicit because whether a site spells the
   any-bracket prefix or a bare `Phase\s+` is a fact about that site's history:
   handing the wider grammar to a bare site retro-grants tolerance it never had,
   in both directions — warnings appear, and a warning that fires today vanishes.

   Both bracket forms CAPTURE. `[GSD.999] Phase 07:` previously matched through
   the base alternative, which captures nothing, so a reader saw no bracket, fell
   back to the legacy token rule, and counted a labeled icebox heading while
   excluding the label-less one beside it — two derivations of one ROADMAP
   disagreeing.

2. ONE bracket identity grammar, one width rule.

   The milestone width is reconciled with the emit validator: pad2 output, so
   two digits or 3+ with no leading zero. Earlier spellings diverged in both
   directions — admitting `002`, which the validator rejects, and a bare `0` pad2
   never produces — and the section recognizers accepted `[GSD.2]`, which SCOPED
   a milestone no phase heading could then resolve into, recreating the
   on-disk-count fallback this epic removes. An unpadded bracket is now uniformly
   malformed: it scopes nothing, bounds nothing, sections nothing. W005 on its
   directories is the surfacing signal.

   The milestone field is boundary-anchored, so a malformed run cannot match by
   its prefix (`GSD.002-01` read as sentinel `00`). Recognition stays
   case-insensitive because readers compile `/i`, but identity helpers match
   `[A-Z]`, so a captured id is folded first — otherwise `### [gsd.999] 07:`
   failed every sentinel test. The qualified key shares the width, the `(?=-|$)`
   boundary and the single-sub-phase shape of the directory token, because
   phaseTokenMatches returns unconditionally on a qualified hit: a key matching a
   directory isPhaseDirName rejects would be a final wrong answer.

3. resolvePhaseIdConvention federates workstream -> root exactly as
   config-loader does — including that root is a fallback only when a WORKSTREAM
   is active, so a project-scoped directory stands alone. loadConfig cannot serve
   this: it merges against CONFIG_DEFAULTS and drops keys it does not know, and
   this key is not among them. It governs the bracket-selection reads ONLY.

PHASE_HEADING_PREFIX_SRC is left byte-identical: PR-1 shipped it, nothing
consumes it, and it is superseded rather than redefined.

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

* feat(#2761): roadmap.cts selects its heading grammar from the convention

Six matchers build their intro through the gated selector, and
cmdRoadmapAnalyze / cmdRoadmapGetPhase / getRoadmapPhaseWithFallback each
resolve the convention ONCE per command and thread it down.

Three sites take the any-bracket baseline (they already tolerated
`[anything] Phase N`); three take label-only (they spelled a bare `Phase\s+`).
Handing the wider grammar to a label-only site retro-grants tolerance it never
had — and not only by adding matches: on a legacy repo an unchecked
`- [ ] **[v1.0] Phase 05: Thing**` bullet would start SUPPRESSING the W006 that
fires today.

Sentinel handling under bracket ADDS a rule rather than replacing one: a
bracketed heading is a sentinel when its bracket milestone is reserved
(`### [GSD.999] 01:`) OR when its token is, so the engine-wide 0/999 backlog
convention keeps applying to `### [GSD.02] 999:`. Replacing the token rule let a
mid-migration ROADMAP — bracket headings plus a legacy backlog block, exactly
the content this epic targets — add entries to the progress denominator. The
captured id is folded before the identity test, so a lowercase
`### [gsd.999] 07:` is excluded too.

The DIRECTORY read is threaded too. `cmdRoadmapAnalyze` resolves the convention
once and hands it to all four of its heading/checklist patterns, but the single
`phaseTokenMatches` call that decides `disk_status`, `plan_count`,
`summary_count`, `has_context` and `has_research` was left two-argument — so
every canonical `{CODE}.{MM}-{PP}-slug` directory read as `no_directory` with
zero counts, on the PR's own headline verb, while the SAME build resolved those
same directories correctly in three other places on the same repo (W006/W007 via
phaseTokenFromDir, `state json` via the milestone filter, and the W021
milestone-complete read through this very helper's three-argument form). It
failed ONLY for the directory shape the convention exists to name: a
mid-migration bracket repo carrying legacy `01-one` dirs resolved fine, which is
why nothing caught it. Measured, bracket vs its flat-legacy twin:
`[["01","no_directory",0,0],["02","no_directory",0,0]]` against
`[["01","complete",1,1],["02","planned",1,0]]`.

The oracle is the twin, computed in the same test run, plus exact literals —
`grep disk_status tests/adr-612-*` was zero hits before this, so neither the fix
nor a future regression had any gate at all.

Disclosed: a ROADMAP written in bracket form before config.json is switched
reads as empty rather than mis-counted. Silent invisibility during the migration
window is the deliberate trade against claiming phases on projects that never
opted in.

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

* feat(#2761): validate.cts selects its grammar; gated directory recognition

The W006/W007 feeders take the resolved convention as a threaded parameter.
These sites carry the letter-tolerant `[\w][\w.-]*` capture, which makes them
where an ungated widening does the most damage: `### [RFC.2119] 5:` enters
roadmapPhases as a phantom and becomes a W007 "in ROADMAP.md but no directory on
disk" on a project that never opted in.

buildRoadmapPhaseVariants also surfaces the tokens borne ONLY by sentinel-bracket
headings. Surfaced rather than filtered in place because roadmapPhases feeds both
a membership check and a missing-directory warning, and only the latter should
ignore an icebox item.

That set is OCCURRENCE-AWARE, and the subtlety is load-bearing: roadmapPhases is
a TOKEN set, so `[GSD.999] 01` and `[GSD.02] 01` collapse to one entry. Keying
suppression on the token alone let an icebox heading silence a REAL phase that
happens to share its number — a false negative strictly worse than the warning it
removed. A token is suppressed only when no non-sentinel heading bears it.

Directory recognition is added as gated FUNCTIONS beside the exported RegExp
constants, which stay byte-identical: the `{CODE}.{MM}-` prefix is
string-indistinguishable from the letter-prefixed-decimal family this repo
documents as ambiguous, and folding a branch in changes those constants' answers
on exactly that family. A RegExp constant has nowhere to attach a gate.

The recognizer mirrors the emit grammar and delegates the token to the canonical
owner, so recognizer and resolver agree on rejected input as well as accepted.
Both functions throw on a non-string, matching the call pattern they replace.

buildRoadmapPhaseVariants' CHECKLIST scan is capturing, like its heading twin
and like the sibling checklist scan in roadmap.cts, and for the reason that one
states: the bracket id has to ride along or the sentinel filter is blind to
`- [ ] **[GSD.999] 01: Icebox**`. Left un-capturing, the scan called every
checklist token REAL, and the occurrence-aware un-suppression loop then deleted
the icebox token the HEADING scan had correctly marked sentinel — so `validate
consistency` warned that a bracket ICEBOX phase had no directory, in the HOUSE
ROADMAP shape where an icebox appears as both a bold bullet and a detail
heading. `validate health` stayed silent on that same repo, so the two verbs
disagreed — which is the disagreement `sentinelPhases` exists to close.

Both directions are pinned, because the failure mode of a careless fix here is
the opposite one: a real phase sharing a sentinel's token must still warn. It
does, in all four shapes that attack it (sentinel heading + real bullet,
lowercase sentinel, sentinel after the real heading, colon-less bullet).

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

* fix(#2761): count bracket headings, and retire them, in both derivations

Both `total_phases` derivations select their grammar from the resolved
convention, in one commit — cmdStateSync already carries the comment that it
mirrors buildStateFrontmatter "so both report consistent percents (#3242 Bug B)",
so teaching one and not the other ships that divergence.

The #1514 retirement filter widens WITH the counter it protects. The canonical
gesture strikes the checklist BULLET and leaves the detail heading intact, so a
bracket-form retirement went undetected and the phase stayed in the denominator
forever. That is half a fix alone: the retired key is compared against
phaseKeyFromDir, which called extractPhaseToken with no convention. Both halves
land here.

Under bracket the sentinel token rule composes as the full engine set {0, 999},
so this counter agrees with `roadmap analyze`, which has always excluded both —
otherwise the two derivations report different numbers for one ROADMAP and the
changeset's "excluded from every count" is false as written. The LEGACY path
keeps its pre-existing 999-only rule: widening it there would move legacy totals,
so the two stay split off the bracket path exactly as they are today.

The sync-side assertion reads the PERCENT sync writes into the STATE.md body, not
the frontmatter total_phases. Sync's own counter never reaches that field — the
read derivation writes it — so asserting the frontmatter after a sync measures the
read path twice and lets a mutation to the write-path guard survive.

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

* feat(#2761): verify.cts bracket-coherence W021 + selected milestone-complete read

The shipped milestone-prefixed W021 gate keeps its ROOT-only config read,
verbatim base semantics. Federating it silently moved a legacy convention's
answer in BOTH directions on workstream repos — a W021 that fires at base
vanishing, and one that is silent at base firing. resolvePhaseIdConvention
governs the new bracket-selection reads only.

B6, the milestone-complete check, keeps its ungated POSTURE (bug-557 pins it
with an empty config) but selects its grammar from the convention. Inferring
'bracket' from the shape of a matched bracket ran a repo-failing check against a
legacy ROADMAP that merely contained `### [RFC.2119] 5:`. Directory resolution
widens with the heading read, so a bracket repo whose phases are on disk stays
silent, and a bracket sentinel is not reported as unstarted.

checkBracketCoherence is advisory and gated. Anchored to tokenizeHeadings so
fenced examples cannot warn and heading level is structural. Its scope rules each
close a way it silently did nothing or fired wrongly: only a genuine MILESTONE
heading opens or closes a section (a `### Notes` used to reset scope and disable
both sub-checks); a legacy `## v3.0` DOES close it; an M-NN or letter-suffixed
phase heading raises missing-bracket and CONTINUES; a bare `#### 2026:` is not a
phase; the full h2-h6 range is processed. Its section recognizer shares the one
milestone width, so an unpadded `### [GSD.3] 05:` can no longer be a phase to the
id grammar and a section to the section grammar at once, silently re-scoping
every warning after it.

validate consistency suppresses bracket sentinels in its missing-directory
warning — the two verbs disagreed, health suppressing via notStartedPhases while
consistency did not. The legacy reading is untouched, including its pre-existing
wart that `### Phase 999:` still warns there.

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

* fix(#2761): scope the milestone by its bracket; select the disk-side filter

Two roadmap-parser reads, both of which made a bracket project's totals track
the disk instead of the ROADMAP.

The ADR pins the bracket milestone heading as `## [GSD.02] Foundation` — a name,
no version — but scoping matched STATE's `milestone: v2.0` STRING against a
heading, so the canonical form matched nothing and total_phases fell back to the
directory count. The rule was re-derived in THREE places: extractCurrentMilestone
plus two `milestoneBounded` guards; fixing one left the others falling back
regardless, so they are now one gated helper. It matches the CANONICAL padded
spelling only — accepting `0*N` bounded a milestone whose phases were invisible,
which un-suppressed a progress percent computed off an unscoped disk count.

getMilestonePhaseFilter's heading scan becomes the 14th selected read. On a
bracket ROADMAP it collected nothing, so the filter degraded to pass-all and
buildStateFrontmatter counted every other milestone's directories — making the
bracket convention strictly worse than the M-NN one it supersedes on the property
that matters most: totals must track the ROADMAP, not the disk.

The DIRECTORY side of that same filter is selected with it. Teaching only the
heading scan was half a fix and a worse one: `milestonePhaseNums` became
non-empty, so the pass-all degrade stopped firing, but no bracket directory could
satisfy the three legacy dir checks (numericRe fails on `GSD.02-05-five`, the
custom-id match captures the project code `GSD`, and stripProjectCodePrefix does
not strip a dotted prefix). Every bracket directory was rejected, and
completed_phases / total_plans / completed_plans / percent all collapsed to 0
while `state sync` went on writing a percent off the unfiltered disk — `state
json` reporting 0% on the same repo, in the same second, that STATE.md's body
called 67%. That is the #3242 Bug B divergence this PR exists to avoid, and
total_phases could not show it: `Math.max(phaseDirs.length, roadmapPhaseCount)`
floors it at the ROADMAP count no matter how many directories are rejected.

The dir side matches on the milestone-QUALIFIED id, delegated to the owner's
gated `phaseTokenMatches(dir, id, 'bracket')`, not on the bare token: READING-B
puts the milestone in the bracket, so `GSD.01-01-old-one` and `GSD.02-01-one`
share the token `01` and only the qualified key separates them. The qualified ids
are kept in their own set — a hyphen in `milestonePhaseNums` would flip
`roadmapUsesHyphenedIds` and silently move the LEGACY dir path on a bracket repo
— and the branch is ADDITIVE: on a miss it falls through to the three legacy
checks, so a bracket project carrying legacy-shaped directories reads unchanged.

Both are resolved lazily and gated, so the legacy path pays neither a config read
nor a second scan and cannot change answer. The scoping call is also GUARDED:
resolvePhaseIdConvention reaches planningDir, which throws a plain Error for a
GSD_PROJECT/GSD_WORKSTREAM segment carrying `/`, `\` or `..`. At base the only
planningDir call in extractCurrentMilestone sits inside the STATE-read try, so
the function returned normally on such an environment; an unguarded one here let
that escape and broke the never-throws invariant that getRoadmapPhaseInternal and
getMilestoneInfo three hundred lines below carry #2245 / ADR-227 notes about.
Unreachable through the CLI — GSD_WORKSTREAM is rejected up front by the
workstream-name policy and GSD_PROJECT throws identically at base — but reachable
by any in-process embedder, which is precisely who that invariant is for. The
filter's own resolve call was already inside its try and is unaffected.

The milestone-qualified key is formed only for a token that is itself a bracket
phase token. `${bracketId}-${token}` is a string SPLICE, so a mid-migration
heading carrying an M-NN label — `### [GSD.02] Phase 02-01:` — spliced to
`GSD.02-02-01`, which the qualified-key grammar reads as milestone 02 / phase 02:
the `-01` truncated, both such headings collapsing to one key, and the heading
claiming `GSD.02-02-two`, the directory it does NOT name, while rejecting
`GSD.02-01-one`, the one it does. The guard drops those headings back to the
unqualified legacy path, restoring the base ACCEPTANCE VECTOR exactly — pinned
against the milestone-prefixed reading of the same ROADMAP, which is
base-identical on this shape.

Scoped precisely, because the fixture moves one number that the guard does not
touch: `total_phases` on it reads 1 at base and 2 here. That is the bracket
heading COUNT this PR exists to add, not the splice — measured identical with and
without the guard, and identical to what the canonical `### [GSD.02] 01:`
spelling does on the same fixture (both read 2 with zero directories on disk,
where base reads 0). The claim is base-equivalent ACCEPTANCE, not a
base-equivalent reading.

One consequence is stated rather than fixed: a heading whose token carries a
hyphen still puts that hyphen into milestonePhaseNums and so still flips
`roadmapUsesHyphenedIds`. Base does the same for that spelling, so preserving it
is what keeps the shape base-equivalent; excluding the token would have moved
answers versus base on malformed input. The comment at the qualified-set
declaration is corrected to claim only what is true — it keeps QUALIFIED IDS out
of that flag's input, not hyphens in general.

The oracles ship with it, and they are the five numbers, not the one: the parity
gate now asserts total_phases, completed_phases, total_plans, completed_plans AND
percent, on both derivations, on two fixture shapes (one milestone; two
milestones with stale prior-milestone directories on disk). The oracle is the
flat-legacy twin, built in the same test run and compared number for number,
plus exact literals so a shared wrong answer cannot pass.

The oracle SUBSTITUTION is itself pinned. The M-NN spelling of these shapes could
not serve, because buildStateFrontmatter's #2445 de-dup key captures only a
directory's leading integer and collapses `02-01-one` / `02-02-two` /
`02-03-three` to one — measured [3,0,1,0,0] against the flat-legacy twin's
[3,2,3,2,67], identically at base and before this fix, and structurally
unreachable from the bracket key space. That reasoning is only sound while it
stays true, so a characterization test holds the M-NN reading down on the two
numbers that do not depend on which directory wins the mtime race. Widen the
de-dup key and it fails, instead of quietly invalidating the changeset's
disclosure.

Also adds the call-site pin. The structural table pins transcription against the
selector; it cannot see a call site whose BASELINE ARGUMENT is wrong. Flipping
verify.cts's milestone-complete site to the wider baseline grants a
fires-on-every-repo check tolerance it has never had, and every behavioural test
still passed. The pin reads the shipped sources and asserts the mode at each of
the 14 sites, count-exact.

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

* test(#2761): pin the bracket read surfaces in the parity gate

This gate exists because #2043 fixed one bug across five hand-edited copies of a
rule and #2232 was the residual that survived, because a later reader could not
tell the copies were one rule. PR-2 adds two consumers, so they belong here.

Surface 7 — the heading read and the directory read must agree about WHICH phase
a `MM-<seg>` pair names, across the shared width corpus, and the bracket and
legacy spellings of one heading must yield the same token.

Surface 8 — the two bracket directory readers, in BOTH directions. Agreement on
ACCEPTED input was already pinned; agreement on REJECTED input is where they
actually diverged.

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

* chore(#2761): changeset

Disclosures for the PR body (deliberate, not defects):

- phase_id_convention is not a CONFIG_DEFAULTS key, so loadConfig drops it and
  cannot serve as the convention resolver however the file is federated. This PR
  ships its own workstream->root resolver; adding the key and its value enum is
  later-slice work.
- Convention matching is strictly === 'bracket'. A misspelled value reads as
  not-configured and the project keeps legacy behaviour silently.
- An UNPADDED bracket milestone (`[GSD.2]`) is malformed: it scopes nothing,
  bounds nothing, sections nothing, and is not a phase id. W005 on its
  directories is the surfacing signal.
- WIDTH UNIFICATION MOVED FOUR MERGED PR-1 EXPORT ANSWERS on non-canonical
  inputs, none of which toDir can emit and none of which had a bracket caller at
  base:
    isSentinelPhaseId('GSD.0-01',    'bracket')  true  -> false
    isSentinelPhaseId('GSD.0999-01', 'bracket')  true  -> false
    getMilestoneFromPhaseId('GSD.2-01',   'bracket')  'v2.0' -> null
    getMilestoneFromPhaseId('GSD.002-01', 'bracket')  'v2.0' -> null
  The canonical pad2 sentinel spelling `[GSD.00]` still tests true.
- FLAG TO MAINTAINER: docs/adr/612:132 reads "Sentinel behavior (0.x / 999.x ->
  milestone null) is preserved". After the unification that holds for the
  canonical `00` spelling only, not for a bare `[GSD.0]`. ADR wording is yours;
  flagging the tension rather than editing it.
- The bracket sentinel rule COMPOSES with the legacy one — a bracketed heading is
  a sentinel when its bracket milestone OR its token is reserved. Under bracket
  the state-side token rule is the full {0, 999} set so both derivations agree;
  the LEGACY path keeps its pre-existing 999-only rule, unchanged.
- validate consistency's legacy reading is untouched, including the pre-existing
  wart that `### Phase 999:` warns there while validate health suppresses it.
- find-phase still cannot resolve a bracket phase directory. phase-locator.cts is
  outside this PR's module set. Sibling PR #2559's matchPhaseDirs calls
  phaseTokenMatches without a convention, so whichever slice lands second must
  thread it through.
- Four of the five bracket readers scan raw ROADMAP content, so a bracket heading
  inside a fenced code block is read as a phase. Pre-existing for the legacy
  spelling; parity, not a new class.
- roadmapPhaseLookupSources gained no bracket source: nothing emits a
  milestone-qualified query into it yet.
- roadmap validate remains a separate, unfederated convention reader.
  Pre-existing and base-identical, but two verbs can disagree about the active
  convention on one project.
- _diskScanCache keys on cwd while the values it caches are now
  convention-dependent. Not reproducible through the CLI; pre-existing for the
  workstream dimension, widened here. Stated as inconclusive.
- A ROADMAP written in bracket form before config.json is switched reads as empty
  rather than mis-counted — the deliberate migration-window trade.
- THE READ AND WRITE PERCENTS STILL DIVERGE ON A MULTI-MILESTONE REPO, and that
  divergence is MIRRORED under bracket rather than closed. buildStateFrontmatter
  applies the milestone filter; cmdStateSync does its own fs.readdirSync and never
  calls it, so on a repo carrying prior-milestone directories the read path
  reports the SCOPED percent and the sync body reports the WHOLE-DISK one.
  Measured on the true base build (d04592de), flat-legacy spelling, 3 in-scope
  phases with 1 complete plus 2 stale prior-milestone dirs: `state json`
  [3,1,3,1,33], sync body 60%. The bracket twin of that repo now reads the same
  two numbers — 33 and 60. Scoping the sync counter would move every legacy
  repo's percent, which a bracket read-path PR must not do. The gate pins both
  sides, so the mirror cannot silently become a one-sided fix.
- THE PARITY ORACLE IS THE FLAT-LEGACY TWIN, NOT THE M-NN ONE, and that is a
  measurement finding rather than a preference. buildStateFrontmatter's #2445
  de-dup key captures only a directory's LEADING integer, so the M-NN dirs
  `02-01-one` / `02-02-two` / `02-03-three` all key to `2` and two of the three
  are dropped before they are ever counted: base reads [3,0,1,0,0] where the
  flat-legacy twin of the same repo reads [3,2,3,2,67]. Present identically at
  base and at HEAD, untouched here, and structurally unreachable from the bracket
  key space — `GSD.02-01-one` does not match that pattern at all, so every bracket
  directory keys to its own name. The source line already carries a
  `phase-id-owner:` sanction recording the divergence. Mirroring it under bracket
  would mean manufacturing a collision that cannot occur, so the gate compares
  against the flat-legacy spelling, which is uncontaminated. This paragraph is
  itself pinned: a characterization test holds the M-NN reading on the two
  numbers that do not depend on which directory wins the mtime race, so widening
  the de-dup key in a later slice fails the suite rather than silently making
  this disclosure false.
- THE `phaseTokenMatches` CALL-SITE CENSUS, stated so the remaining gaps are
  auditable rather than implied. 13 call sites outside the owner (phase-id.cts).
  THREE are three-argument: verify.cts:2229 (the W021 milestone-complete read,
  already was), roadmap.cts:436 (`roadmap analyze`'s directory lookup, threaded
  by this PR) and roadmap-parser.cts:792 (the disk-side milestone filter, added
  by this PR). The other TEN are two-argument and stay that way — phase.cts ×5
  (220, 277, 444, 585, 1547), phase-locator.cts:62, smart-entry.cts:243,
  init.cts:1414, milestone.cts:551 and verify.cts:2467. All ten are untouched by
  this PR and base-identical.
  One of them sits in a file this PR DOES edit, so it is named rather than left
  to a reader's grep: verify.cts:2467, `verify schema-drift <phase>`. Measured on
  a bracket repo across base / pre-fix branch / this HEAD, all three agree on all
  three argument forms — `verify schema-drift GSD.02-01` and `… 01` both report
  "Phase directory not found" on every build, and `… GSD.02-01-one` resolves on
  every build through the exact-directory-name fallback. So the user-visible
  shape of what stays broken is: a bracket phase is addressable there by full
  directory name only, exactly as at base. Threading the convention into a
  function this PR never touched, in the last round before ship, is the wrong
  trade; it is where the same one-argument fix goes next, alongside
  milestone.cts:551 and init.cts:1414.
- A BRACKET HEADING WHOSE TOKEN CARRIES A HYPHEN (`### [GSD.02] Phase 02-01:`,
  a mid-migration spelling) forms NO milestone-qualified key, and therefore
  scopes through the unqualified legacy path — base-equivalent ACCEPTANCE, which
  is the claim, and not a base-equivalent reading: `total_phases` on that shape
  moves 1 -> 2 for the same reason it moves on the canonical `### [GSD.02] 01:`
  spelling, because counting bracket headings is what this PR does. Such a token
  still flips `roadmapUsesHyphenedIds`, as it also does at base. The comment at
  the qualified-set declaration now claims only that narrower, true thing.

The `pr:` field carries the sub-issue number as a placeholder — it must be
updated to the real PR number when the PR is opened.

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

* chore(#2761): point the changeset at PR #2867

* test(#2761): fast-check properties for the convention-selection layer

CONTRIBUTING.md mandates a generative property test for parser/bijective-contract
changes; PR-2 shipped six example-based files and none. This adds the missing
layer, scoped to what PR-2 actually contracts — WHICH pattern each reader
compiles, decided by the resolved `phase_id_convention` — rather than restating
PR-1's grammar round-trip properties, which already live in
tests/adr-612-bracket-grammar.test.cjs.

Four properties: P1 an opted-in repo reads the ADR-canonical label-less bracket
heading/dir and a non-opted-in repo is byte-blind to the identical input; P2
every non-bracket convention agrees with the hand-transcribed BASE source over
generated content, including bracket-DOTTED legacy prose (`[RFC.2119] 5:`) that
must never be claimed as a phase; P3 nine per-field mutations are rejected and
the one case variation folds instead; P4 both sides of a phase comparison derive
the same key under the same convention.

Generators template every input from raw primitives — nothing is seeded through
renderPhaseId/toDir, the p2() tautology that made #2258 round 1's property test
structurally unable to find B1. Domain reaches past 99 into the 3+-digit branch
(round 2's numArb-capped-at-99 miss), forces sub-phases in at weight, and pins
both sentinel milestones.

Falsified against the COMPILED lib, not the source: five deliberate mutants
(gate never fires; gate always fires; milestone width widened to \d+; the #612
convention forwarding dropped from phaseKeyFromDir; extractPhaseToken's bracket
branch ungated) each fail the specific property that should catch them —
16/2, 16/2, 15/3, 17/1, 17/1 pass/fail — and the lib restores byte-identical.

An earlier draft of P2 held vacuously: its base regex omitted the markdown
furniture the selected one carried, so every realistic `### Phase NN:` line
matched neither side. The gate-always-fires mutant did not kill it. Both are now
compiled through one function, and that mutant kills P2.

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

* test(#2761): adversarial malformed bracket tokens across the tolerant readers

The existing boundary coverage stopped at shapes the emit grammar rejects
(unpadded `[GSD.3]`, wrong-case, `12A`). It never exercised a STRUCTURALLY
broken token — a non-numeric milestone, a bracket that never closes, a bracket
nested in another — which is the input a tolerant reader is most likely to
half-read, and the one the PR's own regex commentary is explicit about.

read-tolerance (roadmap heading scan + validate's dir and variant builders):
ten malformed headings, each asserted to be read as a phase by NO convention and
to give the opted-in repo the same answer as the legacy one; the corpus driven
through `roadmap analyze` end to end; malformed DIRECTORY names asserted
unrecognized and non-throwing on all four conventions; and the two variant
builders asserted to agree, since a widening that reaches only one splits
`validate consistency` from `validate health` (the #3242 Bug B shape).

coherence (verify.cts W021): the same six broken shapes asserted to raise no
W021 of their own AND not to re-scope the W021 that follows them — the G2
failure mode reached from a different shape, where a heading that is not a phase
but IS read as a section silently moves later warnings onto the wrong milestone.

Both files gain a pathological-input time bound. Nested quantifiers over a long
unclosed bracket are the classic ReDoS shape and two commits on next (#2828,
#2944) were CodeQL-flagged for exactly that, so the bound is asserted rather
than argued from reading the pattern. The probes themselves parse no regex.

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

* docs(#2761): document "bracket" as a phase_id_convention value

The row listed only `"milestone-prefixed"` and `null`, so after two shipped
slices (#2258 grammar, this PR's read path) the convention had no documented
enum value. CONFIGURATION.md is also a top-10 historical co-changer of both
src/verify.cts and src/state.cts and was absent from this PR.

The row states the boundary rather than the ambition: `"bracket"` changes the
READ path only, there is no migrator and no emit yet, and a project on any other
value compiles the patterns it compiled before. That keeps the docs honest for
the two releases before PR-3 and PR-4 land, instead of describing a convention a
user cannot yet migrate to.

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

* chore(#2761): retype the changeset Added, drop the docs-exempt marker

`Fixed` was wrong by CONTRIBUTING.md's own definition — a fix restores
documented behavior, and bracket read tolerance is the second slice of a
capability that did not exist before #2258. The type also carried a
`docs-exempt` marker, and `Fixed`/`Security` are exempt from the docs-required
lint, so the typing had the effect of routing around a gate this change should
pass. It now passes it: `lint-docs-required` returns ok_docs_updated on the
CONFIGURATION.md row added in the previous commit.

Body gains one sentence pointing at that row and restating that `"bracket"` is a
read-path opt-in until the migrator and write path land.

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

* test(#2761): pin the version-less bracket milestone scoping gap; narrow the claim

The changeset asserted that milestone scoping "recognises the ADR-canonical
`## [GSD.02] Foundation` heading and applies to the phase DIRECTORIES too." A
CLI probe on that exact heading form falsifies the second half: with no `vN.N`
in the milestone heading the directory side does not scope, and directories from
BOTH the prior and the later milestone are admitted. Measured 4 dirs counted
where the milestone declares 2.

Every bracket fixture in the suite writes `## [GSD.02] v2.0: …`, so nothing
covered the form the ADR actually specifies — and the state.cts doc comment
calls that version-less form canonical.

Mechanism, in extractCurrentMilestone: the bracket scope branch selects the
right currentSection, but `preambleCutoff` keys off a pattern requiring a
version or status emoji, so a version-less roadmap falls back to the current
milestone's own offset and every PRIOR milestone lands in the preamble — whose
phase-stripping regex only strips `Phase N:`-labelled headings, so bracket phase
headings survive it. Independently, `computeSectionEnd` accepts a boundary only
on a version/emoji heading, so the section runs to EOF and every LATER milestone
is swept in. Two sites, bidirectional.

Not fixed here: it changes milestone scoping, which is shared with the legacy
path. Five characterization tests pin today's reading plus a versioned CONTROL
proving the version string is the only difference, and the changeset sentence is
narrowed to what the code does. The DEFECT assertions are written to be
INVERTED by the fix, not deleted — that inversion is its regression proof.

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

* docs(#2761): re-anchor the branch's own cross-file line citations after the rebase

Three of this branch's code comments cite sibling call sites by line number, and
the rebase onto 178ec000 moved two of the three targets:

  roadmap-parser.cts  validate.cts:210 -> :218   (const g = capturing ? 1 : 0)
                      state.cts:1715   -> :1752  (const bg = … 'bracket' ? 1 : 0)
  roadmap.cts         verify.cts:2229  -> :2355  (phaseTokenMatches 3-arg form)

`state.cts:1715` had drifted 37 lines and now lands on the retirement skip, not
the capture-offset idiom the sentence is about — the citation read as evidence
for a claim the cited line does not support.

planning-workspace.cts's `config-loader.cts:618/:649` was checked and is still
correct; left alone.

Comment-only. Build, drift guard and the bracket suites re-run unchanged.

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

* fix(#2761): scope the version-less bracket milestone heading too (B1)

computeSectionEnd and the preambleCutoff scan in extractCurrentMilestone
(roadmap-parser.cts) only recognized a milestone boundary heading that
carried a vN.N token or a status emoji. The ADR-canonical bracket
heading (## [GSD.02] Foundation) carries neither, so on that shape
computeSectionEnd fell through to content.length (sweeping every LATER
milestone into scope) and preambleCutoff fell back to the current
milestone's own offset (leaking every PRIOR milestone's bracket phases
into the preamble, whose Phase-N: strip regex never matches them).

Under the bracket scope branch, both sites now also accept a
`#{1,2}\s+\[CODE.MM\]` boundary, built from phase-id.cts's BRACKET_ID_SRC
(single owner of the bracket-id grammar) rather than a re-typed literal.
`#{1,2}` is the deliberate discriminator: a bracket PHASE heading is
level 3 and shares the same `[CODE.MM]` prefix, so a `#{1,3}` boundary
would swallow it too. Reachable only when bracketScopeConvention ===
'bracket' was already resolved (i.e. the bracket scope branch actually
fired), so version-bearing/emoji headings and non-bracket conventions
take the exact pre-existing code path byte-identically — confirmed by
the full adr-612 suite staying green.

Inverts the four DEFECT assertions in the
"#612 PR-2 CHARACTERIZATION: a version-less bracket milestone does not
scope" describe block (tests/adr-612-bracket-phase-counting.test.cjs)
into their regression-proof form, per the block's own doc comment, and
reframes the describe title/comments accordingly. Corrects the
.changeset/2761-bracket-read-tolerance.md fragment, which described the
directory-side version-less gap as an open, un-closed bound.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): thread sentinelPhases into validate health's W006 loop (B2)

cmdValidateHealth's W006 loop (src/verify.cts) destructured only
roadmapPhases from buildRoadmapPhaseVariants, not sentinelPhases —
unlike cmdValidateConsistency, which already skips sentinelPhases with
the identical guard a few hundred lines up. A heading-only bracket
icebox/pre-milestone entry ([GSD.999] / [GSD.00]) therefore gained a
false W006 "no directory on disk" from validate health while validate
consistency correctly stayed silent on the very same ROADMAP — the two
validators contradicting each other.

Threads sentinelPhases through and skips it before the existsOnDisk
check, mirroring the consistency guard exactly. Gated the same way
sentinelPhases already is (empty unless phase_id_convention is
'bracket'), so a legacy repo's W006 reading — including its own
pre-existing wart where a legacy `### Phase 999:` still warns on both
verbs — is untouched; confirmed by the existing "INHERITED WART,
unchanged" test staying green.

Adds the paired-agreement regression test (#612 PR-2 B2 describe block
in tests/adr-612-bracket-read-tolerance.test.cjs): a sentinel-only
bracket roadmap must produce no missing-directory warning from EITHER
validator, plus a CONTROL proving a real phase with no directory still
warns on both. Confirmed red (health false-W006) against the pre-fix
code before applying the fix.

Corrects the .changeset/2761-bracket-read-tolerance.md fragment, which
described the asymmetry as already closed and in the wrong direction
(it credited validate health with already staying silent, when health
was the one falsely warning).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#2761): pin mixed-shape preamble cutoff and boundary heading levels

Closes two self-flagged coverage gaps in the B1 fix (commit 08d5b0c4)
ahead of adversarial review. No src change — all three new tests are
green against the code as committed.

1. earliest-of-either preambleCutoff comparison: only exercised where
   the version/emoji match and the bracket match happen to land on the
   same heading. Adds the mid-migration mixed shape (version-bearing
   PRIOR + version-less CURRENT) and asserts scoping outcomes (accepts
   booleans + total_phases), not internals.

2. `h.level <= 2` conjunct in computeSectionEnd: provably redundant
   whenever the selected milestone heading is level 2 (every existing
   fixture), since `h.level > level` alone already implies it there —
   a mutant deleting the conjunct would have survived every prior test
   in this file. Adds a level-3 CURRENT-heading fixture (with a real
   PRIOR milestone so the preamble side-channel can't independently
   rescue the truncated phases) that makes the conjunct's deletion
   test-visible, confirmed by hand-mutating a throwaway copy of the
   compiled output (never touching tracked src or the real build) and
   observing the assertion flip. Also pins a level-1 companion case
   (#{1,2} tolerance, not just level 2).

NOT included here: the other mixed-shape direction (version-less PRIOR
+ version-bearing CURRENT) turned out to be a genuine, currently-unfixed
gap — reported separately rather than silently patched or weakened, per
instruction not to touch src while a probe run is in flight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): engage bracket boundaries when the current milestone heading is version-bearing (B3, self-caught)

Found during round-2 self-verification of B1 (commit 08d5b0c4), while
closing the mixed-heading-shape coverage gaps flagged in my own review
notes. The B1 fix resolved `bracketScopeConvention` only inside the
`if (headingMatches.length === 0)` gate that also drives SELECTION's
own bracket fallback (which heading counts as "current"). That gate is
correct for selection, but `bracketScopeConvention` also feeds
computeSectionEnd's and preambleCutoff's boundary detection further
down — which accidentally inherited selection's gate instead of having
its own.

Trigger shape: the CURRENT milestone heading is itself version-bearing
(`## [GSD.02] v2.0: Current Milestone`), so the primary version-string
match succeeds immediately — headingMatches.length !== 0 from the very
first check — and the entire bracket-resolution branch was skipped. A
sibling milestone (PRIOR or LATER) that is version-less then got
neither the version/emoji boundary rule (it has none) nor the bracket
boundary rule (never resolved), reproducing the original #612 defect
(total_phases falling back to the whole-disk count) through a
structural shape B1's own fixtures never exercised — every one of them
is uniformly version-bearing or uniformly version-less across all
three milestones, never mixed with CURRENT specifically being the
version-bearing one.

Fix: resolve `bracketScopeConvention` unconditionally, decoupled from
`headingMatches.length`. SELECTION is deliberately left untouched — the
`if (headingMatches.length === 0 && bracketScopeConvention === 'bracket')`
fallback that picks which heading is "current" keeps its original gate
byte-for-byte (confirmed by diff: that line is unmodified). Only the
convention *resolution* moved out from behind it, so boundary detection
can consult it regardless of which branch selected the heading. The
extra `resolvePhaseIdConvention` call this now costs on every
invocation (previously paid only when the version match found nothing)
is the accepted cost: a non-bracket repo still resolves to something
other than 'bracket' (or null on a poisoned env, caught exactly as
before), so `bracketMilestoneHeadingRe` stays null and every downstream
branch is byte-identical to today — confirmed by the full adr-612 +
roadmap-parser + state + verify + health-validation suite staying green
(1260/1260) and the all-version-bearing/legacy fixtures showing no
behavior change.

TDD: tests/adr-612-bracket-phase-counting.test.cjs describe block
"#612 PR-2 B3: bracket boundaries engage even when CURRENT is
version-bearing but a sibling is not" — 4 tests, confirmed red against
pre-fix code (leak-in booleans true/true, total_phases 4) before this
change, green after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): reject same-milestone continuation headings as boundaries (B1)

Gate-2 adversarial review Blocker 1: the B1/B3 boundary fired on ANY
as the one currently selected — a version-less checklist/detail split
(`## [GSD.02] Foundation (Phase Details)`, or an ad-hoc continuation
heading) truncated the current milestone's own section instead of
being recognised as a continuation of it. The `(Phase Details)`
re-append only searches VERSION-STRING matches, so a version-less
continuation heading was cut out and never re-appended — a confidently
wrong, non-degraded phase count for a still-incomplete milestone
(repro8 case 1: 1/1/100 instead of 2/1/50; repro5: same, on a fully
version-less roadmap with no sibling milestones at all).

Introduces one shared helper, isBracketMilestoneBoundary(headingText,
level, selectedBracketId), used by both computeSectionEnd and the
preambleCutoff bracket scan, replacing the ungated `h.level <= 2 &&
bracketMilestoneHeadingRe.test(...)` inline check. `selectedBracketId`
(case-folded via phase-id.cts's foldBracketId, matching the branch's
own fold-before-identity convention) is derived from `selected[0]`,
which is the full matched heading line on BOTH selection paths
(version-string and bracket-fallback), so one extraction covers both.

Level cap stays at `level > 2` for now (temporary — ADR-612's content
discriminator replaces it in the next commit); same-milestone rejection
is the change this commit is scoped to.

DEVIATION from the reviewed plan, caught empirically: applying the
same-milestone rejection at the preambleCutoff site (as literally
specified) regressed an existing pin ("boundary heading level: a
level-1 CURRENT milestone heading also scopes correctly") and a
fenced-heading case (repro10 A3) — because preambleCutoff's job is
"where does the earliest milestone-shaped heading sit, scanning from
the TOP of the document," and the selected heading's own occurrence is
always a correct answer to that question regardless of same-id-ness;
rejecting it let the earliest-of-either comparison fall through to a
stray LATER heading instead. `selectedBracketId` is threaded through as
`null` at the preambleCutoff call site for this reason — bracket-shaped
(and, from the next commit, phase-tail) discrimination still applies
uniformly at both sites; only the same-milestone component is
call-site-specific, since it encodes a "keep scanning past this
heading" instruction with no counterpart in a top-of-document search.

Tests: new describe block "#612 PR-2 B1 round-2: a same-milestone
continuation heading is not a boundary" — RED-turned-GREEN fixtures for
repro8 case 1 and repro5, plus PINs for repro8 case 3 (trailing
different-id icebox still terminates) and repro10 A1 (all-version-
bearing + icebox + Phase Details stays exactly 2/1/50 — no double-count
from the same-milestone exclusion interacting with the pre-existing
detailsMatch re-append). syncedTotal()/syncedPercent() assertions
omitted from the repro10 A1 pin: that fixture carries dirs outside the
current milestone, which exposes the SEPARATE Major 1 defect
(cmdStateSync's body percent from an unfiltered disk scan) — asserted
once Major 1 is fixed, not here.

Full suite green (796/796 across the targeted adr-612 + roadmap-parser
+ state files); node scripts/lint-phase-id-drift.cjs clean; eslint
clean on both changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): bracket boundary discriminates by content, not heading level (B2)

Gate-2 adversarial review Blocker 2: three sites disagreed about which
heading levels are a bracket milestone. The selector
(roadmap-parser.cts's bracket-fallback SELECTION branch,
`^#{1,3}\s+\[CODE.MM\]`) and `isMilestoneBounded` (state.cts) both
admit level 1-3, but isBracketMilestoneBoundary's level cap only
admitted level 1-2 (`h.level <= 2`, from the B1 commit). A `###`-level
bracket milestone heading was therefore SELECTED and BOUNDED but never
TERMINATED: computeSectionEnd ran with level=3, a level-3 SIBLING
milestone survived the pre-existing `h.level > level` (not-deeper)
filter, failed the version/emoji test (version-less), then failed
`h.level <= 2` — falling through to `return content.length` and
sweeping the sibling milestone's own phases into the current one.
Reproduces trek-e's original #612 defect verbatim ("a safe degrade
became a confidently-wrong persisted number") on a heading level the
selector and bounding predicate both already admit (repro2 case C:
4/75% instead of 2/100%; mechanism confirmed directly via repro7 —
extractCurrentMilestone returned the whole 214-byte document).

ADR-612 Decision 1 (docs/adr/612-bracket-phase-id-convention.md:56)
specifies the discriminator as CONTENT, not level: "a phase heading is
a bracket followed by a digit-then-colon ([GSD.02] 05:); a milestone
heading is a bracket followed by a name." Replaces the `level > 2`
rejection with BRACKET_PHASE_TAIL_RE — built by interpolating
phase-id.cts's single-owner phaseHeadingPrefixSrcFor(ANY_BRACKET,
'bracket', false) plus the digit + optional-tag + colon tail every
phase-heading counter in this file already spells, not a re-typed
grammar — and widens the level check to a depth-sanity cap of 3
(mirroring the selector's own `#{1,3}` ceiling; NOT itself a
phase/milestone discriminator). Covers the dotted sub-phase heading
form (`[GSD.02] 05.03:`) via the same `[\w][\w.-]*` token, pinned by a
new fixture — the shape where a regex slip in the tail grammar would
hide.

preambleCutoff's own raw-scan regex is widened from `^(#{1,2})` to
`^(#{1,3})` in lockstep: the outer pattern's level ceiling must track
the helper's cap, or a level-3 PRIOR milestone heading is invisible to
that scan and its own phase heading leaks into the preamble
un-stripped (a real double-count this widening closes, verified
against repro2 case C directly).

The existing "boundary heading level: a level-3 CURRENT milestone
heading still scopes correctly" pin (3e562f12) now passes via a
DIFFERENT mechanism than before — its own neighbours are version-
bearing, so it previously passed via the version/emoji rule (the level
cap was never actually exercised by that fixture, per the round-2
review's own finding); with the content discriminator, the SAME
fixture's level-3 phase headings are now correctly excluded because
they are phase-tail-shaped, not because they are too deep. A
deliberate mechanism change, confirmed by re-running that test green
after this commit.

Also updates the "every selector call site declares the right
baseline" governance pin (adr-612-bracket-heading-selection.test.cjs):
BRACKET_PHASE_TAIL_RE is a new, legitimate ANY_BRACKET call site in
roadmap-parser.cts (always passing the literal 'bracket' convention,
since its only caller is already gated on bracketBoundaryActive) —
EXPECTED count bumped 1->2, with a matching BASE_SITES transcription
entry (identical src to every other ANY_BRACKET site, since the
function is pure).

Tests: new describe block "#612 PR-2 B2 round-2: the bracket boundary
is a CONTENT discriminator, not a level cap" — RED-turned-GREEN for
repro2 case C (exact total AND truthful percent, since
isMilestoneBounded already returns true at #{1,3}) and repro7's
mechanism, a PIN for the dotted sub-phase form, and a re-pin of repro8
case 3 (icebox) under the new mechanism.

Full suite green (907/907 across the targeted adr-612 + roadmap-parser
+ state + phase-id files); node scripts/lint-phase-id-drift.cjs clean;
eslint clean on all changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): fence-aware preamble cutoff on the bracket branch (Blocker 3)

Gate-2 adversarial review Blocker 3: preambleCutoff's bracket scan used
a raw content.match/matchAll — blind to fenced code blocks — while its
sibling computeSectionEnd (a few lines above it) already consumed
tokenizeHeadings(content), which strips fences. The two halves of one
boundary semantic disagreed about what a heading is.

A fenced markdown example in the preamble containing a bracket heading
(ADR-612's own docs do exactly this) was textually the earliest
`#{1,3} [CODE.MM]` match: preambleCutoff landed INSIDE the fence,
`preamble = content.slice(0, preambleCutoff)` ended with an unclosed
opener, and the unbalanced fence then blinded
getMilestonePhaseFilter's own tokenizeHeadings(scope) call — every
heading in the returned scope vanished, phaseCount degraded to 0, and
the pass-all filter admitted every directory on disk (repro11's
mechanism, confirmed directly: fence count 1/odd, tokenizeHeadings(scope)
-> only "Roadmap"). Regression vs round-1, which had no bracket pattern
to blind and so fell back to the correct heading (repro12 bracket row:
2/1/50 at round-1, 4/3/75 at HEAD).

Fixed by hoisting one tokenizeHeadings(content) call
(currentMilestoneHeadings) shared by computeSectionEnd and the
preambleCutoff scan, which now iterates that same fence-aware token
list instead of a raw regex. HeadingToken.text is already hash-stripped
and trimmed, so isBracketMilestoneBoundary needs no `^#{1,3}\s+`
re-derivation at this site (that spelling would not match h.text — a
note the round-2 review called out explicitly, confirmed while
porting). selectedBracketId stays `null` here, unchanged from the B1
commit's same-milestone-exclusion reasoning.

DISCLOSED, not fixed (explicitly out of scope per the round-2 review's
own minimal-fix note): the LEGACY (non-bracket) anyMilestonePattern
raw-match path shares the identical fence-blindness hazard and stays
byte-identical — a bracket repo whose preamble has a fenced
VERSION-BEARING heading still has the legacy raw-match win the
earliest-of-either min() (repro12's LEGACY control: 4/3/75, unchanged
across base/round-1/HEAD/this commit). Pinned here so a future reviewer
files this as a known, pre-existing gap rather than a new regression.

Tests: new describe block "#612 PR-2 Blocker 3 round-2: preambleCutoff
is fence-aware (bracket branch only)" — RED-turned-GREEN for repro12's
bracket row and repro11's mechanism (fence balance + non-degraded
phaseCount + correct per-directory admission), a PIN for repro12's
LEGACY control (the disclosed gap, explicitly unchanged), and a PIN for
repro10 A3 (a fenced heading INSIDE the current section must still not
terminate it).

Full suite green (1072/1072 across the targeted adr-612 + roadmap-
parser + state + phase-id + markdown-sectionizer files); node
scripts/lint-phase-id-drift.cjs clean; eslint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): scope cmdStateSync's disk scan by milestone under bracket (Major 1)

Gate-2 adversarial review Major 1: `state sync` wrote a Progress
PERCENT computed from an UNFILTERED whole-disk scan, beside the
milestone-scoped total_phases/completed_phases it writes into the same
STATE.md via the refreshed frontmatter (syncStateFrontmatter ->
buildStateFrontmatter, which has always applied getMilestonePhaseFilter
for the READ path). cmdStateSync's own `fs.readdirSync` chain (the
WRITE-path scan) never called the milestone filter at all, unlike
buildStateFrontmatter's identical-purpose scan. One command therefore
wrote two contradictory numbers into one file: on the ADR-canonical
version-less bracket fixture (4 dirs, 3 complete; asserted milestone =
2 phases, both complete), base wrote total_phases:2/completed_phases:2
(correct, from the READ derivation) alongside body Progress 75% (wrong
— from the unfiltered WRITE derivation; repro3).

Fixed by threading `getMilestonePhaseFilter(cwd)` through the same
`.filter()` chain buildStateFrontmatter already applies, gated on
`syncConvention === 'bracket'` (falling back to a pass-all predicate
otherwise) — so totalDiskPlans/totalDiskSummaries/diskCompletedPhases/
syncTotalPhases become milestone-scoped under bracket, byte-identical
under legacy.

DEVIATION (approved, stated plainly): an earlier phrasing of this fix
called for mirroring buildStateFrontmatter's filter UNCONDITIONALLY.
Implemented GATED instead — an unconditional filter would ALSO move
every LEGACY repo's persisted percent, since the milestone-scoping-vs-
whole-disk divergence this closes is engine-wide, not bracket-specific.
The gate keeps legacy byte-identical, which is the binding constraint:
this is a bracket read-path PR, not a legacy behavior change.

Nit 2 (informational, no code change): 10 calls to
extractCurrentMilestone on a legacy repo cost 10 config.json
existsSync + 10 readFileSync (0 before B3); accepted, unmemoized cost,
unaffected by this commit.

Also folds in two minors from the round-2 review:
- Corrects .changeset/2761-bracket-read-tolerance.md: the sibling-
  exclusion sentence now states it holds at any heading level 1-3 and
  across a milestone split over two headings (true again now that
  Blockers 1 and 2 are fixed); the percent sentence states plainly that
  `state sync`'s body percent is now milestone-scoped under bracket,
  and unaffected under legacy.
- Records the read/write scoping divergence at currentMilestoneRawRanges
  (src/roadmap-parser.cts) in a comment: it did not receive B1/B2's
  bracket boundary fixes, currently harmless (its only consumer falls
  back to whole-content mutation, and every mutation there is still
  Phase-labelled-only, not bracket-widened), but live the moment the
  write path is bracket-widened — flagged so a future PR closes it in
  lockstep with that work, not after.

Tests: 6 pre-existing tests in tests/adr-612-bracket-phase-counting.test.cjs
needed fixture updates, not logic changes — they used the default
single directory (`GSD.02-01-setup`, phase "01"), which the SENTINEL/
retirement/mixed-heading fixtures in those tests never declare as a
real phase (only 04/05/06/999/etc are declared). Before this fix,
cmdStateSync's unfiltered scan counted that off-roadmap directory
anyway; after this fix the milestone filter correctly excludes it,
which for several of these fixtures made `state sync` a no-op (the
computed 0% coincided with STATE.md's initial template default) and
broke `syncedTotal()`/`syncedPercent()`'s ability to observe anything.
Updated each to pass an EXPLICIT directory naming one of the fixture's
REAL declared phases, preserving each test's original numerator/
denominator intent. One test — "shape 2 WRITE" — was substantively
rewritten: it was a CHARACTERIZATION of the Major 1 bug itself ("the
DISCLOSED legacy gap, mirrored — not closed"), and now correctly pins
bracket closing to 33% (agrees with the read path) while legacy stays
at the disclosed 60% (unchanged, deliberately, per the gating decision
above).

Full suite green: `npm test` 1449/1449 (0 fail, 0 skipped, 0 todo,
single-shard "all" run — includes issue-2765-brace-expansion-lockfile
passing); `npm run lint:ci` clean (0 errors; 2 pre-existing timing-
assertion warnings in files this PR does not touch); node
scripts/lint-phase-id-drift.cjs clean; node scripts/changeset/lint.cjs
ok.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): preambleCutoff identity is offset- and child-aware (round-3 Blocker 1)

Gate-2 round-3 re-verify Blocker 1 (NEW): the round-2 B1 deviation
(39c42a89) threaded `selectedBracketId` as the real value at
computeSectionEnd but as bare `null` at the preambleCutoff scan. The
deviation's rationale — "the selected heading's own occurrence is
always a correct earliest answer" — was right, but `null` disables the
same-milestone check for EVERY candidate, not just the selected one.
Any bracket-shaped heading earlier than the selected milestone was
accepted as a boundary regardless of identity: a same-id checklist/
overview heading preceding the version-bearing selected heading (cases
A, B — the version lands on the LATER half of a split, or a plain
overview heading with no "(Phase Details)" spelling), or a DIFFERENT-id
bracket-shaped PROSE heading with no children of its own sitting above
the current milestone's content (case D — `## [ADR.612] Heading
convention used by this roadmap`). In every case the region between
that false boundary and the real sectionStart was silently dropped —
a completed phase vanished and `state sync` persisted a confident 0%
where base and round-1 both correctly wrote 50%. Regression vs base
AND round-1 (not merely "under-fixed", per the round-3 review's own
severity note).

Fixed with two changes, both scoped to the preambleCutoff scan only
(computeSectionEnd already threads the real `selectedBracketId` and is
untouched):

(a) `h.offset === sectionStart` now bypasses BOTH the same-milestone
    check inside isBracketMilestoneBoundary (passing the REAL
    `selectedBracketId` for every other candidate) and the new child
    rule below — the selected heading's own position is definitionally
    the correct answer, so neither discriminator should run against it
    (rejecting it would mean rejecting the heading against ITSELF).
    Closes cases A and B — verified by the reviewer's own one-liner,
    reproduced here.

(b) New `bracketHeadingHasMatchingChild`: an otherwise-accepted
    candidate (bracket-shaped, not phase-tail-shaped, not the same id
    as the selected milestone) must ALSO have a next-strictly-deeper
    heading carrying its OWN bracket id to count as a boundary. This is
    what a genuine sibling milestone has (its own phase children share
    its bracket id — `## [GSD.01] Setup` / `### [GSD.01] 01: …`) and an
    unrelated bracket-shaped prose heading does not. A candidate with
    no such child at all (childless — e.g. an empty prior milestone, or
    one immediately followed by a same-or-shallower heading) degrades
    to NOT a boundary — over-inclusive, the safe direction: its own
    heading text stays in the preamble, contributing nothing to any
    phase count (not phase-shaped). Closes case D, which (a) alone does
    not — verified: without this rule, `[ADR.612]`'s prose heading is
    indistinguishable from a genuine prior sibling at this site.

As a side effect, also neutralizes Nit 2 (a colon-less `[GSD.02] 05`
heading spuriously terminating the preamble): a colon-less bracket
heading is not phase-tail-shaped so isBracketMilestoneBoundary alone
would accept it, but it is — precisely because it is malformed/
incomplete rather than a real milestone — childless, so the child rule
rejects it too. Pinned.

Known interaction with the fence-blind SELECTION path (disclosed by
the reviewer, not introduced here, tracked for the next commit): when
`sectionPattern` selects a FENCED version-bearing heading (an
extremely pathological shape — a fenced example whose text happens to
match STATE's asserted version), no token exists at `sectionStart`, so
the `h.offset === sectionStart` bypass never fires and the loop falls
through to the ordinary same-id / child-rule checks. This composes
with the round-3 Major 1 fix (next commit) rather than introducing a
new defect — SELECTION itself is untouched by any of this — but is
worth stating plainly rather than rediscovering.

Tests: new describe block "#612 PR-2 Blocker 1 round-3: preambleCutoff
identity is offset- and child-aware" — RED-turned-GREEN for cases A, B
(rv-attack1) and D (rv-attack1b) with syncedTotal()/syncedPercent()
assertions (the persisted 0% is the point), a PIN for a genuine prior
sibling with real children (still excluded), a PIN for a childless
prior sibling (degrades to not-cutting, over-inclusive/safe), a PIN
for the colon-less Nit 2 shape, and the reviewer's rv-mech1 mechanism
re-run as a proper test (scope now equals the full input document,
phaseCount 2, both dirs accepted).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 937/937 pass (930 baseline + 7 new). node
scripts/lint-phase-id-drift.cjs clean; eslint clean on both changed
files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): fence-aware version/emoji half of preambleCutoff on the bracket branch (round-3 Major 1)

Gate-2 round-3 re-verify Major 1 (NEW): ff6bf0a8 (round-2 Blocker 3)
made the BRACKET half of preambleCutoff's "earliest milestone-shaped
heading" search fence-aware, but left the VERSION/emoji half a raw
`content.match` even on the bracket branch. A fenced VERSION-BEARING
example heading in a bracket repo's preamble (ADR-612's own docs
illustrate the LEGACY heading shape exactly this way, inside a fenced
authoring-guide block) was still textually the earliest match for that
raw regex, winning the min() and un-suppressing a wrong persisted 75%
that base correctly suppressed (rv-attack3c fixture C1: base
suppressed the percent entirely — `isMilestoneBounded` false — HEAD
wrote 75% where truth is 50%).

Fixed by deriving the version/emoji half from the SAME fence-aware
`currentMilestoneHeadings` token list as the bracket half, on the
bracket branch only — the exact `/^Phase\s+\S/i` / `/v\d+\.\d+|✅|📋|🚧/i`
pair `computeSectionEnd` already uses against `h.text`. The non-bracket
(legacy) path is untouched: it keeps the raw `content.match`, byte-
identical to before, including its own fence-blindness (repro12's
LEGACY control, pinned unchanged in the round-2 Blocker-3 test block —
not re-pinned here to avoid duplicating an already-covered assertion).

Not rated Blocker (per the review) because it is not a regression vs
round-1 and the fixture (a version-BEARING fenced example in a bracket
repo) is rarer than the already-fixed bracket-heading case; still
fixed now rather than disclosed, per this arc's own precedent (every
prior "disclose instead of fix" call in this PR has been overturned on
re-review).

Tests: new describe block "#612 PR-2 Major 1 round-3: preambleCutoff's
version/emoji half is fence-aware on the bracket branch" — RED-turned-
GREEN for case C1 (readTotal + syncedPercent, so the persisted 75% is
directly observed, not just the read-path total), PIN for case C2 (the
already-fixed fenced-bracket-heading shape, unchanged).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 939/939 pass (937 + 2 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean on both changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): correct changeset claims + mark runtime-gated BASE_SITES row (round-3 minors)

Gate-2 round-3 re-verify Minor 2: three changeset sentences in
.changeset/2761-bracket-read-tolerance.md were overstated in a new
direction after round-2:

- The split-milestone claim ("across a milestone split over two
  headings") was true only when the version-bearing heading came
  FIRST (repro8 case 1); false when it came LATER (round-3 Blocker 1
  case A). Now restated to say plainly "with the version-bearing
  heading in EITHER position" — true again now that round-3's Blocker
  1 fix lands earlier in this range.
- The "counted from the phases... rather than from every directory on
  disk" claim was false on cases A/B/D (a strict subset of the
  milestone's own phases). Restated as "ALL of the phases... not a
  subset", and extended to state that an unrelated bracket-shaped
  heading with no phase children of its own (case D's `[ADR.612]`
  shape) does not truncate the milestone either — true now, not before.
- "Each widened read is SELECTED by the project's phase_id_convention"
  was literally false for BRACKET_PHASE_TAIL_RE, which is RUNTIME-gated
  (via its only caller, isBracketMilestoneBoundary, itself only
  consulted when bracketBoundaryActive) rather than selector-gated.
  Restated behaviourally: "every widened read ENGAGES only when the
  project's resolved phase_id_convention is bracket" — true for both
  gating mechanisms, so it no longer implies a selector call this site
  does not make.

Minor 1: the STRUCTURAL IDENTITY test's BASE_SITES row for
BRACKET_PHASE_TAIL_RE (added in the B2 commit) asserts a property of
`phaseHeadingPrefixSrcFor` — the function — not of the call site; it
would pass unchanged even if the site were deleted. Safety at that
specific site rests entirely on a runtime gate the test cannot see.
Added `runtimeGated: true` to the row and threaded it into the
generated test's own title (`… [runtime-gated, not selector-covered]`),
so the gating mechanism is visible in test OUTPUT, not only in a source
comment that could drift silently.

No production code changed. Targeted suite green (48/48 in the
affected file); `node scripts/changeset/lint.cjs` ok; eslint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): harden round-3 preamble cutoff — subtree child scan + level cap

Team-lead review of f87bba0e found two edges in the round-3 preamble-
cutoff code, both hardened here.

AMENDMENT 1 — bracketHeadingHasMatchingChild (2e06aef5) checked only
`headings[index + 1]`, the IMMEDIATE next heading, not the candidate's
whole subtree. A genuine prior sibling milestone whose section opens
with a non-bracket subsection before its first phase heading
(`## [GSD.01] Setup` / `### Notes` / `### [GSD.01] 01: Old`) was
therefore wrongly rejected as a boundary — its real phase heading sits
TWO headings deep, not one — leaking its entire section into the
preamble unstripped.

CONFIRMED RED, not merely theoretical (built and ran the fixture
against f87bba0e before touching the fix, per instruction): scope
membership DOES drive the disk-side filter on this shape.
`GSD.01-01-old`'s directory was wrongly admitted into the CURRENT
milestone's filter via the leaked heading's qualified key
(`GSD.01-01`) — 3/2/67% where truth is 2/1/50%.

Fixed by scanning the candidate's full SUBTREE: continue past a
non-matching deeper heading instead of returning false on the first
one; only a same-or-shallower heading actually closes the subtree and
yields "no match found". A candidate whose entire subtree closes with
no same-id hit (including a genuinely childless one) still degrades to
`false` — over-inclusive, safe, unchanged from before.

AMENDMENT 2 — c483552a ported the version/emoji half of preambleCutoff
to the token-based scan with no level cap; the raw
`content.match(anyMilestonePattern)` it replaced was anchored
`^#{1,3}\s+`. A level-4+ version-bearing heading in the preamble
(`#### v2.0 notes`) therefore won the scan on the bracket branch where
the raw pattern — and the legacy path, unaffected — ignores it
outright. Fixed with `if (h.level > 3) continue;`, mirroring the
depth-sanity cap isBracketMilestoneBoundary already applies to the
bracket half of this same scan.

Tests: new describe block "#612 PR-2 round-3 hardening: subtree child
scan + level cap on preambleCutoff" —
- RED-turned-GREEN for the Notes-intervening fixture: exact 2/1/50 (was
  3/2/67), plus the disk-filter observable (`GSD.01-01-old` now
  correctly excluded).
- PIN for the level-4 preamble heading: the scope now PRESERVES the
  heading's text (was silently dropped before this fix — harmless in
  this minimal fixture's total_phases specifically, since the dropped
  text carries no phase-shaped content, but a real correctness gap
  against the raw pattern's own ceiling) — asserted via scope content,
  not total_phases, since that number is invariant here either way.
- PIN for the LEGACY control on the same level-4 shape — unchanged,
  confirming the raw content.match path is untouched.

Re-verified the existing genuine-prior-sibling and childless-sibling
pins (round-3 Blocker 1 commit) still pass under the subtree scan —
both fixtures' outcomes are unchanged since their same-id hit (or its
absence) was already at the first deeper heading.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 942/942 pass (939 + 3 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean on both changed files. Full `npm test` + `npm run
lint:ci` deferred to the team lead's own run per instruction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): bracketHeadingHasMatchingChild requires a same-id PHASE child (round-4 Blocker 1)

Gate-2 round-4 re-verify Blocker 1 (NEW): the round-3 hardening's
subtree scan (fbfd0fca) proved SAME-ID-NESS but never asked whether
the matching child was PHASE-shaped. Case F1 re-opens round-3's case D
one heading later: `## [ADR.612] Heading convention` is followed by
its OWN sub-heading `### [ADR.612] Examples` — same bracket id as the
candidate, but MILESTONE-shaped (a name, no digit-then-colon), not a
phase. Same-id-ness alone satisfied the subtree scan and re-cut the
preamble at exactly the shape the round-3 hardening was written to
close.

Failing input: `## [ADR.612] Heading convention` / `### [ADR.612]
Examples` (prose) / `### [GSD.02] 01: One` (the current milestone's
own first phase, now unreachable) / `## [GSD.02] v2.0: Foundation` /
`### [GSD.02] 02: Two`. Truth 2/1/50. HEAD read 1/0/0, and `state sync`
reported "nothing to do" (exit 0, `{synced:true,changes:[]}`) because
its wrong 0% happened to equal the STATE.md seed — a half-done
milestone read as untouched with no write-path signal at all.

Fixed with the reviewer's one-conjunct addition: a same-id child only
counts if it is ALSO phase-tail-shaped (`BRACKET_PHASE_TAIL_RE`) — the
same single-owner discriminator `isBracketMilestoneBoundary` already
uses one level up for the identical distinction (phase vs milestone),
reused here rather than re-derived. This is exactly what the
changeset's own wording already claimed ("no phase children of its
own") — the code now matches the sentence rather than the other way
around.

Docstring updated at the function itself: the rule is "same-id PHASE
child", not "same-id child".

Tests: new describe block "#612 PR-2 round-4 Blocker 1: the same-id
child must be PHASE-shaped" — RED-turned-GREEN for F1 with
syncedTotal()/syncedPercent() (the persisted 0% — and the
report-nothing-to-do write-path silence — is the point), PINs for F11
(colon-less same-id child) and F11b (bullet-only phase list): both
correctly stay excluded either way, and the leak the phase-shape
requirement newly creates for these two shapes is INERT — a colon-less
heading forms no qualified key (getMilestonePhaseFilter's own
phase-heading pattern requires the colon too) and a bracket bullet
never matches the legacy-only BULLET_PHASE_LINE_PATTERN — confirmed
directly via getMilestonePhaseFilter, not merely inferred. Re-verified
the four existing child-rule pins (F2 subtree-closure, F3 deep-nested
same-id, F4 level-4 same-id, F5 childless-at-EOF) are unaffected by the
phase-shape requirement, since every one of them already used a
colon-bearing same-id child.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 946/946 pass (942 + 4 new). Zero drift measured across the full F1-F12
corpus except F1 itself (F9/F10/F12 remain red, deferred to the
separate Major 1 fix). node scripts/lint-phase-id-drift.cjs clean;
eslint clean on both changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): fence-aware phase counting, milestone bounding, and bracket-fallback selection (round-4 Major 1)

Gate-2 round-4 re-verify Major 1 (NEW): roadmapPhaseCount is a
fence-blind raw `.exec()` over the scope string, duplicated in TWO
independent copies (buildStateFrontmatter's read path, cmdStateSync's
write path). With the bracket alternative now compiled into it (#612),
a fenced EXAMPLE phase heading in the preamble inflates total_phases
and persists a wrong percent that base got right. Two further
fence-blind sites participate: isMilestoneBounded (a raw
`.test(roadmapRaw)`) and the bracket-fallback SELECTOR inside
extractCurrentMilestone (a raw `content.matchAll`, only reachable when
version-string selection finds nothing).

Failing inputs:
- F10 (clean isolate, version-bearing selection): a fenced
  `### [GSD.02] 05: Example phase` in the preamble inflates
  total_phases 2->3, persisting 33% where truth is 50%. LEGACY control
  on the same shape is correct on every build — not a pre-existing
  hazard being inherited, bracket-only.
- F9 (version-less selection): a fenced example carrying the project's
  OWN milestone id additionally confuses the bracket-fallback selector
  (the fenced heading gets SELECTED), compounding with the same
  fence-blind counter. base suppressed the percent; round-1 and HEAD
  both wrote 33%.
- F12 (isMilestoneBounded isolate): the ONLY `[GSD.02]` heading in the
  document is inside a fence, and the asserted milestone genuinely has
  no section at all — HEAD persisted 67% where base correctly
  suppressed the percent (the milestone is absent from the roadmap).

Fixed at the CONSUMER level, not the producer — extractCurrentMilestone's
returned scope string is deliberately UNCHANGED, since every other
consumer of that string needs its full content fidelity and legacy
identity forbids touching the shared string (this branch's own
precedent, ff6bf0a8/c483552a, was producer-level; here the ruling is
consumer-level because the string is shared far more broadly than the
two round-3 fixes' narrower producer edits):

(a) New `countRoadmapPhaseHeadings` (src/state.cts, immediately above
    extractRetiredPhaseNumbers) — ONE shared implementation for both
    call sites, replacing two independently-maintained copies. BRACKET
    convention counts via `tokenizeHeadings(scope)` at levels 2-4,
    testing each heading's hash-stripped text directly — fence-aware by
    construction, since tokenizeHeadings never produces a token for a
    fenced line. LEGACY convention keeps the exact pre-existing raw
    `.exec()` loop, byte-for-byte. A pre-existing, deliberately
    PRESERVED asymmetry between the two original call sites — the read
    path always excluded a bare `/^999\b/` token, the write path never
    did — is threaded through as an explicit
    `includeUnconditional999Check` parameter per call site, so sharing
    the implementation does not silently unify (and thereby move)
    either total.
(b) isMilestoneBounded's bracket branch now scans
    `tokenizeHeadings(roadmapRaw)` for a matching heading (level <= 3)
    instead of a raw regex test. Legacy version-string branch untouched.
(c) The bracket-fallback SELECTOR now builds its candidate set from
    `tokenizeHeadings(content)` instead of `content.matchAll`,
    reconstructing a match-shaped array so every downstream consumer of
    `headingMatches` sees the identical shape the raw-regex path always
    produced. This is the ONE site in this entire arc where SELECTION
    itself changes — selection SEMANTICS are otherwise unchanged (same
    pattern, same first-match-wins by document order); only the
    candidate set is now fence-aware. Pinned that unfenced selection is
    byte-identical.

Zero drift measured across the full historical corpus (repro2-13,
rv-attack1/1b/3c, rv-mech1, rv2-amend1/2, and F1-F11b) except the three
target fixtures.

Tests: new describe block "#612 PR-2 round-4 Major 1: four fence-blind
sites on the bracket path" — RED-turned-GREEN for F10 (with
syncedTotal()/syncedPercent()) and F9 (both layers), PINs for F10's
LEGACY control and F10c (non-phase-shaped fence, unaffected either
way), RED-turned-GREEN for F12 (asserts the percent KEY is absent from
`state json`'s output and that `state sync`'s body stays at its
unmodified seed — the persisted-suppression signal, not merely a
total_phases number), and an explicit PIN that unfenced bracket-fallback
selection (first real milestone-shaped heading wins, no fences
involved) is unaffected.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 952/952 pass (946 + 6 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean on all three changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): correct docstring overstatement + stale consumer-count sentence (round-4 minors)

Gate-2 round-4 re-verify Minor 1 + Nit 1. No production code changed.

Minor 1: bracketHeadingHasMatchingChild's own docstring said a
rejected (no-same-id-PHASE-child) candidate's degrade "contributes
nothing to any phase count" — true of the candidate's OWN heading
text, but not of its SUBTREE, which is what actually stays in the
preamble. F7 (`## [GSD.01] Setup` / `### [GSD.07] 01: Foreign`) shows
a DIFFERENT-id bracket PHASE heading inside a rejected candidate's
subtree DOES form a qualified key and CAN admit a foreign directory —
3/2/67%, stable across base, round-1 and HEAD (base via its own
pass-all degrade). Not a regression, still the declared over-inclusive
/ never-under-inclusive safe direction — the comment now says that,
with F7's numbers cited, at the call site that actually decides
`isBoundary` (roadmap-parser.cts's preambleCutoff loop) rather than
only at the helper's own definition.

Minor 2 (changeset) — VERIFIED, no wording change needed: re-ran F1,
F9, F10, F12 at this HEAD. The "no phase children of its own... does
not truncate it either" sentence (naming the `[ADR.612]` shape
directly) is now literally true — F1 reads 2/1/50. The "counted from
ALL of the phases... not a subset" sentence is now true on every
measured shape — F1/F9/F10 all read 2/1/50, F12 correctly suppresses
the percent. No carve-out for F9/F10 is needed since round-4 Major 1
(3be5c412) closes both; per the fix-round instruction to "only carve
out anything genuinely left," nothing is.

Nit 1: tests/adr-612-bracket-heading-selection.test.cjs's runtimeGated
row claimed `BRACKET_HEADING_INTRO_RE` has "no other consumers" — true
when round-3's f87bba0e wrote it, stale since 2e06aef5 (round-3's own
earlier commit) had already added two more uses inside
bracketHeadingHasMatchingChild. Corrected to state the true count
(three consumers) and re-confirm the conclusion is unaffected: all
three are still nested inside the same bracketBoundaryActive runtime
gate, and BRACKET_HEADING_INTRO_RE is built from BRACKET_ID_SRC, not
phaseHeadingPrefixSrcFor, so it was never a selector site regardless of
consumer count.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 952/952 pass (comment-only changes, no count movement). eslint
clean; node scripts/changeset/lint.cjs ok.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): restore the bracketId guard in countRoadmapPhaseHeadings (round-5 Blocker 1)

Gate-2 round-5 re-verify Blocker 1 (NEW, introduced by 3be5c412): the
merge that created the shared countRoadmapPhaseHeadings helper dropped
the `bracketId && ` guard both original inline loops carried before
calling isSentinelPhaseId. Every other isSentinelPhaseId call site in
src/ (roadmap-parser.cts, roadmap.cts, validate.cts x2, verify.cts)
keeps the guard; state.cts's shared counter was the only one of seven
without it.

When the phase-heading-intro grammar's LEGACY alternative matches (a
`### Phase 00:` heading in a `phase_id_convention: "bracket"` repo —
the mid-migration shape this PR exists for), the bracket capture group
is `undefined`, so the unguarded call became
`isSentinelPhaseId("undefined-00", 'bracket')` — measured TRUE, so
phase 00 (and 000, 0a, 0.5, 999.1 — any token whose splice with the
literal string "undefined" happens to fall in a sentinel range) was
silently dropped from the denominator. `getMilestonePhaseFilter` (which
still carries its own guard) counts the phase and admits its directory
regardless, so the filter and the counter disagree — a half-done
milestone reads as 100% complete, persisted.

One-line fix, restoring the guard every sibling call site already has:

    if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket')) continue;

Line count: `git diff --stat src/state.cts` -> 1 file changed, 1
insertion(+), 1 deletion(-).

Tests: new describe block "#612 PR-2 round-5 Blocker 1:
countRoadmapPhaseHeadings restores the bracketId guard" — RED-turned-
GREEN for G3 (3/2/67, was 2/2/100) and G3d (the mixed bracket+legacy
mid-migration shape, same numbers) with syncedTotal()/syncedPercent(),
PIN for G3's legacy control (unaffected), PIN for G3b (isolates the
counter with no directory to admit), PIN for G3c (legacy 01/02 only,
no sentinel-shaped token present).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 957/957 pass (952 + 5 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): extractRetiredPhaseNumbers is fence-aware on the bracket path (round-5 Major 1)

Gate-2 round-5 re-verify Major 1 (NEW) — a FIFTH fence-blind site on
the bracket path, missed by 3be5c412's own enumeration of "four".
extractRetiredPhaseNumbers' line scan (`scope.split(/\r?\n/)`) has no
fence awareness. This PR compiles the bracket alternative into
`introSrc` ("the retirement filter has to widen with the counter it
protects" — the function's own pre-existing comment), so a FENCED
authoring EXAMPLE showing the #1514 retirement gesture in bracket
spelling is now indistinguishable from a real one: it retires a
genuine phase, shrinking the denominator and persisting a confident
100% where base correctly read 50%.

Fixed with the SAME consumer-level ruling this arc has used at every
other fence-blind site, reusing markdown-sectionizer's existing
exported `stripFencedCode` rather than hand-rolling a second fence
parser (single-owner rule) — retirement lines are BULLETS, not
headings, so `tokenizeHeadings` doesn't serve here; `stripFencedCode`
is the general-purpose fence stripper the tokenizer itself is built on.
Gated on `convention === 'bracket'`; the LEGACY line scan stays the raw
`scope` string, byte-identical — its own fenced-example hazard is
pre-existing (wrong at base too) and out of scope.

Line count: `git diff --stat src/state.cts` -> 1 file changed, 9
insertions(+), 2 deletions(-) — one import added, four lines inside
the function (a comment + the `scanScope` computation + the changed
`.split()` call).

Tests: new describe block "#612 PR-2 round-5 Major 1:
extractRetiredPhaseNumbers is fence-aware on the bracket path" —
RED-turned-GREEN for G2 (fenced example in the preamble) and G2b (the
same example placed INSIDE the milestone section, ruling out a
preamble-scoping artifact — the site itself was fence-blind wherever
the fence sits), PIN for the LEGACY control (unchanged, pre-existing,
out of scope — base is wrong on this shape too).

Also folds in the "four fence-blind sites" correction: 3be5c412's
commit message and any restatement of it should read FIVE going
forward; this commit's own message states the count correctly.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 960/960 pass (957 + 3 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): state sync's counter excludes the bracket 999 icebox token (round-5 Major 2)

Gate-2 round-5 re-verify Major 2 (NEW) — `includeUnconditional999Check`
left `state json` and `state sync` reporting different totals for one
bracket repo. Under bracket, READING-B puts the sentinel in the
bracket, so `isSentinelPhaseId("GSD.02-999", 'bracket')` is false —
the `/^999\b/` TOKEN rule is the only thing excluding a
`### [GSD.02] 999:` icebox heading, and it ran on the read path
(buildStateFrontmatter, `true`) and on getMilestonePhaseFilter
(unconditional), but not on cmdStateSync's own counter (`false`). One
`state sync` call could leave a single STATE.md with its own
frontmatter (percent 50, from the read-path re-sync inside
writeStateMd) and body (percent 33, from the write-path counter that
alone still counted the icebox heading) disagreeing — falsifying this
PR's own stated invariant that sharing countRoadmapPhaseHeadings made
"the two counters must see the same phases" structural.

Functional change is one argument, exactly as specified: the write
call site now passes `syncConvention === 'bracket'` instead of the
literal `false`. `syncConvention === 'bracket'` is `false` for every
non-bracket value, so the LEGACY path resolves to the exact same
`false` it always did — this file's own pre-existing, deliberately-
unchanged read/write divergence on that path is untouched. The READ
site (`:1860`) is NOT touched — its historical behaviour applied
`/^999\b/` to legacy and bracket alike, so changing it would move
legacy READ totals, exactly the class of mistake this arc's own
Blocker 1 (this round) was.

Line count: the functional change is ONE argument
(`false` -> `syncConvention === 'bracket'`); the surrounding comment
was rewritten because the previous one asserted the now-superseded
behaviour ("preserving this file's pre-existing... divergence... a
bare 999 token is not excluded here") and leaving it would mislead the
next reader — not a structural change.

Tests: new describe block "#612 PR-2 round-5 Major 2: state sync
excludes the bracket 999 icebox token like the read path" —
RED-turned-GREEN for G1, asserting `state sync`'s body percent equals
`state json`'s own percent (both 50, not 33 vs 50), PIN for G1's
legacy control (33 vs 50 unchanged, deliberately).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 962/962 pass (960 + 2 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): restore indent parity at the two line-start-anchored bracket-heading reconstructions (round-5 Minor 1)

`HeadingToken.offset` (tokenizeHeadings) is the LINE-START character offset,
not necessarily the `#` character's own offset — a ≤3-space-indented ATX
heading has both. Two round-4 reconstructions built on tokenizeHeadings
inherited this gap and accepted indented headings their raw, line-start-
anchored predecessors (`^#{1,3}\s+\[...`) never matched:

  1. roadmap-parser.cts's bracket-fallback SELECTOR (extractCurrentMilestone,
     ~line 377) — an indented, version-less `[GSD.02]` milestone heading
     could be reconstructed into headingMatches, then mis-parsed downstream
     (selectedBracketId null, level fallback to 1), leaking a SIBLING
     milestone's phases into the counted scope (G6: HEAD read 3/2/67 instead
     of 2/1/50 — a real phase heading's own directory belonging to the NEXT
     milestone got counted).

  2. state.cts's isMilestoneBounded (~line 1571) — the same gap let an
     indented-only `[GSD.02]`-shaped heading wrongly bound a milestone
     absent from the roadmap, un-suppressing a percent that should stay
     suppressed (mirrors round-4's F12 fenced-only case, but via indentation
     instead of a fence).

Fix: one added conjunct per site — `content[h.offset] === '#'` (source-named
`roadmapRaw` in state.cts) — filtering to tokens whose LINE-START offset IS
the `#` character, i.e. exactly the set the raw line-start-anchored regex
would ever have matched. Restores byte-for-byte raw parity; no other logic
in either function changes. computeSectionEnd and the preamble version/
emoji-token scan are untouched, as instructed — they consumed tokenizeHeadings
output before this arc and are out of scope here.

Also corrects roadmap-parser.cts's now-provably-false docstring claim that
`h.offset` is unconditionally "the same `#`-character coordinate space
`content.match().index` used" — true only for the survivors of the new
filter, not for every token tokenizeHeadings produces.

Line count: the FUNCTIONAL change is exactly 2 lines (one added `&&` conjunct
per call site — `git diff --stat` on the two source files shows 20
insertions/7 deletions, but only those 2 lines change behavior; the rest is
docstring/comment rationale, per this round's "state the line count" ask).

TDD: both fixtures verified RED at HEAD before this commit, GREEN after,
via /tmp/pr612rev/rv5-attack.cjs G6 and a locally-authored isMilestoneBounded-
isolating probe (G6 alone doesn't distinguish the two sites — its unindented
phase headings already satisfy isMilestoneBounded's loose prefix regex
either way, so a second, indentation-only fixture was needed to prove that
site's fix is not a no-op; verified by temporarily reverting just that one
conjunct, confirming 100%-wrongly-bounded RED, then restoring it, confirming
suppressed-percent GREEN).

New tests (adr-612-bracket-phase-counting.test.cjs):
  - RED (G6): indented version-less bracket milestone heading — 2/1/50, not
    the pinned-before-fix 3/2/67.
  - PIN (G6c unindented control): identical document, no indent — 2/1/50
    unaffected on every build.
  - RED (isMilestoneBounded site, indented-ONLY): mirrors round-4's F12
    shape (fenced-ONLY → indented-ONLY) — percent stays suppressed instead
    of the pinned-before-fix wrongly-bounded 100%.

Full G1-G10 (rv5-attack.cjs) + G2b/G3c/G3d/G6c (rv5b.cjs) re-verified
zero-drift against TRUTH after this change. Targeted suite (adr-612-*,
roadmap-parser, state, verify): 962 -> 965 (+3), 0 fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): changeset discloses counting-set narrowing + correct stale consumer-count sentence (round-5 minors)

FIX 5 (Minor 2): .changeset/2761-bracket-read-tolerance.md did not disclose
that bracket phase-heading counting is narrower than the raw-regex
predecessor in two ways the review's G4/G5 fixtures surfaced: a level-5
heading (`##### [GSD.02] 05: ...`) is no longer counted (the counter's
tokenizeHeadings scan caps at level 4, matching the selector/isMilestoneBounded
ceiling), and a space-less heading (`###[GSD.02] 05: ...`) is no longer
counted (CommonMark requires ≥1 space/tab after the hashes, which
tokenizeHeadings correctly enforces and the old raw regex did not). Both
G4 and G5 moved from round-1's wrong (inflated) values back to base's
original values as an incidental side effect of routing through
tokenizeHeadings — never a deliberate feature of this PR, and previously
undocumented. One clause added to the existing run-on paragraph; no other
wording in the changeset touched.

FIX 6 (Nit 1): tests/adr-612-bracket-heading-selection.test.cjs:76 —
`BRACKET_PHASE_TAIL_RE` has TWO consumers as of round-4's 65d257ce
(isBracketMilestoneBoundary's own use, plus bracketHeadingHasMatchingChild's
same-id-PHASE-child conjunct), not the "no other consumers" the comment
claimed. Same correction pattern round-4 already applied to this row's
BRACKET_HEADING_INTRO_RE neighbor (that sentence's own staleness was fixed
in 4d7184b8): note the true consumer count, confirm both stay nested inside
the same bracketBoundaryActive runtime gate (verified at
src/roadmap-parser.cts:178 and :250, both reached only through the
`if (bracketBoundaryActive)` block starting at :529), and record which
commit and which fix introduced the drift. Comment-only; no assertion
logic changed.

Line count: 2 files, 8 insertions / 2 deletions total — one added clause
in the changeset (1 line changed) and one comment block replacing the
single stale line in the test file (6 comment lines replacing 1).

Verified: targeted suite (adr-612-*, roadmap-parser, state, verify)
unchanged at 965/965 pass (comment/prose-only diffs, no test count change).
`node scripts/changeset/lint.cjs` and `npx eslint` on the touched files both
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): restore the bracketId guard on the bracket-only /^0\b/ sibling rule (round-6 Blocker 1)

Round-5's Blocker 1 was `isSentinelPhaseId("undefined-00")` — the merge
that introduced the shared counter dropped the `bracketId &&` guard on the
sentinel check. This is the identical failure one line further down, in the
sibling rule this PR itself added: when the phase-heading grammar's LEGACY
alternative matches (`### Phase 0:` in a `phase_id_convention: "bracket"`
repo — the mid-migration shape this PR exists for), `bracketId` is
`undefined`, and the unguarded `/^0\b/` fires on the bare token anyway.
Neither the LEGACY branch of this same function nor
`getMilestonePhaseFilter` has a `/^0\b/` rule at all, so the filter counts
the phase and admits its completed directory while the counter refuses to
count its heading — a milestone with an unstarted phase 02 persists as a
confident 100%.

`/^0\b/` matches `0` and `0.5` (word boundary before the `.`) but not `00`
(no boundary between the two zeros), which is exactly why round-5's G3/G3d
fixtures (`### Phase 00:`) never tripped this one — same defect class,
different token spelling.

Fix: one word, mirroring the guard round 5 restored two lines above —
`if (/^0\b/.test(token)) continue;` -> `if (bracketId && /^0\b/.test(token)) continue;`.

Expected and intentional side effect: `roadmap analyze` and `state json`
now disagree again on this bracket-repo shape (analyze phase_count=2, json
total_phases=3) — exactly as they already do under the legacy convention
today (verified via /tmp/pr612rev/rv6c.cjs on both conventions). That is
the counter regaining agreement with `getMilestonePhaseFilter` (the tighter
constraint — it is what actually decides `completed_phases`), not a new
break; the counter/filter disagreement is what was wrong.

Out of scope, deliberately NOT fixed here (Minor 1, disclosed via a PIN
test only): the bracket-SPELLED `### [GSD.02] 0:` shape has the same
counter/filter disagreement, but reads 2/2/100 on base too — never closed
by any build in this arc, so it is a pre-existing gap rather than a
regression this commit could introduce.

Line count: src/state.cts is exactly 1 insertion / 1 deletion (one word,
`bracketId && ` prepended to the existing condition).

TDD: T0 (`Phase 0:`) and T05 (`Phase 0.5:`) verified RED at HEAD before this
commit (json 2/2/100, sync body 100%) via /tmp/pr612rev/rv6b.cjs, GREEN
after (3/2/67 on both derivations, matching TRUTH). Zero-drift verified by
diffing the FULL corpus (rv5-attack, rv5b, rv4-attack, rv-attack1,
rv-attack1b, rv-attack3c, rv-mech1, rv2-amend1, rv2-amend2, rv6-attack
H1-H19, rv6b) between a pre-fix and post-fix build: the only differing
lines in the entire diff are T0, T05, and H14 — the three target fixtures.

New tests (adr-612-bracket-phase-counting.test.cjs):
  - RED (T0) + PIN (T0L legacy control)
  - RED (T05) + PIN (T05L legacy control)
  - PIN (B0, bracket-spelled `0` — base-parity characterization, Minor 1,
    not fixed this round)
  Round-5's own G3 test (`### Phase 00:`) already serves as the T00 pin —
  `/^0\b/` never matched `00`, so it is unaffected and untouched.

Targeted suite (adr-612-*, roadmap-parser, state, verify): 965 -> 970 (+5),
0 fail. `lint-phase-id-drift.cjs` and `npx eslint` on touched files clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): changeset re-attributes the phase-count level cap + discloses the bare-0 carve-out; correct two stale test comments (round-6 Minor 1 + Nits)

FIX 2 (Minor 1 — disclosure only, NOT a code fix): the bracket-SPELLED
`### [GSD.02] 0:` / `0.5:` shape has the same counter/filter disagreement
round-6's Blocker fixed for the legacy spelling, but it reads 2/2/100 on
BASE too — never closed by any build in this arc, so it is a pre-existing
gap rather than a regression this round could introduce. Two changes:

  - Pinned as a base-parity characterization: new PIN test (B0) in
    04b5a95f's describe block asserting the exact unchanged value with a
    comment stating why it's deliberately not touched.
  - .changeset/2761-bracket-read-tolerance.md: one qualifying clause added
    to the "counted from ALL of the phases … not a subset" sentence — a
    bare `0`/`0.x` phase token (however spelled) keeps `roadmap analyze`'s
    own pre-existing sentinel reading under bracket too, so it stays
    excluded from these counts. Carried over, not newly introduced by this
    PR — `roadmap analyze` has always read it this way.

FIX 3(a) — same changeset paragraph misattributed the phase counter's
2-4 level cap to "the milestone-boundary machinery in this PR" (the
selector, `isMilestoneBounded`, the preamble scan) — that machinery caps
at `###` (level ≤3), not 2-4. The 2-4 cap belongs to
`getMilestonePhaseFilter`'s own phase scan. Re-pointed the attribution;
the paragraph's earlier, correct ≤3 claim (selector/isMilestoneBounded)
is untouched.

FIX 3(b) — tests/adr-612-bracket-heading-selection.test.cjs:76-82 (added by
1395bd89, round-5's own Nit-1 correction) claimed both
`BRACKET_PHASE_TAIL_RE` consumers are reached "only through the
`if (bracketBoundaryActive)` block starting at :529". Verified at HEAD:
`bracketHeadingHasMatchingChild` is (only caller :593, inside that block).
`isBracketMilestoneBoundary` is not — it has two callers, an inline
`bracketBoundaryActive &&` conjunct at `:473` (inside `computeSectionEnd`)
and the `:529` block at `:567` — the very fact this row's own earlier
"exactly two callers" paragraph already stated correctly. Corrected the
mechanism claim; the CONCLUSION (every consumer is still gated on the same
flag) is unchanged, exactly as round-5's own Nit-1 fix left round-4's
conclusion unchanged when it corrected the consumer count.

FIX 3(c) — tests/adr-612-bracket-phase-counting.test.cjs:2311,2340 still
said "four fence-blind sites" after round-5 (357ba671) added a fifth
(the retirement scan) in its own block below. Reworded both the section
comment and the `describe()` label to read as historical scoping of
round-4's own fix ("the four sites known at round 4 … a fifth was found at
round 5, see its own block below") rather than a live exhaustive claim.

Line count: 3 files, changeset 1/1, heading-selection test 17/8,
phase-counting test 6/2 (comment/prose-only; B0's own PIN test landed in
04b5a95f alongside T0/T05 since it was investigated as part of that same
Blocker's defect class, not in this commit — noted here for the record).

Verified: targeted suite (adr-612-*, roadmap-parser, state, verify)
unchanged at 970/970 (comment/prose-only diffs, no test count change).
`npx eslint` and `node scripts/changeset/lint.cjs` both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): W021's bracket remediation hint stops pointing at a command that hard-errors (round-7 Minor 2)

`checkBracketCoherence`'s W021 (added by 94abf5df) attached the fix hint
`Run \`gsd-tools roadmap upgrade --convention bracket\` to migrate
(dry-run by default)` to every bracket-convention W021 — both sub-checks
(`missing-bracket` and `mismatch`) share the single `addIssue` call at
src/verify.cts:2255-2256. But `roadmap-command-router.cts:204` throws
unconditionally for any `--convention` value other than
`milestone-prefixed`:

  $ gsd-tools roadmap upgrade --convention bracket
  Error: Only --convention milestone-prefixed is supported

This contradicts the PR's own two disclosures: the changeset ("`bracket`
is a READ-path opt-in until the migrator and write path land") and
docs/CONFIGURATION.md:188 ("There is no bracket migrator and no bracket
emit yet"). Reachability is the exact mid-migration repo this PR targets —
any bracket project with one un-migrated heading gets an unfollowable
instruction on every `validate health`.

Fix: one string. Replaced the hint with what a user can actually do today —
manually align the heading's bracket milestone to its section — and named
the tracked future landing (#612 PR-3) instead of a command that errors.
The milestone-prefixed sibling hint at src/verify.cts:2240 (a different,
already-functional convention/command pair — verified against
roadmap-command-router.cts:65) is untouched.

Line count: src/verify.cts is exactly 1 insertion / 1 deletion (one string
literal).

No test in the suite previously asserted this string's content
(`grep -rn "upgrade --convention bracket" tests/` was empty), so the
unfollowable hint shipped unpinned — `lint-fix-has-regression-test.cjs`
would not have caught a string-only change without a new test. Added one:
asserts the fix string both (a) does not match `--convention bracket`
(the specific pinned-before-this-fix hazard) and (b) equals the new string
exactly, covering the invariant a future edit must not re-break: no
unsupported `--convention` value named in remediation text users are
expected to run verbatim.

Verified end-to-end (not just unit-level) via /tmp/pr612rev/rv7f.cjs: the
W021 issue's `fix` field now reads the new string; `gsd-tools roadmap
upgrade --convention bracket` (and its --dry-run variant) still correctly
hard-error — that command remains unsupported, only the hint text changed.

Targeted suite (adr-612-*, roadmap-parser, state, verify): 970 -> 971 (+1),
0 fail. `lint-phase-id-drift.cjs` clean; `npx eslint` on touched files:
0 errors (1 pre-existing unrelated no-elapsed-assertion warning, same file,
same line this arc's prior rounds already disclosed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): correct the bare-0 changeset disclosure + disclose W021's second sub-check (round-7 Minor 1 + Nit 1)

FIX 1 (Minor 1) — round-6's bare-0 changeset clause (1395bd89) was wrong in
three falsifiable ways:

  (a) Its own illustration (`### Phase 0:`) is exactly the LEGACY spelling
      04b5a95f made COUNTED. The residual exclusion after that fix applies
      only to the BRACKET-spelled token (`### [GSD.02] 0:`) — the clause
      named the wrong shape as its example.
  (b) "excluded from these counts" over-scoped the carve-out. The phase-0
      directory is admitted into `completed_phases`/`total_plans` in BOTH
      spellings (measured: B0 at HEAD has `completed_phases=2`,
      `accepts {"GSD.02-0-bootstrap":true}`). The exclusion that survives
      lives in `total_phases`/`phase_count` (heading counting) only.
  (c) The pre-existing "so `roadmap analyze` and `state json` report the
      same number" sentence (present since before this arc's bracket work)
      is now false for the bare-0 LEGACY-spelled shape: 04b5a95f's own
      commit message discloses this exact re-divergence as expected —
      `roadmap analyze` phase_count=2 vs `state json` total_phases=3 on T0
      — matching the disagreement legacy already carries today. The
      changeset still asserted unconditional agreement.

Rewrote both sentences: dropped the `### Phase 0:` example, scoped the
carve-out to `total_phases`/`phase_count`, named the bracket-spelled token
as the one that keeps analyze's sentinel reading, stated plainly that the
legacy-spelled form in a bracket repo IS counted (the mid-migration guard),
and qualified the "report the same number" claim to the `999` token, with
the bare-0 legacy-spelled shape named as the one exception and why.

FIX 3 (Nit 1) — `checkBracketCoherence` has always had two sub-checks
(its own docstring: "Two sub-checks, both surfaced as W021") but both the
changeset and docs/CONFIGURATION.md:188 described only the `mismatch`
sub-check. The `missing-bracket` sub-check — which fires on every
legacy-spelled heading in a bracket repo, the noisier of the two on a
mid-migration project — was undisclosed in both places. One clause added
to each.

Line count: 2 files, 1 line changed each (both are single-paragraph/
single-row files; git diff --stat reports 1/1 per file though several
distinct clauses were edited within that one line each).

Verified: `node scripts/changeset/lint.cjs` clean. Targeted suite
(adr-612-*, roadmap-parser, state, verify) unchanged at 971/971
(prose-only diffs, no test count change, no code touched).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): resolvePhaseIdConvention's docstring no longer claims loadConfig drops the key

Upstream #2997 (aa7697fe, landed in `next` during this PR's final
verification) added `phase_id_convention: get('phase_id_convention') ?? null`
to `_baseConfig` in config-loader.cts — `loadConfig` now surfaces
`phase_id_convention` in its resolved config. This function's docstring
gave "loadConfig merges against CONFIG_DEFAULTS and drops keys it does not
know, and `phase_id_convention` is not among them" as the reason for
reading config.json directly instead of calling `loadConfig(cwd)`. That
rationale is now stale against live `next`.

Corrected the comment to state the two reasons that actually survive #2997:

  1. The workstream->root federation this function performs is a standalone
     resolution run against a GIVEN cwd, not necessarily the same base a
     `loadConfig(cwd)` call elsewhere in the codebase would federate from.
  2. Convention-ENUM validation is still #612 PR-4 work — this function
     returns the raw string unvalidated, exactly as the now-surfaced
     resolved key would.

Noted that #2997 surfacing the key makes consuming it from resolved config
(instead of re-reading config.json here) a natural PR-4 consolidation —
not this PR's scope. The "cycles were never the obstacle" close and every
other paragraph in the docstring (federation rationale, SCOPE note) are
untouched; they still hold.

Comment-only — no code behavior changed. This worktree's own history does
not contain aa7697fe (git merge-base --is-ancestor confirms neither branch
is an ancestor of the other; not rebasing per instruction), so this is a
textual correction against a documented external fact, not a functional
sync with upstream.

git diff --stat:
 src/planning-workspace.cts | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

Verified: `npm run build:lib` clean, `node scripts/lint-phase-id-drift.cjs`
clean, `npm run lint:ci` exit 0 (same 2 pre-existing unrelated eslint
warnings as every prior round this arc, 0 errors; lint-fix-has-regression-test
PASS). Full suite not re-run per instruction (comment-only diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#2761): resolve phase_id_convention against the caller's workstream

`resolvePhaseIdConvention` took no workstream, so it resolved from
`planningDir(cwd)` — which falls back to `GSD_WORKSTREAM` only when its `ws`
argument is `undefined`. Every caller that passes a workstream by ARGUMENT
(they cannot set the env var per iteration) therefore read the convention from
the ROOT config while reading that workstream's ROADMAP.

Two reproduced consequences:

- A workstream that explicitly declares its own `phase_id_convention` had it
  ignored. Flipping ONLY the root config between bracket and milestone-prefixed
  changed which milestone that workstream extracted.
- `--workstream foo` and `GSD_WORKSTREAM=foo` disagreed on the same repo: the
  arg form fell through to the root config, the env form did not.

`resolvePhaseIdConvention(cwd, ws?)` now forwards `ws` to `planningDir`, and
both roadmap-parser call sites pass theirs — `extractCurrentMilestoneScoped`
(which already reads STATE from `planningDir(cwd, ws)`) and
`getMilestonePhaseFilter`'s lazy branch. `undefined` keeps the env fallback, so
convention-less call sites are byte-identical; `null` still means "explicitly no
workstream". The `undefined` vs `null` discriminator on `phaseIdConvention` is
untouched — only the base the `undefined` branch resolves FROM moves.

workstream-inventory's two sites (`countRoadmapPhases`, `inspectWorkstream`)
passed a literal `null` where they meant `undefined`, pinning every workstream
to the legacy grammar. On a bracket workstream whose milestone declares 3
phases that returned phaseCount 0 and fell back to the on-disk directory count;
it now returns 3. A non-bracket workstream is unchanged (legacy control pinned).

The workstream -> root federation is preserved: a workstream that declares no
convention still inherits the root, as config-loader does. That inheritance is
pinned so the fix cannot be over-applied into isolation.

* fix(#2761): classify missing phase details per occurrence, not per token

Under READING-B a phase's sentinel status lives in the BRACKET, so
`[GSD.999] 01` and `[GSD.02] 01` share a token and are not the same phase.
Both sides of the missing-detail check were keyed by the bare token anyway, and
both produced false negatives in `missing_phase_details`:

- The checklist scan built a token -> bracket-id Map, FIRST-WINS. Of two entries
  sharing a token, whichever the author wrote first classified both. With
  `- [ ] **[GSD.999] 01: Icebox**` above `- [ ] **[GSD.02] 01: ...**` the real
  phase inherited the icebox's sentinel verdict and vanished from the report;
  swapping the two bullets — same document, same phases — reported it. Pinned
  with a test asserting BOTH orders.

- The detail set was `new Set(phases.map(p => p.number))`, also token-keyed, so
  `[GSD.02] 01`'s heading marked token `01` present and satisfied
  `[GSD.03] 01`, which has no heading anywhere. Order-independent, same class.

Both sides now key on an occurrence key: the owner's `bracketQualifiedKey`
(fold- and padding-insensitive, so `[gsd.2] 01` and `[GSD.02] 01` are one
phase), falling back to a fold-normalized composite for the two shapes that
grammar refuses — a token carrying its own hyphen, which splices to an id whose
trailing segment the qualified-key grammar truncates (the hazard
`getMilestonePhaseFilter` guards with its own `!token.includes('-')`), and any
id it does not accept. With no bracket id the key IS the bare token, so the
legacy path keeps its exact keys and dedupe order.

The emitted value is unchanged — `missing_phase_details` stays an array of bare
tokens, matching `phases[].number`. Only the classification moved to the
qualified key, so two brackets' `01` both missing report `01` once instead of
one silently covering for the other.

* test(#2761): replace the two wall-clock ReDoS assertions with algorithmic bounds

Both guards asserted elapsed wall-clock time — `Date.now()` against a 20s
ceiling in the coherence suite, `process.hrtime.bigint()` against 1s in the
read-tolerance suite. Those measure the host machine rather than the SUT and
flake on a loaded CI runner (RULESET.TESTS.no-timing-assertion). Per
RULESET.TESTS.delete-bad-tests they are REPLACED, not skipped, and the
behavioral property each one guarded is preserved.

The property is "the widened bracket patterns do not backtrack
catastrophically", which is a claim about growth, so it is now stated by
scaling the input instead of by timing it:

- validate health runs the pathological unclosed bracket at 4,000 and 16,000
  characters and must return the same correct result (no W021) at both.
- The four reader entry points run five ReDoS shapes at 5,000 and 20,000 and
  must name exactly the phases each shape should name at each size, with the
  variant cardinality unchanged across the two.

Catastrophic backtracking is superlinear, so a regression cannot complete the
4x leg under any ceiling, while a bounded matcher is indifferent to the
scaling. The one attack that is well-formed-but-oversized now pins its reading
precisely (`['1'.repeat(n)]`) rather than being lumped in with the malformed
ones. A positive control asserts the readers still extract a well-formed
bracket heading, so "names no phase" cannot pass by the readers being inert.

`{ timeout }` is a hang backstop, not an assertion: it turns a runaway into a
deterministic failure instead of a suite that never returns.

* fix(#2761): give the bracket grammar one owner and teach the drift guard to see it

The bracket milestone-intro grammar was re-typed verbatim in three readers —
roadmap-parser's bracket-fallback selector, state's `isMilestoneBounded` and
verify's `checkBracketCoherence` — which is exactly what #2761's own gate
forbids ("no token literal outside src/phase-id.cts"). `check:phase-id-drift`
reported clean the whole time: its detector only ever knew the phase-NUMBER
token grammar, so the bracket class `[A-Z][A-Z0-9_]*` was invisible to it.

Ownership. `src/phase-id.cts` now exports the class as
`BRACKET_PROJECT_CODE_SRC` and the intro in the two shapes its readers need:
`bracketMilestoneIntroSrcFor(milestone)` (pinned to one milestone) and
`BRACKET_MILESTONE_INTRO_CAPTURING_SRC` (milestone captured). The pinned
builder owns the pad2 spelling rule too — "canonical spelling only, not `0*N`"
was previously restated in prose beside each copy, a convention two files had
to keep agreeing on by hand. `BRACKET_ID_SRC`, `BRACKET_ID_PREFIX_RE` and
`BRACKET_QUALIFIED_KEY_RE` now compose the class rather than re-spelling it.
All three call sites consume the owner; the regex sources are byte-identical to
what they spelled, asserted against hand transcriptions of the pre-fix lines.

Guard. `scripts/lint-phase-id-drift.cjs` gains a bracket rule
(`findBracketGrammarDrift`), wired into `scanRepo` and tagged `kind`. It
deliberately does NOT copy the token rule's `line.includes(CANON_REF)` escape:
that escape is line-level, and verify's copy referenced the owner for the
MILESTONE field on the same line as the re-typed PROJECT-CODE class — so a
bracket rule with that escape would have kept passing on the very site under
review. Partial ownership is the drift; only a dedicated `// phase-id-owner:`
comment suppresses it.

Proof, end to end: planting the shipped verify.cts literal back into src/ makes
`npm run check:phase-id-drift` exit 1 naming `[bracket] src/verify.cts:1475`;
restoring it returns the gate to ok.

Tests. phase-id-drift-guard carries all three shipped literals as negative
fixtures, the same-line-owner-reference case, the case-widened evasion variant,
the sanction rules, and a temp-tree scan proving the rule is wired into
scanRepo rather than merely exported. continuation-grammar-parity drives the
pinned and capturing shapes over a 12-entry corpus and requires the same
verdict from both plus the same captured milestone — widening either alone
fails there.

* test(#2761): drop the out-of-scope source-grep exemption from the selector pin

The baseline-selector pin in tests/adr-612-bracket-heading-selection.test.cjs
read `src/*.cts` with readFileSync and regex, claiming the no-source-grep
escape with a source-text-is-the-product reason. CONTEXT.md's documented scope
(RULESET.TESTS.no-source-grep.exemption) reserves that escape for tests whose
subject is a runtime CONTRACT FILE — STATE.md, config.toml, hooks.json, agent
.md — and `src/*.cts` is none of those.

It was also broader than it looked: eslint-rules/no-source-grep.cjs matches the
marker in ANY comment in the file, so one block's claim disarmed the rule for
the whole ~700-line suite.

The pin itself is worth keeping — the BASELINE ARGUMENT at each call site is a
fact no behavioural test can recover (flipping verify's milestone-complete site
from LABEL_ONLY to ANY_BRACKET grants a tolerance it has never had, and every
behavioural test still passes), so pinning it does require reading the authored
source. That reading moved to `scripts/lint-phase-id-drift.cjs` — the seam's
own guard, where source scanning is sanctioned (`warn` scope) and already
happens for the grammar rules — as `countSelectorBaselines` /
`scanSelectorBaselines`, which return a structured census. The test asserts on
the returned data and touches no file text.

CONTEXT.md is unchanged: the exemption scope was not widened to fit the test.
No marker remains in the suite, so the rule is live across all of it again, and
eslint passes with the escape removed rather than relocated. A floor assertion
pins that the census actually found the five consumers, so the "no other file
consumes the selector unpinned" check cannot pass on an empty scan.

* docs(#2761): rewrite the changeset as a lead + deltas instead of one paragraph

The fragment was a single ~6,400-character paragraph that opened on internal
mechanics, buried the user-visible change, and named an internal test path
(tests/adr-612-bracket-phase-counting.test.cjs) that means nothing to a reader
of the CHANGELOG.

It now leads with what a user sees — bracket-style phase IDs are recognized on
the read path across roadmap, validate, verify and state — followed by compact
bullets for the behavioral deltas, the opt-in caveat, and the upstream
consequences. Both merge-added disclosures are kept: the #3185 legacy-sentinel
Phase-0 delta with the reason the bracket counter keeps the narrower rule, and
the enumerator's convention-argument default flip with the four newly-scoped
read surfaces and the note that archival and milestone-completion paths are
unchanged. Two deltas from this review round are stated as their own bullets:
per-occurrence classification in `missing_phase_details`, and workstream-scoped
convention resolution including the workstream-rollup count change.

The internal test path is gone and the body is down to ~3,850 characters. The
bullets render as sub-bullets under the CHANGELOG entry and the `(#NNNN)`
suffix still lands on a trailing paragraph rather than mid-list.
`npm run lint:changeset` passes.

* test(#2761): use helpers.cleanup in the drift-guard temp-tree test

`local/no-raw-rmsync-in-tests` rejects a bare `fs.rmSync` in tests — the shared
helper carries the Windows-EBUSY retry budget. The temp-tree scan added with
M3's guard coverage test used the raw call.

* docs(#2761): correct the occurrenceKey comment on qualified-key normalization

The comment claimed `bracketQualifiedKey` is "fold- and padding-insensitive, so
`[gsd.2] 01` and `[GSD.02] 01` are one phase". The fold half is right; the
padding half is not. `BRACKET_MILESTONE_NUMERIC_SRC` is `(?:[1-9]\d{2,}|\d{2})`,
so `bracketQualifiedKey('GSD.2-01', 'bracket')` returns null and that input
takes the composite fallback instead.

No behavior change — the two key spaces use different separators and cannot
collide — but the claim was checkable and wrong. Restated: the owner case-folds,
and padding-tolerance is not a property it has or needs, because each accepted
milestone has exactly one canonical spelling and `[GSD.2]` is malformed rather
than an alternate spelling of `[GSD.02]`.

* ci(#2761): give the coverage-gate job the heap floor the shard jobs already have

The gate's report steps parse ~2.5GB of merged raw V8 dumps in one
process at the runner's implicit ~4GB ceiling, so pass/fail comes down
to GC timing (run 31338081337 OOM'd; next passes the same volume at
28s). Deterministic locally: crashes at --max-old-space-size=4096,
completes in 11s at 8192. PR shard data is within 0.15% of green next
runs — the load is pre-existing, only the ceiling was missing. #2952
set 6144 on the shard-collection step; the gate job was missed.

* Revert "ci(#2761): give the coverage-gate job the heap floor the shard jobs already have"

This reverts commit 03c74a97c911e9ddc53675aded9c9bbb89f14cd1.

* fix(#2761): thread the resolved convention into cmdStateValidate's phase-directory lookup

#3208 replaced cmdStateValidate's `startsWith` prefix test with the canonical
`phaseKeyFromDir(...) === selectedPhaseKey` comparison. That is the right
surface, and it is why the lookup now needs the resolved `phase_id_convention`,
which the rewrite does not pass.

`phaseKeyFromDir` deliberately refuses to read a bracket directory without an
explicit signal (ADR-2121: a bracket dir is string-indistinguishable from the
legacy letter-prefixed-decimal family), so un-threaded it returns the whole dir
name as the key — `GSD.02-05-real-work` -> `GSD.02-5-REAL-WORK` — while the
STATE side is the bare `05` that `parsePhaseFromProse` yields. Both sides of one
comparison derived under different conventions is #2562's defect class, and this
file's other three `phaseKeyFromDir` call sites already thread against it.

Observable: a bracket repo whose phase directory plainly exists reported
`valid: false` and "no phase directory matches phase 05", and the drift scan
(plan-count mismatch, verification status) never ran. The pre-#3208 `startsWith`
missed the same directory but skipped silently, so this is a visible-failure
regression on bracket repos, not a new miss.

Non-bracket conventions are byte-identical by construction: `extractPhaseToken`
branches only on `=== 'bracket'`, so null / 'milestone-prefixed' / unresolvable
compile the same path as the un-threaded call. The flat-legacy twin assertion
pins that.

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

* docs(#2761): note the cmdStateValidate convention threading in the changeset fragment

The fragment described the bracket read path as of the pre-merge branch. 504c64ff
added a shipped behaviour change — `state validate` now resolves bracket phase
directories — that the fragment did not mention, so the rendered changelog would
have under-described what ships.

Body text only; `type` and `pr` are untouched.

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

* test(#2761): invert the inherited-wart characterization — #3225 fixed it upstream

Required by the merge of next @ 86101ee6; test-only, no src delta.

This branch disclosed rather than fixed a pre-existing upstream wart: on a
LEGACY repo, `### Phase 999:` warned from `validate consistency` while
`validate health` suppressed it — the two verbs contradicting each other.
The case was pinned as a characterization test ("INHERITED WART, unchanged")
precisely so it would INVERT if upstream ever closed it, rather than rot
silently.

ae7dc529 (#3225) closed it, by adding the `isSentinelPhaseId` guard to this
very loop. So the assertion inverted on the merge — as designed. Flipped to
assert the FIXED behaviour rather than deleted: it is the negative-space
proof that this branch's `sentinelPhases` guard never had to grow a legacy
reading of its own, and it reds if a future conflict resolution keeps our
guard while dropping upstream's.

Added a scope control alongside it (a legacy NON-sentinel `### Phase 09:`
with no directory still warns), so deleting the loop outright cannot pass.

Union proven load-bearing in BOTH directions against the merged tree —
neither guard subsumes the other:
  - drop `isSentinelPhaseId(p)` (ours only) -> 1 red, exactly this case.
    Note upstream's own #3225 tests stay GREEN there: they cover the
    disk-side loops (sentinel dir on disk) and the gap-numbering filter,
    not the ROADMAP-side loop. This case is that site's only coverage,
    which is the second reason to keep it rather than delete it.
  - drop `sentinelPhases.has(p)` (upstream only) -> 4 red bracket suites
    (icebox-not-missing, health/consistency agreement, occurrence-aware
    suppression, checklist-index suppression).

tests/adr-612-bracket-read-tolerance.test.cjs 79 -> 80, all green.

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

* test(#2761): pin the three re-homed W006/W007 reads the merge left unfalsifiable

#3309/#3310 deleted the helpers this PR threaded (collectDiskPhases,
collectDiskPhaseEntries, collectArchivedPhaseDirNames,
forEachArchivedPhaseToken) and rebuilt their reads inside
src/planning-snapshot.cts + src/health-diagnostic-rules/*.cts. The convention
threading moved with them in the merge commit — but a mutation sweep over the
re-homed sites found three where reverting the convention argument changed real
CLI output and NOT ONE existing test went red. The pre-migration sites were
covered indirectly, through helpers that no longer exist, so the coverage did
not survive the relocation even though the behaviour did.

Each case is pinned at the CLI with its flat-legacy twin as the byte-identity
control, plus a non-vacuity control in the opposite direction:

  archivedPhaseTokens      revert -> "Phase 05 in ROADMAP.md but no directory on
  (planning-snapshot.cts)            disk" for a phase whose only directory is
                                     under .planning/milestones/v1.0-phases/
  roadmapPhaseCheckboxes   revert -> "Phase 09 in ROADMAP.md but no directory on
  (planning-snapshot.cts)            disk" for an unstarted `- [ ]` phase
  W007's extractPhaseToken revert -> "Phase GSD.02-77-orphan exists on disk"
  (roadmap-disk-consistency)         instead of "Phase 77 exists on disk"

Red/green: each single-line revert reds exactly its own case and nothing else;
every legacy control stays green in all three runs.

NOT pinned, and disclosed as droppable rather than given an unfalsifiable test:
buildValidPhaseSet's extractPhaseToken(dir, convention) in W002. That rule
unions disk tokens with roadmapDeclaredPhases and archivedPhaseTokens, and any
STATE.md reference a bracket disk token would rescue is already rescued by the
ROADMAP half — probed directly, the argument makes no observable difference. It
is threaded because it restores collectDiskPhases(planBase, convention)'s
derivation exactly, not because a test needs it.

Test-only; revertable independently of the merge commit.

* test(#2761): pin the two reads the #3165 extraction left unfalsifiable

DROPPABLE, offered as such. Test-only; the merge commit is correct without it.

#3428's extraction gave `collectAnalyzePhases` TWO call sites — the scoped
milestone window and the truncated-window recovery path. The merge threads
`convention` into both and swaps `detailKeys` with `phases` across the recovery.
Neither of those was falsifiable by the suite as it stood:

  - passing a NULL convention at the FALLBACK site, with the scoped site still
    threaded, is green across all 518 tests of the bracket, roadmap and
    milestone-window files. Every pre-existing bracket assertion reaches the
    enrichment through the scoped window, so none of them can observe the
    fallback's reading at all;
  - dropping `detailKeys = fallbackScan.detailKeys;` — the line this merge
    authored — is likewise green, because no fixture that reaches the recovery
    path carries a checklist bullet, so `missing_phase_details` is `null`
    either way.

That is the same hole class lap 3 closed in 9914359c: an argument whose revert
changes real CLI output with zero reds.

Pinned at the CLI on the shape that reaches the fallback on a bracket repo — a
MID-MIGRATION ROADMAP: bracketed ACTIVE milestone, its phase-detail sections
and checklist bullets sitting after an intervening CLOSED legacy milestone (so
the window closes over prose only), plus one legacy `### Phase N:` section of
its own. Six tests:

  - a NON-VACUITY control that removes the phase directories, so #3428's own
    precondition fails and the result is the empty one the recovery exists to
    replace — this is what proves the rest read the FALLBACK's output rather
    than the scoped scan's;
  - the directory assertion (`disk_status`/`plan_count`/`summary_count` for
    canonical `{CODE}.{MM}-{PP}-slug` dirs) + a flat-legacy twin asserting
    byte-equal shape, and that the twin is the right answer rather than a
    shared wrong one;
  - `missing_phase_details: null` for phases the recovery just found, and its
    companion direction — a bullet with no heading anywhere is STILL reported,
    so a fix that merely suppressed the report does not pass;
  - a non-bracket repo taking the same path unaffected.

Red/green, each mutation reverted afterwards:
  - fallback call site -> `null` convention: exactly 2 reds, both directory
    assertions in this block; 307 other tests green, including upstream's own
    #3428 tests in tests/milestone-window-single-owner.test.cjs.
  - `detailKeys` swap deleted: exactly 2 reds, both `missing_phase_details`
    assertions; 414 other tests green.
  - `matchPhaseDirs` 3rd argument dropped (the lap-2 site): 4 reds — the 2
    already on record plus this block's 2, which is the point: one seam, now
    reached by two paths.

DISCLOSED IN THE TEST BODY WITH MEASURED OUTPUT, not fixed: `hasPhaseEntries`
(src/roadmap-parser.cts) is convention-blind, so on a PURE-bracket ROADMAP the
window classifies COMPLETE and #3428's recovery is gated off entirely. That
document returns `{"scope":"complete","phase_count":0,"next_phase":null,
"phases":[]}` — the "genuinely empty milestone" answer, the indistinguishability
#3184 introduced `scope` to remove — where the flat-legacy twin returns
`{"scope":"truncated","phase_count":2,...}`. Widening it changes the value of an
upstream-owned output field on bracket repos, so it is a behaviour slice (the
PR-2.5/PR-4 convention-less-readers question), not a merge-round change. The
mid-migration shape pinned here is the reachable half.

* fix(#2761): mirror the bracket terminator in the milestone-scope write guard

parser's terminator vocabulary — "a level 1-3 heading that is not a Phase
heading and carries a milestone signal". On this branch that vocabulary is
convention-SELECTED: `computeBracketSectionEnd` adds `isBracketMilestoneBoundary`
as a terminator arm, and the ADR-canonical `## [GSD.09] Hidden` carries NO
vN.N token, NO ✅/📋/🚧/🔄 marker, and not the word "Milestone". Left
unmirrored, the guard accepted exactly the description it exists to reject.

NOT a defect on clean next — a bracket heading terminates nothing there. The
branch widens the terminator set, so the branch owns the mirror.

MEASURED at the CLI seam before the fix (bracket fixture, one milestone,
one phase):

  $ gsd-tools phase add $'Sneaky\n## [GSD.09] Hidden'   -> exit 0, written
  $ gsd-tools phase add 'Innocent follow up'            -> exit 0, written
  $ gsd-tools roadmap milestone-scope
    { "scope": "complete", "phases": ["01","1"], "phase_count": 2 }
  $ grep '^### Phase' .planning/ROADMAP.md
    ### Phase 1: Sneaky
    ### Phase 2: Innocent follow up      <- in the document, out of the window

After the fix the first add exits 1, ROADMAP.md is byte-unchanged and no
phase directory is created. The legacy twin (same text, no
`phase_id_convention`) still exits 0 — opt-in only, base behaviour preserved.

SHAPE
- `findMilestoneScopeHeadingLines(text, convention)` — REQUIRED, the same
  tripwire `scanMilestonePhaseIds` carries in the merge commit and for the
  sharper reason: a blind call here fails OPEN (the guard quietly ACCEPTS a
  window-narrowing description), so a future call site must fail to COMPILE.
  Census is one caller, which pays nothing for it. A non-bracket value takes
  the pre-existing path byte-identically. The bracket arm routes through
  `isBracketMilestoneBoundary`, the same single-owner phase-vs-milestone
  discriminator `computeBracketSectionEnd` consults; no second bracket-heading
  grammar is spelled here.
- `selectedBracketId` is deliberately `null`, so the same-milestone
  CONTINUATION exemption never fires and a value naming the ACTIVE milestone
  is flagged too. That is this predicate's third stated conservatism and
  rests on its own existing argument: which milestone is active is a property
  of the document at write time, not of the text being validated, and
  over-rejecting is one-directional.
- `assertDescriptionPreservesMilestoneScope` takes `cwd` and resolves through
  the branch's tolerant try/catch — this guard runs BEFORE `loadConfig` and
  before the ROADMAP existence check, so an unresolvable convention must
  degrade to the legacy vocabulary, never turn a rejection into a crash.
- The error's marker list gains the bracket form only when the project is on
  the bracket convention.

RED/GREEN
- Revert the bracket arm -> 2 reds (phase add; insert + add-batch), the
  non-bracket control and both no-false-positive cases stay green.
- Revert the probe threading in the merge commit -> 1 red (the probe case).
- 6 new cases in the #3262 file, each with a non-bracket or
  no-false-positive control: fenced bracket milestone heading and bracket
  PHASE heading are both non-violations.

Droppable: revert this commit and the merge stands on its own; the branch
then ships the gap as a disclosure instead of a fix.

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

* docs(changeset): narrow the enumerator claim to the directory set — per-entry rendering in progress/stats/init-manager is display-slice scope

Round-7 review confirmed 3 of the 4 surfaces named by the closing claim
parse each directory or heading with legacy-only patterns that live in
files this PR does not touch (commands.cts:1766, commands.cts:2116,
init.cts:2241). The claim now states exactly what this slice delivers:
the scoped directory set. Their per-entry conversion is display work,
deferred to the epic's display PR with the statusline/progress-card
gates it belongs beside.

* fix(#2761): de-accident the unmatched-milestone bracket fixture, re-pin to #3480's withhold contract

tests/adr-612-bracket-phase-counting.test.cjs:515 ("a milestone that
does NOT match STATE is not scoped in") asserted total_phases === 0.
Since today's merge brought in 70b5c1a1 (#3354/#3480, already in
`next`), buildStateFrontmatter withholds total_phases (omits the key,
read back as null) for a milestone that is genuinely sectioned but
matches no ROADMAP heading, instead of substituting a computed number
— the fixture's `state json` read now returns null, failing the
strictEqual(0) assertion.

The original single-section fixture's 0 only ever survived by
accident: hasMilestoneSectioning requires >=2 milestone-vocabulary
headings to call a ROADMAP sectioned, and its isPhaseHeading helper
recognizes only the legacy `Phase N:` text form — so the bracket
phase heading `### [GSD.03] 09: Not this milestone` (title containing
the word "milestone") miscounted as a second milestone heading,
tipping hasMilestoneSectioning true and routing to the disk-count
branch. With that miscount removed, the same one-section fixture
reads 1 (the non-matching milestone's phase count leaking into v2.0's
total) — proving the pinned 0 was never validating the scoping this
test claims to exercise.

Replaced the fixture with two genuine milestone sections (GSD.03,
GSD.04), neither matching STATE's v2.0, with phase titles carrying no
incidental vocabulary — the real #3354 shape. Re-pinned the assertion
to null, matching upstream's own tested contract (tests/state-
document.test.cjs, "#3354 with nothing stored, the key is omitted
rather than written from the dir count").

Verified: file green 3/3 runs (104/104), full state-suite regression
771/771 green. The isPhaseHeading gap that made the old fixture
accidental is a real, pre-existing, upstream-owned limitation
(hasMilestoneSectioning only guards >=2-section conflation, not a
single non-matching section leaking through) — not introduced by

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#2761): exempt two self-authored-fixture regexes from #3441's unbounded-quantifier rule

Both sites parse the STATE.md the test itself just wrote to its own
tmpdir — fixed-size fixture output, not adversarial or document-scale
input. Exempted with the justification-comment pattern the tree's own
tests use for this exact case (settings-integrations, copilot-install,
research-agent-profiles). The sites predate the rule; #3441 landed on
next this morning and this branch picked it up in the catch-up merge.

* fix(#2761): fix forward two next-movement test regressions from the round-11 rebase

origin/next's #3573 newly threads STATE's stored `milestone:` value into
`state json`'s buildStateFrontmatter call (previously always `undefined` on
that read surface). Two pre-existing PR-2 fixtures reach code paths that
call never exercised before that merge:

- RED (repro2 case C): a version-less, all-bracket-id ROADMAP now hits
  getMilestonePhaseFilter's pre-existing (unaffected by this branch) row-5
  `versionResolved && !headingFound => SCOPE.UNSCOPED` rule, which withholds
  `progress.percent` even though the scoped total_phases/completed_phases
  are still correct. That row-5 rule is load-bearing for six other
  version-less-document pins in this same file; narrowing it broke seven of
  them in testing, so production code is untouched here. Reassert the
  test's real claim (scoping, via total_phases/completed_phases) and
  disclose the now-withheld percent instead of silently dropping it.

- PIN (repro12 LEGACY control): a fenced-example-vs-real version heading
  fixture. #3573 routes this read through sliceMilestoneWindow (fence-aware)
  instead of the legacy anyMilestonePattern raw scan (fence-blind) this pin
  was disclosing as out of scope, so the fixture no longer exercises the
  blind path — total_phases moves from the whole-doc 4 to the correctly
  scoped 2. Re-pinned with an upstream-attributed comment, matching this
  file's existing house style for prior origin/next movements (see the
  sibling "PIN (G3 LEGACY control)" comment).

Both are test-only fix-forwards: no production code changed. The remaining
12 failures in this file at 27363e9d0 (dropped seam commits' VERIFICATION.md
naming + fixture updates) are pre-existing and deferred to the stacked
follow-up per the task's own scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2761): thread phaseIdConvention explicitly at the two write-adjacent enumerator call sites (round-11 BLOCKER)

listMilestonePhaseDirs' phaseIdConvention param lost its `= null` default
(phase-locator.cts), so an omitted convention now means "resolve from
config" instead of "explicitly not bracket" — a deliberate flip, but
milestone.cts and state.cts were not in the PR-2 diff and both call the
enumerator without threading it:

- milestone.cts cmdMilestoneComplete (the single #3597 shared derivation
  feeding the stats loop, --dry-run preview, and the real archive/rename
  pass) now resolves phase_id_convention once and threads it explicitly,
  so a bracket project's `milestone complete` archives its real
  bracket-declared phase directories instead of silently inheriting
  whatever the enumerator's lazy default resolves to.
- state.cts cmdStateUpdateProgress's own enumerator call threads the same
  resolved convention. Empirically this does not change the #3217 withhold
  gate (scope is assigned before headingConvention resolves in
  getMilestonePhaseFilter, so it's convention-independent either way) or
  the reported percent (already correctly threaded via
  computeUpdateProgressPreview -> buildStateFrontmatter); it closes a
  second, silently-resolved answer to the same question this file's own
  ONCE-and-THREAD rule (~:2300) already states as policy.
- state.cts's other listMilestonePhaseDirs call site (the state-sync
  scope-only read, ~:4791) is left unthreaded on purpose, with an inline
  note explaining why: only `.scope` is consumed, and `.scope` is set
  before convention resolution in getMilestonePhaseFilter, so it cannot
  disagree with a threaded convention.

Both enumerated sets are pinned by new tests (round-11 BLOCKER block in
tests/adr-612-bracket-phase-counting.test.cjs), including a mutation-style
regression check on the milestone.cts fix (forcing the un-threaded default
back on makes the pinned test fail, catching a regression to the
pass-all-degrade legacy reading).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2761): correct the changeset's false archival claim and amend ADR-612 for the round-11 M2(2) split scope

The changeset (.changeset/2761-bracket-read-tolerance.md) asserted "The
archival and milestone-completion paths are unchanged" — false: both
paths reach the widened enumerator, and this PR now threads their
convention explicitly (previous commit). Replaced the closing paragraph
with an accurate description of what changes for a bracket project at
those two call sites, and notes both enumerated sets are pinned by tests.

ADR-612 (docs/adr/612-bracket-phase-id-convention.md) amended per M2(2):
the 2026-08-03 PR-2/PR-4 boundary proposal is added in-body (PROPOSED,
not stamped — mechanics per docs/contributor-standards.md:143 reserve ADR
ratification to maintainers), rescoped to what actually ships in PR-2
(#2761) now that the round-11 M1 split moved the completion-seam
threading (isPhaseArtifact/scopeToPhase, phase-id.cts:964-1090) out into
a separate, stacked follow-up PR referenced generically via epic #612:

- state.cts read-tolerance (both total_phases derivations, the #1514
  retirement filter) moves into PR-2's row, alongside milestone.cts,
  reflecting the round-11 fix above.
- the write-observability point is restated in terms of what actually
  ships: explicit threading at two named call sites, pinned by tests,
  rather than silent inheritance.
- the completion-seam threading is named and explicitly excluded from
  PR-2's scope, with a new ratify item (5) and a Negative consequence
  bullet covering the sequencing cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2761): repair round-11 response gaps — disk-side sentinel bug, test-fixture bug, scanMilestonePhaseIds caller drift

Four independently-verified gaps in the round-11 repair response:

1. isSentinelPhaseId (src/phase-id.cts) 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 when the bracket-tag match failed. This silently
   dropped a real, on-disk, milestone-declared phase directory from
   `listMilestonePhaseDirs` (phase-locator.cts:424, the only unguarded call
   site) and undercounted completed_phases/percent. Mirrors the carve-out
   already present on both heading-side counters (state.cts's
   countRoadmapPhaseHeadings guards its bare-0 exclusion with `bracketId &&`;
   roadmap-parser.cts's scanMilestonePhaseIds composes the bare-token rule as
   999-only) — under bracket convention, milestone 0 is expressed only via an
   explicit bracket tag, so an untagged leading 0 is a real phase token.

2. tests/adr-612-bracket-phase-counting.test.cjs's writeProject fixture
   hardcoded `01-VERIFICATION.md` for every "complete" phase dir regardless
   of the dir's real phase token. Under legacy convention this file already
   correctly failed #3511's strict isPhaseArtifact match for any dir other
   than phase 01 (the fixture never modeled what it claimed to); under
   bracket convention the pre-existing ambiguity fail-safe admitted it
   regardless. The two readings' disagreement was mistaken for a missing
   completion-seam threading (isPhaseArtifact/scopeToPhase convention
   awareness, correctly split out to the stacked #3644 per round-11's M1).
   Replaced the hardcoded name with verificationNameFor(dir), deriving the
   real per-phase filename from the production normalizePhaseName the same
   way cmdScaffold does — the 7 failing "flat-legacy-twin" / CHARACTERIZATION
   assertions pass on #2867's own code with no seam threading required, and
   the genuine seam-dependent cross-phase-stray-exclusion test the fixture
   fix would otherwise have hidden lives on the stacked branch instead.

3. tests/roadmap-parser.test.cjs's two #3577 tests still called
   scanMilestonePhaseIds with the old single-Set return shape; this PR
   changed it to `{ ids, qualifiedIds }` for every other caller but missed
   these two, which don't touch #2761/bracket code at all. TypeErrors at
   runtime, not silently-wrong assertions. Updated both call sites.

4. .changeset/2761-bracket-read-tolerance.md gains a paragraph disclosing
   fix 1 above, so the changeset stays accurate to what actually ships (the
   round-11 BLOCKER was exactly this changeset going stale once).

Verified: tests/adr-612-bracket-phase-counting.test.cjs +
tests/continuation-grammar-parity.test.cjs + tests/roadmap-parser.test.cjs =
354/354. adr-612-{coherence,grammar,heading-selection,read-tolerance,
selection.property}.test.cjs + collision-characterization = 287/287
(CONFIRMED-CLEAN set, unaffected). Full unfiltered suite run separately.

PR #3643 (round-11's M1 split, opened draft per that round's explicit
requirement) was auto-closed by this repo's own draft-PR policy 11 seconds
after opening — draft PRs are unconditionally closed here. Reopened as
non-draft #3644 (same branch, same commits, DO NOT MERGE / stacked-on-#2867
marker in the body, enhancement template) since the repo's own bot confirms
non-draft contributor PRs are tolerated even off-template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2761): narrow the state-update-progress pin claim to what mutation testing actually proved, add the test that covers the rest

Round-11 BLOCKER response gap (verified, not a guess): the changeset and
ADR-612 amendment point 2 both claimed the round-11 tests pin
`cmdStateUpdateProgress` so "a future change to the enumerator's default
cannot silently move ... what state update-progress reports without failing
a test." Mutation testing disproves this. Reverting BOTH the state.cts
explicit `phaseIdConvention` thread AND simulating the phase-locator.cts
pre-#612 hardcoded-null default (`phaseIdConvention: null` at that one call
site) leaves the existing PIN test ("writes a real percent for a
bracket-scoped milestone") green, because that percent comes from
`computeUpdateProgressPreview` -> `buildStateFrontmatter`, a separately and
already-correctly-threaded derivation the state.cts inline comment at ~:965
already candidly documents.

What the reverted thread DOES change, empirically, is `phaseDirs`/`totalPlans`
— the enumerated `.value` this call site feeds into the #3233 zero-plans
no-op gate a few lines below. That is the one place a regression at this call
site is observable in the command's output. Added a test that pins exactly
that: a bracket milestone whose declared phases carry no plans on disk,
alongside a decoy directory that plainly does not belong to the milestone
(no bracket tag, no phase token) but does have a plan. Correctly scoped, the
decoy is excluded and the #3233 no-op fires (`updated: false`). Degraded to
the pass-all legacy reading, the decoy is swept in, `totalPlans` flips
nonzero, and the no-op never fires (`updated: true`).

Mutation-tested against both scenarios:
- Reverting ONLY the state.cts explicit thread (falls back to `undefined`,
  which `getMilestonePhaseFilter` resolves via the identical
  `resolvePhaseIdConvention(cwd, undefined)` call the explicit thread also
  makes): new test stays green — confirms the single-hunk thread really is
  pure single-derivation hygiene, exactly as the existing inline comment
  claims, for this test too.
- Forcing `phaseIdConvention: null` at that call site (the combined-revert
  scenario the round-11 mutation testing actually exercised): new test FAILS
  (`updated: true, percent: 0` instead of the expected `updated: false`).
  Restoring the real code makes it pass again.

Narrowed the changeset and ADR-612 point 2 prose to match: `cmdMilestoneComplete`'s
enumerated set is pinned against a pass-all-degrade regression (genuine,
already verified in round 11); `cmdStateUpdateProgress`'s own call site is now
pinned against the #3233 zero-plans no-op specifically, not against the
reported percent, which the prose no longer claims. Added a one-line pointer
to the new test in the state.cts inline comment. No production behavior
changes — test and documentation only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(#2761): reconcile ADR PR-2 module map

* fix(#2761): restore #3639's dir-aware W007 sentinel exclusion lost in the rebase replay

The rebase replayed this file's pre-#3639 patch over next, reverting the
isSentinelPhaseId(token) -> isSentinelPhaseDir(dirName) fix: the extracted
token is milestone-stripped, so a bracket sentinel (GSD.999-07-icebox,
GSD.00-01-backlog) was invisible to the id predicate and false-fired W007.
Restores upstream's call and comment verbatim; the branch's convention-aware
token remains for the diagnostic message only.

Caught by next's own #3639 regression tests (CI shard 2). Local: the file's
18/18, health-diagnostic-rules 165/165, full npm test exit 0.

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

* docs(#2761): ratify scope and correct surface claims

* test(#2761): strengthen legacy selection and drift claims

* docs(#2761): correct the enumerator claim, scope list, and ratification receipt

Three documentation corrections, none touching production code.

The changeset claimed the shared phase-directory enumerator "now defaults its
convention argument to 'not yet resolved' rather than 'resolved, and not
bracket'". That is false: `phase-locator.cts:375` still destructures
`phaseIdConvention = null`, and 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'"). Measured: only 4 of 17
`listMilestonePhaseDirs` call sites thread a resolved convention (`milestone
complete` and `state`'s three). The changeset went on to name `progress`,
`stats`, `phase list` and the init manager view as now receiving a correctly
scoped set — those are precisely callers that omit it. Replaced with what the
code does, and the deferral stated plainly.

The ADR's PR-2 row omitted `scripts/lint-phase-id-drift.cjs` (+141/-14) and
`scripts/lint-phase-enumeration-drift.cjs`, both changed by this PR. An
under-claim rather than an over-claim, but the row is the epic's
scope-of-record.

Ratify item 5 asserted maintainer ratification while citing only the review that
raised the question. It now cites the review that granted it (#2867 review
`5012940978`, 2026-08-24) and quotes its terms, so the claim carries its receipt.

Found by an adversarial review pass over the round-12 diff. (#2761)

* fix(#2761): drop the no-op --json that strict argv now rejects

Surfaced by the rebase onto next, not by a change in this PR's subject.

#3884 ("failure is a value — strict argv", e20744eac) made an unrecognized
flag a hard error: `state validate --json` now exits 1 with
`unknown flag "--json"; accepted: --strict` on stderr and EMPTY stdout,
where the token was previously accepted and ignored. `state validate` never
had a `--json` flag — JSON is its only output shape — so the argument was a
silent no-op from the start.

The helper parsed that empty stdout, so all five subtests in the
"state validate resolves bracket phase DIRECTORIES" suite failed identically
with `SyntaxError: Unexpected end of JSON input` at the JSON.parse, masking
what they actually assert.

Dropping the token restores the same envelope the helper already parsed. No
assertion changes. Verified against the fixture the suite builds: exit 0, and
the output carries the S005 plan-count warning the drift assertions require
with no S004 phase-directory warning — i.e. the bracket directory resolves,
which is the behaviour these tests exist to pin. 94/94 in the file.

* fix(#2761): exempt the phase-counting suite from the docs-guard lane

Surfaced by the rebase onto next. #3753 (107eb8c1d) added
lint-docs-guard-registration, which requires every test file that reads a
docs/ path to be either registered in the docs-guard lane or carry an
explicit marker. It flags adr-612-bracket-phase-counting.test.cjs, which
reads no docs/ path at all.

The file's only docs/ occurrence is the ADR-612 Decision 1 citation in a line
comment; every read call it makes targets a tmpdir .planning fixture. It trips
Detector 3, whose DOCS_TEMPLATE_LITERAL_RE sees an odd prose backtick in the
comment block above that citation as opening a template literal and reads the
span between them — citation included — as a docs/ path expression. That
detector documents this trade in its own header: it favours recall, and says a
false positive costs one docs-guard-exempt marker with a reason. The baseline
records the same class ("comment-only mentions") for 46 of its entries.

Registration was the wrong side of the trade here: it would run this suite in
the docs-guard lane on docs/ changes whose content it never reads.

Three pieces, matching what the gate requires and what its 54 existing entries
already do:
  - the marker in the file's header window, written without backticks so it
    cannot itself disturb the parity tracking findExemption does;
  - the basename in DOCS_GUARD_EXEMPT_BASELINE, since the ratchet fails a new
    marker until the baseline is deliberately updated — the reviewable diff is
    the point;
  - the FIX 3 per-file fingerprint, derived with the lint's own
    extractDocsPathReferences rather than retyped, so the exemption fails loudly
    if the set of docs/ paths this file mentions ever changes.

Gates: lint-docs-guard-registration 0 violations, ci-docs-guard-registry 51/51.

* docs(#2761): correct the bare-0 comment and state what opting into bracket costs

Round-13 review items Minor 2 and Minor 3, both still live on the previous head.

Minor 2 — src/phase-id.cts. The comment block claimed "Bare `0` is admitted
alongside it because a 0.x sentinel is a legitimate identity that predates
padding", which contradicted both the shipped constant and its own next
paragraph. BRACKET_CANONICAL_NUMERIC_SOURCE is `(?:[1-9]\d{2,}|\d{2})`;
measured against it, `0` is rejected while `00`, `05`, `99`, `100` and `999`
are admitted. The paragraph four lines below already recorded the removal
("the earlier `(?:\d{2,}|0)` ... admitted ... a bare `0` that pad2 never
produces"), so the block asserted and denied the same fact. The stale sentence
is replaced with what ships: `00` is the backlog sentinel's canonical padded
identity, `\d{2}` already covers it, and nothing needs the unpadded spelling.
No behaviour change — the constant is untouched.

Minor 3 — docs/CONFIGURATION.md. The phase_id_convention row described the
read-path widening but never said what a repo GIVES UP by opting in. Added:
on a bracket repo a heading whose bracket is followed directly by a digit is
read as a phase heading, so `### [RFC.2119] 5:`, `### [v1.0] 2024:` and
`### [ADR.612] 3:` — legal prose headings under any other convention — are
claimed as phases and move phase_count, total_phases and W006. Those three
shapes are the ones phase-id.cts's own selector comment names as the reason
the widened read is selected at construction time from this value rather than
applied everywhere; the row now carries that trade instead of only its
upside.

Gates: tsc --noEmit exit 0, npm run lint:ci exit 0, npm run test:unit
33890/33891 with the single failure being emitted-attribution's base drift
against a next that moved after the rebase (133/133 against the rebase base;
this push re-bases onto the current tip, which resolves it).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-31 14:03:48 -04:00

3222 lines
127 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// docs-guard-exempt: this file performs no filesystem read of any docs/ path.
// Its only docs/ occurrence is the ADR-612 Decision 1 citation inside a line
// comment further down; every read call here targets a tmpdir .planning
// fixture. It trips Detector 3 because an odd prose backtick in the comment
// block above that citation opens a span the template-literal heuristic reads
// as a literal containing the citation. Registering it in the docs-guard lane
// would run this suite on docs/ changes whose content it never reads, so the
// marker is the correct side of that trade per this lint's own guidance.
/**
* PR-2 (#2761 / epic #612) — total_phases counts bracket phase headings.
*
* `total_phases` is derived TWICE: buildStateFrontmatter feeds `state json`, and
* cmdStateSync feeds `state sync`. The second carries the comment "Mirrors the
* logic in buildStateFrontmatter so both report consistent percents (#3242 Bug
* B)". Teaching one and not the other ships that divergence.
*
* TWO ORACLE HAZARDS this file is shaped around:
*
* 1. #1446 removed total_phases from the ratchet, so it corrects DOWNWARD
* silently. Every assertion here is an EXACT number; "no error" or ">= n"
* passes straight through the bug.
*
* 2. Reading `state json` after `state sync` measures the READ derivation
* twice — sync leaves STATE.md untouched when its computed total already
* matches, so the write-path guard is never observed and a mutation to it
* survives. The sync assertions below pre-write a WRONG total into the
* frontmatter, run sync, and then read THE FILE, so the number asserted is
* the one sync actually wrote.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const phaseIdHelpers = require('../gsd-core/bin/lib/phase-id.cjs');
let tmpDir;
const stateMd = () => [
'---',
'gsd_state_version: 1.0',
'milestone: v2.0',
'milestone_name: Expansion',
'status: executing',
'---',
'',
'# Project State',
'',
'**Phase:** 05',
'',
// The body Progress line is what makes sync derive and WRITE the progress
// block; without it sync has nothing to update and STATE.md is left untouched.
'**Progress:** [░░░░░░░░░░] 0%',
'',
].join('\n');
function writeProject(roadmap, convention, dirs = ['GSD.02-01-setup']) {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), stateMd(), 'utf-8');
fs.writeFileSync(
path.join(planning, 'config.json'),
JSON.stringify(convention === undefined ? {} : { phase_id_convention: convention }), 'utf-8',
);
// `state sync` short-circuits with no phase directories, leaving STATE.md
// byte-unchanged — which is exactly how a mutation to the write-path counter
// survives a test that reads `state json` afterwards. Give it real work.
// #3186 made completion disk-strict: a PLAN and SUMMARY no longer imply a
// completed phase without a passing verification verdict. Keep these fixtures
// modeling the complete phases they modeled before that upstream change.
// A dir spec is either `'name'` (a PLAN, SUMMARY, and passing verification)
// or `['name', false]` (a PLAN alone — incomplete). The mixed fixtures need
// both shapes to distinguish completed_phases from total_phases.
// Write verification last so it cannot be stale relative to the summary.
for (const spec of dirs) {
const [d, complete = true] = Array.isArray(spec) ? spec : [spec, true];
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-01-x-PLAN.md'), '# plan\n', 'utf-8');
if (complete) {
fs.writeFileSync(path.join(dir, '01-01-x-SUMMARY.md'), '# summary\n', 'utf-8');
fs.writeFileSync(
path.join(dir, verificationNameFor(d)),
'---\nstatus: passed\n---\n# Verification\n',
'utf-8',
);
}
}
}
/**
* Name each fixture's verification report for that phase's own padded token,
* matching cmdScaffold. A hardcoded `01-VERIFICATION.md` turns every other
* "complete" phase into a cross-phase stray once artifact scans are scoped.
* Prefix stripping stays fixture-local because these cases intentionally model
* the unqualified artifact layout; padding comes from the production owner.
*/
function verificationNameFor(dir) {
const rest = dir.replace(/^[A-Z][A-Z0-9_]*\.\d+-/i, '');
const token = [];
for (const segment of rest.split('-')) {
if (/^\d+(?:\.\d+)*$/.test(segment)) token.push(segment);
else break;
}
assert.ok(token.length > 0, `fixture dir ${dir} carries no phase token to name its verification`);
return `${phaseIdHelpers.normalizePhaseName(token.join('-'))}-VERIFICATION.md`;
}
/** total_phases as the READ path derives it. */
function readTotal() {
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
return JSON.parse(r.output).progress?.total_phases ?? null;
}
/**
* total_phases as the WRITE path derives it — read back out of STATE.md, not out
* of `state json`. This is the assertion that observes cmdStateSync at all.
*/
function syncedTotal() {
const r = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(r.success, `state sync failed: ${r.error}`);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const m = raw.match(/^\s*total_phases:\s*(\d+)\s*$/m);
assert.ok(m, 'state sync must have written a total_phases into STATE.md');
return parseInt(m[1], 10);
}
/**
* The PERCENT `state sync` wrote into the STATE.md body.
*
* This is the only observable of cmdStateSync's own counter. Its
* `syncTotalPhases` never reaches the frontmatter `total_phases` field — that
* one is written by the read derivation — it reaches `computeProgressPercent`
* and nothing else. Asserting the frontmatter number after a sync therefore
* measures the READ path twice and lets a mutation to the write-path counter
* survive, which is exactly how this guard shipped untested the first time.
* Percent is completed/total, so the denominator is visible here.
*/
function syncedPercent() {
const r = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(r.success, `state sync failed: ${r.error}`);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input
const m = raw.match(/^\*\*Progress:\*\*[^\r\n]*?(\d+)%/m);
assert.ok(m, `state sync must have written a Progress percent; got:\n${raw}`);
return parseInt(m[1], 10);
}
/** The explicit reason `state sync` withheld progress for an unsafe scope. */
function syncSkipReason() {
const r = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(r.success, `state sync failed: ${r.error}`);
const changes = JSON.parse(r.output).changes || [];
const skipped = changes.find((change) => /^Progress: skipped/.test(change));
assert.ok(skipped, `state sync must record why Progress was skipped; got: ${JSON.stringify(changes)}`);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(raw, /^\*\*Progress:\*\* \[░{10}\] 0%$/m,
'no percent may be rendered when sync says it skipped');
return skipped;
}
const BRACKET_ROADMAP = `# Roadmap
## [GSD.02] v2.0
### [GSD.02] 01: Setup
**Goal:** a
### [GSD.02] 05: Real work
**Goal:** b
### [GSD.02] 06: Follow-up
**Goal:** c
`;
describe('#612 PR-2: bracket phase headings enter total_phases', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-count-'); });
afterEach(() => { cleanup(tmpDir); });
test('the read path counts all three (exact)', () => {
writeProject(BRACKET_ROADMAP, 'bracket');
assert.equal(readTotal(), 3);
});
test('#3242: the WRITE path writes the same number, observed in STATE.md', () => {
// Stale total forces sync to do real work, so the number in the file is the
// one cmdStateSync computed rather than the one that was already there.
writeProject(BRACKET_ROADMAP, 'bracket');
assert.equal(syncedTotal(), 3, 'state sync must WRITE 3');
assert.equal(readTotal(), 3, 'and the read path must agree');
});
test('a NON-bracket repo counts neither form of the same roadmap', () => {
// One phase directory on disk, three bracket headings the reader cannot see:
// the total falls back to the directory count, exactly as it did before.
writeProject(BRACKET_ROADMAP, undefined);
assert.equal(readTotal(), 1, 'bracket headings are invisible without the convention');
assert.equal(syncedTotal(), 1);
});
test('a mixed legacy + bracket roadmap counts both on a bracket repo', () => {
writeProject(`# Roadmap
## v2.0
### Phase 1: Legacy one
**Goal:** a
### [GSD.02] 05: Bracket one
**Goal:** b
### Phase Overview:
`, 'bracket',
// #2761 Major 1: the default single dir ('GSD.02-01-setup') names a phase
// number ("01") this roadmap never declares — only "1" (legacy-labelled)
// and "05" (bracket) are real — so post-Major-1 the disk-side milestone
// filter correctly excludes it and `state sync` becomes a no-op (nothing
// to write). Naming the ACTUAL bracket-declared phase here keeps this
// test about what it says it's about (mixed heading-style counting), not
// an accidental side effect of the disk-scan gating fixed elsewhere.
['GSD.02-05-bracket-one']);
assert.equal(readTotal(), 2, '`Phase Overview:` still excluded');
assert.equal(syncedTotal(), 2);
});
});
describe('#612 PR-2: bracket sentinels stay OUT of both derivations', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-count-sent-'); });
afterEach(() => { cleanup(tmpDir); });
const SENTINEL_ROADMAP = `# Roadmap
## [GSD.02] v2.0
### [GSD.999] 01: Icebox item
**Goal:** a
### [GSD.00] 02: Pre-milestone
**Goal:** b
### [GSD.02] 05: Real work
**Goal:** c
### [GSD.02] 06: Follow-up
**Goal:** d
`;
test('999.x and 0.x are excluded from the READ derivation (exact)', () => {
writeProject(SENTINEL_ROADMAP, 'bracket');
assert.equal(readTotal(), 2);
});
test('999.x and 0.x are excluded from the WRITE derivation too (observed in STATE.md)', () => {
// The assertion that actually exercises cmdStateSync's sentinel guard —
// reading `state json` after sync would measure the read path a second time
// and let a mutation to the write path survive.
// One completed phase directory — named for the REAL "05" phase this
// roadmap declares (#2761 Major 1: the default dir names phase "01",
// which this roadmap never declares, and the disk-side milestone filter
// now correctly excludes an undeclared phase number from the WRITE-path
// scan too, making `state sync` a no-op on the default dir). With the
// sentinel guard the denominator is 2 (05, 06) so the percent is 50;
// without it the two sentinel headings inflate it to 4 and the percent
// drops to 25.
writeProject(SENTINEL_ROADMAP, 'bracket', ['GSD.02-05-real-work']);
assert.equal(syncedPercent(), 50, 'sentinel headings must not inflate the sync denominator');
});
test('a LOWERCASE sentinel bracket is excluded from both', () => {
const doc = SENTINEL_ROADMAP.replace(/GSD\./g, 'gsd.');
writeProject(doc, 'bracket', ['gsd.02-05-real-work']);
assert.equal(readTotal(), 2);
assert.equal(syncedTotal(), 2);
});
test('G5: a 999 token under a real milestone is still a backlog sentinel', () => {
writeProject(`# Roadmap
## [GSD.02] v2.0
### [GSD.02] 999: Late work
**Goal:** a
### [GSD.02] 05: Real
**Goal:** b
`, 'bracket', ['GSD.02-05-real']);
// The composed rule: bracket-sentinel OR legacy 999 token.
assert.equal(readTotal(), 1);
assert.equal(syncedTotal(), 1);
});
});
describe('#612 PR-2: #1514 retired bracket phases leave the denominator', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-retired-'); });
afterEach(() => { cleanup(tmpDir); });
// The canonical #1514 gesture, verbatim from the shipped legacy tests: strike
// the checklist BULLET and leave the detail heading intact. A bracket-form
// retirement went undetected, so the phase stayed in the denominator forever
// and a shipped bracket milestone could never reach 100%.
const retiredRoadmap = (bullet, heading) => `# Roadmap
## [GSD.02] v2.0
- [x] ${bullet} — folded into 05; number retired
- [ ] **[GSD.02] 05: Real work**
- [ ] **[GSD.02] 06: Follow-up**
${heading}
**Goal:** folded
### [GSD.02] 05: Real work
**Goal:** b
### [GSD.02] 06: Follow-up
**Goal:** c
`;
test('bullet-only strike (the canonical gesture) excludes the phase', () => {
writeProject(
retiredRoadmap('~~**[GSD.02] 04: Delta**~~', '### [GSD.02] 04: Delta'), 'bracket',
// #2761 Major 1: name one of the two REAL (non-retired) phases so the
// WRITE-path milestone filter has something to admit — the retired "04"
// itself is deliberately never used here (that's a separate dedicated
// test below).
['GSD.02-05-real-work']);
assert.equal(readTotal(), 2, 'the retired bracket phase must leave the denominator');
assert.equal(syncedTotal(), 2);
});
test('unbolded bullet strike also excludes', () => {
writeProject(
retiredRoadmap('~~[GSD.02] 04: Delta~~', '### [GSD.02] 04: Delta'), 'bracket');
assert.equal(readTotal(), 2);
});
test('a struck HEADING is excluded as well (it simply stops matching)', () => {
writeProject(
retiredRoadmap('~~**[GSD.02] 04: Delta**~~', '#### ~~**[GSD.02] 04: Delta**~~'), 'bracket');
assert.equal(readTotal(), 2);
});
test('the legacy retirement gesture is unchanged', () => {
writeProject(`# Roadmap
## v2.0
- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired
- [ ] **Phase 05: Real**
### Phase 04: Delta
**Goal:** folded
### Phase 05: Real
**Goal:** b
### Phase 06: Other
**Goal:** c
`, undefined);
assert.equal(readTotal(), 2, 'legacy 04 retired, 05 and 06 remain');
});
test('a retired bracket phase DIRECTORY is skipped too', () => {
// The other half of the same comparison: the retired key has to match the
// directory's key, and phaseKeyFromDir needs the convention to produce one.
writeProject(
retiredRoadmap('~~**[GSD.02] 04: Delta**~~', '### [GSD.02] 04: Delta'), 'bracket',
['GSD.02-04-delta', 'GSD.02-05-real-work', 'GSD.02-06-follow-up']);
// Three directories on disk, one of them retired. If phaseKeyFromDir cannot
// key a bracket directory the retired one is counted anyway and the total is
// 3 — the denominator a shipped bracket milestone could never work off.
assert.equal(readTotal(), 2, 'the retired phase must not be re-added by its directory');
});
});
describe('#612 PR-2: legacy counting is byte-identical', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-count-legacy-'); });
afterEach(() => { cleanup(tmpDir); });
test('#549: pure-word section headings still excluded (exact)', () => {
writeProject(`# Roadmap
## v2.0
## Phase Overview:
### Phase 1: One
**Goal:** a
### Phase 2.1: Two point one
**Goal:** b
### Phase 12A: Letter suffix
**Goal:** c
#### Phase Details:
`, undefined);
assert.equal(readTotal(), 3);
assert.equal(syncedTotal(), 3);
});
test('#1445: a legacy 999.x heading is still excluded from the read path', () => {
writeProject(`# Roadmap
## v2.0
### Phase 999.1: Icebox
**Goal:** a
### Phase 5: Real
**Goal:** b
`, undefined);
assert.equal(readTotal(), 1);
});
test('a project-code phase id still counts (exact)', () => {
writeProject(`# Roadmap
## v2.0
### Phase PROJ-42: Coded
**Goal:** a
### Phase 5: Real
**Goal:** b
`, undefined);
assert.equal(readTotal(), 2);
assert.equal(syncedTotal(), 2);
});
});
describe('#612 PR-2: the ADR-canonical milestone heading scopes the milestone', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-scope-'); });
afterEach(() => { cleanup(tmpDir); });
// ADR-612 Decision 1 pins the bracket milestone heading as `## [GSD.02] Foundation`
// — a NAME, with no version. Milestone scoping matches STATE's `milestone: v2.0`
// STRING against a heading, so the canonical form matched nothing, scoping was
// lost, and total_phases fell back to the on-disk directory count. Every earlier
// fixture in this file embeds `v2.0` in the heading and so never ran the form
// the ADR actually specifies.
const roadmap = (heading) => `# Roadmap
${heading}
### [GSD.02] 05: Real work
**Goal:** a
### [GSD.02] 06: Follow-up
**Goal:** b
### [GSD.02] 07: Third
**Goal:** c
`;
test('name-only heading: total_phases comes from the ROADMAP, not the dir count', () => {
writeProject(roadmap('## [GSD.02] Foundation'), 'bracket', ['GSD.02-05-real-work']);
assert.equal(readTotal(), 3, 'three headings in scope, not one directory');
});
test('the dir count no longer drives the answer', () => {
// The tell for the fallback: without scoping the total tracks the number of
// directories instead of staying at the ROADMAP's phase count.
writeProject(roadmap('## [GSD.02] Foundation'), 'bracket',
['GSD.02-05-real-work', 'GSD.02-06-follow-up']);
assert.equal(readTotal(), 3);
});
test('the version-embedded heading still works', () => {
writeProject(roadmap('## [GSD.02] v2.0 — Foundation'), 'bracket', ['GSD.02-05-real-work']);
assert.equal(readTotal(), 3);
});
test('an unpadded bracket milestone scopes NOTHING (emit-grammar strict)', () => {
// Post-unification an unpadded `[GSD.2]` is malformed: it is not a phase id,
// so it must not bound or scope a milestone either. The tell is that the
// bracket reading equals the null-convention reading — if either behaviour
// flips, these two numbers diverge.
// The tell is that the unpadded heading SCOPES NOTHING: with it, the reading
// must equal the reading of a roadmap that has no milestone heading at all.
// If `[GSD.2]` ever starts scoping again, these two diverge.
const dirs = ['GSD.02-05-real-work'];
writeProject(roadmap('## [GSD.2] Foundation'), 'bracket', dirs);
const unpadded = readTotal();
writeProject(roadmap('## Some heading with no milestone'), 'bracket', dirs);
const unscoped = readTotal();
assert.equal(unpadded, unscoped,
'an unpadded bracket milestone must bound nothing, exactly like no milestone heading');
// And the canonical spelling DOES scope, so the pair is not trivially equal.
writeProject(roadmap('## [GSD.02] Foundation'), 'bracket', dirs);
assert.equal(readTotal(), 3, 'the padded spelling scopes');
});
test('a milestone that does NOT match STATE is not scoped in', () => {
// Use two genuine milestone sections, neither matching STATE's v2.0. This
// removes the old fixture's accidental dependence on the word "milestone"
// appearing in a bracket phase title. Upstream #3354/#3480, strengthened by
// #3642 for the single-section sibling, classifies this as milestoned but
// unbounded: neither the whole-document count nor the on-disk directory
// count is authoritative. With no stored total, `state json` reports null
// rather than guessing.
writeProject(`# Roadmap
## [GSD.03] Later milestone
### [GSD.03] 09: Not scoped here
**Goal:** a
## [GSD.04] Even later milestone
### [GSD.04] 10: Also not scoped
**Goal:** b
`, 'bracket', ['GSD.02-05-real-work']);
assert.equal(readTotal(), null, 'no phases for the asserted milestone — withheld, not computed');
});
test('a NON-bracket repo does not gain bracket scoping', () => {
writeProject(roadmap('## [GSD.02] Foundation'), undefined, ['GSD.02-05-real-work']);
assert.equal(readTotal(), 1, 'no scoping, no counting — invisible as designed');
});
});
describe('#612 PR-2: labeled sentinels, composed sentinels, and the disk-side filter', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-g3g4g5-'); });
afterEach(() => { cleanup(tmpDir); });
const analyze = () => {
const r = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(r.success, `roadmap analyze failed: ${r.error}`);
return JSON.parse(r.output);
};
test('G3: a LABELED bracket sentinel is excluded from every counter', () => {
// `### [GSD.999] Phase 07:` fell through to the base alternative, which
// captures nothing — so analyze applied the legacy token rule and counted it
// while state json excluded it. Two derivations of one ROADMAP disagreed.
writeProject(`# Roadmap
## [GSD.02] v2.0
### [GSD.999] Phase 07: Icebox labeled
**Goal:** a
### [GSD.999] 08: Icebox bare
**Goal:** b
### [GSD.02] 01: Real
**Goal:** c
`, 'bracket');
const out = analyze();
assert.deepEqual(out.phases.map(p => p.number), ['01'], 'labeled and bare both excluded');
assert.equal(readTotal(), 1);
assert.equal(out.phase_count, readTotal(), 'analyze and state must agree');
});
test('G5: the legacy 999/0 token rule still applies to a bracketed heading', () => {
// READING-B ADDS a rule; it does not replace one. A mid-migration ROADMAP
// carrying a legacy backlog block under bracket headings must not gain
// denominator entries.
writeProject(`# Roadmap
## [GSD.02] v2.0
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 999: Backlog
**Goal:** b
### [GSD.02] 0: Zero
**Goal:** c
`, 'bracket');
const out = analyze();
assert.deepEqual(out.phases.map(p => p.number), ['01'],
'analyze excludes both the 999 and the 0 token, as it does for legacy headings');
// DISCLOSED, pre-existing: the state counter's legacy token rule is 999-only
// — it has never excluded a bare `0` — so `[GSD.02] 0:` still reaches the
// denominator there. Adding a 0 filter would move legacy totals, which is out
// of scope; what this pin asserts is that the 999 rule was not DROPPED for
// bracketed headings.
// Under bracket the token rule composes as the full {0, 999} set, so this
// counter now agrees with roadmap analyze. The LEGACY path keeps its
// pre-existing 999-only rule — pinned separately.
assert.equal(readTotal(), 1, 'both 999 and 0 excluded under bracket');
});
test('G4: the disk-side milestone filter does not count another milestone dirs', () => {
// getMilestonePhaseFilter's heading scan collected nothing on a bracket
// ROADMAP, so it degraded to pass-all and Math.max(dirs, roadmap) counted
// the previous milestone's directories — making bracket strictly worse than
// the M-NN convention it supersedes.
writeProject(`# Roadmap
## [GSD.02] v2.0
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
### [GSD.02] 03: Three
**Goal:** c
`, 'bracket', ['GSD.01-01-prev', 'GSD.01-02-prev2', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.02-03-three']);
assert.equal(readTotal(), 3, 'scoped to this milestone, not the whole disk');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// The four numbers `total_phases` was hiding.
//
// Every bracket counting assertion above reads `total_phases` and nothing else,
// and `total_phases` is the ONE number the disk-side milestone filter cannot
// move: `Math.max(phaseDirs.length, roadmapPhaseCount)` (state.cts) floors it at
// the ROADMAP count no matter how many directories the filter rejects. So a
// filter that rejects EVERY bracket directory leaves that number right and
// zeroes `completed_phases`, `total_plans`, `completed_plans` and `percent` —
// green suite, `state json` reporting 0% on a repo `state sync` calls 67% in the
// same second. These pin all five.
//
// THE ORACLE IS THE LEGACY TWIN, and it is computed in the same run rather than
// quoted: each test builds the identical repo in the flat legacy spelling and
// asserts the bracket reading equals it, number for number. Exact literals are
// asserted too — an equality alone would pass with both sides broken.
//
// WHY FLAT LEGACY AND NOT M-NN. The M-NN spelling of these shapes cannot serve
// as the oracle: buildStateFrontmatter's #2445 de-dup key is
// `dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/)`, which captures only the LEADING
// integer, so `02-01-one`, `02-02-two` and `02-03-three` all key to `2` and two
// of the three directories are dropped before they are ever counted. Measured
// on the true base build (d04592de), the M-NN twin of the first shape below
// reads `[3,0,1,0,0]` where flat legacy reads `[3,2,3,2,67]`; the divergence is
// present identically at base and is untouched by this PR. It is a legacy defect
// in a key space bracket directories cannot enter — `GSD.02-01-one` does not
// match that regex at all, so each bracket dir keys to its own name. The
// source's own `phase-id-owner:` sanction at that line records the divergence.
// ─────────────────────────────────────────────────────────────────────────────
/** The whole progress block as the READ path derives it. */
function readProgress() {
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
const p = JSON.parse(r.output).progress || {};
return [p.total_phases, p.completed_phases, p.total_plans, p.completed_plans, p.percent];
}
/**
* The progress block `state sync` WROTE into STATE.md.
*
* Labelled precisely: this is the READ derivation observed a second time (sync
* rebuilds the frontmatter through buildStateFrontmatter). It is asserted
* because a write that disagrees with `state json` is the #3242 Bug B artifact
* this file exists to prevent — but it is NOT coverage of cmdStateSync's own
* counter. That counter reaches `computeProgressPercent` and nothing else, so
* `syncedPercent()` above is its only observable.
*
* `state json` echoes a `progress:` frontmatter block verbatim when one exists
* and only derives when there is none, so this must be read out of the FILE and
* `stateMd()` must stay block-free. (Measured: same repo, block-free `state
* json` → 3/2/3/2/67; with a `progress: 99…` block → 99/99/99/99/99.)
*/
function syncedProgress() {
const r = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(r.success, `state sync failed: ${r.error}`);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const m = raw.match(
// eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input
/total_phases:\s*(\d+)[\s\S]*?completed_phases:\s*(\d+)[\s\S]*?total_plans:\s*(\d+)[\s\S]*?completed_plans:\s*(\d+)[\s\S]*?percent:\s*(-?\d+)/);
assert.ok(m, `state sync must have written a full progress block; got:\n${raw}`);
return m.slice(1).map(Number);
}
describe('#612 PR-2: the disk-side filter scopes bracket dirs — all five numbers', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-fivenum-'); });
afterEach(() => { cleanup(tmpDir); });
// ── SHAPE 1: one milestone, three phases, three dirs, the first two complete ──
const ONE_MILESTONE_BRACKET = `# Roadmap
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
### [GSD.02] 03: Three
**Goal:** c
`;
const ONE_MILESTONE_LEGACY = `# Roadmap
## v2.0: Current
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
### Phase 03: Three
**Goal:** c
`;
// The M-NN spelling of the same shape — pinned as a CHARACTERIZATION at the end
// of this block, not used as an oracle. See the comment there.
const ONE_MILESTONE_MNN = `# Roadmap
## v2.0: Current
### Phase 2-01: One
**Goal:** a
### Phase 2-02: Two
**Goal:** b
### Phase 2-03: Three
**Goal:** c
`;
const ONE_BRACKET_DIRS = ['GSD.02-01-one', 'GSD.02-02-two', ['GSD.02-03-three', false]];
const ONE_LEGACY_DIRS = ['01-one', '02-two', ['03-three', false]];
const ONE_MNN_DIRS = ['02-01-one', '02-02-two', ['02-03-three', false]];
test('shape 1 READ: 3 phases, 2 complete, 3 plans, 2 done, 67% — not four zeros', () => {
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67],
'every bracket directory must satisfy the milestone filter');
});
test('shape 1 READ equals its flat-legacy twin exactly', () => {
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
const bracket = readProgress();
writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS);
const legacy = readProgress();
assert.deepEqual(bracket, legacy, 'bracket must read exactly what the legacy twin reads');
assert.deepEqual(legacy, [3, 2, 3, 2, 67], 'and the twin is the right answer, not a shared wrong one');
});
test('shape 1 WRITE: sync writes 67%, and its frontmatter agrees with `state json`', () => {
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
// The write derivation's own observable.
assert.equal(syncedPercent(), 67, 'state sync must write 67% into the body');
// …and the block it wrote must not contradict it (#3242 Bug B).
assert.deepEqual(syncedProgress(), [3, 2, 3, 2, 67]);
assert.deepEqual(readProgress(), syncedProgress(), 'the two derivations must not disagree');
});
test('shape 1 WRITE percent equals its flat-legacy twin', () => {
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
const bracket = syncedPercent();
writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS);
assert.equal(bracket, syncedPercent());
assert.equal(bracket, 67);
});
// ── SHAPE 2: two milestones, scoped to v2.0, two stale prior-milestone dirs ──
const TWO_MILESTONE_BRACKET = `# Roadmap
## [GSD.01] v1.0: Prior
### [GSD.01] 01: Old one
**Goal:** a
### [GSD.01] 02: Old two
**Goal:** b
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** c
### [GSD.02] 02: Two
**Goal:** d
### [GSD.02] 03: Three
**Goal:** e
`;
const TWO_MILESTONE_LEGACY = `# Roadmap
## v1.0: Prior
### Phase 01: Old one
**Goal:** a
### Phase 02: Old two
**Goal:** b
## v2.0: Current
### Phase 03: One
**Goal:** c
### Phase 04: Two
**Goal:** d
### Phase 05: Three
**Goal:** e
`;
const TWO_BRACKET_DIRS = ['GSD.01-01-old-one', 'GSD.01-02-old-two', 'GSD.02-01-one',
['GSD.02-02-two', false], ['GSD.02-03-three', false]];
const TWO_LEGACY_DIRS = ['01-old-one', '02-old-two', '03-one', ['04-two', false], ['05-three', false]];
test('shape 2 READ: the prior milestone dirs are excluded — 3/1/3/1/33', () => {
// Both milestones number their phases 01/02/…, so the bare token cannot tell
// `GSD.01-01-old-one` from `GSD.02-01-one`. Only the milestone-qualified key
// separates them; matching on the token would read 5/3/5/3/60 instead.
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
assert.deepEqual(readProgress(), [3, 1, 3, 1, 33]);
});
test('shape 2 READ equals its flat-legacy twin exactly', () => {
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
const bracket = readProgress();
writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS);
const legacy = readProgress();
assert.deepEqual(bracket, legacy);
assert.deepEqual(legacy, [3, 1, 3, 1, 33]);
});
test('shape 2 WRITE (#2761 Major 1 fix): bracket closes to 33% (matches read); legacy stays at the disclosed 60%', () => {
// UPDATED by #2761 Major 1 — this test used to pin the BUG this fix
// closes, titled "the DISCLOSED legacy gap, mirrored — not closed":
// cmdStateSync did its own `fs.readdirSync` and never called the
// milestone filter, so its denominator was the whole disk (3 summaries
// over 5 plans = 60%) against the read path's scoped 33% — for BOTH
// bracket and legacy alike, since the divergence was engine-wide, not
// bracket-specific.
//
// Major 1 gates cmdStateSync's disk scan by getMilestonePhaseFilter under
// `phase_id_convention === 'bracket'` ONLY — deliberately NOT
// unconditionally: an unconditional filter would ALSO move every legacy
// repo's persisted percent, which is out of this fix's scope (the
// binding constraint is "legacy stays byte-identical"). So: bracket now
// closes to 33% (agrees with the read path — repro3's fix, verified
// here on a SECOND fixture with real prior-milestone noise dirs);
// legacy stays at the pre-existing 60% (unchanged, deliberately).
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
assert.equal(syncedPercent(), 33, 'bracket: the gap is now closed, agrees with the read path');
writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS);
assert.equal(syncedPercent(), 60, 'legacy: the gap remains — moving it is out of this fix\'s scope');
});
test('shape 2 WRITE frontmatter agrees with `state json` on both spellings', () => {
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
assert.deepEqual(syncedProgress(), [3, 1, 3, 1, 33]);
assert.deepEqual(readProgress(), syncedProgress());
writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS);
assert.deepEqual(syncedProgress(), [3, 1, 3, 1, 33]);
assert.deepEqual(readProgress(), syncedProgress());
});
test('a bracket repo carrying LEGACY-shaped dirs is unaffected (the branch is additive)', () => {
// The bracket branch tries the qualified key first and FALLS THROUGH on a
// miss, so the three legacy dir checks still run on a bracket project. An
// early `return false` there would silently drop this repo to zero.
writeProject(ONE_MILESTONE_LEGACY, 'bracket', ONE_LEGACY_DIRS);
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67]);
});
test('CHARACTERIZATION: the M-NN twin of shape 1 no longer collapses — three plans, not one', () => {
// Holds the oracle substitution honest. The two "equals its flat-legacy
// twin" tests above compare bracket against FLAT legacy; nothing else in the
// suite pins the M-NN spelling, so the changeset's claim that the M-NN
// divergence is pre-existing and untouched would go stale silently the first
// time a sibling slice widens the de-dup key.
//
// Upstream #3185 replaced the leading-integer de-dup key with canonical
// phaseKeyFromDir ownership. The M-NN directories now key as 02-01,
// 02-02, and 02-03, so all three distinct phases and plans survive.
writeProject(ONE_MILESTONE_MNN, undefined, ONE_MNN_DIRS);
const mnn = readProgress();
assert.equal(mnn[0], 3, 'M-NN: total_phases still tracks the ROADMAP');
assert.equal(mnn[2], 3, 'M-NN: all three dirs survive the de-dup after #3185');
// Build the flat-legacy twin in a fresh project. Reusing this project would
// stack both directory spellings now that the M-NN entries no longer
// collapse under the old leading-integer key.
cleanup(tmpDir);
tmpDir = createTempProject('adr-612-fivenum-mnn-twin-');
writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS);
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// R4-m3 — the milestone-qualified key is a string SPLICE, so a heading whose
// token carries its own hyphen mis-parses into the wrong directory.
//
// `### [GSD.02] Phase 02-01:` spliced to `GSD.02-02-01`, which the qualified-key
// grammar reads as milestone 02 / phase 02 — the trailing `-01` truncated, both
// such headings collapsing to one key, and the heading claiming
// `GSD.02-02-two` (the directory it does NOT name) while rejecting
// `GSD.02-01-one` (the one it does).
//
// The oracle is the SAME ROADMAP read under `milestone-prefixed`, which is
// base-identical on this shape — so the bracket acceptance vector must equal it.
// Scope note: only the ACCEPTANCE VECTOR is claimed base-equivalent.
// `total_phases` on this fixture does move 1 -> 2, because the bracket heading
// count is the feature this PR ships; measured, that move is identical with and
// without this guard, and identical to what the canonical `### [GSD.02] 01:`
// spelling does (both read 2 with zero directories on disk, where base reads 0).
// ─────────────────────────────────────────────────────────────────────────────
describe('#612 PR-2: a hyphenated heading token forms no qualified key', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-hyphen-tok-'); });
afterEach(() => { cleanup(tmpDir); });
const MIXED = `# Roadmap
## [GSD.02] v2.0: Current
### [GSD.02] Phase 02-01: One
**Goal:** a
### [GSD.02] Phase 02-02: Two
**Goal:** b
`;
const DIRS = ['GSD.02-02-two', 'GSD.02-01-one', '02-01-mnn', '02-2026-photos', '46-6-rs-thing', '2-01-x'];
/** The disk-side filter's own acceptance vector, read straight off the module. */
const acceptance = () => {
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
return Object.fromEntries(DIRS.map(d => [d, !!f(d)]));
};
test('the heading does not claim the directory it does not name', () => {
writeProject(MIXED, 'bracket', DIRS);
const a = acceptance();
assert.equal(a['GSD.02-02-two'], false,
'`[GSD.02] Phase 02-01` must not claim GSD.02-02-two by a truncated key');
assert.equal(a['GSD.02-01-one'], false,
'and it does not resolve its own dir either — unqualified, exactly as at base');
assert.equal(a['02-01-mnn'], true, 'the legacy fall-through is untouched');
});
test('the acceptance vector equals the milestone-prefixed control on the same ROADMAP', () => {
writeProject(MIXED, 'bracket', DIRS);
const bracket = acceptance();
cleanup(tmpDir);
tmpDir = createTempProject('adr-612-hyphen-tok-ctl-');
writeProject(MIXED, 'milestone-prefixed', DIRS);
const control = acceptance();
assert.deepEqual(bracket, control,
'a hyphenated token must read the disk identically under both conventions');
});
test('a CANONICAL bracket heading still forms its qualified key', () => {
// Guards the guard: `!token.includes('-')` must not disable qualified
// matching for the spelling the convention actually specifies.
writeProject(`# Roadmap
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
`, 'bracket', ['GSD.02-01-one', 'GSD.01-01-old-one']);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(f('GSD.02-01-one'), true, 'the canonical qualified key still resolves');
assert.equal(f('GSD.01-01-old-one'), false, 'and still scopes out the foreign milestone');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// R4-m2 — extractCurrentMilestone must not throw.
//
// Its bracket-scoping fallback calls resolvePhaseIdConvention, which reaches
// planningDir, which throws a plain Error for a GSD_PROJECT/GSD_WORKSTREAM
// segment containing `/`, `\` or `..`. At base the only planningDir call in this
// function sits inside the STATE-read try, so the function returned normally;
// an unguarded call broke the never-throws invariant that getRoadmapPhaseInternal
// and getMilestoneInfo carry #2245 / ADR-227 notes about.
//
// Module level on purpose: the CLI rejects a bad GSD_WORKSTREAM up front, so
// this contract is only observable to an in-process embedder — which is exactly
// who the invariant protects.
// ─────────────────────────────────────────────────────────────────────────────
describe('#612 PR-2: extractCurrentMilestone never throws on a poisoned env', () => {
const ROADMAP = `# Roadmap
## Milestones
- 🚧 **v1.0 Alpha** — in progress
## Alpha
### Phase 01: Setup
`;
test('a traversal segment in GSD_WORKSTREAM degrades instead of escaping', () => {
const dir = createTempProject('adr-612-envguard-');
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP, 'utf-8');
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}', 'utf-8');
const prior = process.env.GSD_WORKSTREAM;
try {
process.env.GSD_WORKSTREAM = '../evil';
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const out = rp.extractCurrentMilestone(ROADMAP, dir);
assert.equal(typeof out, 'string', 'must return content, not throw');
assert.ok(out.length > 0);
} finally {
// Restore before anything else in this chunk runs — a leaked
// GSD_WORKSTREAM would poison every later test in the same process.
if (prior === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prior;
cleanup(dir);
}
});
});
// ─── REGRESSION: the version-less bracket milestone heading now scopes ──────
/**
* FIXED (#2761 B1, reviewer trek-e blocker). Every other bracket fixture in
* this repo writes its milestone heading as `## [GSD.02] v2.0: Current` — with a
* version. ADR-612's canonical form is a NAME and no version
* (`## [GSD.02] Foundation`), which `isMilestoneBounded`'s own doc comment in
* state.cts calls out as canonical. Until this fix, that form left the
* disk-side filter unscoped: directories from BOTH the prior and the later
* milestone were admitted into the current one.
*
* Mechanism, in `extractCurrentMilestone` (roadmap-parser.cts):
* - the bracket scope branch selects the right `currentSection`, but
* - `preambleCutoff` was driven only by `anyMilestonePattern`, which requires
* `v\d+\.\d+` or a status emoji. A version-less roadmap matches none, so the
* cutoff fell back to the CURRENT milestone's own offset and every PRIOR
* milestone landed in the preamble — whose phase-stripping regex only strips
* `Phase N:`-labelled headings, so bracket phase headings survived it; and
* - `computeSectionEnd` accepted a boundary only if the heading carried a
* version or emoji, so with none present the section ran to EOF and every
* LATER milestone was swept in too.
*
* The leak was bidirectional and had two sites. The fix: under the bracket
* scope branch (`bracketScopeConvention === 'bracket'`), both
* `computeSectionEnd` and the `preambleCutoff` scan ALSO accept a
* `#{1,2}\s+\[CODE.MM\]` heading as a milestone boundary, built from
* phase-id.cts's `BRACKET_ID_SRC` (the single owner of the bracket-id grammar)
* rather than a re-typed literal. `#{1,2}` is the discriminator because bracket
* PHASE headings are `###` and carry the same `[CODE.MM]` prefix (a `#{1,3}`
* pattern would match both, which is why the scope branch's own matcher returns
* the phase headings as well). Reachable ONLY when the bracket scope branch has
* fired, so version-bearing/emoji repos and non-bracket conventions take the
* exact pre-existing code path.
*
* These tests used to assert the pre-fix reading; they are inverted here as the
* fix's regression proof, not deleted.
*/
describe('#612 PR-2 REGRESSION: a version-less bracket milestone scopes correctly', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-versionless-'); });
afterEach(() => { cleanup(tmpDir); });
const VERSIONLESS = `# Roadmap
## [GSD.01] Prior Milestone
### [GSD.01] 01: Old one
**Goal:** a
## [GSD.02] Current Milestone
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.03] Later Milestone
### [GSD.03] 01: Later one
**Goal:** d
`;
// Same roadmap, milestone headings carrying their version — the shape every
// other fixture in this file uses, and the control that proves the difference
// is the VERSION STRING and nothing else.
const VERSIONED = VERSIONLESS
.replace('## [GSD.01] Prior Milestone', '## [GSD.01] v1.0: Prior Milestone')
.replace('## [GSD.02] Current Milestone', '## [GSD.02] v2.0: Current Milestone')
.replace('## [GSD.03] Later Milestone', '## [GSD.03] v3.0: Later Milestone');
const DIRS = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later-one'];
const accepts = () => {
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
return Object.fromEntries(DIRS.map(d => [d, !!f(d)]));
};
test('CONTROL: with a version in the heading, scoping works in both directions', () => {
writeProject(VERSIONED, 'bracket', DIRS);
assert.deepEqual(accepts(), {
'GSD.01-01-old-one': false,
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later-one': false,
});
});
test('without a version, scoping ALSO works in both directions (regression proof)', () => {
// Mirrors the CONTROL assertion exactly, on the version-less fixture — the
// fix makes the two shapes agree.
writeProject(VERSIONLESS, 'bracket', DIRS);
assert.deepEqual(accepts(), {
'GSD.01-01-old-one': false,
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later-one': false,
});
});
test('without a version, the PRIOR milestone no longer leaks in (preambleCutoff)', () => {
writeProject(VERSIONLESS, 'bracket', DIRS);
assert.equal(accepts()['GSD.01-01-old-one'], false,
'regression proof — pinned true before the #2761 B1 scoping fix');
});
test('without a version, the LATER milestone no longer leaks in (computeSectionEnd)', () => {
writeProject(VERSIONLESS, 'bracket', DIRS);
assert.equal(accepts()['GSD.03-01-later-one'], false,
'regression proof — pinned true before the #2761 B1 scoping fix');
});
test('total_phases counts only the asserted milestone, not the whole disk', () => {
writeProject(VERSIONLESS, 'bracket', DIRS);
// 4 directories on disk, 2 phases in the milestone STATE.md asserts.
assert.equal(readTotal(), 2, 'regression proof — pinned 4 before the #2761 B1 scoping fix');
});
test('the current milestone\'s own dirs are admitted either way (no under-count)', () => {
// Whatever else leaked before the fix, the milestone's real phases must
// always resolve — that property held before and still holds after.
writeProject(VERSIONLESS, 'bracket', DIRS);
const a = accepts();
assert.equal(a['GSD.02-01-one'], true);
assert.equal(a['GSD.02-02-two'], true);
});
});
// ─── #2761 B1 FOLLOW-UP: mixed heading shapes and boundary heading levels ───
//
// Two gaps flagged during self-review of the B1 fix above, closed here with
// deterministic fixtures:
//
// 1. The earliest-of-either comparison added to `preambleCutoff` (taking
// whichever of the version/emoji match or the bracket match sits first in
// the document) was only exercised where the two patterns happen to agree
// on the same heading (every milestone in the REGRESSION block above is
// uniformly version-bearing or uniformly version-less). A genuinely mixed
// roadmap — one milestone version-bearing, its sibling version-less — was
// untested.
//
// 2. `computeSectionEnd`'s `h.level <= 2` conjunct (added alongside the
// pre-existing `h.level > level` skip) is REDUNDANT whenever the selected
// milestone heading is level 2 — the ADR-canonical shape, and every
// existing fixture in this repo: `h.level > level` alone already implies
// `h.level <= 2` there, so a mutant deleting the conjunct would survive
// every test written before this one. It is NOT redundant when the
// selected heading is level 3 (or level 1) — see the fixtures below.
describe('#612 PR-2 B1 FOLLOW-UP: mixed heading shapes and boundary heading levels', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-mixed-'); });
afterEach(() => { cleanup(tmpDir); });
const acceptsFor = (dirs) => {
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
return Object.fromEntries(dirs.map((d) => [d, !!f(d)]));
};
test('mixed shape: version-bearing PRIOR + version-less CURRENT — prior stays out of the preamble leak set', () => {
// The mid-migration shape: an already-versioned milestone sits before a
// newer one that has not yet had its version added. anyMilestonePattern
// alone already finds GSD.01 here — it's the first (and only) version-
// bearing heading in the document — so this fixture pins that the
// earliest-of-either comparison does not regress that pre-existing path
// when the two patterns agree on the same heading, while computeSectionEnd
// still needs the bracket-boundary fix to correctly exclude GSD.03 (which
// remains version-less).
const roadmap = `# Roadmap
## [GSD.01] v1.0: Prior Milestone
### [GSD.01] 01: Old one
**Goal:** a
## [GSD.02] Current Milestone
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.03] Later Milestone
### [GSD.03] 01: Later one
**Goal:** d
`;
const dirs = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later-one'];
writeProject(roadmap, 'bracket', dirs);
assert.deepEqual(acceptsFor(dirs), {
'GSD.01-01-old-one': false,
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later-one': false,
});
assert.equal(readTotal(), 2);
});
test('boundary heading level: a level-3 CURRENT milestone heading still scopes correctly (kills the h.level<=2 equivalent-mutant)', () => {
// The selected milestone heading is written with THREE hashes
// (`### [GSD.02] Foundation`) — unusual, but syntactically admitted by the
// same `#{1,3}` grammar every heading matcher in this function already
// compiles. With level=3, `h.level > level` alone no longer excludes a
// level-3 heading, so computeSectionEnd's own first phase heading
// (`### [GSD.02] 01: One`) — itself bracket-shaped — would ALSO satisfy the
// bracket-boundary test if the `h.level <= 2` conjunct were removed,
// truncating the section to nothing but the bare milestone heading and
// dropping BOTH of its own phases. A real PRIOR milestone precedes it so the
// preamble side-channel cannot independently rescue the truncated phases —
// confirmed by hand-mutating a throwaway build copy: without the guard this
// fixture's own phases vanish from the returned scope entirely, and
// getMilestonePhaseFilter's zero-token pass-all degrade then admits every
// directory on disk instead (the exact pre-#612 symptom).
const roadmap = `# Roadmap
## [GSD.01] v1.0: Prior Milestone
### [GSD.01] 01: Old
**Goal:** z
### [GSD.02] Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
## [GSD.03] v3.0: Next Milestone
### [GSD.03] 01: Later
**Goal:** c
`;
const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later'];
writeProject(roadmap, 'bracket', dirs);
assert.deepEqual(acceptsFor(dirs), {
'GSD.01-01-old': false,
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later': false,
});
assert.equal(readTotal(), 2);
});
test('boundary heading level: a level-1 CURRENT milestone heading also scopes correctly (#{1,2} tolerance, not just level 2)', () => {
// A level-1/level-1 pairing (consistent heading-level convention across
// sibling milestones) — distinct from the level-3 case above: this pins
// that the `#{1,2}` bracket-boundary source tolerates level 1, not only the
// ADR-canonical level 2.
const roadmap = `# [GSD.02] Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
# [GSD.03] v3.0: Next Milestone
### [GSD.03] 01: Later
**Goal:** c
`;
const dirs = ['GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later'];
writeProject(roadmap, 'bracket', dirs);
assert.deepEqual(acceptsFor(dirs), {
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later': false,
});
assert.equal(readTotal(), 2);
});
});
// ─── #2761 B1 (round-2 adversarial review, Blocker 1): a SAME-MILESTONE ──────
// ─── continuation heading is not mistaken for a DIFFERENT milestone's ───────
// ─── boundary ────────────────────────────────────────────────────────────────
//
// The B1 fix above (08d5b0c4) taught computeSectionEnd/preambleCutoff to
// recognise ANY `#{1,2} [CODE.MM]` heading as a milestone boundary. It did not
// distinguish "a heading for a DIFFERENT milestone" (a real boundary) from "a
// heading that merely CONTINUES the CURRENT milestone's own section" (e.g. a
// version-less checklist/detail split: `## [GSD.02] Foundation (Phase
// Details)`, or an ad-hoc continuation heading `## [GSD.02] Foundation —
// continued`) — the latter is not a boundary at all. The `(Phase Details)`
// re-append at `detailsMatch` below only searches `allMatches` (the
// VERSION-STRING matches from the top of this function), so a version-less
// continuation heading was cut out by the boundary and never re-appended: the
// milestone's OWN later phases silently vanished from the returned scope,
// while `getMilestonePhaseFilter`'s phaseCount stayed non-zero (unlike the
// original #612 defect), so the pass-all degrade never caught it either — a
// confidently wrong, non-degraded phase count for a still-incomplete
// milestone.
//
// Fixed here by teaching isBracketMilestoneBoundary a same-milestone
// exclusion: a candidate heading whose OWN bracket id (case-folded) equals the
// SELECTED milestone's bracket id is never a boundary.
describe('#612 PR-2 B1 round-2: a same-milestone continuation heading is not a boundary', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-b1r2-'); });
afterEach(() => { cleanup(tmpDir); });
const D = [['GSD.02-01-one', true], ['GSD.02-02-two', false]];
/**
* Per the file header's oracle-hazard #2: pre-write a WRONG total_phases
* into STATE.md's frontmatter before calling `syncedTotal()`/
* `syncedPercent()`, so `state sync` is forced to do real work rather than
* silently no-op because its computed total already happens to match.
*/
function poisonTotalPhases() {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
test('RED (repro8 case 1): version-bearing head + version-less "(Phase Details)" continuation — 2/1/50, not 1/1/100', () => {
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** b
## [GSD.02] Foundation (Phase Details)
### [GSD.02] 02: Two
**Goal:** c
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 1 before this fix — the continuation heading truncated the section');
poisonTotalPhases();
assert.equal(syncedTotal(), 2, 'the WRITE path must agree with the READ path');
assert.equal(syncedPercent(), 50, 'pinned 100 before this fix');
});
test('RED (repro5): fully version-less milestone split across two headings, no siblings — 2/1/50, not 1/1/100', () => {
writeProject(`# Roadmap
## [GSD.02] Foundation
### [GSD.02] 01: One
**Goal:** b
## [GSD.02] Foundation — continued
### [GSD.02] 02: Two
**Goal:** c
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 1 before this fix');
poisonTotalPhases();
assert.equal(syncedTotal(), 2);
// The roadmap is version-less while STATE pins v2.0, so upstream #3217
// withholds the percentage rather than treating the scope as complete.
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/);
});
test('PIN (repro8 case 3): a trailing DIFFERENT-id icebox section still terminates the primary section — unchanged at 2/1/50', () => {
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.999] Icebox
### [GSD.999] 07: Someday
**Goal:** z
`, 'bracket', D);
assert.equal(readTotal(), 2);
poisonTotalPhases();
assert.equal(syncedTotal(), 2);
assert.equal(syncedPercent(), 50);
});
test('PIN (repro10 A1): all-version-bearing + icebox + "(Phase Details)" — exact 2/1/50, no double-count', () => {
// The same-milestone exclusion must not make the PRIMARY section swallow
// the (Phase Details) section a second time on top of the pre-existing
// detailsMatch re-append — total_phases must read EXACTLY 2, not 4.
const dirs = [
['GSD.01-01-old', true],
['GSD.02-01-one', true],
['GSD.02-02-two', false],
['GSD.03-01-later', true],
];
writeProject(`# Roadmap
## [GSD.01] v1.0: Prior
### [GSD.01] 01: Old
**Goal:** a
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** b
## [GSD.03] v3.0: Later
### [GSD.03] 01: Later
**Goal:** d
## [GSD.02] v2.0: Current (Phase Details)
### [GSD.02] 02: Two
**Goal:** c
## [GSD.999] Icebox
### [GSD.999] 07: Someday
**Goal:** z
`, 'bracket', dirs);
assert.equal(readTotal(), 2, 'exact — a regression here would double-count to 4');
// NOTE: no syncedTotal()/syncedPercent() assertions here — this fixture
// carries dirs OUTSIDE the current milestone (GSD.01-01-old,
// GSD.03-01-later), which is exactly the shape that exposes Major 1
// (cmdStateSync's body percent is computed from an unfiltered whole-disk
// scan). Asserting the synced percent here before Major 1's fix lands
// would fail on a DIFFERENT, not-yet-fixed defect. Covered once Major 1 is
// fixed (#2761 Major 1 commit), where this fixture's synced values are
// asserted directly.
});
});
// ─── #2761 B2 (round-2 adversarial review, Blocker 2): the heading ──────────
// ─── discriminator is CONTENT, not level ────────────────────────────────────
//
// Three sites disagreed about which heading levels are a bracket milestone:
// the selector (`^#{1,3}\s+\[CODE.MM\]`) and `isMilestoneBounded` (state.cts)
// both admit level 1-3, but the B1 boundary only admitted level 1-2
// (`h.level <= 2`). A `###`-level bracket milestone was therefore SELECTED and
// BOUNDED but never TERMINATED: computeSectionEnd ran with level=3, a level-3
// SIBLING milestone survived `h.level > level` (not deeper), failed the
// version/emoji test (version-less), then failed `h.level <= 2` — so the
// function fell through to `return content.length`, sweeping the sibling
// milestone's own phases into the current one. This reproduces trek-e's
// original #612 defect verbatim (a safe degrade became a confidently-wrong
// persisted number) on a heading level the selector and bounding predicate
// both already admit.
//
// ADR-612 Decision 1 (docs/adr/612-bracket-phase-id-convention.md:56)
// specifies the discriminator as CONTENT: "a phase heading is a bracket
// followed by a digit-then-colon; a milestone heading is a bracket followed
// by a name." Fixed by replacing the `level > 2` rejection with
// BRACKET_PHASE_TAIL_RE (built from phase-id.cts's single-owner
// phaseHeadingPrefixSrcFor, not a re-typed grammar), with the level check
// widened to a depth-sanity cap of 3 (mirroring the selector's own `#{1,3}`
// ceiling — NOT a phase/milestone discriminator itself).
describe('#612 PR-2 B2 round-2: the bracket boundary is a CONTENT discriminator, not a level cap', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-b2r2-'); });
afterEach(() => { cleanup(tmpDir); });
test('RED (repro2 case C): all milestones ###, all phases ####, version-less — total 2, completion scoped, percent withheld', () => {
// GSD.02 (asserted milestone) has 2 phases, both complete. isMilestoneBounded
// already returns true at #{1,3} (state.cts, unaffected by this fix), so the
// phase totals remain directly observable — pinned 4/3 (whole-disk
// fallback) before this fix. Upstream #3573 now threads the asserted v2.0
// milestone into truthful progress scoping. Because this deliberately
// version-less roadmap cannot resolve that versioned window, percentage
// is withheld instead of manufacturing 100 from an UNSCOPED input.
const dirs = [
['GSD.01-01-old', true],
['GSD.02-01-one', true],
['GSD.02-02-two', true],
['GSD.03-01-later', false],
];
writeProject(`# Roadmap
### [GSD.01] Prior Milestone
#### [GSD.01] 01: Old one
**Goal:** a
### [GSD.02] Current Milestone
#### [GSD.02] 01: One
**Goal:** b
#### [GSD.02] 02: Two
**Goal:** c
### [GSD.03] Later Milestone
#### [GSD.03] 01: Later one
**Goal:** d
`, 'bracket', dirs);
assert.equal(readTotal(), 2, 'pinned 4 before this fix (whole-doc fallback)');
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
const progress = JSON.parse(r.output).progress;
assert.equal(progress?.total_phases, 2, 'pinned 4 before this fix (whole-doc fallback)');
assert.equal(progress?.completed_phases, 2, 'both real GSD.02 phases are complete — scoping, not the whole disk, is what this fixture pins');
// Upstream #3573 threads STATE's stored `milestone: v2.0` into this read.
// A version-less ROADMAP cannot resolve that version, so the scope remains
// UNSCOPED and the percent is withheld even though the bracket-scoped
// totals above are correct.
assert.equal(progress?.percent, undefined, 'percent is withheld (upstream #3573 x version-less-bracket-document interaction) — disclosed, not a scoping regression');
});
test('mechanism (repro7): extractCurrentMilestone actually scopes a level-3 milestone, not the whole document', () => {
const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later'];
const roadmap = `# Roadmap
### [GSD.01] Prior
#### [GSD.01] 01: Old
**Goal:** a
### [GSD.02] Current
#### [GSD.02] 01: One
**Goal:** b
#### [GSD.02] 02: Two
**Goal:** c
### [GSD.03] Later
#### [GSD.03] 01: Later
**Goal:** d
`;
writeProject(roadmap, 'bracket', dirs);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const scope = rp.extractCurrentMilestone(
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
tmpDir,
);
assert.ok(scope.length < roadmap.length, 'pinned scope === full document before this fix (fell through to content.length)');
assert.ok(scope.includes('01: One') && scope.includes('02: Two'), 'the milestone\'s own phases must survive scoping');
assert.ok(!scope.includes('Old') && !scope.includes('Later'), 'sibling milestones must not leak in');
});
test('PIN: a dotted sub-phase heading ([GSD.02] 05.03:) is phase-tail-shaped, not a boundary', () => {
// The tail grammar must cover BOTH `05:` and the dotted `05.03:` forms —
// this is precisely where a regex slip in the tail discriminator would
// hide (a milestone-shaped false negative would truncate the section at
// what is actually a real sub-phase heading).
const dirs = ['GSD.02-01-one', 'GSD.02-05.03-sub', 'GSD.03-01-later'];
writeProject(`# Roadmap
## [GSD.02] Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 05.03: Name
**Goal:** b
## [GSD.03] Later
### [GSD.03] 01: Later
**Goal:** c
`, 'bracket', dirs);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(!!f('GSD.02-01-one'), true);
assert.equal(!!f('GSD.02-05.03-sub'), true, 'the dotted sub-phase heading must not be excluded as a boundary');
assert.equal(!!f('GSD.03-01-later'), false);
assert.equal(readTotal(), 2, 'both GSD.02 phases (01 and 05.03) must be counted');
});
test('PIN (repro8 case 3, content-discriminator mechanism): a trailing DIFFERENT-id icebox section still terminates — 2/1/50', () => {
// Re-pins the same shape as the B1 round-2 block above, now that the
// discriminator is CONTENT rather than level: [GSD.999] is bracket-shaped
// and NOT phase-tail-shaped ("Icebox" carries no digit-colon), and its id
// differs from the selected milestone's — a boundary either way.
const D = [['GSD.02-01-one', true], ['GSD.02-02-two', false]];
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.999] Icebox
### [GSD.999] 07: Someday
**Goal:** z
`, 'bracket', D);
assert.equal(readTotal(), 2);
});
});
// ─── #2761 Blocker 3 (round-2 adversarial review): the preamble-cutoff ──────
// ─── bracket scan is now fence-aware ─────────────────────────────────────────
//
// `preambleCutoff`'s bracket branch used a raw `content.match`/`matchAll` —
// blind to fenced code blocks — while its sibling `computeSectionEnd` (a few
// lines above it) already consumed `tokenizeHeadings(content)`, which strips
// fences. A fenced markdown example in the preamble containing a bracket
// heading (ADR-612's own docs do exactly this) was textually the earliest
// `#{1,3} [CODE.MM]` match: `preambleCutoff` landed INSIDE the fence,
// `preamble = content.slice(0, preambleCutoff)` ended with an unclosed
// opener, and the unbalanced fence then blinded
// `getMilestonePhaseFilter`'s own `tokenizeHeadings(scope)` call — EVERY
// heading in the returned scope vanished, `phaseCount` degraded to 0, and the
// pass-all filter admitted every directory on disk (repro11's mechanism).
// Regression vs round-1, which had no bracket pattern to blind and so fell
// back to the correct (non-fenced) heading.
//
// Fixed by scanning the SAME `currentMilestoneHeadings` token list
// computeSectionEnd consumes, instead of a raw regex — closing the asymmetry
// between the two halves of one boundary semantic.
describe('#612 PR-2 Blocker 3 round-2: preambleCutoff is fence-aware (bracket branch only)', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-b3fence-'); });
afterEach(() => { cleanup(tmpDir); });
const D = [
['GSD.01-01-stray', true],
['GSD.02-01-one', true],
['GSD.02-02-two', false],
['GSD.03-01-stray', true],
];
test('RED (repro12 bracket row): a fenced example bracket heading in the preamble no longer blinds the scan — 2/1/50', () => {
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
## [GSD.00] Example milestone heading
\`\`\`
## [GSD.02] Current
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 4 before this fix (fence-blind scan degraded phaseCount to 0)');
});
test('mechanism (repro11): the returned scope has an EVEN, balanced fence count and a non-degraded phaseCount', () => {
const roadmap = `# Roadmap
Docs for authors:
\`\`\`markdown
## [GSD.00] Example milestone heading
### [GSD.00] 01: Example phase
\`\`\`
## [GSD.02] Current
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.03] Later
### [GSD.03] 01: Later
**Goal:** d
`;
const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later'];
writeProject(roadmap, 'bracket', dirs);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const ms = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
const scope = rp.extractCurrentMilestone(
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
tmpDir,
);
const fenceCount = (scope.match(/^```/gm) || []).length;
assert.equal(fenceCount % 2, 0, 'pinned an ODD (unbalanced) fence count before this fix');
const headings = ms.tokenizeHeadings(scope).map((h) => h.text);
assert.ok(headings.some((t) => /GSD\.02.*01: One/.test(t)), 'real headings must survive tokenization, not collapse to just "Roadmap"');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.notEqual(f.phaseCount, 0, 'pinned 0 (pass-all degrade) before this fix');
assert.deepEqual(
Object.fromEntries(dirs.map((d) => [d, !!f(d)])),
{ 'GSD.01-01-old': false, 'GSD.02-01-one': true, 'GSD.02-02-two': true, 'GSD.03-01-later': false },
'pinned every directory admitted (pass-all) before this fix',
);
});
test('UPSTREAM COMPOSITION (repro12 LEGACY control): #3573 scopes the fenced-example fixture to total 2', () => {
// This bracket-only fix still does not widen the LEGACY
// `anyMilestonePattern` raw-match path. Upstream's newer state derivation,
// however, routes this read through fence-aware `sliceMilestoneWindow`
// using STATE's stored milestone. The fixture therefore no longer reaches
// the blind path and must pin the improved scoped result, not the old
// whole-document total of 4.
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
## Milestone v9.0: Example
\`\`\`
## Milestone v2.0: Current
### Phase 01: One
**Goal:** b
### Phase 02: Two
**Goal:** c
`, undefined, [['03-stray', true], ['01-one', true], ['02-two', false], ['04-stray', true]]);
assert.equal(readTotal(), 2, 'upstream #3573 now scopes this read via sliceMilestoneWindow instead of the fence-blind anyMilestonePattern path');
});
test('PIN (repro10 A3): a fenced heading INSIDE the current section still must not terminate it', () => {
const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later'];
writeProject(`# Roadmap
## [GSD.02] Current
### [GSD.02] 01: One
**Goal:** b
\`\`\`markdown
## [GSD.03] Not a real heading
\`\`\`
### [GSD.02] 02: Two
**Goal:** c
## [GSD.03] Later
### [GSD.03] 01: Later
**Goal:** d
`, 'bracket', dirs);
assert.equal(readTotal(), 2);
});
});
// ─── #2761 B3 (self-caught, round-2 verification): CURRENT version-bearing, ──
// ─── a sibling milestone is not ──────────────────────────────────────────────
//
// The B1 fix (commit 08d5b0c4) resolved `bracketScopeConvention` only inside
// the `if (headingMatches.length === 0)` gate that also drives SELECTION's own
// bracket fallback. That gate is correct for SELECTION (only try the bracket
// heading shape when the version-string match found nothing), but
// `bracketScopeConvention` also feeds `computeSectionEnd`'s and
// `preambleCutoff`'s boundary-detection — which accidentally inherited
// SELECTION's gate instead of having its own.
//
// Trigger shape: the CURRENT milestone heading is ITSELF version-bearing
// (`## [GSD.02] v2.0: Current Milestone`), so the primary version-string
// `sectionPattern` matches it directly — `headingMatches.length !== 0` from the
// very first check — and the entire bracket-resolution branch is skipped. A
// sibling milestone (PRIOR or LATER) that is version-less then gets NEITHER
// the version/emoji boundary rule (it has no version) NOR the bracket boundary
// rule (never resolved) — reproducing the exact #612 defect symptom
// (total_phases falls back to the whole-disk count) through a structural
// shape B1's own fixtures never exercised (every B1 fixture is uniformly
// version-bearing or uniformly version-less across all three milestones).
describe('#612 PR-2 B3: bracket boundaries engage even when CURRENT is version-bearing but a sibling is not', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-b3-'); });
afterEach(() => { cleanup(tmpDir); });
const ROADMAP = `# Roadmap
## [GSD.01] Prior Milestone
### [GSD.01] 01: Old one
**Goal:** a
## [GSD.02] v2.0: Current Milestone
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.03] Later Milestone
### [GSD.03] 01: Later one
**Goal:** d
`;
const DIRS = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later-one'];
const acceptsFor = (dirs) => {
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
return Object.fromEntries(dirs.map((d) => [d, !!f(d)]));
};
test('CURRENT version-bearing + both siblings version-less: scoping works in both directions', () => {
writeProject(ROADMAP, 'bracket', DIRS);
assert.deepEqual(acceptsFor(DIRS), {
'GSD.01-01-old-one': false,
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later-one': false,
});
});
test('the PRIOR (version-less) milestone does not leak into the preamble (preambleCutoff twin)', () => {
writeProject(ROADMAP, 'bracket', DIRS);
assert.equal(acceptsFor(DIRS)['GSD.01-01-old-one'], false);
});
test('the LATER (version-less) milestone does not leak into scope (computeSectionEnd twin)', () => {
writeProject(ROADMAP, 'bracket', DIRS);
assert.equal(acceptsFor(DIRS)['GSD.03-01-later-one'], false);
});
test('total_phases counts only the asserted milestone, not the whole disk', () => {
writeProject(ROADMAP, 'bracket', DIRS);
// 4 directories on disk, 2 phases in the milestone STATE.md asserts.
assert.equal(readTotal(), 2);
});
});
// ─── #2761 Blocker 1 (round-3 adversarial re-verify): the preambleCutoff ────
// ─── scan drops current-milestone content whenever ANY bracket-shaped ──────
// ─── heading precedes the selected milestone heading ────────────────────────
//
// The round-2 fix (39c42a89) threaded `selectedBracketId` as the REAL value
// at computeSectionEnd but as bare `null` at the preambleCutoff scan — the
// deviation's own rationale ("the selected heading's own occurrence is
// always the correct earliest answer") was right, but `null` disables the
// same-milestone check for EVERY heading, not just the selected one. Any
// bracket-shaped heading earlier than the selected milestone — same id
// (cases A, B) or a DIFFERENT id with no children of its own (case D) — was
// wrongly accepted as the earliest boundary, and the region between it and
// the real `sectionStart` was silently dropped: a completed phase vanished
// and `state sync` persisted a confident 0% where base and round-1 both
// correctly wrote 50%.
//
// Fixed with two changes, both scoped to the preambleCutoff scan only
// (computeSectionEnd is untouched — it already threads the real
// `selectedBracketId`):
// (a) `h.offset === sectionStart` bypasses BOTH the same-milestone check
// and the same-id-child rule below — the selected heading's own
// position is definitionally correct, and this is the one case where
// neither discriminator should even run (rejecting it would be
// rejecting the heading against ITSELF).
// (b) every OTHER bracket-shaped, non-phase-tail-shaped candidate must
// additionally have a matching-id CHILD (the next strictly-deeper
// heading beneath it) to count as a boundary — `bracketHeadingHasMatchingChild`.
// This is what distinguishes a genuine sibling milestone (whose own
// phase children carry ITS bracket id) from an unrelated bracket-shaped
// PROSE heading like `## [ADR.612] Heading convention used by this
// roadmap` sitting above the current milestone's own content.
describe('#612 PR-2 Blocker 1 round-3: preambleCutoff identity is offset- and child-aware', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-b1r3-'); });
afterEach(() => { cleanup(tmpDir); });
const D = [['GSD.02-01-one', true], ['GSD.02-02-two', false]];
function poisonTotalPhases() {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
test('RED case A (rv-attack1): a same-milestone version-LESS heading earlier than the version-bearing selected heading — 2/1/50, not 1/0/0', () => {
writeProject(`# Roadmap
## [GSD.02] Foundation
- [ ] **[GSD.02] 01: One**
- [ ] **[GSD.02] 02: Two**
### [GSD.02] 01: One
**Goal:** a
## [GSD.02] v2.0: Foundation (Phase Details)
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 1 before this fix — the earlier checklist heading dropped everything before sectionStart');
poisonTotalPhases();
assert.equal(syncedTotal(), 2);
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix — a confidently wrong persisted 0%');
});
test('RED case B (rv-attack1): a same-milestone OVERVIEW heading (no "(Phase Details)" spelling) earlier than the version-bearing selected heading — 2/1/50, not 1/0/0', () => {
writeProject(`# Roadmap
## [GSD.02] Foundation (overview)
### [GSD.02] 01: One
**Goal:** a
## [GSD.02] v2.0: Foundation
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 1 before this fix');
poisonTotalPhases();
assert.equal(syncedTotal(), 2);
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix');
});
test('RED case D (rv-attack1b): a DIFFERENT-id bracket-shaped PROSE heading before the selected milestone — 2/1/50, not 1/0/0', () => {
writeProject(`# Roadmap
## [ADR.612] Heading convention used by this roadmap
Phases are listed under their milestone.
### [GSD.02] 01: One
**Goal:** a
## [GSD.02] v2.0: Foundation
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 1 before this fix — [ADR.612] read as a genuine boundary with no child-id check');
poisonTotalPhases();
assert.equal(syncedTotal(), 2);
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix');
});
test('PIN: a genuine prior sibling milestone (real children sharing its own id) is still excluded from the preamble', () => {
const dirs = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two'];
writeProject(`# Roadmap
## [GSD.01] Prior Milestone
### [GSD.01] 01: Old one
**Goal:** a
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
`, 'bracket', dirs.map((d, i) => [d, i !== 2]));
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(!!f('GSD.01-01-old-one'), false, 'the prior milestone must still be excluded — the child rule must not re-admit a genuine sibling');
assert.equal(readTotal(), 2);
});
test('PIN: a CHILDLESS prior sibling milestone (no phases of its own) degrades to NOT cutting the preamble — over-inclusive, safe', () => {
// "## [GSD.01] Empty Prior Milestone" has no deeper heading before the
// next heading at its own level — the child rule rejects it as a
// boundary, so its own heading TEXT stays in the preamble. That text is
// not phase-shaped (no digit-colon), so it contributes nothing to any
// count — over-inclusive, not under-inclusive, the safe direction.
writeProject(`# Roadmap
## [GSD.01] Empty Prior Milestone
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D);
assert.equal(readTotal(), 2, 'the current milestone\'s own two phases must still be counted, unpolluted by the childless sibling');
poisonTotalPhases();
assert.equal(syncedPercent(), 50);
});
test('Nit 2 PIN: a colon-less bracket heading ("[GSD.02] 05" with no trailing colon) does not spuriously terminate the preamble', () => {
// A colon-less bracket heading is bracket-shaped but NOT phase-tail-shaped
// (BRACKET_PHASE_TAIL_RE requires the trailing colon), so
// isBracketMilestoneBoundary alone would read it as a boundary. It is
// also — precisely because it is malformed/incomplete rather than a real
// milestone — childless (nothing deeper follows it before the next
// same-or-shallower heading), so the child rule neutralizes it here.
writeProject(`# Roadmap
### [GSD.02] 05
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D);
assert.equal(readTotal(), 2);
});
test('mechanism (rv-mech1): extractCurrentMilestone now scopes the FULL document for case A, phaseCount reflects both real phases', () => {
const roadmap = `# Roadmap
## [GSD.02] Foundation
- [ ] **[GSD.02] 01: One**
- [ ] **[GSD.02] 02: Two**
### [GSD.02] 01: One
**Goal:** a
## [GSD.02] v2.0: Foundation (Phase Details)
### [GSD.02] 02: Two
**Goal:** b
`;
writeProject(roadmap, 'bracket', D);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const scope = rp.extractCurrentMilestone(
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
tmpDir,
);
assert.equal(scope, roadmap, 'pinned a truncated scope (bytes [11,124) dropped) before this fix');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(f.phaseCount, 2, 'pinned phaseCount 1 before this fix');
assert.equal(!!f('GSD.02-01-one'), true, 'pinned false before this fix — a real dir of the CURRENT milestone');
assert.equal(!!f('GSD.02-02-two'), true);
});
});
// ─── #2761 Major 1 (round-3 adversarial re-verify): the version/emoji half ──
// ─── of preambleCutoff's min() is now fence-aware too, on the bracket ───────
// ─── branch only ─────────────────────────────────────────────────────────────
//
// ff6bf0a8 (round-2 Blocker 3) made the BRACKET half of preambleCutoff's
// "earliest milestone-shaped heading" search fence-aware, but left the
// VERSION/emoji half a raw `content.match` even on the bracket branch. A
// fenced VERSION-BEARING example heading in a bracket repo's preamble
// (ADR-612's own docs illustrate the LEGACY heading shape this way, inside a
// fenced authoring-guide block) was still textually the earliest match for
// that raw regex, winning the min() and formerly un-suppressing a wrong
// persisted 75%. On the rebased base, #3217 keeps the unsafe percentage
// suppressed explicitly when the version-less milestone cannot yield a
// COMPLETE version override.
describe('#612 PR-2 Major 1 round-3: preambleCutoff\'s version/emoji half is fence-aware on the bracket branch', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-m1r3-'); });
afterEach(() => { cleanup(tmpDir); });
const D = [
['GSD.02-01-one', true], ['GSD.02-02-two', false],
['GSD.03-01-x', true], ['GSD.03-02-y', true],
];
test('RED case C1 (rv-attack3c): a fenced VERSION-bearing example does not inflate the total; unsafe percent stays withheld', () => {
writeProject(`# Roadmap
Authoring guide — a milestone heading looks like:
\`\`\`markdown
## Milestone v9.0: Example
\`\`\`
## [GSD.02] Current
### [GSD.02] 01: One
### [GSD.02] 02: Two
## [GSD.03] Later
### [GSD.03] 01: X
### [GSD.03] 02: Y
`, 'bracket', D);
assert.equal(readTotal(), 2, 'pinned 4 before this fix — the fenced VERSION heading won the min() and swallowed everything before it into the preamble unstripped');
poisonTotalPhases();
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/,
'upstream must keep the unsafe percentage withheld rather than resurface the old 75%');
});
test('PIN case C2: a fenced BRACKET-shaped heading in the same position (round-2\'s own fix target) stays unchanged at 2/1/50', () => {
writeProject(`# Roadmap
Authoring guide — a milestone heading looks like:
\`\`\`markdown
## [GSD.00] Example
\`\`\`
## [GSD.02] Current
### [GSD.02] 01: One
### [GSD.02] 02: Two
## [GSD.03] Later
### [GSD.03] 01: X
### [GSD.03] 02: Y
`, 'bracket', D);
assert.equal(readTotal(), 2);
});
function poisonTotalPhases() {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
});
// ─── #2761 round-3 hardening (team-lead review of f87bba0e): two edges in ──
// ─── the new preambleCutoff code ────────────────────────────────────────────
//
// AMENDMENT 1 — bracketHeadingHasMatchingChild originally checked only
// `headings[index + 1]` (the IMMEDIATE next heading), not the candidate's
// whole subtree. A genuine prior sibling milestone whose section opens with
// a non-bracket subsection before its first phase (`## [GSD.01] Setup` /
// `### Notes` / `### [GSD.01] 01: Old`) was therefore wrongly rejected as a
// boundary — its own real phase heading is TWO headings deep, not one — and
// its entire section leaked into the preamble unstripped.
//
// CONFIRMED RED at f87bba0e before this fix (per the team lead's request to
// check observability, not just theory): the leak is NOT merely inert —
// `GSD.01-01-old`'s directory was wrongly admitted into the CURRENT
// milestone's filter via the leaked heading's qualified key
// (`GSD.01-01`), reading **3/2/67%** where truth is **2/1/50%**. Scope
// membership DOES drive the disk-side filter on this shape. Fixed by
// scanning the candidate's full SUBTREE (continue past a non-matching
// deeper heading instead of returning false immediately; only a
// same-or-shallower heading actually closes the subtree).
//
// AMENDMENT 2 — the round-3 Major 1 fix (c483552a) ported the version/emoji
// half of preambleCutoff to the token-based scan with no level cap; the raw
// `content.match(anyMilestonePattern)` it replaced was anchored `^#{1,3}\s+`.
// A level-4+ version-bearing heading in the preamble (`#### v2.0 notes`)
// therefore won the scan on the bracket branch where the raw pattern (and
// the legacy path, unaffected) ignores it outright — a heading neither the
// selector nor `isMilestoneBounded` would ever treat as a milestone marker.
// Fixed with `if (h.level > 3) continue;`, mirroring the depth-sanity cap
// `isBracketMilestoneBoundary` already applies to the bracket half.
describe('#612 PR-2 round-3 hardening: subtree child scan + level cap on preambleCutoff', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r3h-'); });
afterEach(() => { cleanup(tmpDir); });
test('RED (rv2-amend1): a genuine prior sibling with an intervening non-bracket subsection is still excluded — 2/1/50, not 3/2/67', () => {
const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]];
writeProject(`# Roadmap
## [GSD.01] Setup
### Notes
Some prose about the prior milestone.
### [GSD.01] 01: Old
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
### [GSD.02] 02: Two
`, 'bracket', dirs);
assert.equal(readTotal(), 2, 'pinned 3 before this fix — the immediate-next-heading-only check rejected [GSD.01] Setup as a boundary because its FIRST child (### Notes) is not bracket-shaped, even though its SECOND child (### [GSD.01] 01: Old) is');
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(!!f('GSD.01-01-old'), false, 'pinned true before this fix — the leaked heading\'s qualified key wrongly admitted the prior milestone\'s own directory');
assert.equal(!!f('GSD.02-01-one'), true);
assert.equal(!!f('GSD.02-02-two'), true);
});
test('PIN (rv2-amend2): a level-4 version-bearing preamble heading is NOT a cutoff on the bracket branch — the preamble text survives unstripped', () => {
const roadmap = `# Roadmap
#### v2.0 notes
Some prose that happens to mention v2.0 in a deep heading.
## [GSD.02] Current
### [GSD.02] 01: One
### [GSD.02] 02: Two
`;
writeProject(roadmap, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false]]);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const scope = rp.extractCurrentMilestone(
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
tmpDir,
);
assert.ok(scope.includes('v2.0 notes'), 'pinned dropped before this fix — the level-4 heading wrongly won the earliest-of-either scan and truncated the preamble at itself');
assert.equal(readTotal(), 2);
});
test('PIN: the LEGACY control for the level-4 preamble heading is unchanged (raw content.match path untouched)', () => {
writeProject(`# Roadmap
#### v2.0 notes
Some prose that happens to mention v2.0 in a deep heading.
## Milestone v2.0: Current
### Phase 01: One
### Phase 02: Two
`, undefined, [['01-one', true], ['02-two', false]]);
assert.equal(readTotal(), 2);
});
});
// ─── #2761 round-4 (team-lead re-verify of fbfd0fca): the child rule must ──
// ─── require a same-id PHASE, not merely a same-id heading ──────────────────
//
// bracketHeadingHasMatchingChild's subtree scan (fbfd0fca) proved SAME-ID-NESS
// but never asked whether the matching child was PHASE-shaped. Case F1
// reopens round-3's case D one heading later: `## [ADR.612] Heading
// convention` is followed by its OWN sub-heading `### [ADR.612] Examples` —
// same bracket id as the candidate, but MILESTONE-shaped (a name, no
// digit-then-colon), not a phase. Same-id-ness alone satisfied the subtree
// scan, re-cutting the preamble at exactly the shape the round-3 hardening
// was written to close.
function poisonTotalPhasesR4(dir) {
const statePath = path.join(dir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
describe('#612 PR-2 round-4 Blocker 1: the same-id child must be PHASE-shaped', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r4b1-'); });
afterEach(() => { cleanup(tmpDir); });
test('RED (F1): a same-id MILESTONE-shaped child on a prose heading no longer satisfies the child rule — 2/1/50, not 1/0/0', () => {
writeProject(`# Roadmap
## [ADR.612] Heading convention
### [ADR.612] Examples
Prose about the convention.
### [GSD.02] 01: One
**Goal:** a
## [GSD.02] v2.0: Foundation
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false]]);
assert.equal(readTotal(), 2, 'pinned 1 before this fix — [ADR.612]\'s own MILESTONE-shaped sub-heading satisfied the same-id-only rule');
poisonTotalPhasesR4(tmpDir);
assert.equal(syncedTotal(), 2);
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix — state sync reported "nothing to do" because its wrong 0% happened to equal the seed');
});
test('PIN (F11): a genuine prior sibling whose same-id child lacks the trailing colon is still correctly excluded (inert leak, not a boundary either way)', () => {
const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]];
writeProject(`# Roadmap
## [GSD.01] Setup
### [GSD.01] 01 Old
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', dirs);
assert.equal(readTotal(), 2);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(!!f('GSD.01-01-old'), false, 'the colon-less heading forms no qualified key either, so the childless-degrade leak is inert');
});
test('PIN (F11b): a genuine prior sibling whose phases exist only as bullets is still correctly excluded', () => {
const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]];
writeProject(`# Roadmap
## [GSD.01] Setup
- [x] **[GSD.01] 01: Old**
### [GSD.01] Retrospective
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', dirs);
assert.equal(readTotal(), 2);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.equal(!!f('GSD.01-01-old'), false, 'a bracket bullet never forms a qualified key (BULLET_PHASE_LINE_PATTERN needs a literal **Phase )');
});
test('re-verify: the four existing child-rule pins (F2-F5 shapes) are unaffected by the phase-shape requirement', () => {
// F2: subtree closure — same-id child appears only after a same-level
// heading closes the subtree; must not count (over-inclusive degrade).
writeProject(`# Roadmap
## [GSD.01] Setup
Prose, no children.
## [DOC.99] Reference
### [GSD.01] 01: Old
### [GSD.02] 01: One
**Goal:** a
## [GSD.02] v2.0: Current
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]);
assert.equal(readTotal(), 3, 'unchanged — the declared over-inclusive/safe degrade (Minor 1), not a regression');
// F3: deep wrong-id children then a same-id PHASE child inside the subtree — must count.
writeProject(`# Roadmap
## [GSD.01] Setup
### Notes
#### Deep note
### [DOC.99] Aside
### [GSD.01] 01: Old
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]);
assert.equal(readTotal(), 2);
// F4: the same-id PHASE child is itself level 4 — the child scan has no depth ceiling.
writeProject(`# Roadmap
## [GSD.01] Setup
### Notes
#### [GSD.01] 01: Old
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]);
assert.equal(readTotal(), 2);
// F5: trailing bracket sibling at document end (childless) — computeSectionEnd
// excludes it without needing the child rule at all.
writeProject(`# Roadmap
## [GSD.02] v2.0: Current
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
## [GSD.03] Later
`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false], ['GSD.03-01-later', true]]);
assert.equal(readTotal(), 2);
});
});
// ─── #2761 round-4 Major 1: the four fence-blind sites known at round 4 ────
// ─── on the bracket path (a fifth — the retirement scan — was found at ────
// ─── round 5 and has its own block further below) ──────────────────────────
//
// The scope string extractCurrentMilestone returns is fence-BALANCED and
// fence-STRIPPED-by-tokenizeHeadings only when its CONSUMERS ask it that way.
// Four sites still read it (or the raw ROADMAP) with a plain regex `.exec`,
// blind to fences:
//
// (a) roadmapPhaseCount — TWO independent copies, buildStateFrontmatter
// (read path) and cmdStateSync (write path). A fenced EXAMPLE phase
// heading in the preamble inflated total_phases (F10); on a
// version-less roadmap the SAME fence-blindness compounds with (c)
// below (F9).
// (b) isMilestoneBounded — a raw `.test(roadmapRaw)`. A fenced-ONLY bracket
// heading (no real section for the asserted milestone at all) wrongly
// BOUNDED a milestone that isn't in the roadmap, un-suppressing a
// percent that should stay suppressed (F12).
// (c) the bracket-fallback SELECTOR (only reachable when the version-string
// selection found nothing) — a raw `content.matchAll`. A fenced example
// sharing the CURRENT project's own bracket id could be SELECTED as the
// current milestone, landing `sectionStart` inside a fence (F9).
//
// Fixed by extracting ONE shared counter (countRoadmapPhaseHeadings, in
// src/state.cts, immediately above buildStateFrontmatter) used by both (a)
// copies, and converting (b) and (c) to tokenizeHeadings-based scans. The
// PRODUCER (extractCurrentMilestone's returned scope string) is deliberately
// UNCHANGED — every other consumer of that string needs its full content
// fidelity, and legacy identity forbids touching the shared string. Legacy
// (non-bracket) behavior at all four sites is byte-identical; this is the
// ONLY commit in this arc that touches SELECTION.
describe('#612 PR-2 round-4 Major 1: the four fence-blind sites known at round 4 (a fifth was found at round 5, see its own block below)', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r4m1-'); });
afterEach(() => { cleanup(tmpDir); });
const D2 = [['GSD.02-01-one', true], ['GSD.02-02-two', false]];
function poisonTotalPhasesR4M1() {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
test('RED (F10): a fenced SAME-id PHASE heading in the preamble no longer inflates roadmapPhaseCount — 2/1/50, not 3/1/33', () => {
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
### [GSD.02] 05: Example phase
\`\`\`
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D2);
assert.equal(readTotal(), 2, 'pinned 3 before this fix — the fenced example phase heading was counted by the fence-blind raw .exec()');
poisonTotalPhasesR4M1();
assert.equal(syncedTotal(), 2);
assert.equal(syncedPercent(), 50, 'pinned 33 before this fix');
});
test('PIN (F10 LEGACY control): the same fenced-phase shape on a non-bracket repo is unchanged (correct on every build)', () => {
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
### Phase 05: Example phase
\`\`\`
## Milestone v2.0: Foundation
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, undefined, [['01-one', true], ['02-two', false]]);
assert.equal(readTotal(), 2);
});
test('PIN (F10c): same document, fenced line is NOT phase-shaped — unaffected either way', () => {
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
### [GSD.02] Example section
\`\`\`
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D2);
assert.equal(readTotal(), 2);
});
test('RED (F9): version-LESS roadmap + a fenced example sharing the SAME milestone id — both the selector and the counter must be fence-aware — 2/1/50, not 3/1/33', () => {
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
## [GSD.02] Example milestone heading
### [GSD.02] 05: Example phase
\`\`\`
## [GSD.02] Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D2);
assert.equal(readTotal(), 2, 'pinned 3 before this fix');
poisonTotalPhasesR4M1();
assert.equal(syncedTotal(), 2);
// The roadmap is version-less while STATE pins v2.0, so upstream #3217
// withholds the percentage rather than treating the scope as complete.
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/);
});
test('RED (F12): a fenced-ONLY bracket heading no longer bounds a milestone absent from the roadmap — percent stays suppressed', () => {
const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]];
writeProject(`# Roadmap
Authoring guide:
\`\`\`markdown
## [GSD.02] Example milestone heading
\`\`\`
## [GSD.01] v1.0: Prior
### [GSD.01] 01: Old
**Goal:** a
`, 'bracket', dirs);
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
const progress = JSON.parse(r.output).progress;
assert.equal(progress?.percent, undefined, 'pinned 67 before this fix — a fenced-only [GSD.02] example wrongly bounded a milestone with no real section');
// state sync must not persist a percent either — the body stays at its seed.
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(syncResult.success);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(raw, /\*\*Progress:\*\*[^\r\n]*?0%/, 'pinned 67% persisted before this fix');
});
test('PIN: unfenced bracket-fallback selection is byte-identical — first real milestone-shaped heading still wins', () => {
// No version anywhere, no fences — the selector's plain first-match-wins
// behavior over REAL headings must be completely unaffected by routing it
// through tokenizeHeadings.
const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later'];
writeProject(`# Roadmap
## [GSD.01] Prior Milestone
### [GSD.01] 01: Old
**Goal:** a
## [GSD.02] Current Milestone
### [GSD.02] 01: One
**Goal:** b
### [GSD.02] 02: Two
**Goal:** c
## [GSD.03] Later Milestone
### [GSD.03] 01: Later
**Goal:** d
`, 'bracket', dirs);
const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs');
const f = rp.getMilestonePhaseFilter(tmpDir);
assert.deepEqual(Object.fromEntries(dirs.map((d) => [d, !!f(d)])), {
'GSD.01-01-old': false,
'GSD.02-01-one': true,
'GSD.02-02-two': true,
'GSD.03-01-later': false,
});
assert.equal(readTotal(), 2);
});
});
// ─── #2761 round-5 Blocker 1: 3be5c412's merge dropped the `bracketId &&` ───
// ─── guard countRoadmapPhaseHeadings' bracket branch needs ──────────────────
//
// Every other isSentinelPhaseId call site in src/ (roadmap-parser.cts,
// roadmap.cts, validate.cts ×2, verify.cts) guards the call with
// `bracketId &&`. The shared counter's bracket branch omitted it: when the
// LEGACY alternative of the intro grammar matches (a `### Phase 00:` heading
// in a `phase_id_convention: "bracket"` repo — the mid-migration shape this
// PR exists for), `m[1]` (bracketId) is `undefined`, and the call becomes
// `isSentinelPhaseId("undefined-00", 'bracket')` — which is TRUE, silently
// dropping a real phase from the denominator. `getMilestonePhaseFilter`
// still counts it and admits its directory, so the filter and the counter
// disagree — a half-done milestone reads as 100% complete.
describe('#612 PR-2 round-5 Blocker 1: countRoadmapPhaseHeadings restores the bracketId guard', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r5b1-'); });
afterEach(() => { cleanup(tmpDir); });
function poisonTotalPhasesR5(dir) {
const statePath = path.join(dir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
test('RED (G3): a legacy `### Phase 00:` heading in a bracket repo is no longer dropped — 3/2/67, not 2/2/100', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 00: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, 'bracket', [['00-bootstrap', true], ['01-one', true]]);
assert.equal(readTotal(), 3, 'pinned 2 before this fix — "undefined-00" read as a sentinel');
poisonTotalPhasesR5(tmpDir);
assert.equal(syncedTotal(), 3);
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix — a half-done milestone read as shipped');
});
test('PIN (G3 LEGACY control): the identical document under the legacy convention is unaffected', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 00: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, undefined, [['00-bootstrap', true], ['01-one', true]]);
// Upstream #3185 canonically treats legacy 00 as a sentinel; the bracket
// branch deliberately remains narrower, which is the distinction above.
assert.equal(readTotal(), 2);
});
test('RED (G3d, mixed mid-migration): bracket headings PLUS one legacy `### Phase 00:` — 3/2/67, not 2/2/100', () => {
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
### Phase 00: Bootstrap
**Goal:** z
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', [['00-bootstrap', true], ['GSD.02-01-one', true]]);
assert.equal(readTotal(), 3, 'pinned 2 before this fix');
poisonTotalPhasesR5(tmpDir);
assert.equal(syncedTotal(), 3);
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix');
});
test('PIN (G3b, isolates the counter): a `### Phase 000:` heading with no directory — total still 3, no dir to admit', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 000: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, 'bracket', [['01-one', true], ['02-two', false]]);
assert.equal(readTotal(), 3, 'pinned 2 before this fix');
});
test('PIN (G3c control): legacy `### Phase 01:`/`02:` only, no sentinel-shaped token — unaffected', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, 'bracket', [['01-one', true], ['02-two', false]]);
assert.equal(readTotal(), 2);
});
});
// ─── #2761 round-5 Major 1: extractRetiredPhaseNumbers is a FIFTH ───────────
// ─── fence-blind site on the bracket path ───────────────────────────────────
//
// The retirement gesture's own line scan iterates `scope.split(/\r?\n/)`
// with no fence awareness. Once the bracket alternative is compiled into
// `introSrc` (this PR's own change), a FENCED authoring EXAMPLE showing the
// #1514 retirement gesture in bracket spelling is indistinguishable from a
// real one — it retires a genuine phase, shrinking the denominator and
// jumping the percent to a confident 100%.
describe('#612 PR-2 round-5 Major 1: extractRetiredPhaseNumbers is fence-aware on the bracket path', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r5m1-'); });
afterEach(() => { cleanup(tmpDir); });
const D2 = [['GSD.02-01-one', true], ['GSD.02-02-two', false]];
test('RED (G2): a fenced retired-strikethrough bracket bullet in the preamble no longer retires a real phase — 2/1/50, not 1/1/100', () => {
writeProject(`# Roadmap
Retiring a phase looks like this:
\`\`\`markdown
- [x] ~~**[GSD.02] 02: Two**~~ — folded into 03; number retired
\`\`\`
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D2);
assert.equal(readTotal(), 2, 'pinned 1 before this fix — the fenced EXAMPLE retirement gesture wrongly retired phase 02');
});
test('RED (G2b): the same fenced retirement example placed INSIDE the milestone section — not a preamble-scoping artifact', () => {
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
Retiring a phase looks like this:
\`\`\`markdown
- [x] ~~**[GSD.02] 02: Two**~~ — folded; number retired
\`\`\`
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', D2);
assert.equal(readTotal(), 2, 'pinned 1 before this fix');
});
test('PIN (G2 LEGACY control): the same fenced-example shape on a non-bracket repo is unchanged (pre-existing, out of scope)', () => {
writeProject(`# Roadmap
Retiring a phase looks like this:
\`\`\`markdown
- [x] ~~**Phase 02: Two**~~ — folded into 03; number retired
\`\`\`
## Milestone v2.0: Foundation
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, undefined, [['01-one', true], ['02-two', false]]);
assert.equal(readTotal(), 1, 'pre-existing legacy hazard — deliberately unchanged, base is wrong here too');
});
});
// ─── #2761 round-5 Major 2: state sync's own counter now excludes the ──────
// ─── bare `999` icebox token like the other two derivations ─────────────────
//
// Under bracket, READING-B puts the sentinel in the BRACKET
// (isSentinelPhaseId), so `/^999\b/` on the bare TOKEN is the only thing
// excluding a `### [GSD.02] 999:` icebox heading — and it ran on the read
// path (buildStateFrontmatter) and getMilestonePhaseFilter, but not on
// cmdStateSync's own counter. One `state sync` call could leave a single
// STATE.md with its frontmatter (percent 50, from the read-path re-sync) and
// its body (percent 33, from the write-path counter that still counted the
// icebox heading) disagreeing.
describe('#612 PR-2 round-5 Major 2: state sync excludes the bracket 999 icebox token like the read path', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r5m2-'); });
afterEach(() => { cleanup(tmpDir); });
test('RED (G1): state sync\'s body percent now agrees with state json\'s percent on a bracket 999 icebox heading', () => {
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
### [GSD.02] 999: Backlog item
**Goal:** later
`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false]]);
const readPercent = (() => {
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
return JSON.parse(r.output).progress?.percent;
})();
assert.equal(readPercent, 50);
assert.equal(syncedPercent(), readPercent, 'pinned 33 (vs read-path 50) before this fix — one STATE.md, two disagreeing numbers');
});
test('PIN (G1 LEGACY control): the pre-existing legacy read/write divergence on the same shape is unchanged', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
### Phase 999: Backlog item
**Goal:** later
`, undefined, [['01-one', true], ['02-two', false]]);
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
assert.equal(JSON.parse(r.output).progress?.percent, 50);
assert.equal(syncedPercent(), 33, 'the legacy asymmetry is genuinely pre-existing — deliberately unchanged');
});
});
// ─── #2761 round-5 Minor 1: the two tokenizeHeadings reconstructions ───────
// ─── (bracket-fallback selector, isMilestoneBounded) accept indented ───────
// ─── headings their raw line-start-anchored predecessors never did ─────────
//
// `HeadingToken.offset` is `tokenizeHeadings`' LINE-START offset. For a
// ≤3-space-indented heading that is NOT the `#` character's own offset, so
// a token the raw `^#{1,3}\s+\[...` regex never matched (indentation moves
// it off the line-start anchor) was still accepted by the reconstruction,
// which then mis-parses (selectedBracketId null, a sibling milestone's
// phases leaking into the scope).
describe('#612 PR-2 round-5 Minor 1: bracket-fallback selector skips indented headings (raw parity)', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r5min1-'); });
afterEach(() => { cleanup(tmpDir); });
test('RED (G6): a 2-space-indented, version-less bracket milestone heading no longer leaks the NEXT milestone\'s phases in — total 2, not 3', () => {
writeProject(`# Roadmap
## [GSD.02] Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
## [GSD.03] Later
### [GSD.03] 01: Later one
**Goal:** c
`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false], ['GSD.03-01-later-one', true]]);
assert.equal(readTotal(), 2, 'pinned 3 before this fix — GSD.03\'s phase leaked into scope');
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/,
'upstream must withhold the unsafe version-less percentage');
});
test('PIN (G6c UNINDENTED control): the identical document with no leading indent is unaffected — total 2', () => {
writeProject(`# Roadmap
## [GSD.02] Foundation
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
## [GSD.03] Later
### [GSD.03] 01: Later one
**Goal:** c
`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false], ['GSD.03-01-later-one', true]]);
assert.equal(readTotal(), 2);
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/);
});
// G6 above happens to leave isMilestoneBounded's own verdict unchanged
// either way — the phase headings (`### [GSD.02] 01: One`, unindented)
// already satisfy its loose bracket-prefix regex, so it returns bounded=true
// both before and after this fix on that fixture. This second fixture
// isolates isMilestoneBounded specifically: the ONLY `[GSD.02]`-shaped
// heading anywhere in the document is indented, mirroring round-4's F12
// (fenced-ONLY) shape but with indentation as the parity gap instead of a
// fence.
test('RED (isMilestoneBounded site, indented-ONLY): an indented-only bracket heading no longer bounds a milestone absent from the roadmap — percent stays suppressed', () => {
const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]];
writeProject(`# Roadmap
Authoring guide:
## [GSD.02] Example milestone heading
## [GSD.01] v1.0: Prior
### [GSD.01] 01: Old
**Goal:** a
`, 'bracket', dirs);
const r = runGsdTools(['state', 'json'], tmpDir);
assert.ok(r.success, `state json failed: ${r.error}`);
const progress = JSON.parse(r.output).progress;
assert.equal(progress?.percent, undefined, 'pinned 100 before this fix — an indented-only [GSD.02] example wrongly bounded a milestone with no real section');
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(syncResult.success);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(raw, /\*\*Progress:\*\*[^\r\n]*?0%/, 'pinned 100% persisted before this fix; body must stay at its seed');
});
});
// ─── #2761 round-6 Blocker 1: countRoadmapPhaseHeadings' bracket-only ──────
// ─── `/^0\b/` sibling rule needs the SAME `bracketId &&` guard round-5's ───
// ─── Blocker 1 restored two lines above it ──────────────────────────────────
//
// Round-5's Blocker 1 was `isSentinelPhaseId("undefined-00")`. This is the
// identical failure one line down: when the phase-heading grammar's LEGACY
// alternative matches (`### Phase 0:` in a `phase_id_convention: "bracket"`
// repo — the mid-migration shape this PR exists for), `bracketId` is
// `undefined`, and the unguarded `/^0\b/` fires on the bare token anyway.
// Neither the LEGACY branch of this same function nor
// `getMilestonePhaseFilter` has a `/^0\b/` rule at all, so the filter counts
// the phase and admits its completed directory while the counter refuses to
// count its heading — a milestone with an unstarted phase 02 persists as a
// confident 100%. `/^0\b/` matches `0` and `0.5` (word boundary before the
// dot) but not `00` (no boundary between the two zeros) — which is exactly
// why round-5's G3/G3d fixtures (both spelled `Phase 00:`) never tripped
// this one.
describe('#612 PR-2 round-6 Blocker 1: countRoadmapPhaseHeadings\' bracket-only /^0\\b/ rule restores the bracketId guard', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-r6b1-'); });
afterEach(() => { cleanup(tmpDir); });
function poisonTotalPhasesR6(dir) {
const statePath = path.join(dir, '.planning', 'STATE.md');
const raw = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
}
test('RED (T0): a legacy `### Phase 0:` heading in a bracket repo is no longer dropped — 3/2/67, not 2/2/100', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 0: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, 'bracket', [['0-bootstrap', true], ['01-one', true]]);
assert.equal(readTotal(), 3, 'pinned 2 before this fix — "0" read as a sentinel with no bracketId guard');
poisonTotalPhasesR6(tmpDir);
assert.equal(syncedTotal(), 3);
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix — a milestone with an unstarted phase 02 read as shipped');
});
test('PIN (T0L LEGACY control): the identical document follows upstream legacy sentinel semantics', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 0: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, undefined, [['0-bootstrap', true], ['01-one', true]]);
assert.equal(readTotal(), 2, 'upstream #3185 canonically excludes legacy phase 0');
});
test('RED (T05): a legacy `### Phase 0.5:` heading in a bracket repo — same defect, second spelling — 3/2/67, not 2/2/100', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 0.5: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, 'bracket', [['0.5-bootstrap', true], ['01-one', true]]);
assert.equal(readTotal(), 3, 'pinned 2 before this fix');
poisonTotalPhasesR6(tmpDir);
assert.equal(syncedTotal(), 3);
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix');
});
test('PIN (T05L LEGACY control): the identical document follows upstream legacy sentinel semantics', () => {
writeProject(`# Roadmap
## Milestone v2.0: Foundation
### Phase 0.5: Bootstrap
**Goal:** z
### Phase 01: One
**Goal:** a
### Phase 02: Two
**Goal:** b
`, undefined, [['0.5-bootstrap', true], ['01-one', true]]);
assert.equal(readTotal(), 2, 'upstream #3185 canonically excludes legacy phase 0.5');
});
test('PIN (B0, bracket-spelled `0` token — Minor 1, NOT fixed this round): counter/filter disagreement is base-parity, deliberately unchanged', () => {
// `### [GSD.02] 0: Bootstrap` — the SAME token shape, spelled in bracket
// form rather than legacy form. `isSentinelPhaseId('GSD.02-0','bracket')`
// is false (READING-B puts the sentinel in the bracket, not the token),
// so `/^0\b/` is the only thing that could exclude it — and per round-6's
// review this shape reads 2/2/100 on base AND HEAD (never closed by any
// build in this arc), so it is a pre-existing gap, not a regression.
// Disclosed in the changeset; pinned here as a base-parity characterization.
writeProject(`# Roadmap
## [GSD.02] v2.0: Foundation
### [GSD.02] 0: Bootstrap
**Goal:** z
### [GSD.02] 01: One
**Goal:** a
### [GSD.02] 02: Two
**Goal:** b
`, 'bracket', [['GSD.02-0-bootstrap', true], ['GSD.02-01-one', true]]);
assert.equal(readTotal(), 2, 'base-parity: this shape has never been closed by any build in this arc');
});
});
// ═════════════════════════════════════════════════════════════════════════
// #2761 round-11 BLOCKER (trek-e review): `listMilestonePhaseDirs`'s third
// param (`phaseIdConvention`) lost its `= null` default so a caller that
// omits it now gets "resolve from config" instead of "explicitly not
// bracket" — a deliberate flip, but the changeset claimed "the archival and
// milestone-completion paths are unchanged." False: `cmdMilestoneComplete`
// (src/milestone.cts) and `cmdStateUpdateProgress` (src/state.cts) both call
// the enumerator and neither file was in the PR-2 diff. Both now thread the
// convention explicitly (resolved once, ambiently, off `cwd`) instead of
// silently inheriting the lazy default. These two blocks PIN the enumerated
// set / write behavior on a bracket project so a future regression to a
// hardcoded (non-bracket) default — or to a different resolve-from-config
// answer — is a failing test, not a silent set change on a command that
// renames/archives directories or writes STATE.md.
// ═════════════════════════════════════════════════════════════════════════
describe('#2761 round-11 BLOCKER: cmdMilestoneComplete enumerates the bracket-scoped set', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-milestone-complete-'); });
afterEach(() => { cleanup(tmpDir); });
test('PIN: --dry-run would_archive.phases is exactly the 3 bracket-declared phase dirs, not the whole phases/ directory', () => {
// BRACKET_ROADMAP (above) declares milestone v2.0 with phases 01, 05, 06
// under the [GSD.02] bracket tag. One directory per declared phase, PLUS
// a decoy that belongs to NO declared phase. Correct (convention-threaded)
// scoping excludes the decoy. A regression back to the pre-#612 hardcoded
// "not bracket" default would fail to recognize the bracket HEADINGS at
// all, `milestonePhaseNums` would come back empty, and
// `getMilestonePhaseFilter` degrades to a pass-all predicate — which
// would sweep the decoy in too. Either failure mode shows up as a
// mismatch against the pinned set below.
writeProject(BRACKET_ROADMAP, 'bracket', [
'GSD.02-01-setup',
'GSD.02-05-real-work',
'GSD.02-06-follow-up',
// Incomplete: the decoy carries no phase token, so it cannot be named
// by verificationNameFor's production-derived scheme (see its
// docstring) — and completeness is irrelevant to what this test pins,
// which is exclusion from would_archive.phases regardless.
['not-a-declared-phase', false],
]);
const r = runGsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--raw'], tmpDir);
assert.ok(r.success, `milestone complete --dry-run failed: ${r.error}`);
const parsed = JSON.parse(r.output);
assert.deepStrictEqual(
[...parsed.would_archive.phases].sort(),
['GSD.02-01-setup', 'GSD.02-05-real-work', 'GSD.02-06-follow-up'],
'would_archive.phases must be exactly the 3 declared bracket phases — never the decoy, ' +
'and never empty (the pre-#612 hardcoded-legacy failure mode)',
);
assert.strictEqual(parsed.stats.phases, 3, 'the read-only stats loop shares the same single enumeration');
});
test('CONTROL: the identical shape under the legacy (non-bracket) convention is unaffected', () => {
// Same directory/decoy shape, legacy headings instead of bracket ones —
// this is the "byte-identical for null/milestone-prefixed projects"
// guarantee the changeset makes; pinned here at the cmdMilestoneComplete
// consumer, not just at the enumerator's own unit level.
writeProject(`# Roadmap
## v2.0
### Phase 01: Setup
**Goal:** a
### Phase 05: Real work
**Goal:** b
### Phase 06: Follow-up
**Goal:** c
`, undefined, ['01-setup', '05-real-work', '06-follow-up', ['not-a-declared-phase', false]]);
const r = runGsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--raw'], tmpDir);
assert.ok(r.success, `milestone complete --dry-run failed: ${r.error}`);
const parsed = JSON.parse(r.output);
assert.deepStrictEqual(
[...parsed.would_archive.phases].sort(),
['01-setup', '05-real-work', '06-follow-up'],
);
});
});
describe('#2761 round-11 BLOCKER: cmdStateUpdateProgress computes+writes a percent on bracket projects', () => {
beforeEach(() => { tmpDir = createTempProject('adr-612-update-progress-'); });
afterEach(() => { cleanup(tmpDir); });
test('PIN: `state update-progress` writes a real (non-withheld) percent for a bracket-scoped milestone', () => {
// One fully-verified phase (01) and one plan-only phase (05) under the
// [GSD.02] v2.0 bracket milestone — mirrors writeProject's own
// PLAN+SUMMARY+VERIFICATION shape for "complete" entries. Round-11 named
// this exact command: "cmdStateUpdateProgress ... now computes/writes a
// completion percent on bracket projects where it previously withheld
// one." Pinning `updated: true` plus the exact percent/completed/total
// triple is what makes a future regression to the withheld shape (or to
// silently mis-scoped counts) a failing assertion instead of an
// unobserved behavior change.
writeProject(BRACKET_ROADMAP, 'bracket', [
['GSD.02-01-setup', true],
['GSD.02-05-real-work', false],
]);
const r = runGsdTools(['state', 'update-progress'], tmpDir);
assert.ok(r.success, `state update-progress failed: ${r.error}`);
const parsed = JSON.parse(r.output);
assert.strictEqual(parsed.updated, true,
'a bracket project with a resolvable milestone window must not withhold — got: ' + JSON.stringify(parsed));
assert.strictEqual(parsed.completed, 1);
assert.strictEqual(parsed.total, 2);
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input
const m = raw.match(/^\*\*Progress:\*\*[^\r\n]*?(\d+)%/m);
assert.ok(m, `state update-progress must have rendered a Progress percent into STATE.md; got:\n${raw}`);
assert.strictEqual(parseInt(m[1], 10), parsed.percent,
'the percent update-progress reports and the one it writes into the body must agree');
});
test('#2761 round-12 GAP: the enumerator .value THIS call site feeds (not the separately-scoped ' +
'percent) gates the #3233 zero-plans no-op — a decoy outside the bracket window must not count', () => {
// Round-11's PIN test above (and its own inline comment at state.cts
// ~:965) established that `cmdStateUpdateProgress`'s reported/written
// percent is IMMUNE to this call site's convention threading — it comes
// from computeUpdateProgressPreview -> buildStateFrontmatter, a separate,
// independently-scoped derivation. Mutation testing on the round-11
// response confirmed that empirically: reverting this call site's thread
// left the PIN test above still green.
//
// What THIS call site's enumerated `.value` actually feeds is
// `totalPlans` (summed across the enumerated dirs, just below) and the
// #3233 zero-plans no-op gate built on it — the ONLY place that value has
// any observable effect on this command's output. This fixture pins
// exactly that gate: BRACKET_ROADMAP declares milestone v2.0's phases
// (01/05/06 under [GSD.02]) but none of their directories are scaffolded
// on disk — zero plans exist for this milestone's real window either way.
// A directory that plainly does not belong to it ('not-a-declared-phase':
// no bracket tag, no phase token at all) sits alongside it WITH a plan.
//
// Correctly bracket-scoped, the decoy is excluded (it matches no
// GSD.02-qualified id) and totalPlans stays 0 -> the #3233 no-op fires.
// Degraded to the pass-all legacy reading (BRACKET_ROADMAP's headings are
// invisible under a non-bracket read — proven above by "a NON-bracket
// repo counts neither form of the same roadmap" — so
// `milestonePhaseNums` comes back empty and the filter admits
// everything), the decoy is swept in and totalPlans flips nonzero -> the
// no-op never fires and the command proceeds past it.
writeProject(BRACKET_ROADMAP, 'bracket', [['not-a-declared-phase', false]]);
const r = runGsdTools(['state', 'update-progress'], tmpDir);
assert.ok(r.success, `state update-progress failed: ${r.error}`);
const parsed = JSON.parse(r.output);
assert.strictEqual(parsed.updated, false,
'a decoy directory outside the bracket-declared milestone window must never contribute to ' +
'totalPlans for that milestone — got: ' + JSON.stringify(parsed));
assert.match(String(parsed.reason ?? ''), /no plans found/,
'must be the #3233 zero-plans no-op specifically (proves the enumerator excluded the decoy), ' +
'not some other withhold reason — got: ' + JSON.stringify(parsed));
});
test('CONTROL: the identical shape under the legacy (non-bracket) convention behaves the same way', () => {
writeProject(`# Roadmap
## v2.0
### Phase 01: Setup
**Goal:** a
### Phase 05: Real work
**Goal:** b
`, undefined, [
['01-setup', true],
['05-real-work', false],
]);
const r = runGsdTools(['state', 'update-progress'], tmpDir);
assert.ok(r.success, `state update-progress failed: ${r.error}`);
const parsed = JSON.parse(r.output);
assert.strictEqual(parsed.updated, true);
assert.strictEqual(parsed.completed, 1);
assert.strictEqual(parsed.total, 2);
});
});